From 6dd02bb3cefc79b2ed4a93716442db1494e6deee Mon Sep 17 00:00:00 2001 From: drkthng Date: Sat, 28 Mar 2026 01:08:13 +0100 Subject: [PATCH] fix(gsd): guard reconcileWorktreeDb against same-file ATTACH corruption (#2825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When worktrees use shared-WAL mode (R012), the worktree DB path resolves to the same physical file as the project root DB via symlink. Calling reconcileWorktreeDb() ATTACHes this WAL-mode file to itself, corrupting the database with 'database disk image is malformed'. Fix 1 — auto-worktree.ts mergeMilestoneToMain(): skip reconciliation when isSamePath() confirms both DB paths resolve to the same file. Fix 2 — gsd-db.ts reconcileWorktreeDb(): defence-in-depth realpathSync guard inside the function itself, before the ATTACH statement. Fix 3 — auto/infra-errors.ts: classify 'database disk image is malformed' as SQLITE_CORRUPT infrastructure error so the auto-loop stops immediately instead of burning 3 retries on a guaranteed failure. Regression tests verify: 1. Same-file via symlink returns zero (no ATTACH) 2. Identical string paths return zero 3. Genuinely different DBs still reconcile normally 4. Malformed DB message classified as infra error 5. Transient SQLITE_BUSY is not falsely classified Closes #2823 --- src/resources/extensions/gsd/auto-worktree.ts | 9 +- .../extensions/gsd/auto/infra-errors.ts | 3 + src/resources/extensions/gsd/gsd-db.ts | 7 +- .../gsd/tests/worktree-db-same-file.test.ts | 175 ++++++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index e94c04655..c07c7d4e5 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -1264,12 +1264,17 @@ export function mergeMilestoneToMain( // 1. Auto-commit dirty state in worktree before leaving autoCommitDirtyState(worktreeCwd); - // Reconcile worktree DB into main DB before leaving worktree context + // Reconcile worktree DB into main DB before leaving worktree context. + // Skip when both paths resolve to the same physical file (shared WAL / + // symlink layout) — ATTACHing a WAL-mode file to itself corrupts the + // database (#2823). if (isDbAvailable()) { try { const worktreeDbPath = join(worktreeCwd, ".gsd", "gsd.db"); const mainDbPath = join(originalBasePath_, ".gsd", "gsd.db"); - reconcileWorktreeDb(mainDbPath, worktreeDbPath); + if (!isSamePath(worktreeDbPath, mainDbPath)) { + reconcileWorktreeDb(mainDbPath, worktreeDbPath); + } } catch { /* non-fatal */ } diff --git a/src/resources/extensions/gsd/auto/infra-errors.ts b/src/resources/extensions/gsd/auto/infra-errors.ts index dc24a58c2..17c1a553d 100644 --- a/src/resources/extensions/gsd/auto/infra-errors.ts +++ b/src/resources/extensions/gsd/auto/infra-errors.ts @@ -41,5 +41,8 @@ export function isInfrastructureError(err: unknown): string | null { for (const code of INFRA_ERROR_CODES) { if (msg.includes(code)) return code; } + // SQLite WAL corruption is not transient — retrying burns LLM budget + // for guaranteed failures (#2823). + if (msg.includes("database disk image is malformed")) return "SQLITE_CORRUPT"; return null; } diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 8557cb0be..1559b8616 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -6,7 +6,7 @@ // Schema is initialized on first open with WAL mode for file-backed DBs. import { createRequire } from "node:module"; -import { existsSync, copyFileSync, mkdirSync } from "node:fs"; +import { existsSync, copyFileSync, mkdirSync, realpathSync } from "node:fs"; import { dirname } from "node:path"; import type { Decision, Requirement, GateRow, GateId, GateScope, GateStatus, GateVerdict } from "./types.js"; import { GSDError, GSD_STALE_STATE } from "./errors.js"; @@ -1761,6 +1761,11 @@ export function reconcileWorktreeDb( ): ReconcileResult { const zero: ReconcileResult = { decisions: 0, requirements: 0, artifacts: 0, milestones: 0, slices: 0, tasks: 0, memories: 0, verification_evidence: 0, conflicts: [] }; if (!existsSync(worktreeDbPath)) return zero; + // Guard: bail when both paths resolve to the same physical file. + // ATTACHing a WAL-mode DB to itself corrupts the WAL (#2823). + try { + if (realpathSync(mainDbPath) === realpathSync(worktreeDbPath)) return zero; + } catch { /* path resolution failed — fall through to existing checks */ } // Sanitize path: reject any characters that could break ATTACH syntax. // ATTACH DATABASE doesn't support parameterized paths in all providers, // so we use strict allowlist validation instead. diff --git a/src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts b/src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts new file mode 100644 index 000000000..6059d97dc --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts @@ -0,0 +1,175 @@ +/** + * worktree-db-same-file.test.ts — Regression test for #2823. + * + * Verifies that reconcileWorktreeDb() does not ATTACH a WAL-mode DB file + * to itself when the worktree DB path resolves to the same physical file + * as the main DB path (shared-WAL / symlink layout). + * + * Also verifies that the auto-loop classifies "database disk image is + * malformed" as an infrastructure error to prevent wasting retries. + */ + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + openDatabase, + closeDatabase, + reconcileWorktreeDb, + insertDecision, +} from "../gsd-db.ts"; +import { isInfrastructureError } from "../auto/infra-errors.ts"; + +// ─── Fix 1 & 2: reconcileWorktreeDb same-file guard ───────────────── + +describe("#2823: reconcileWorktreeDb same-file guard", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "gsd-2823-")); + }); + + afterEach(() => { + closeDatabase(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns zero result when both paths resolve to the same file", () => { + const mainGsd = join(tmpDir, "main", ".gsd"); + mkdirSync(mainGsd, { recursive: true }); + const mainDbPath = join(mainGsd, "gsd.db"); + + // Create a real DB at mainDbPath + openDatabase(mainDbPath); + insertDecision({ + id: "D001", + when_context: "2026-01-01", + scope: "M001", + decision: "Test decision", + choice: "Test choice", + rationale: "Test rationale", + revisable: "yes", + made_by: "agent", + superseded_by: null, + }); + + // Create a worktree path that resolves to the same file via symlink + const wtGsd = join(tmpDir, "worktree", ".gsd"); + mkdirSync(join(tmpDir, "worktree"), { recursive: true }); + symlinkSync(mainGsd, wtGsd, "junction"); + const worktreeDbPath = join(wtGsd, "gsd.db"); + + // Both paths exist and resolve to the same physical file + assert.ok(existsSync(mainDbPath), "main DB exists"); + assert.ok(existsSync(worktreeDbPath), "worktree DB path exists (via symlink)"); + + // This should NOT attempt ATTACH — should return zero result + const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath); + + assert.equal(result.decisions, 0, "no decisions reconciled"); + assert.equal(result.requirements, 0, "no requirements reconciled"); + assert.equal(result.artifacts, 0, "no artifacts reconciled"); + assert.equal(result.conflicts.length, 0, "no conflicts"); + }); + + test("returns zero result when both paths are identical strings", () => { + const mainGsd = join(tmpDir, "project", ".gsd"); + mkdirSync(mainGsd, { recursive: true }); + const dbPath = join(mainGsd, "gsd.db"); + + openDatabase(dbPath); + insertDecision({ + id: "D001", + when_context: "2026-01-01", + scope: "M001", + decision: "Test", + choice: "Test", + rationale: "Test", + revisable: "yes", + made_by: "agent", + superseded_by: null, + }); + + // Same exact path — should bail immediately + const result = reconcileWorktreeDb(dbPath, dbPath); + + assert.equal(result.decisions, 0); + assert.equal(result.conflicts.length, 0); + }); + + test("still reconciles when paths are genuinely different files", () => { + // Main DB + const mainGsd = join(tmpDir, "main", ".gsd"); + mkdirSync(mainGsd, { recursive: true }); + const mainDbPath = join(mainGsd, "gsd.db"); + + openDatabase(mainDbPath); + insertDecision({ + id: "D001", + when_context: "2026-01-01", + scope: "M001", + decision: "Main decision", + choice: "Main choice", + rationale: "Main rationale", + revisable: "yes", + made_by: "agent", + superseded_by: null, + }); + closeDatabase(); + + // Create a separate worktree DB with different data + const wtGsd = join(tmpDir, "worktree", ".gsd"); + mkdirSync(wtGsd, { recursive: true }); + const worktreeDbPath = join(wtGsd, "gsd.db"); + + openDatabase(worktreeDbPath); + insertDecision({ + id: "D002", + when_context: "2026-01-01", + scope: "M001", + decision: "WT decision", + choice: "WT choice", + rationale: "WT rationale", + revisable: "yes", + made_by: "agent", + superseded_by: null, + }); + closeDatabase(); + + // Re-open main and reconcile — should work normally + openDatabase(mainDbPath); + const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath); + + assert.ok( + result.decisions > 0, + "should reconcile decisions from a genuinely different DB", + ); + }); +}); + +// ─── Fix 3: infrastructure error classification ───────────────────── + +describe("#2823: malformed DB classified as infrastructure error", () => { + test("database disk image is malformed is detected as infra error", () => { + const err = new Error("database disk image is malformed"); + const code = isInfrastructureError(err); + assert.ok(code !== null, "should be classified as infrastructure error"); + assert.equal(code, "SQLITE_CORRUPT"); + }); + + test("other SQLite errors are not falsely classified", () => { + const err = new Error("SQLITE_BUSY: database is locked"); + const code = isInfrastructureError(err); + assert.equal(code, null, "SQLITE_BUSY should not be infra error (it's transient)"); + }); +});