fix(gsd): guard reconcileWorktreeDb against same-file ATTACH corruption (#2825)

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
This commit is contained in:
drkthng 2026-03-28 01:08:13 +01:00 committed by GitHub
parent 1ad4137892
commit 6dd02bb3ce
4 changed files with 191 additions and 3 deletions

View file

@ -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 */
}

View file

@ -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;
}

View file

@ -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.

View file

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