diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 85a79340c..6932ddb93 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -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 }) => { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 4774eeef9..82b93939c 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -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) diff --git a/src/resources/extensions/gsd/tests/run-uat.test.ts b/src/resources/extensions/gsd/tests/run-uat.test.ts index dde1276b5..c5acaf4d7 100644 --- a/src/resources/extensions/gsd/tests/run-uat.test.ts +++ b/src/resources/extensions/gsd/tests/run-uat.test.ts @@ -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 { } } + // ─── (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(); }