fix: check ASSESSMENT file for UAT verdict in checkNeedsRunUat (#2646)

The run-uat prompt instructs the agent to write the UAT verdict to the
ASSESSMENT file (via gsd_summary_save artifact_type:"ASSESSMENT"), but
checkNeedsRunUat only checked the UAT spec file for a verdict. Since the
spec file never receives a verdict, hasVerdict() always returned false and
the run-uat unit was re-dispatched indefinitely — triggering the stuck-loop
detector after 3 identical dispatches.

Add ASSESSMENT file checks on both the DB-primary and file-based fallback
paths in checkNeedsRunUat. If either the UAT spec or the ASSESSMENT file
contains a verdict, UAT has been run and dispatch is skipped.

Closes #2644
This commit is contained in:
mastertyko 2026-03-26 16:15:03 +01:00 committed by GitHub
parent f8c6ab0c54
commit 6f3275ff59
2 changed files with 161 additions and 0 deletions

View file

@ -784,6 +784,14 @@ export async function checkNeedsRunUat(
if (!uatContent) return null;
// If the UAT file already contains a verdict, UAT has been run — skip
if (hasVerdict(uatContent)) return null;
// Also check the ASSESSMENT file — the run-uat prompt writes the verdict
// there (via gsd_summary_save artifact_type:"ASSESSMENT"), not into the
// UAT spec file. Without this check the unit re-dispatches indefinitely.
const assessmentFile = resolveSliceFile(base, mid, sid, "ASSESSMENT");
if (assessmentFile) {
const assessmentContent = await loadFile(assessmentFile);
if (assessmentContent && hasVerdict(assessmentContent)) return null;
}
const uatType = getUatType(uatContent);
return { sliceId: sid, uatType };
}
@ -808,6 +816,13 @@ export async function checkNeedsRunUat(
if (!uatContentFb) return null;
// If the UAT file already contains a verdict, UAT has been run — skip
if (hasVerdict(uatContentFb)) return null;
// Also check the ASSESSMENT file for the file-based fallback path (same
// reason as the DB path above — verdict lives in ASSESSMENT, not UAT).
const assessmentFileFb = resolveSliceFile(base, mid, uatSid, "ASSESSMENT");
if (assessmentFileFb) {
const assessmentContentFb = await loadFile(assessmentFileFb);
if (assessmentContentFb && hasVerdict(assessmentContentFb)) return null;
}
const uatTypeFb = getUatType(uatContentFb);
return { sliceId: uatSid, uatType: uatTypeFb };
}

View file

@ -460,4 +460,150 @@ test('(n) stale replay guard', async () => {
}
});
test('(q) verdict in ASSESSMENT file skips UAT dispatch (file-based path)', async () => {
// Regression test for #2644: run-uat prompt writes the verdict to
// S{sid}-ASSESSMENT.md (via gsd_summary_save artifact_type:"ASSESSMENT"),
// but checkNeedsRunUat only checked S{sid}-UAT.md — causing a stuck loop.
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'),
);
// UAT spec file WITHOUT a verdict (the spec never gets one)
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven'));
// ASSESSMENT file WITH a verdict (where run-uat actually writes it)
writeSliceFile(base, 'M001', 'S01', 'ASSESSMENT', '---\nverdict: PASS\n---\n# UAT Assessment\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);
assert.deepStrictEqual(
result,
null,
'verdict in ASSESSMENT file should prevent re-dispatch of run-uat',
);
} finally {
cleanup(base);
}
});
test('(r) no ASSESSMENT file still dispatches UAT (no false skip)', async () => {
// Guard: when there is no ASSESSMENT file at all, UAT should still dispatch
// normally. The ASSESSMENT check must not cause a false-negative skip.
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'),
);
// UAT spec file WITHOUT a verdict, and NO ASSESSMENT file
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven'));
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);
assert.deepStrictEqual(
result,
{ sliceId: 'S01', uatType: 'artifact-driven' },
'without ASSESSMENT file, UAT still dispatches normally',
);
} finally {
cleanup(base);
}
});
test('(s) ASSESSMENT without verdict does not skip UAT dispatch', async () => {
// Guard: an ASSESSMENT file that exists but has no verdict line should
// NOT suppress UAT dispatch — only a file with an actual verdict should.
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'),
);
// UAT spec WITHOUT verdict
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven'));
// ASSESSMENT file WITHOUT verdict (partial/incomplete assessment)
writeSliceFile(base, 'M001', 'S01', 'ASSESSMENT', '# UAT Assessment\n\nStill running checks...\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);
assert.deepStrictEqual(
result,
{ sliceId: 'S01', uatType: 'artifact-driven' },
'ASSESSMENT without verdict should not suppress UAT dispatch',
);
} finally {
cleanup(base);
}
});
});