fix: prevent infinite loops in paired review (merge_ready oscillation, arbiter re-invocation)
- Owner finalize with concerns now checks deadlock threshold before looping back — prevents merge_ready ↔ active infinite oscillation - Arbiter verdict resets round_trip to threshold-1 instead of 0, giving agents one round before re-triggering arbiter
This commit is contained in:
@@ -668,9 +668,7 @@ function createSchema(database: Database): void {
|
||||
|
||||
// Migration: add completion_reason column to paired_tasks if it doesn't exist
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`,
|
||||
);
|
||||
database.exec(`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('paired execution context', () => {
|
||||
status: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
updated_at: '2026-03-28T00:00:00.000Z',
|
||||
});
|
||||
@@ -238,7 +238,7 @@ describe('paired execution context', () => {
|
||||
status: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
updated_at: '2026-03-28T00:00:00.000Z',
|
||||
});
|
||||
@@ -276,7 +276,7 @@ describe('paired execution context', () => {
|
||||
status: 'active',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
updated_at: '2026-03-28T00:00:00.000Z',
|
||||
});
|
||||
@@ -320,7 +320,7 @@ describe('paired execution context', () => {
|
||||
status: 'in_review',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
updated_at: '2026-03-28T00:00:00.000Z',
|
||||
});
|
||||
|
||||
@@ -426,6 +426,32 @@ export function completePairedExecutionContext(args: {
|
||||
ownerVerdict === 'blocked' ||
|
||||
ownerVerdict === 'needs_context'
|
||||
) {
|
||||
// Check deadlock threshold before looping back — prevents
|
||||
// merge_ready ↔ active infinite oscillation.
|
||||
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
|
||||
if (isArbiterEnabled()) {
|
||||
updatePairedTask(taskId, {
|
||||
status: 'arbiter_requested',
|
||||
arbiter_requested_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, ownerVerdict, roundTrips: task.round_trip_count },
|
||||
'Owner finalize loop detected — requesting arbiter',
|
||||
);
|
||||
} else {
|
||||
updatePairedTask(taskId, {
|
||||
status: 'completed',
|
||||
completion_reason: 'escalated',
|
||||
updated_at: now,
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, ownerVerdict, roundTrips: task.round_trip_count },
|
||||
'Owner finalize loop detected — escalating to user',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
updatePairedTask(taskId, { status: 'active', updated_at: now });
|
||||
logger.info(
|
||||
{
|
||||
@@ -444,7 +470,11 @@ export function completePairedExecutionContext(args: {
|
||||
|
||||
if (hasNewChanges === false) {
|
||||
// No code changes since reviewer approved → finalize complete
|
||||
updatePairedTask(taskId, { status: 'completed', completion_reason: 'done', updated_at: now });
|
||||
updatePairedTask(taskId, {
|
||||
status: 'completed',
|
||||
completion_reason: 'done',
|
||||
updated_at: now,
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, summary: args.summary?.slice(0, 100) },
|
||||
'Owner finalized after reviewer approval — task completed',
|
||||
@@ -575,7 +605,11 @@ export function completePairedExecutionContext(args: {
|
||||
'Reviewer blocked/needs_context — requesting arbiter before escalating',
|
||||
);
|
||||
} else {
|
||||
updatePairedTask(taskId, { status: 'completed', completion_reason: 'escalated', updated_at: now });
|
||||
updatePairedTask(taskId, {
|
||||
status: 'completed',
|
||||
completion_reason: 'escalated',
|
||||
updated_at: now,
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, verdict, summary: args.summary?.slice(0, 100) },
|
||||
'Reviewer escalated to user — ping-pong stopped',
|
||||
@@ -600,7 +634,11 @@ export function completePairedExecutionContext(args: {
|
||||
'Deadlock detected — requesting arbiter intervention',
|
||||
);
|
||||
} else {
|
||||
updatePairedTask(taskId, { status: 'completed', completion_reason: 'escalated', updated_at: now });
|
||||
updatePairedTask(taskId, {
|
||||
status: 'completed',
|
||||
completion_reason: 'escalated',
|
||||
updated_at: now,
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, verdict, roundTrips: task.round_trip_count },
|
||||
'Stopped ping-pong — escalating to user (arbiter not configured)',
|
||||
@@ -632,10 +670,12 @@ export function completePairedExecutionContext(args: {
|
||||
case 'proceed':
|
||||
case 'revise':
|
||||
case 'reset':
|
||||
// Non-escalate: reset counter, continue ping-pong
|
||||
// Non-escalate: resume ping-pong with reduced headroom.
|
||||
// Set to threshold-1 so agents get one more round before
|
||||
// re-triggering the arbiter — prevents infinite arbiter loops.
|
||||
updatePairedTask(taskId, {
|
||||
status: 'active',
|
||||
round_trip_count: 0,
|
||||
round_trip_count: Math.max(0, ARBITER_DEADLOCK_THRESHOLD - 1),
|
||||
arbiter_verdict: arbiterVerdict,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user