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:
Codex
2026-06-22 19:30:22 +09:00
parent 5d60df8122
commit 4d3ab20378
7 changed files with 256 additions and 5 deletions

View File

@@ -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 {