fix(paired): stop silent halt on reviewer PROCEED + reviewer-unavailable
Two causes of the paired room "keeps stopping" symptom: - Reviewer approvals worded as "PROCEED" were parsed as 'continue' (a change request), causing an owner TASK_DONE <-> reviewer PROCEED ping-pong until the deadlock cap. parseReviewerVerdict() now treats a leading PROCEED as approval so the turn finalizes after one round. - When the Codex reviewer was unavailable, the owner's answer was held for review and the user saw nothing. Now the held owner answer is emitted with a "review skipped" notice on reviewer_codex_unavailable. Verified: tsc --noEmit clean; 27 related vitest tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,64 @@ export function parseVisibleVerdict(
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a leading status/verdict marker line (e.g. "TASK_DONE", "STEP_DONE")
|
||||
* from owner output so a held answer can be shown to the user directly without
|
||||
* the internal status token. Only strips when the very first non-empty line is
|
||||
* a recognised marker; otherwise returns the text untouched.
|
||||
*/
|
||||
export function stripLeadingStatusLine(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
let firstNonEmpty = 0;
|
||||
while (firstNonEmpty < lines.length && lines[firstNonEmpty].trim() === '') {
|
||||
firstNonEmpty += 1;
|
||||
}
|
||||
if (
|
||||
firstNonEmpty >= lines.length ||
|
||||
!parseVisibleVerdictLine(lines[firstNonEmpty].trim())
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const remainder = lines.slice(firstNonEmpty + 1);
|
||||
while (remainder.length > 0 && remainder[0].trim() === '') {
|
||||
remainder.shift();
|
||||
}
|
||||
return remainder.join('\n').trim();
|
||||
}
|
||||
|
||||
const REVIEWER_APPROVAL_LINE =
|
||||
/^\*{0,2}(?:VERDICT\s*[:—-]\s*)?PROCEED(?:\*{0,2})?\b/i;
|
||||
|
||||
/**
|
||||
* Reviewers — especially the Codex reviewer — approve a turn with an
|
||||
* arbiter-style "PROCEED" line instead of the documented TASK_DONE. Plain
|
||||
* parseVisibleVerdict() does not know "PROCEED" and maps it to 'continue',
|
||||
* which resolveReviewerCompletionSignal() treats as a change request. So an
|
||||
* *approving* reviewer silently re-opens the owner turn and the two roles
|
||||
* ping-pong (owner TASK_DONE ↔ reviewer PROCEED) until the deadlock cap fires —
|
||||
* the user sees a burst of near-identical messages that then just stops.
|
||||
*
|
||||
* Treat a leading PROCEED as an approval (equivalent to DONE) so the state
|
||||
* machine routes to owner finalize and the task actually completes after one
|
||||
* round instead of looping. Only PROCEED is upgraded; REVISE/RESET/plain text
|
||||
* stay 'continue' (a genuine change request).
|
||||
*/
|
||||
export function parseReviewerVerdict(
|
||||
summary: string | null | undefined,
|
||||
): VisibleVerdict {
|
||||
const verdict = parseVisibleVerdict(summary);
|
||||
if (verdict !== 'continue' || !summary) return verdict;
|
||||
const cleaned = stripInternalBlocks(summary).trim();
|
||||
if (!cleaned) return verdict;
|
||||
for (const line of leadingVisibleLines(
|
||||
cleaned,
|
||||
VISIBLE_VERDICT_SCAN_LINE_LIMIT,
|
||||
)) {
|
||||
if (REVIEWER_APPROVAL_LINE.test(line)) return 'done';
|
||||
}
|
||||
return verdict;
|
||||
}
|
||||
|
||||
export function classifyArbiterVerdict(
|
||||
summary: string | null | undefined,
|
||||
): ArbiterVerdictResult {
|
||||
|
||||
Reference in New Issue
Block a user