fix(gsd): stop replaying completed run-uat units (#1270)

This commit is contained in:
TÂCHES 2026-03-18 16:24:50 -06:00 committed by GitHub
parent f2b637a596
commit ce550b2423
3 changed files with 74 additions and 31 deletions

View file

@ -104,25 +104,6 @@ const DISPATCH_RULES: DispatchRule[] = [
};
},
},
{
name: "run-uat (post-completion)",
match: async ({ state, mid, basePath, prefs }) => {
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
if (!needsRunUat) return null;
const { sliceId, uatType } = needsRunUat;
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
const uatContent = await loadFile(uatFile);
return {
action: "dispatch",
unitType: "run-uat",
unitId: `${mid}/${sliceId}`,
prompt: await buildRunUatPrompt(
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
),
pauseAfterDispatch: uatType !== "artifact-driven",
};
},
},
{
name: "uat-verdict-gate (non-PASS blocks progression)",
match: async ({ mid, basePath, prefs }) => {
@ -152,6 +133,25 @@ const DISPATCH_RULES: DispatchRule[] = [
return null;
},
},
{
name: "run-uat (post-completion)",
match: async ({ state, mid, basePath, prefs }) => {
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
if (!needsRunUat) return null;
const { sliceId, uatType } = needsRunUat;
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
const uatContent = await loadFile(uatFile);
return {
action: "dispatch",
unitType: "run-uat",
unitId: `${mid}/${sliceId}`,
prompt: await buildRunUatPrompt(
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
),
pauseAfterDispatch: uatType !== "artifact-driven",
};
},
},
{
name: "reassess-roadmap (post-completion)",
match: async ({ state, mid, midTitle, basePath, prefs }) => {

View file

@ -530,21 +530,14 @@ export async function checkNeedsRunUat(
const uatContent = await loadFile(uatFile);
if (!uatContent) return null;
// If UAT result already exists with a PASS verdict, skip (idempotent).
// Non-PASS verdicts (FAIL, surfaced-for-human-review) should block slice
// progression — return the slice for re-evaluation (#1231).
// If a UAT result already exists, the UAT unit has already run and must not
// be re-dispatched. PASS means progression can continue; any non-PASS verdict
// must be handled by the dispatch table's verdict gate, which stops progression
// with a human-action message instead of replaying the same run-uat unit.
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
if (uatResultFile) {
const resultContent = await loadFile(uatResultFile);
if (resultContent) {
const verdictMatch = resultContent.match(/verdict:\s*([\w-]+)/i);
const verdict = verdictMatch?.[1]?.toLowerCase();
if (verdict === "pass" || verdict === "passed") return null; // PASS — skip
// Non-PASS verdict exists — don't re-run UAT, but don't advance either.
// Return null here since the UAT already ran; the dispatch table's
// complete-slice rule should check the verdict before advancing.
// For now, returning the slice signals it still needs attention.
}
if (resultContent) return null;
}
// Classify UAT type; unknown type → treat as human-experience (human review)

View file

@ -6,6 +6,7 @@
// (a)(j) extractUatType classification (17 assertions from T01)
// (k) run-uat prompt template loading and content integrity (8 assertions)
// (l) dispatch precondition assertions via resolveSliceFile (4 assertions)
// (m) stale replay guard: existing UAT-RESULT never re-dispatches (2 assertions)
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
@ -14,6 +15,7 @@ import { fileURLToPath } from 'node:url';
import { extractUatType } from '../files.ts';
import { resolveSliceFile } from '../paths.ts';
import { checkNeedsRunUat } from '../auto-prompts.ts';
import { createTestContext } from './test-helpers.ts';
// ─── Worktree-aware prompt loader ──────────────────────────────────────────
@ -308,6 +310,54 @@ async function main(): Promise<void> {
}
}
// ─── (m) stale replay guard: existing UAT-RESULT never re-dispatches ─────
console.log('\n── (m) stale replay guard');
{
const base = createFixtureBase();
try {
const roadmapDir = join(base, '.gsd', 'milestones', 'M001');
mkdirSync(roadmapDir, { recursive: true });
writeFileSync(
join(roadmapDir, 'M001-ROADMAP.md'),
[
'# M001: Test roadmap',
'',
'## Slices',
'',
'- [x] **S01: First slice** `risk:low` `depends:[]`',
'- [ ] **S02: Next slice** `risk:low` `depends:[S01]`',
'',
'## Boundary Map',
'',
].join('\n'),
);
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven'));
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: surfaced-for-human-review\n---\n');
const state = {
activeMilestone: { id: 'M001', title: 'Test roadmap' },
activeSlice: { id: 'S02', title: 'Next slice' },
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: 'Plan S02',
registry: [],
} as const;
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertEq(
result,
null,
'existing UAT-RESULT with non-PASS verdict does not re-dispatch run-uat; verdict gate owns blocking',
);
} finally {
cleanup(base);
}
}
report();
}