fix: treat any extracted verdict as terminal in isValidationTerminal (#2774)

* fix: treat any extracted verdict as terminal in isValidationTerminal

If the LLM writes a VALIDATION file with an unrecognized verdict like
`fail`, the allowlist in isValidationTerminal() returned false, keeping
the state machine in validating-milestone phase and re-dispatching
validate-milestone indefinitely (14+ times observed).

Any non-null verdict from extractVerdict() means validation completed.
Only return false when no verdict could be parsed.

Closes #2769

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add regression tests for isValidationTerminal with fail verdict

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: update existing test to match new any-verdict-is-terminal behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-26 20:09:00 -06:00 committed by GitHub
parent d5b318a222
commit f4736f47ae
2 changed files with 17 additions and 12 deletions

View file

@ -90,18 +90,13 @@ export function isMilestoneComplete(roadmap: Roadmap): boolean {
}
/**
* Check whether a VALIDATION file's verdict is terminal (pass or needs-attention).
* A non-terminal verdict (needs-remediation) means validation must re-run
* after remediation slices are executed.
* Check whether a VALIDATION file's verdict is terminal.
* Any successfully extracted verdict (pass, needs-attention, needs-remediation,
* fail, etc.) means validation completed. Only return false when no verdict
* could be parsed i.e. extractVerdict() returns undefined (#2769).
*/
export function isValidationTerminal(validationContent: string): boolean {
const v = extractVerdict(validationContent);
if (!v) return false;
// 'pass' and 'needs-attention' are always terminal.
// 'needs-remediation' is treated as terminal to prevent infinite loops
// when no remediation slices exist in the roadmap (#832). The validation
// report is preserved on disk for manual review.
return v === 'pass' || v === 'needs-attention' || v === 'needs-remediation';
return extractVerdict(validationContent) != null;
}
// ─── State Derivation ──────────────────────────────────────────────────────

View file

@ -110,6 +110,16 @@ test("isValidationTerminal returns true for verdict: passed (#1429)", () => {
assert.equal(isValidationTerminal(content), true);
});
test("isValidationTerminal returns true for verdict: fail (#2769)", () => {
const content = "---\nverdict: fail\nremediation_round: 1\n---\n\n# Validation";
assert.equal(isValidationTerminal(content), true);
});
test("isValidationTerminal returns true for any arbitrary verdict string (#2769)", () => {
const content = "---\nverdict: custom-verdict\nremediation_round: 0\n---\n\n# Validation";
assert.equal(isValidationTerminal(content), true);
});
test("isValidationTerminal returns false for missing frontmatter", () => {
const content = "# Validation\nNo frontmatter here.";
assert.equal(isValidationTerminal(content), false);
@ -327,14 +337,14 @@ test("verifyExpectedArtifact rejects VALIDATION with missing verdict field", ()
}
});
test("verifyExpectedArtifact rejects VALIDATION with unrecognized verdict", () => {
test("verifyExpectedArtifact accepts VALIDATION with any extracted verdict", () => {
const base = makeTmpBase();
try {
writeValidation(base, "M001", "---\nverdict: unknown-value\nremediation_round: 0\n---\n\n# Validation");
clearPathCache();
clearParseCache();
const result = verifyExpectedArtifact("validate-milestone", "M001", base);
assert.equal(result, false, "VALIDATION with unrecognized verdict should fail verification");
assert.equal(result, true, "VALIDATION with any extracted verdict should pass verification");
} finally {
cleanup(base);
}