Merge pull request #3686 from Tibsfox/fix/run-uat-replay-cap

fix(gsd): cap run-uat dispatch attempts to prevent infinite replay loop
This commit is contained in:
Jeremy McSpadden 2026-04-07 07:03:54 -05:00 committed by GitHub
commit 3e0cdcd7ea
2 changed files with 87 additions and 0 deletions

View file

@ -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 {

View file

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