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>
147 lines
3.8 KiB
TypeScript
147 lines
3.8 KiB
TypeScript
import { updatePairedTaskIfUnchanged } from './db.js';
|
|
import { logger } from './logger.js';
|
|
import type { PairedTaskStatus } from './types.js';
|
|
|
|
export const ALLOWED_PAIRED_STATUS_TRANSITIONS: Record<
|
|
PairedTaskStatus,
|
|
ReadonlySet<PairedTaskStatus>
|
|
> = {
|
|
active: new Set(['review_ready', 'arbiter_requested', 'completed']),
|
|
review_ready: new Set([
|
|
'active',
|
|
'in_review',
|
|
'arbiter_requested',
|
|
'completed',
|
|
]),
|
|
in_review: new Set([
|
|
'active',
|
|
'review_ready',
|
|
'merge_ready',
|
|
'arbiter_requested',
|
|
'completed',
|
|
]),
|
|
merge_ready: new Set(['active', 'arbiter_requested', 'completed']),
|
|
completed: new Set(),
|
|
arbiter_requested: new Set(['in_arbitration', 'completed']),
|
|
in_arbitration: new Set([
|
|
'active',
|
|
'review_ready',
|
|
'arbiter_requested',
|
|
'completed',
|
|
]),
|
|
};
|
|
|
|
export function assertPairedTaskStatusTransition(args: {
|
|
currentStatus: PairedTaskStatus;
|
|
nextStatus: PairedTaskStatus;
|
|
}): void {
|
|
const { currentStatus, nextStatus } = args;
|
|
if (currentStatus === nextStatus) {
|
|
return;
|
|
}
|
|
|
|
if (ALLOWED_PAIRED_STATUS_TRANSITIONS[currentStatus].has(nextStatus)) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
`Invalid paired task status transition: ${currentStatus} -> ${nextStatus}`,
|
|
);
|
|
}
|
|
|
|
export function transitionPairedTaskStatus(args: {
|
|
taskId: string;
|
|
currentStatus: PairedTaskStatus;
|
|
nextStatus: PairedTaskStatus;
|
|
expectedUpdatedAt: string;
|
|
updatedAt: string;
|
|
patch?: {
|
|
title?: string | null;
|
|
source_ref?: string | null;
|
|
plan_notes?: string | null;
|
|
review_requested_at?: string | null;
|
|
round_trip_count?: number;
|
|
owner_failure_count?: number;
|
|
owner_step_done_streak?: number;
|
|
finalize_step_done_count?: number;
|
|
task_done_then_user_reopen_count?: number;
|
|
empty_step_done_streak?: number;
|
|
arbiter_intervention_count?: number;
|
|
arbiter_verdict?: string | null;
|
|
arbiter_requested_at?: string | null;
|
|
completion_reason?: string | null;
|
|
};
|
|
}): boolean {
|
|
assertPairedTaskStatusTransition({
|
|
currentStatus: args.currentStatus,
|
|
nextStatus: args.nextStatus,
|
|
});
|
|
|
|
const updated = updatePairedTaskIfUnchanged(
|
|
args.taskId,
|
|
args.expectedUpdatedAt,
|
|
{
|
|
...args.patch,
|
|
status: args.nextStatus,
|
|
updated_at: args.updatedAt,
|
|
},
|
|
);
|
|
if (!updated) {
|
|
logger.warn(
|
|
{
|
|
taskId: args.taskId,
|
|
currentStatus: args.currentStatus,
|
|
nextStatus: args.nextStatus,
|
|
expectedUpdatedAt: args.expectedUpdatedAt,
|
|
},
|
|
'Skipped stale paired task status transition because the task revision changed',
|
|
);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export function applyPairedTaskPatch(args: {
|
|
taskId: string;
|
|
expectedUpdatedAt: string;
|
|
updatedAt: string;
|
|
patch: {
|
|
title?: string | null;
|
|
source_ref?: string | null;
|
|
plan_notes?: string | null;
|
|
review_requested_at?: string | null;
|
|
round_trip_count?: number;
|
|
owner_failure_count?: number;
|
|
owner_step_done_streak?: number;
|
|
finalize_step_done_count?: number;
|
|
task_done_then_user_reopen_count?: number;
|
|
empty_step_done_streak?: number;
|
|
arbiter_intervention_count?: number;
|
|
status?: PairedTaskStatus;
|
|
arbiter_verdict?: string | null;
|
|
arbiter_requested_at?: string | null;
|
|
completion_reason?: string | null;
|
|
};
|
|
}): boolean {
|
|
const updated = updatePairedTaskIfUnchanged(
|
|
args.taskId,
|
|
args.expectedUpdatedAt,
|
|
{
|
|
...args.patch,
|
|
updated_at: args.updatedAt,
|
|
},
|
|
);
|
|
if (!updated) {
|
|
logger.warn(
|
|
{
|
|
taskId: args.taskId,
|
|
expectedUpdatedAt: args.expectedUpdatedAt,
|
|
},
|
|
'Skipped stale paired task patch because the task revision changed',
|
|
);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export type TransitionPairedTaskStatusFn = typeof transitionPairedTaskStatus;
|
|
export type ApplyPairedTaskPatchFn = typeof applyPairedTaskPatch;
|