import { execFileSync } from 'child_process'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { ARBITER_DEADLOCK_THRESHOLD, DATA_DIR, PAIRED_MAX_ROUND_TRIPS, isArbiterEnabled, } from './config.js'; import { createPairedTask, getLatestPairedTaskForChat, getLatestOpenPairedTaskForChat, getPairedTaskById, getPairedWorkspace, hasActiveCiWatcherForChat, updatePairedTask, upsertPairedProject, } from './db.js'; import { logger } from './logger.js'; import { markPairedTaskReviewReady, prepareReviewerWorkspaceForExecution, provisionOwnerWorkspaceForPairedTask, } from './paired-workspace-manager.js'; import type { PairedRoomRole, PairedTask, PairedWorkspace, RegisteredGroup, RoomRoleContext, } from './types.js'; // --------------------------------------------------------------------------- // Reviewer verdict detection // --------------------------------------------------------------------------- type ReviewerVerdict = | 'done' | 'done_with_concerns' | 'blocked' | 'needs_context' | 'continue'; function classifyReviewerVerdict( summary: string | null | undefined, ): ReviewerVerdict { if (!summary) return 'continue'; // Strip ... tags — these are internal reasoning, not the verdict const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); if (!cleaned) return 'continue'; const firstLine = cleaned.split('\n')[0].trim(); // Match verdict markers at the start of the first visible line if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context'; if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) return 'done_with_concerns'; if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done'; if (/^\*{0,2}Approved\.?\*{0,2}/i.test(firstLine)) return 'done'; if (/^\*{0,2}LGTM\*{0,2}/i.test(firstLine)) return 'done'; return 'continue'; } // --------------------------------------------------------------------------- // Arbiter verdict detection // --------------------------------------------------------------------------- type ArbiterVerdictResult = | 'proceed' | 'revise' | 'reset' | 'escalate' | 'unknown'; function classifyArbiterVerdict( summary: string | null | undefined, ): ArbiterVerdictResult { if (!summary) return 'unknown'; const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); if (!cleaned) return 'unknown'; const firstLine = cleaned.split('\n')[0].trim(); // Match verdict keywords with optional prefix like "VERDICT:" and markdown bold const verdictMatch = firstLine.match( /\*{0,2}(?:VERDICT\s*[:—-]\s*)?(PROCEED|REVISE|RESET|ESCALATE)\*{0,2}/i, ); if (verdictMatch) { return verdictMatch[1].toLowerCase() as ArbiterVerdictResult; } return 'unknown'; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function resolveCanonicalSourceRef(workDir: string): string { const treeHash = resolveCanonicalTreeHash(workDir); return treeHash || 'HEAD'; } function resolveCanonicalTreeHash(workDir: string): string | null { try { const treeHash = execFileSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: workDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); return treeHash || null; } catch { return null; } } function hasCodeChangesSinceRef( workDir: string, sourceRef: string | null | undefined, ): boolean | null { if (!sourceRef) return null; try { execFileSync('git', ['diff', '--quiet', sourceRef, 'HEAD'], { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'], }); return false; } catch (error) { const exitCode = typeof error === 'object' && error !== null && 'status' in error && typeof (error as { status?: unknown }).status === 'number' ? (error as { status: number }).status : null; if (exitCode === 1) { return true; } return null; } } function ensurePairedProject( group: RegisteredGroup, chatJid: string, ): string | null { if (!group.workDir) { return null; } const now = new Date().toISOString(); upsertPairedProject({ chat_jid: chatJid, group_folder: group.folder, canonical_work_dir: group.workDir, created_at: now, updated_at: now, }); return group.workDir; } // --------------------------------------------------------------------------- // ensureActiveTask // --------------------------------------------------------------------------- function ensureActiveTask( group: RegisteredGroup, chatJid: string, roomRoleContext: RoomRoleContext, hasHumanMessage?: boolean, ): PairedTask | null { const canonicalWorkDir = ensurePairedProject(group, chatJid); if (!canonicalWorkDir) { return null; } const existing = getLatestOpenPairedTaskForChat(chatJid); if (existing) { return existing; } // Don't create a new task for bot-only messages — prevents // ESCALATE → completed → bot message triggers new task → loop. if (!hasHumanMessage) { return null; } const now = new Date().toISOString(); const task: PairedTask = { id: crypto.randomUUID(), chat_jid: chatJid, group_folder: group.folder, owner_service_id: roomRoleContext.ownerServiceId, reviewer_service_id: roomRoleContext.reviewerServiceId, title: null, source_ref: resolveCanonicalSourceRef(canonicalWorkDir), plan_notes: null, review_requested_at: null, round_trip_count: 0, status: 'active', arbiter_verdict: null, arbiter_requested_at: null, completion_reason: null, created_at: now, updated_at: now, }; createPairedTask(task); logger.info( { chatJid, groupFolder: group.folder, taskId: task.id, sourceRef: task.source_ref, }, 'Created active paired task for room', ); return task; } // --------------------------------------------------------------------------- // preparePairedExecutionContext // --------------------------------------------------------------------------- export interface PreparedPairedExecutionContext { task: PairedTask; workspace: PairedWorkspace | null; envOverrides: Record; gateTurnKind?: string | null; requiresVisibleVerdict?: boolean; blockMessage?: string; } export interface PairedExecutionRecoveryPlan { task: PairedTask; role: RoomRoleContext['role']; checkpointFingerprint: string | null; recoveryKey: string; prompt: string; } export function preparePairedExecutionContext(args: { group: RegisteredGroup; chatJid: string; runId: string; roomRoleContext?: RoomRoleContext; hasHumanMessage?: boolean; }): PreparedPairedExecutionContext | undefined { const { group, chatJid, roomRoleContext } = args; if (!roomRoleContext || !group.workDir) { return undefined; } const task = ensureActiveTask( group, chatJid, roomRoleContext, args.hasHumanMessage, ); if (!task) { return undefined; } const latestTask = getPairedTaskById(task.id) ?? task; let workspace: PairedWorkspace | null = null; let blockMessage: string | undefined; const now = new Date().toISOString(); if (roomRoleContext.role === 'owner') { // New human message → new ping-pong cycle. Reset round trip counter // AND status so the owner turn is not treated as a finalize turn. // Reset status on new human message so the owner gets a fresh working // turn. merge_ready is only reset when a human message is present — // without it, this is a finalize turn after reviewer approval and // resetting would prevent task completion. // Only reset round_trip_count when a human message is present — // bot-only ping-pong must accumulate the counter for loop detection. const hasHuman = args.hasHumanMessage === true; const needsStatusReset = (latestTask.status === 'merge_ready' && hasHuman) || latestTask.status === 'review_ready' || latestTask.status === 'in_review'; if (hasHuman || needsStatusReset) { updatePairedTask(latestTask.id, { ...(hasHuman ? { round_trip_count: 0 } : {}), ...(needsStatusReset ? { status: 'active' as const } : {}), updated_at: now, }); } // Use a stable per-channel worktree (not per-task) so the Claude SDK // session persists across tasks. Different channels still get isolation. workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id); // Update source_ref from workspace HEAD so change detection compares // against the correct repo. At task creation, source_ref is from the // canonical workDir which may differ from the workspace clone. if (workspace?.workspace_dir && latestTask.status === 'active') { const wsRef = resolveCanonicalSourceRef(workspace.workspace_dir); if (wsRef !== latestTask.source_ref) { updatePairedTask(latestTask.id, { source_ref: wsRef, updated_at: now, }); } } } else if (roomRoleContext.role === 'reviewer') { const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask); workspace = reviewerWorkspace.workspace; blockMessage = reviewerWorkspace.blockMessage; const refreshedTask = getPairedTaskById(latestTask.id) ?? latestTask; if (workspace && refreshedTask.status === 'review_ready') { updatePairedTask(latestTask.id, { status: 'in_review', updated_at: now, }); } } else if (roomRoleContext.role === 'arbiter') { // Arbiter uses same read-only workspace as reviewer const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask); workspace = reviewerWorkspace.workspace; blockMessage = reviewerWorkspace.blockMessage; const refreshedTask = getPairedTaskById(latestTask.id) ?? latestTask; if (workspace && refreshedTask.status === 'arbiter_requested') { updatePairedTask(latestTask.id, { status: 'in_arbitration', updated_at: now, }); } } const envOverrides: Record = { EJCLAW_PAIRED_TASK_ID: task.id, EJCLAW_PAIRED_ROLE: roomRoleContext.role, }; if (workspace?.workspace_dir) { envOverrides.EJCLAW_WORK_DIR = workspace.workspace_dir; } if (roomRoleContext.role === 'reviewer') { envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; // Use a separate Claude config dir so the reviewer's SDK session cache // doesn't collide with the owner's. Without this, the Claude SDK picks // up the owner's cached session from disk even when sessionId is undefined. const reviewerSessionDir = path.join( DATA_DIR, 'sessions', `${group.folder}-reviewer`, ); fs.mkdirSync(reviewerSessionDir, { recursive: true }); envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir; } else if (roomRoleContext.role === 'arbiter') { envOverrides.EJCLAW_ARBITER_RUNTIME = '1'; const arbiterSessionDir = path.join( DATA_DIR, 'sessions', `${group.folder}-arbiter`, ); // Clear arbiter session each invocation — each deadlock is a fresh // judgment call, previous verdicts should not bias the decision. fs.rmSync(arbiterSessionDir, { recursive: true, force: true }); fs.mkdirSync(arbiterSessionDir, { recursive: true }); envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir; } return { task: getPairedTaskById(task.id) ?? task, workspace, envOverrides, blockMessage, }; } // --------------------------------------------------------------------------- // completePairedExecutionContext // --------------------------------------------------------------------------- export function completePairedExecutionContext(args: { taskId: string; role: PairedRoomRole; status: 'succeeded' | 'failed'; summary?: string | null; }): void { const { taskId, role, status } = args; logger.info( { taskId, role, status, summary: args.summary?.slice(0, 200), }, 'Paired execution completed', ); const task = getPairedTaskById(taskId); if (!task) return; // On failure: for reviewers, still check verdict from summary — output may // have been delivered even though the executor classified it as failed // (e.g. intermediate buffer → null result). This prevents infinite loops. if (status !== 'succeeded') { if (role === 'reviewer' && args.summary) { const verdict = classifyReviewerVerdict(args.summary); if ( verdict === 'done' || verdict === 'blocked' || verdict === 'needs_context' ) { const now = new Date().toISOString(); const ownerWs = verdict === 'done' ? getPairedWorkspace(taskId, 'owner') : null; const approvedSourceRef = verdict === 'done' && ownerWs?.workspace_dir ? resolveCanonicalSourceRef(ownerWs.workspace_dir) : task.source_ref; updatePairedTask(taskId, { status: verdict === 'done' ? 'merge_ready' : 'completed', ...(verdict === 'done' ? { source_ref: approvedSourceRef } : {}), ...(verdict !== 'done' ? { completion_reason: 'escalated' } : {}), updated_at: now, }); logger.info( { taskId, verdict, approvedSourceRef, summary: args.summary?.slice(0, 100), }, 'Reviewer verdict detected from failed execution — stopping ping-pong', ); return; } } if (task.status !== 'active') { const now = new Date().toISOString(); updatePairedTask(taskId, { status: 'active', updated_at: now }); logger.info( { taskId, role, previousStatus: task.status }, 'Reset task to active after failed execution', ); } return; } // Owner finished if (role === 'owner') { const now = new Date().toISOString(); // merge_ready → reviewer already approved. Check owner verdict and // whether owner made additional changes that need re-review. if (task.status === 'merge_ready') { // Owner can raise concerns even during finalize (e.g. push failed, // detached HEAD, discovered issue). Respect the owner's verdict. const ownerVerdict = classifyReviewerVerdict(args.summary); if (ownerVerdict === 'blocked' || ownerVerdict === 'needs_context') { if (isArbiterEnabled()) { updatePairedTask(taskId, { status: 'arbiter_requested', arbiter_requested_at: now, updated_at: now, }); logger.info( { taskId, ownerVerdict, summary: args.summary?.slice(0, 100), }, 'Owner blocked during finalize — requesting arbiter', ); } else { updatePairedTask(taskId, { status: 'completed', completion_reason: 'escalated', updated_at: now, }); logger.info( { taskId, ownerVerdict, summary: args.summary?.slice(0, 100), }, 'Owner blocked during finalize — escalating to user', ); } return; } if (ownerVerdict === 'done_with_concerns') { // 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( { taskId, ownerVerdict, summary: args.summary?.slice(0, 100), }, 'Owner raised concerns during finalize — task set back to active', ); // Fall through to auto-trigger reviewer for the new concern } else { const workspace = getPairedWorkspace(task.id, 'owner'); const hasNewChanges = workspace?.workspace_dir ? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref) : null; if (hasNewChanges === true) { 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, roundTrips: task.round_trip_count, hasNewChanges }, 'Owner finalize DONE loop detected — requesting arbiter', ); } else { updatePairedTask(taskId, { status: 'completed', completion_reason: 'escalated', updated_at: now, }); logger.info( { taskId, roundTrips: task.round_trip_count, hasNewChanges }, 'Owner finalize DONE loop detected — escalating to user', ); } return; } // Owner made changes after approval → needs re-review logger.info( { taskId, sourceRef: task.source_ref, hasNewChanges, }, 'Owner made changes after reviewer approval — re-triggering review', ); } else { // No code changes (false) or unable to determine (null) → // finalize complete. Treating null as "no changes" prevents // infinite DONE↔DONE loops when source_ref is from a different // repo or the workspace has no matching ref. updatePairedTask(taskId, { status: 'completed', completion_reason: 'done', updated_at: now, }); logger.info( { taskId, hasNewChanges, summary: args.summary?.slice(0, 100) }, 'Owner finalized after reviewer approval — task completed', ); return; } } } // Active CI watcher → skip auto-review until watcher completes. // The watcher result will be posted via reviewer bot, triggering // the owner to act on it, which then resumes the review loop. if (hasActiveCiWatcherForChat(task.chat_jid)) { logger.info( { taskId, chatJid: task.chat_jid }, 'Active CI watcher found, deferring auto-review until watcher completes', ); return; } // Owner blocked/needs_context → request arbiter (same as reviewer path). // Without this, only reviewer verdicts can summon the arbiter, leaving // owner-side deadlocks (e.g. owner can't implement reviewer's request) // without a resolution mechanism. if (task.status !== 'merge_ready') { const normalOwnerVerdict = classifyReviewerVerdict(args.summary); if ( (normalOwnerVerdict === 'blocked' || normalOwnerVerdict === 'needs_context') && isArbiterEnabled() ) { updatePairedTask(taskId, { status: 'arbiter_requested', arbiter_requested_at: now, updated_at: now, }); logger.info( { taskId, ownerVerdict: normalOwnerVerdict, summary: args.summary?.slice(0, 100), }, 'Owner blocked/needs_context — requesting arbiter', ); return; } } // Normal turn → auto-trigger reviewer (if within round trip limit) if (task.round_trip_count >= PAIRED_MAX_ROUND_TRIPS) { logger.info( { taskId, roundTrips: task.round_trip_count, max: PAIRED_MAX_ROUND_TRIPS, }, 'Round trip limit reached, skipping auto-review', ); return; } const result = markPairedTaskReviewReady(taskId); if (result) { updatePairedTask(taskId, { round_trip_count: task.round_trip_count + 1, review_requested_at: now, updated_at: now, }); logger.info( { taskId, roundTrip: task.round_trip_count + 1 }, 'Auto-triggered reviewer after owner completion', ); } } // Reviewer finished → classify verdict and route accordingly if (role === 'reviewer') { const now = new Date().toISOString(); const verdict = classifyReviewerVerdict(args.summary); switch (verdict) { case 'done': { // Approved → owner gets final turn to commit/push. // Record the current HEAD as source_ref so the finalize turn // can detect if the owner made additional code changes. const ownerWs = getPairedWorkspace(taskId, 'owner'); const approvedSourceRef = ownerWs?.workspace_dir ? resolveCanonicalSourceRef(ownerWs.workspace_dir) : task.source_ref; updatePairedTask(taskId, { status: 'merge_ready', source_ref: approvedSourceRef, updated_at: now, }); logger.info( { taskId, verdict, approvedSourceRef, summary: args.summary?.slice(0, 100), }, 'Reviewer approved — owner gets final turn to finalize', ); break; } case 'blocked': case 'needs_context': // If arbiter is enabled, let arbiter judge before escalating to user. // Arbiter may resolve the block (e.g. owner can fix it, info exists in context). if (isArbiterEnabled()) { updatePairedTask(taskId, { status: 'arbiter_requested', arbiter_requested_at: now, updated_at: now, }); logger.info( { taskId, verdict, summary: args.summary?.slice(0, 100) }, 'Reviewer blocked/needs_context — requesting arbiter before escalating', ); } else { 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', ); } break; case 'done_with_concerns': case 'continue': default: // If both sides keep echoing DONE_WITH_CONCERNS without progress, // request arbiter intervention (or escalate to user if arbiter not configured). 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, verdict, roundTrips: task.round_trip_count }, 'Deadlock detected — requesting arbiter intervention', ); } else { 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)', ); } break; } // Owner needs to address feedback — ping-pong continues updatePairedTask(taskId, { status: 'active', updated_at: now }); logger.info( { taskId, verdict }, 'Reviewer has feedback, task set back to active for owner', ); break; } } // Arbiter finished → classify verdict and route accordingly if (role === 'arbiter') { const now = new Date().toISOString(); const arbiterVerdict = classifyArbiterVerdict(args.summary); logger.info( { taskId, arbiterVerdict, summary: args.summary?.slice(0, 200) }, 'Arbiter verdict rendered', ); switch (arbiterVerdict) { case 'proceed': case 'revise': case 'reset': // 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: Math.max(0, ARBITER_DEADLOCK_THRESHOLD - 1), arbiter_verdict: arbiterVerdict, updated_at: now, }); logger.info( { taskId, arbiterVerdict }, 'Arbiter resolved deadlock — resuming ping-pong', ); break; case 'escalate': updatePairedTask(taskId, { status: 'completed', arbiter_verdict: 'escalate', completion_reason: 'arbiter_escalated', updated_at: now, }); logger.info({ taskId }, 'Arbiter escalated to user — task completed'); break; default: // Unknown verdict — fallback to proceed so the loop continues. // Stopping the task on a parse failure is worse than letting it run. updatePairedTask(taskId, { status: 'active', round_trip_count: Math.max(0, ARBITER_DEADLOCK_THRESHOLD - 1), arbiter_verdict: 'unknown', updated_at: now, }); logger.warn( { taskId, summary: args.summary?.slice(0, 200) }, 'Arbiter verdict unrecognized — falling back to proceed', ); break; } } }