From 03328e04e84f611ae64c855aff41f8b7cbed6688 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 19:19:20 -0700 Subject: [PATCH 1/2] fix(gsd): cap run-uat dispatch attempts to prevent infinite replay loop When verification commands fail before writing an ASSESSMENT verdict, checkNeedsRunUat keeps returning the same slice, causing infinite re-dispatch until the provider throttles the session. Adds a per-slice disk-persisted counter (survives crash/restart) that caps run-uat at 3 attempts. After the cap, stops auto-mode with an actionable message instead of looping. Closes #3624 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto-dispatch.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index ccc3921e6..2a3d676e2 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -130,6 +130,32 @@ export function setRewriteCount(basePath: string, count: number): void { writeFileSync(filePath, JSON.stringify({ count, updatedAt: new Date().toISOString() }) + "\n"); } +// ─── Run-UAT dispatch counter (per-slice) ──────────────────────────────── +// Caps run-uat dispatches to prevent infinite replay when verification +// commands fail before writing a verdict (#3624). +const MAX_UAT_ATTEMPTS = 3; + +function uatCountPath(basePath: string, mid: string, sid: string): string { + return join(gsdRoot(basePath), "runtime", `uat-count-${mid}-${sid}.json`); +} + +export function getUatCount(basePath: string, mid: string, sid: string): number { + try { + const data = JSON.parse(readFileSync(uatCountPath(basePath, mid, sid), "utf-8")); + return typeof data.count === "number" ? data.count : 0; + } catch { + return 0; + } +} + +export function incrementUatCount(basePath: string, mid: string, sid: string): number { + const count = getUatCount(basePath, mid, sid) + 1; + const filePath = uatCountPath(basePath, mid, sid); + mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true }); + writeFileSync(filePath, JSON.stringify({ count, updatedAt: new Date().toISOString() }) + "\n"); + return count; +} + // ─── Helpers ───────────────────────────────────────────────────────────── /** @@ -203,6 +229,16 @@ export const DISPATCH_RULES: DispatchRule[] = [ const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs); if (!needsRunUat) return null; const { sliceId, uatType } = needsRunUat; + + // Cap run-uat dispatch attempts to prevent infinite replay (#3624) + const attempts = incrementUatCount(basePath, mid, sliceId); + if (attempts > MAX_UAT_ATTEMPTS) { + return { + action: "stop" as const, + reason: `run-uat for ${mid}/${sliceId} has been dispatched ${attempts - 1} times without producing a verdict. Verification commands may be broken — fix the UAT spec or manually write an ASSESSMENT verdict.`, + level: "warning" as const, + }; + } const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; const uatContent = await loadFile(uatFile); return { From b461cf75ed349f59892a3801d428360f03e6c50c Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 22:25:12 -0700 Subject: [PATCH 2/2] test: add regression test for run-uat replay cap Structural verification that MAX_UAT_ATTEMPTS constant exists and incrementUatCount is called before dispatch (#3624). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gsd/tests/run-uat-replay-cap.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/run-uat-replay-cap.test.ts diff --git a/src/resources/extensions/gsd/tests/run-uat-replay-cap.test.ts b/src/resources/extensions/gsd/tests/run-uat-replay-cap.test.ts new file mode 100644 index 000000000..0d4b80b65 --- /dev/null +++ b/src/resources/extensions/gsd/tests/run-uat-replay-cap.test.ts @@ -0,0 +1,51 @@ +/** + * Regression test for #3624 — cap run-uat dispatch attempts + * + * When verification commands fail before writing a verdict, the run-uat + * dispatch rule fires repeatedly in an infinite loop. The fix adds a + * MAX_UAT_ATTEMPTS constant and calls incrementUatCount before dispatch + * to cap the number of attempts. + * + * Structural verification test — reads source to confirm MAX_UAT_ATTEMPTS + * and incrementUatCount exist. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const source = readFileSync(join(__dirname, '..', 'auto-dispatch.ts'), 'utf-8'); + +describe('run-uat replay cap (#3624)', () => { + test('MAX_UAT_ATTEMPTS constant is defined', () => { + assert.match(source, /const MAX_UAT_ATTEMPTS\s*=\s*\d+/, + 'MAX_UAT_ATTEMPTS constant should be defined'); + }); + + test('incrementUatCount function is exported', () => { + assert.match(source, /export function incrementUatCount\(/, + 'incrementUatCount should be an exported function'); + }); + + test('getUatCount function is exported', () => { + assert.match(source, /export function getUatCount\(/, + 'getUatCount should be an exported function'); + }); + + test('incrementUatCount is called before dispatch in rule', () => { + // incrementUatCount should be called before the dispatch return + const ruleSection = source.slice(source.indexOf('checkNeedsRunUat')); + assert.match(ruleSection, /incrementUatCount\(/, + 'incrementUatCount should be called in the dispatch rule'); + }); + + test('attempts are compared against MAX_UAT_ATTEMPTS', () => { + assert.match(source, /attempts\s*>\s*MAX_UAT_ATTEMPTS/, + 'dispatch should check attempts > MAX_UAT_ATTEMPTS'); + }); +});