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:
parent
3e4612c67e
commit
e9a41c0df1
3 changed files with 114 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue