fix(paired): cap consecutive reviewer silent-failure retries

This commit is contained in:
claude-bot
2026-06-01 17:38:35 +09:00
parent 7e16fce17e
commit ccc747ae1c
11 changed files with 116 additions and 11 deletions

View File

@@ -2,7 +2,10 @@ import { ARBITER_DEADLOCK_THRESHOLD } from './config.js';
import { getPairedWorkspace } from './db.js';
import { logger } from './logger.js';
import { requestArbiterOrEscalate } from './paired-arbiter-request.js';
import { transitionPairedTaskStatus } from './paired-task-status.js';
import {
applyPairedTaskPatch,
transitionPairedTaskStatus,
} from './paired-task-status.js';
import {
resolveReviewerCompletionSignal,
resolveReviewerFailureSignal,
@@ -11,6 +14,17 @@ 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;
@@ -75,6 +89,35 @@ export function handleFailedReviewerExecution(args: {
}
}
// 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'
@@ -86,17 +129,30 @@ export function handleFailedReviewerExecution(args: {
nextStatus: fallbackStatus,
expectedUpdatedAt: task.updated_at,
updatedAt: now,
});
logger.warn(
{
taskId,
role: 'reviewer',
previousStatus: task.status,
nextStatus: fallbackStatus,
patch: {
reviewer_failure_count: nextFailureCount,
},
'Preserved reviewer task in review-ready state after failed execution',
);
});
} 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: {
@@ -128,6 +184,7 @@ export function handleReviewerCompletion(args: {
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,
@@ -164,6 +221,7 @@ export function handleReviewerCompletion(args: {
arbiterLogMessage,
escalateLogMessage,
logContext: { taskId, verdict, summary: summary?.slice(0, 100) },
patch: { reviewer_failure_count: 0 },
});
return;
}
@@ -175,6 +233,7 @@ export function handleReviewerCompletion(args: {
nextStatus: 'active',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: { reviewer_failure_count: 0 },
});
logger.info(
{ taskId, verdict },