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)"); + }); +});