diff --git a/src/db.ts b/src/db.ts index 55e1ffe..4647fa7 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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 */ } diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index d1e07fa..69a0383 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -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', }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index cd4af75..67a3046 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -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, });