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

@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import {
classifyArbiterVerdict,
parseReviewerVerdict,
parseVisibleVerdict,
stripLeadingStatusLine,
} from './paired-verdict.js';
describe('paired verdict parser', () => {
@@ -80,6 +82,45 @@ describe('paired verdict parser', () => {
).toBe('continue');
});
it('treats a reviewer PROCEED line as an approval (DONE)', () => {
expect(parseReviewerVerdict('PROCEED\n위치와 첨부는 맞습니다.')).toBe('done');
expect(parseReviewerVerdict('**PROCEED**\n최종 답변 내용 맞습니다.')).toBe(
'done',
);
expect(
parseReviewerVerdict(
['검토 결과를 정리합니다.', 'PROCEED', '확정합니다.'].join('\n'),
),
).toBe('done');
});
it('does not upgrade non-approval reviewer verdicts', () => {
// REVISE / RESET / plain prose remain a change request ('continue').
expect(parseReviewerVerdict('REVISE\n이 부분을 고쳐주세요')).toBe('continue');
expect(parseReviewerVerdict('RESET\n방향을 다시 잡읍시다')).toBe('continue');
expect(parseReviewerVerdict('추가 수정이 필요합니다')).toBe('continue');
// Documented reviewer tokens still parse to their own verdicts.
expect(parseReviewerVerdict('TASK_DONE\n승인합니다')).toBe('task_done');
expect(parseReviewerVerdict('DONE_WITH_CONCERNS\n우려가 있습니다')).toBe(
'done_with_concerns',
);
// "PROCEED" mentioned in prose, not as the leading verdict, is ignored.
expect(
parseReviewerVerdict('리뷰어가 PROCEED 했는지 확인이 필요합니다'),
).toBe('continue');
});
it('strips a leading status line for direct delivery', () => {
expect(stripLeadingStatusLine('TASK_DONE\n\n실제 답변입니다.')).toBe(
'실제 답변입니다.',
);
expect(stripLeadingStatusLine('STEP_DONE\n다음 내용')).toBe('다음 내용');
// No leading status token → text is returned untouched.
expect(stripLeadingStatusLine('그냥 본문입니다.\n둘째 줄')).toBe(
'그냥 본문입니다.\n둘째 줄',
);
});
it('classifies arbiter verdicts from leading visible lines', () => {
expect(classifyArbiterVerdict('PROCEED\ncontinue')).toBe('proceed');
expect(classifyArbiterVerdict('**VERDICT: REVISE**\nfix it')).toBe(