import { ARBITER_DEADLOCK_THRESHOLD } from './config.js'; import { getPairedWorkspace } from './db.js'; import { isTerminalCodexAccountFailure } from './agent-error-detection.js'; import { logger } from './logger.js'; import { requestArbiterOrEscalate } from './paired-arbiter-request.js'; import { applyPairedTaskPatch, transitionPairedTaskStatus, } from './paired-task-status.js'; import { resolveReviewerCompletionSignal, resolveReviewerFailureSignal, } from './paired-completion-signals.js'; import { resolveCanonicalSourceRef } from './paired-source-ref.js'; import { parseVisibleVerdict } from './paired-verdict.js'; import type { PairedTask } from './types.js'; /** * Maximum consecutive reviewer executions that may produce no verdict * before we stop the ping-pong loop. Mirrors the owner-side failure cap and * closes the "reviewer silently fails forever" hole: codex sometimes emits a * final result with no visible text, the runtime marks it failed, the * next-turn scheduler re-queues another reviewer-turn, and the original * round_trip_count never increments — so the existing round-trip / arbiter * caps never fire. */ const REVIEWER_FAILURE_ESCALATION_THRESHOLD = 2; export function handleFailedReviewerExecution(args: { task: PairedTask; taskId: string; summary?: string | null; }): void { const { task, taskId, summary } = args; const now = new Date().toISOString(); if (isTerminalCodexAccountFailure(summary)) { transitionPairedTaskStatus({ taskId, currentStatus: task.status, nextStatus: 'completed', expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { arbiter_verdict: 'escalate', arbiter_requested_at: null, completion_reason: 'reviewer_codex_unavailable', }, }); logger.warn( { taskId, role: 'reviewer', status: task.status, summary: summary?.slice(0, 200), }, 'Completed reviewer task after terminal Codex account failure instead of preserving review loop', ); return; } if (summary) { const verdict = parseVisibleVerdict(summary); const signal = resolveReviewerFailureSignal({ visibleVerdict: verdict, }); if ( signal.kind === 'request_owner_finalize' || signal.kind === 'complete' || signal.kind === 'request_owner_changes' ) { const ownerWs = signal.kind === 'request_owner_finalize' ? getPairedWorkspace(taskId, 'owner') : null; const approvedSourceRef = signal.kind === 'request_owner_finalize' && ownerWs?.workspace_dir ? resolveCanonicalSourceRef(ownerWs.workspace_dir) : task.source_ref; transitionPairedTaskStatus({ taskId, currentStatus: task.status, nextStatus: signal.kind === 'request_owner_finalize' ? 'merge_ready' : signal.kind === 'request_owner_changes' ? 'active' : 'completed', expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { ...(signal.kind === 'request_owner_finalize' ? { source_ref: approvedSourceRef, owner_failure_count: 0, owner_step_done_streak: 0, finalize_step_done_count: 0, empty_step_done_streak: 0, arbiter_verdict: null, arbiter_requested_at: null, } : {}), ...(signal.kind === 'complete' ? { completion_reason: signal.completionReason } : {}), }, }); logger.info( { taskId, verdict, approvedSourceRef, summary: summary.slice(0, 100), }, 'Reviewer verdict detected from failed execution — stopping ping-pong', ); return; } } // Reviewer produced no usable verdict and no recognised infrastructure // failure. Track consecutive flakes — after the threshold, stop the // ping-pong loop instead of preserving review_ready forever. const nextFailureCount = (task.reviewer_failure_count ?? 0) + 1; if (nextFailureCount >= REVIEWER_FAILURE_ESCALATION_THRESHOLD) { requestArbiterOrEscalate({ taskId, currentStatus: task.status, expectedUpdatedAt: task.updated_at, now, arbiterLogMessage: 'Reviewer failed repeatedly without a visible verdict — requesting arbiter', escalateLogMessage: 'Reviewer failed repeatedly without a visible verdict — escalating to user', logContext: { taskId, role: 'reviewer', previousStatus: task.status, reviewerFailureCount: nextFailureCount, summary: summary?.slice(0, 160), }, patch: { reviewer_failure_count: nextFailureCount, }, }); return; } const fallbackStatus = task.status === 'in_review' || task.status === 'review_ready' ? 'review_ready' : task.status; if (fallbackStatus !== task.status) { transitionPairedTaskStatus({ taskId, currentStatus: task.status, nextStatus: fallbackStatus, expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { reviewer_failure_count: nextFailureCount, }, }); } else { applyPairedTaskPatch({ taskId, expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { reviewer_failure_count: nextFailureCount, }, }); } logger.warn( { taskId, role: 'reviewer', previousStatus: task.status, reviewerFailureCount: nextFailureCount, summary: summary?.slice(0, 160), }, 'Preserved reviewer task in review-ready state after failed execution', ); } export function handleReviewerCompletion(args: { task: PairedTask; taskId: string; summary?: string | null; }): void { const { task, taskId, summary } = args; const now = new Date().toISOString(); const verdict = parseVisibleVerdict(summary); const signal = resolveReviewerCompletionSignal({ visibleVerdict: verdict, roundTripCount: task.round_trip_count, deadlockThreshold: ARBITER_DEADLOCK_THRESHOLD, }); switch (signal.kind) { case 'request_owner_finalize': { const ownerWs = getPairedWorkspace(taskId, 'owner'); const approvedSourceRef = ownerWs?.workspace_dir ? resolveCanonicalSourceRef(ownerWs.workspace_dir) : task.source_ref; transitionPairedTaskStatus({ taskId, currentStatus: task.status, nextStatus: 'merge_ready', expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { source_ref: approvedSourceRef, owner_failure_count: 0, reviewer_failure_count: 0, owner_step_done_streak: 0, finalize_step_done_count: 0, empty_step_done_streak: 0, arbiter_verdict: null, arbiter_requested_at: null, }, }); logger.info( { taskId, verdict, approvedSourceRef, summary: summary?.slice(0, 100), }, 'Reviewer approved — owner gets final turn to finalize', ); return; } case 'request_arbiter': { const arbiterLogMessage = verdict === 'blocked' || verdict === 'needs_context' ? 'Reviewer blocked/needs_context — requesting arbiter before escalating' : 'Deadlock detected — requesting arbiter intervention'; const escalateLogMessage = verdict === 'blocked' || verdict === 'needs_context' ? 'Reviewer escalated to user — ping-pong stopped' : 'Stopped ping-pong — escalating to user (arbiter not configured)'; requestArbiterOrEscalate({ taskId, currentStatus: task.status, expectedUpdatedAt: task.updated_at, now, arbiterLogMessage, escalateLogMessage, logContext: { taskId, verdict, summary: summary?.slice(0, 100) }, arbiterInterventionCount: task.arbiter_intervention_count ?? 0, patch: { reviewer_failure_count: 0 }, }); return; } case 'request_owner_changes': transitionPairedTaskStatus({ taskId, currentStatus: task.status, nextStatus: 'active', expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { reviewer_failure_count: 0 }, }); logger.info( { taskId, verdict }, 'Reviewer has feedback, task set back to active for owner', ); return; default: return; } }