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:
parent
f8c6ab0c54
commit
6f3275ff59
2 changed files with 161 additions and 0 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue