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>
165 lines
5.1 KiB
TypeScript
165 lines
5.1 KiB
TypeScript
import type { ArbiterVerdict } from './types.js';
|
|
|
|
export type VisibleVerdict =
|
|
| 'step_done'
|
|
| 'task_done'
|
|
| 'done'
|
|
| 'done_with_concerns'
|
|
| 'blocked'
|
|
| 'needs_context'
|
|
| 'continue';
|
|
|
|
export type ArbiterVerdictResult = ArbiterVerdict | 'unknown';
|
|
|
|
const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 12;
|
|
const ARBITER_VERDICT_SCAN_LINE_LIMIT = 12;
|
|
|
|
function leadingVisibleLines(text: string, limit: number): string[] {
|
|
const lines: string[] = [];
|
|
let inFence = false;
|
|
|
|
for (const rawLine of text.split('\n')) {
|
|
const line = rawLine.trim();
|
|
if (/^(```|~~~)/.test(line)) {
|
|
inFence = !inFence;
|
|
continue;
|
|
}
|
|
if (inFence || line.length === 0) {
|
|
continue;
|
|
}
|
|
lines.push(line);
|
|
if (lines.length >= limit) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
function stripInternalBlocks(text: string): string {
|
|
return text.replace(/<internal>[\s\S]*?(?:<\/internal>|$)/g, '');
|
|
}
|
|
|
|
function parseVisibleVerdictLine(line: string): VisibleVerdict | null {
|
|
if (/^\*{0,2}BLOCKED(?:\*{0,2})?\b/i.test(line)) return 'blocked';
|
|
if (/^\*{0,2}NEEDS_CONTEXT(?:\*{0,2})?\b/i.test(line)) return 'needs_context';
|
|
if (/^\*{0,2}STEP_DONE(?:\*{0,2})?\b/i.test(line)) return 'step_done';
|
|
if (/^\*{0,2}TASK_DONE(?:\*{0,2})?\b/i.test(line)) return 'task_done';
|
|
if (/^\*{0,2}DONE_WITH_CONCERNS(?:\*{0,2})?\b/i.test(line))
|
|
return 'done_with_concerns';
|
|
if (/^\*{0,2}DONE(?:\*{0,2})?\b/i.test(line)) return 'done';
|
|
if (/^\*{0,2}Approved\.?(?:\*{0,2})?/i.test(line)) return 'done';
|
|
if (/^\*{0,2}LGTM(?:\*{0,2})?/i.test(line)) return 'done';
|
|
return null;
|
|
}
|
|
|
|
export function parseVisibleVerdict(
|
|
summary: string | null | undefined,
|
|
): VisibleVerdict {
|
|
if (!summary) return 'continue';
|
|
const cleaned = stripInternalBlocks(summary).trim();
|
|
if (!cleaned) return 'continue';
|
|
for (const line of leadingVisibleLines(
|
|
cleaned,
|
|
VISIBLE_VERDICT_SCAN_LINE_LIMIT,
|
|
)) {
|
|
const verdict = parseVisibleVerdictLine(line);
|
|
if (verdict) return verdict;
|
|
}
|
|
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 {
|
|
if (!summary) return 'unknown';
|
|
const cleaned = stripInternalBlocks(summary).trim();
|
|
if (!cleaned) return 'unknown';
|
|
for (const line of leadingVisibleLines(
|
|
cleaned,
|
|
ARBITER_VERDICT_SCAN_LINE_LIMIT,
|
|
)) {
|
|
const verdictMatch = line.match(
|
|
/\*{0,2}(?:VERDICT\s*[:—-]\s*)?(PROCEED|REVISE|RESET|ESCALATE|CONTINUE)\*{0,2}/i,
|
|
);
|
|
if (verdictMatch) {
|
|
const normalized = verdictMatch[1].toLowerCase();
|
|
return normalized === 'continue'
|
|
? 'proceed'
|
|
: (normalized as ArbiterVerdict);
|
|
}
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
export function resolveStoredVisibleVerdict(args: {
|
|
verdict?: VisibleVerdict | null;
|
|
outputText?: string | null;
|
|
}): VisibleVerdict | null {
|
|
if (args.verdict) {
|
|
return args.verdict;
|
|
}
|
|
if (!args.outputText) {
|
|
return null;
|
|
}
|
|
return parseVisibleVerdict(args.outputText);
|
|
}
|