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
|
// Migration: add completion_reason column to paired_tasks if it doesn't exist
|
||||||
try {
|
try {
|
||||||
database.exec(
|
database.exec(`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`);
|
||||||
`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`,
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* column already exists */
|
/* column already exists */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,6 +426,32 @@ export function completePairedExecutionContext(args: {
|
|||||||
ownerVerdict === 'blocked' ||
|
ownerVerdict === 'blocked' ||
|
||||||
ownerVerdict === 'needs_context'
|
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 });
|
updatePairedTask(taskId, { status: 'active', updated_at: now });
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -444,7 +470,11 @@ export function completePairedExecutionContext(args: {
|
|||||||
|
|
||||||
if (hasNewChanges === false) {
|
if (hasNewChanges === false) {
|
||||||
// No code changes since reviewer approved → finalize complete
|
// 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(
|
logger.info(
|
||||||
{ taskId, summary: args.summary?.slice(0, 100) },
|
{ taskId, summary: args.summary?.slice(0, 100) },
|
||||||
'Owner finalized after reviewer approval — task completed',
|
'Owner finalized after reviewer approval — task completed',
|
||||||
@@ -575,7 +605,11 @@ export function completePairedExecutionContext(args: {
|
|||||||
'Reviewer blocked/needs_context — requesting arbiter before escalating',
|
'Reviewer blocked/needs_context — requesting arbiter before escalating',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updatePairedTask(taskId, { status: 'completed', completion_reason: 'escalated', updated_at: now });
|
updatePairedTask(taskId, {
|
||||||
|
status: 'completed',
|
||||||
|
completion_reason: 'escalated',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId, verdict, summary: args.summary?.slice(0, 100) },
|
{ taskId, verdict, summary: args.summary?.slice(0, 100) },
|
||||||
'Reviewer escalated to user — ping-pong stopped',
|
'Reviewer escalated to user — ping-pong stopped',
|
||||||
@@ -600,7 +634,11 @@ export function completePairedExecutionContext(args: {
|
|||||||
'Deadlock detected — requesting arbiter intervention',
|
'Deadlock detected — requesting arbiter intervention',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updatePairedTask(taskId, { status: 'completed', completion_reason: 'escalated', updated_at: now });
|
updatePairedTask(taskId, {
|
||||||
|
status: 'completed',
|
||||||
|
completion_reason: 'escalated',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId, verdict, roundTrips: task.round_trip_count },
|
{ taskId, verdict, roundTrips: task.round_trip_count },
|
||||||
'Stopped ping-pong — escalating to user (arbiter not configured)',
|
'Stopped ping-pong — escalating to user (arbiter not configured)',
|
||||||
@@ -632,10 +670,12 @@ export function completePairedExecutionContext(args: {
|
|||||||
case 'proceed':
|
case 'proceed':
|
||||||
case 'revise':
|
case 'revise':
|
||||||
case 'reset':
|
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, {
|
updatePairedTask(taskId, {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
round_trip_count: 0,
|
round_trip_count: Math.max(0, ARBITER_DEADLOCK_THRESHOLD - 1),
|
||||||
arbiter_verdict: arbiterVerdict,
|
arbiter_verdict: arbiterVerdict,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user