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:
parent
1ad4137892
commit
6dd02bb3ce
4 changed files with 191 additions and 3 deletions
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
175
src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts
Normal file
175
src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue