diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index fca3efb3a..638564b0e 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -17,6 +17,7 @@ import { isDbAvailable, getMilestoneSlices, getPendingGates, markAllGatesOmitted import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js"; import { + gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, @@ -26,7 +27,7 @@ import { buildMilestoneFileName, buildSliceFileName, } from "./paths.js"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { hasImplementationArtifacts } from "./auto-recovery.js"; import { @@ -105,6 +106,29 @@ function findMissingSummaries(basePath: string, mid: string): string[] { const MAX_REWRITE_ATTEMPTS = 3; +// ─── Disk-persisted rewrite attempt counter ────────────────────────────────── +// The counter must survive session restarts (crash recovery, pause/resume, +// step-mode). Storing it on the in-memory session object caused the circuit +// breaker to never trip — see https://github.com/gsd-build/gsd-2/issues/2203 +function rewriteCountPath(basePath: string): string { + return join(gsdRoot(basePath), "runtime", "rewrite-count.json"); +} + +export function getRewriteCount(basePath: string): number { + try { + const data = JSON.parse(readFileSync(rewriteCountPath(basePath), "utf-8")); + return typeof data.count === "number" ? data.count : 0; + } catch { + return 0; + } +} + +export function setRewriteCount(basePath: string, count: number): void { + const filePath = rewriteCountPath(basePath); + mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true }); + writeFileSync(filePath, JSON.stringify({ count, updatedAt: new Date().toISOString() }) + "\n"); +} + // ─── Rules ──────────────────────────────────────────────────────────────── export const DISPATCH_RULES: DispatchRule[] = [ @@ -113,14 +137,14 @@ export const DISPATCH_RULES: DispatchRule[] = [ match: async ({ mid, midTitle, state, basePath, session }) => { const pendingOverrides = await loadActiveOverrides(basePath); if (pendingOverrides.length === 0) return null; - const count = session?.rewriteAttemptCount ?? 0; + const count = getRewriteCount(basePath); if (count >= MAX_REWRITE_ATTEMPTS) { const { resolveAllOverrides } = await import("./files.js"); await resolveAllOverrides(basePath); - if (session) session.rewriteAttemptCount = 0; + setRewriteCount(basePath, 0); return null; } - if (session) session.rewriteAttemptCount++; + setRewriteCount(basePath, count + 1); const unitId = state.activeSlice ? `${mid}/${state.activeSlice.id}` : mid; return { action: "dispatch", diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 847a0635a..222cd064e 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -331,6 +331,10 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV if (s.currentUnit.type === "rewrite-docs") { await runSafely("postUnit", "rewrite-docs-resolve", async () => { await resolveAllOverrides(s.basePath); + // Reset both disk and in-memory counters. Disk counter is authoritative + // (survives restarts); in-memory is kept in sync for the current session. + const { setRewriteCount } = await import("./auto-dispatch.js"); + setRewriteCount(s.basePath, 0); s.rewriteAttemptCount = 0; ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); }); diff --git a/src/resources/extensions/gsd/tests/rewrite-count-persist.test.ts b/src/resources/extensions/gsd/tests/rewrite-count-persist.test.ts new file mode 100644 index 000000000..d7c313431 --- /dev/null +++ b/src/resources/extensions/gsd/tests/rewrite-count-persist.test.ts @@ -0,0 +1,82 @@ +/** + * Regression tests for #2203: rewrite-docs circuit breaker must persist + * across session restarts. + * + * The rewrite attempt counter was stored in-memory on the session object, + * resetting to 0 on every session restart. This allowed the rewrite-docs + * dispatch rule to fire indefinitely, never tripping the MAX_REWRITE_ATTEMPTS + * circuit breaker. + * + * The fix persists the counter to `.gsd/runtime/rewrite-count.json`. + */ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, existsSync, readFileSync, writeFileSync, rmSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { getRewriteCount, setRewriteCount } from "../auto-dispatch.ts"; + +describe("rewrite-docs circuit breaker persistence (#2203)", () => { + let tempBase: string; + + beforeEach(() => { + tempBase = mkdtempSync(join(tmpdir(), "gsd-rewrite-test-")); + // Create .gsd/ directory so gsdRoot resolves to it + mkdirSync(join(tempBase, ".gsd", "runtime"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tempBase, { recursive: true, force: true }); + }); + + test("getRewriteCount returns 0 when no file exists", () => { + const count = getRewriteCount(tempBase); + assert.equal(count, 0); + }); + + test("setRewriteCount writes and getRewriteCount reads back", () => { + setRewriteCount(tempBase, 2); + const count = getRewriteCount(tempBase); + assert.equal(count, 2); + }); + + test("counter persists across simulated session restarts", () => { + // Session 1: increment to 1 + setRewriteCount(tempBase, 1); + + // "Session restart" — only the disk file survives, session object is gone + const countAfterRestart = getRewriteCount(tempBase); + assert.equal(countAfterRestart, 1, "counter should survive session restart"); + + // Session 2: increment to 2 + setRewriteCount(tempBase, countAfterRestart + 1); + assert.equal(getRewriteCount(tempBase), 2); + }); + + test("setRewriteCount(0) resets the counter", () => { + setRewriteCount(tempBase, 3); + assert.equal(getRewriteCount(tempBase), 3); + + setRewriteCount(tempBase, 0); + assert.equal(getRewriteCount(tempBase), 0); + }); + + test("getRewriteCount handles corrupt JSON gracefully", () => { + const filePath = join(tempBase, ".gsd", "runtime", "rewrite-count.json"); + // writeFileSync is imported at the top of this file + writeFileSync(filePath, "not json{{{"); + const count = getRewriteCount(tempBase); + assert.equal(count, 0, "corrupt file should return 0"); + }); + + test("rewrite-count.json is written to .gsd/runtime/", () => { + setRewriteCount(tempBase, 1); + const filePath = join(tempBase, ".gsd", "runtime", "rewrite-count.json"); + assert.ok(existsSync(filePath), "rewrite-count.json should exist"); + + const content = JSON.parse(readFileSync(filePath, "utf-8")); + assert.equal(content.count, 1); + assert.ok(content.updatedAt, "should include timestamp"); + }); +});