The owner↔reviewer↔arbiter loop could repeat forever: when the arbiter ruled PROCEED/REVISE/RESET it reset round_trip_count to 0, and nothing tracked how many times the arbiter had already intervened. A re-deadlock re-invoked the arbiter without bound. Add a persistent arbiter_intervention_count (new column + migration 020) that survives the round-trip reset, and a configurable cap ARBITER_MAX_INTERVENTIONS (default 1). Once the arbiter has intervened that many times and the loop still deadlocks, requestArbiterOrEscalate escalates straight to the user instead of re-invoking the arbiter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
182 lines
5.4 KiB
TypeScript
182 lines
5.4 KiB
TypeScript
import { logger } from './logger.js';
|
|
import { isTerminalCodexAccountFailure } from './agent-error-detection.js';
|
|
import { transitionPairedTaskStatus } from './paired-task-status.js';
|
|
import { classifyArbiterVerdict } from './paired-verdict.js';
|
|
import type { PairedTask } from './types.js';
|
|
|
|
const ARBITER_RESOLUTION_ROUND_TRIP_COUNT = 0;
|
|
|
|
export function handleFailedArbiterExecution(args: {
|
|
task: PairedTask;
|
|
taskId: string;
|
|
summary?: string | null;
|
|
}): void {
|
|
const { task, taskId, summary } = args;
|
|
if (isTerminalCodexAccountFailure(summary)) {
|
|
const now = new Date().toISOString();
|
|
const nextOwnerFailureCount = Math.max(
|
|
(task.owner_failure_count ?? 0) + 1,
|
|
1,
|
|
);
|
|
transitionPairedTaskStatus({
|
|
taskId,
|
|
currentStatus: task.status,
|
|
nextStatus: 'active',
|
|
expectedUpdatedAt: task.updated_at,
|
|
updatedAt: now,
|
|
patch: {
|
|
owner_failure_count: nextOwnerFailureCount,
|
|
owner_step_done_streak: 0,
|
|
finalize_step_done_count: 0,
|
|
empty_step_done_streak: 0,
|
|
arbiter_verdict: null,
|
|
arbiter_requested_at: null,
|
|
},
|
|
});
|
|
logger.warn(
|
|
{
|
|
taskId,
|
|
role: 'arbiter',
|
|
status: task.status,
|
|
nextOwnerFailureCount,
|
|
summary: summary?.slice(0, 200),
|
|
},
|
|
'Returned arbiter task to owner after terminal Codex account failure',
|
|
);
|
|
return;
|
|
}
|
|
const now = new Date().toISOString();
|
|
const fallbackStatus =
|
|
task.status === 'in_arbitration' || task.status === 'arbiter_requested'
|
|
? 'arbiter_requested'
|
|
: task.status;
|
|
if (fallbackStatus !== task.status) {
|
|
transitionPairedTaskStatus({
|
|
taskId,
|
|
currentStatus: task.status,
|
|
nextStatus: fallbackStatus,
|
|
expectedUpdatedAt: task.updated_at,
|
|
updatedAt: now,
|
|
});
|
|
logger.warn(
|
|
{
|
|
taskId,
|
|
role: 'arbiter',
|
|
previousStatus: task.status,
|
|
nextStatus: fallbackStatus,
|
|
},
|
|
'Preserved arbiter task in arbitration-requested state after failed execution',
|
|
);
|
|
}
|
|
}
|
|
|
|
export function handleArbiterCompletion(args: {
|
|
task: PairedTask;
|
|
taskId: string;
|
|
summary?: string | null;
|
|
}): void {
|
|
const { task, taskId, summary } = args;
|
|
const now = new Date().toISOString();
|
|
const arbiterVerdict = classifyArbiterVerdict(summary);
|
|
// Persist a running count of arbiter interventions so the loop can be bounded.
|
|
// This must NOT be reset by the round_trip_count reset below — otherwise the
|
|
// owner↔reviewer loop could re-trigger the arbiter without limit.
|
|
const nextArbiterInterventionCount =
|
|
(task.arbiter_intervention_count ?? 0) + 1;
|
|
|
|
logger.info(
|
|
{
|
|
taskId,
|
|
arbiterVerdict,
|
|
arbiterInterventionCount: nextArbiterInterventionCount,
|
|
summary: summary?.slice(0, 200),
|
|
},
|
|
'Arbiter verdict rendered',
|
|
);
|
|
|
|
switch (arbiterVerdict) {
|
|
case 'proceed':
|
|
transitionPairedTaskStatus({
|
|
taskId,
|
|
currentStatus: 'in_arbitration',
|
|
nextStatus: 'review_ready',
|
|
expectedUpdatedAt: task.updated_at,
|
|
updatedAt: now,
|
|
patch: {
|
|
round_trip_count: ARBITER_RESOLUTION_ROUND_TRIP_COUNT,
|
|
owner_failure_count: 0,
|
|
owner_step_done_streak: 0,
|
|
finalize_step_done_count: 0,
|
|
empty_step_done_streak: 0,
|
|
arbiter_intervention_count: nextArbiterInterventionCount,
|
|
arbiter_verdict: arbiterVerdict,
|
|
arbiter_requested_at: null,
|
|
},
|
|
});
|
|
logger.info(
|
|
{ taskId, arbiterVerdict },
|
|
'Arbiter proceeded — returning task to reviewer for approval',
|
|
);
|
|
return;
|
|
case 'revise':
|
|
case 'reset':
|
|
transitionPairedTaskStatus({
|
|
taskId,
|
|
currentStatus: 'in_arbitration',
|
|
nextStatus: 'active',
|
|
expectedUpdatedAt: task.updated_at,
|
|
updatedAt: now,
|
|
patch: {
|
|
round_trip_count: ARBITER_RESOLUTION_ROUND_TRIP_COUNT,
|
|
owner_failure_count: 0,
|
|
owner_step_done_streak: 0,
|
|
finalize_step_done_count: 0,
|
|
empty_step_done_streak: 0,
|
|
arbiter_intervention_count: nextArbiterInterventionCount,
|
|
arbiter_verdict: arbiterVerdict,
|
|
arbiter_requested_at: null,
|
|
},
|
|
});
|
|
logger.info(
|
|
{
|
|
taskId,
|
|
arbiterVerdict,
|
|
arbiterInterventionCount: nextArbiterInterventionCount,
|
|
},
|
|
'Arbiter requested owner changes — resuming owner flow',
|
|
);
|
|
return;
|
|
case 'escalate':
|
|
transitionPairedTaskStatus({
|
|
taskId,
|
|
currentStatus: 'in_arbitration',
|
|
nextStatus: 'completed',
|
|
expectedUpdatedAt: task.updated_at,
|
|
updatedAt: now,
|
|
patch: {
|
|
arbiter_verdict: 'escalate',
|
|
completion_reason: 'arbiter_escalated',
|
|
},
|
|
});
|
|
logger.info({ taskId }, 'Arbiter escalated to user — task completed');
|
|
return;
|
|
default:
|
|
transitionPairedTaskStatus({
|
|
taskId,
|
|
currentStatus: 'in_arbitration',
|
|
nextStatus: 'completed',
|
|
expectedUpdatedAt: task.updated_at,
|
|
updatedAt: now,
|
|
patch: {
|
|
arbiter_verdict: 'unknown',
|
|
completion_reason: 'arbiter_escalated',
|
|
},
|
|
});
|
|
logger.warn(
|
|
{ taskId, summary: summary?.slice(0, 200) },
|
|
'Arbiter verdict unrecognized — escalating instead of treating it as approval',
|
|
);
|
|
return;
|
|
}
|
|
}
|