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:
@@ -149,7 +149,9 @@ describe('initializeDatabaseSchema', () => {
|
|||||||
};
|
};
|
||||||
for (let version = 1; version <= 15; version += 1) {
|
for (let version = 1; version <= 15; version += 1) {
|
||||||
database
|
database
|
||||||
.prepare('INSERT INTO schema_migrations (version, name) VALUES (?, ?)')
|
.prepare(
|
||||||
|
'INSERT INTO schema_migrations (version, name) VALUES (?, ?)',
|
||||||
|
)
|
||||||
.run(version, collidedNames[version] ?? `legacy_${version}`);
|
.run(version, collidedNames[version] ?? `legacy_${version}`);
|
||||||
}
|
}
|
||||||
// Precondition: the collided database is missing the progress columns.
|
// Precondition: the collided database is missing the progress columns.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock('./db.js', () => ({
|
|||||||
getLastHumanMessageSender: vi.fn(() => '216851709744513024'),
|
getLastHumanMessageSender: vi.fn(() => '216851709744513024'),
|
||||||
getLatestTurnNumber: vi.fn(() => 0),
|
getLatestTurnNumber: vi.fn(() => 0),
|
||||||
getPairedTaskById: vi.fn(),
|
getPairedTaskById: vi.fn(),
|
||||||
|
getPairedTurnOutputs: vi.fn(() => []),
|
||||||
insertPairedTurnOutput: vi.fn(),
|
insertPairedTurnOutput: vi.fn(),
|
||||||
refreshPairedTaskExecutionLease: vi.fn(() => true),
|
refreshPairedTaskExecutionLease: vi.fn(() => true),
|
||||||
releasePairedTaskExecutionLease: vi.fn(),
|
releasePairedTaskExecutionLease: vi.fn(),
|
||||||
@@ -239,6 +240,96 @@ describe('createPairedExecutionLifecycle completion handling', () => {
|
|||||||
expect(outputs).toEqual([]);
|
expect(outputs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delivers the held owner answer and a notice when the reviewer Codex is unavailable', async () => {
|
||||||
|
const outputs: AgentOutput[] = [];
|
||||||
|
|
||||||
|
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
task_id: 'paired-task-reviewer-down',
|
||||||
|
turn_number: 1,
|
||||||
|
role: 'owner',
|
||||||
|
output_text: 'TASK_DONE\n\n원하시던 .env 위치는 여기에 있습니다.',
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'paired-task-reviewer-down',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'completed',
|
||||||
|
arbiter_verdict: 'escalate',
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: 'reviewer_codex_unavailable',
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:01.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lifecycle = createPairedExecutionLifecycle({
|
||||||
|
pairedExecutionContext: {
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-reviewer-down',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {},
|
||||||
|
},
|
||||||
|
pairedTurnIdentity: {
|
||||||
|
turnId:
|
||||||
|
'paired-task-reviewer-down:2026-04-09T00:00:00.000Z:reviewer-turn',
|
||||||
|
taskId: 'paired-task-reviewer-down',
|
||||||
|
taskUpdatedAt: '2026-04-09T00:00:00.000Z',
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
role: 'reviewer',
|
||||||
|
},
|
||||||
|
completedRole: 'reviewer',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-reviewer-down',
|
||||||
|
enqueueMessageCheck: vi.fn(),
|
||||||
|
onOutput: async (output) => {
|
||||||
|
outputs.push(output);
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
lifecycle.markStatus('failed');
|
||||||
|
lifecycle.updateSummary({
|
||||||
|
errorText:
|
||||||
|
'auth-expired: All Codex rotation accounts unavailable; re-auth required before launching Codex',
|
||||||
|
});
|
||||||
|
await lifecycle.asyncFinalize();
|
||||||
|
|
||||||
|
const texts = outputs.map((o) =>
|
||||||
|
o.output?.visibility === 'public' ? o.output.text : o.result,
|
||||||
|
);
|
||||||
|
expect(texts).toEqual([
|
||||||
|
'원하시던 .env 위치는 여기에 있습니다.',
|
||||||
|
'ℹ️ 리뷰어 일시 사용 불가 — 리뷰 없이 owner 답변으로 진행했습니다.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('releases an owner turn interrupted by a human message without counting an owner failure', async () => {
|
it('releases an owner turn interrupted by a human message without counting an owner failure', async () => {
|
||||||
const enqueueMessageCheck = vi.fn();
|
const enqueueMessageCheck = vi.fn();
|
||||||
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getLastHumanMessageSender,
|
getLastHumanMessageSender,
|
||||||
getLatestTurnNumber,
|
getLatestTurnNumber,
|
||||||
getPairedTaskById,
|
getPairedTaskById,
|
||||||
|
getPairedTurnOutputs,
|
||||||
insertPairedTurnOutput,
|
insertPairedTurnOutput,
|
||||||
refreshPairedTaskExecutionLease,
|
refreshPairedTaskExecutionLease,
|
||||||
releasePairedTaskExecutionLease,
|
releasePairedTaskExecutionLease,
|
||||||
@@ -14,7 +15,10 @@ import {
|
|||||||
completePairedExecutionContext,
|
completePairedExecutionContext,
|
||||||
type PreparedPairedExecutionContext,
|
type PreparedPairedExecutionContext,
|
||||||
} from './paired-execution-context.js';
|
} from './paired-execution-context.js';
|
||||||
import { parseVisibleVerdict } from './paired-verdict.js';
|
import {
|
||||||
|
parseVisibleVerdict,
|
||||||
|
stripLeadingStatusLine,
|
||||||
|
} from './paired-verdict.js';
|
||||||
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
|
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
|
||||||
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
|
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
|
||||||
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
@@ -91,6 +95,17 @@ export function resolveOwnerNextStepNotice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLatestOwnerAnswer(taskId: string): string | null {
|
||||||
|
const outputs = getPairedTurnOutputs(taskId);
|
||||||
|
for (let i = outputs.length - 1; i >= 0; i -= 1) {
|
||||||
|
const output = outputs[i];
|
||||||
|
if (output.role !== 'owner') continue;
|
||||||
|
const text = stripLeadingStatusLine(output.output_text ?? '').trim();
|
||||||
|
if (text.length > 0) return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyPairedCompletionIfNeeded(args: {
|
async function notifyPairedCompletionIfNeeded(args: {
|
||||||
task: PairedTaskRecord | null | undefined;
|
task: PairedTaskRecord | null | undefined;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
@@ -116,6 +131,22 @@ async function notifyPairedCompletionIfNeeded(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reviewer (Codex) was unavailable, so the turn ended before the reviewer
|
||||||
|
// could approve. Instead of stopping silently, deliver the owner's already
|
||||||
|
// produced answer and tell the user the review was skipped — otherwise the
|
||||||
|
// user sees nothing at all (the owner answer is held until review completes).
|
||||||
|
if (
|
||||||
|
task.status === 'completed' &&
|
||||||
|
task.completion_reason === 'reviewer_codex_unavailable'
|
||||||
|
) {
|
||||||
|
const ownerAnswer = getLatestOwnerAnswer(task.id);
|
||||||
|
if (ownerAnswer) {
|
||||||
|
await emit(ownerAnswer);
|
||||||
|
}
|
||||||
|
await emit('ℹ️ 리뷰어 일시 사용 불가 — 리뷰 없이 owner 답변으로 진행했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Owner turn: surface the next routing step so the ending is unambiguous.
|
// Owner turn: surface the next routing step so the ending is unambiguous.
|
||||||
if (args.completedRole !== 'owner') return;
|
if (args.completedRole !== 'owner') return;
|
||||||
const notice = resolveOwnerNextStepNotice(task);
|
const notice = resolveOwnerNextStepNotice(task);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
resolveReviewerFailureSignal,
|
resolveReviewerFailureSignal,
|
||||||
} from './paired-completion-signals.js';
|
} from './paired-completion-signals.js';
|
||||||
import { resolveCanonicalSourceRef } from './paired-source-ref.js';
|
import { resolveCanonicalSourceRef } from './paired-source-ref.js';
|
||||||
import { parseVisibleVerdict } from './paired-verdict.js';
|
import { parseReviewerVerdict } from './paired-verdict.js';
|
||||||
import type { PairedTask } from './types.js';
|
import type { PairedTask } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +60,7 @@ export function handleFailedReviewerExecution(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
const verdict = parseVisibleVerdict(summary);
|
const verdict = parseReviewerVerdict(summary);
|
||||||
const signal = resolveReviewerFailureSignal({
|
const signal = resolveReviewerFailureSignal({
|
||||||
visibleVerdict: verdict,
|
visibleVerdict: verdict,
|
||||||
});
|
});
|
||||||
@@ -191,7 +191,7 @@ export function handleReviewerCompletion(args: {
|
|||||||
}): void {
|
}): void {
|
||||||
const { task, taskId, summary } = args;
|
const { task, taskId, summary } = args;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const verdict = parseVisibleVerdict(summary);
|
const verdict = parseReviewerVerdict(summary);
|
||||||
const signal = resolveReviewerCompletionSignal({
|
const signal = resolveReviewerCompletionSignal({
|
||||||
visibleVerdict: verdict,
|
visibleVerdict: verdict,
|
||||||
roundTripCount: task.round_trip_count,
|
roundTripCount: task.round_trip_count,
|
||||||
|
|||||||
@@ -104,6 +104,34 @@ describe('paired execution routing loop guards', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats a reviewer PROCEED as approval and routes to owner finalize', () => {
|
||||||
|
// Regression: Codex reviewers approve with an arbiter-style "PROCEED" line.
|
||||||
|
// It used to parse as 'continue' (a change request), so the reviewer's
|
||||||
|
// approval bounced the task back to active and owner↔reviewer ping-ponged
|
||||||
|
// (owner TASK_DONE ↔ reviewer PROCEED) until the deadlock cap. A PROCEED
|
||||||
|
// approval must move the task to merge_ready so the owner can finalize.
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
|
buildPairedTask({
|
||||||
|
status: 'in_review',
|
||||||
|
source_ref: 'approved-ref',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
completePairedExecutionContext({
|
||||||
|
taskId: 'task-1',
|
||||||
|
role: 'reviewer',
|
||||||
|
status: 'succeeded',
|
||||||
|
summary: 'PROCEED\n위치와 첨부는 맞습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'merge_ready',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears stale owner loop state when reviewer approval is recovered from a failed run', () => {
|
it('clears stale owner loop state when reviewer approval is recovered from a failed run', () => {
|
||||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
buildPairedTask({
|
buildPairedTask({
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
classifyArbiterVerdict,
|
classifyArbiterVerdict,
|
||||||
|
parseReviewerVerdict,
|
||||||
parseVisibleVerdict,
|
parseVisibleVerdict,
|
||||||
|
stripLeadingStatusLine,
|
||||||
} from './paired-verdict.js';
|
} from './paired-verdict.js';
|
||||||
|
|
||||||
describe('paired verdict parser', () => {
|
describe('paired verdict parser', () => {
|
||||||
@@ -80,6 +82,45 @@ describe('paired verdict parser', () => {
|
|||||||
).toBe('continue');
|
).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', () => {
|
it('classifies arbiter verdicts from leading visible lines', () => {
|
||||||
expect(classifyArbiterVerdict('PROCEED\ncontinue')).toBe('proceed');
|
expect(classifyArbiterVerdict('PROCEED\ncontinue')).toBe('proceed');
|
||||||
expect(classifyArbiterVerdict('**VERDICT: REVISE**\nfix it')).toBe(
|
expect(classifyArbiterVerdict('**VERDICT: REVISE**\nfix it')).toBe(
|
||||||
|
|||||||
@@ -69,6 +69,64 @@ export function parseVisibleVerdict(
|
|||||||
return 'continue';
|
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(
|
export function classifyArbiterVerdict(
|
||||||
summary: string | null | undefined,
|
summary: string | null | undefined,
|
||||||
): ArbiterVerdictResult {
|
): ArbiterVerdictResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user