fix: persist rewrite-docs attempt counter to disk for session restart survival (#2671)

The rewrite-docs circuit breaker counter (MAX_REWRITE_ATTEMPTS=3) was
stored on the in-memory session object, resetting to 0 on every session
restart (crash recovery, pause/resume, step-mode). This allowed the
rewrite-docs dispatch rule to fire indefinitely without ever tripping
the circuit breaker.

The fix persists the counter to .gsd/runtime/rewrite-count.json using
the established runtime directory pattern. The dispatch rule reads from
disk instead of the session object, and the post-unit completion handler
resets both disk and in-memory counters.

Closes #2203
This commit is contained in:
mastertyko 2026-03-26 16:30:26 +01:00 committed by GitHub
parent 3e4612c67e
commit e9a41c0df1
3 changed files with 114 additions and 4 deletions

View file

@ -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",

View file

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

View file

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