feat: implement MAGI 3-agent arbiter system for deadlock resolution
Introduces a third agent role (arbiter) that is summoned on-demand when owner and reviewer reach a deadlock (same verdict 3+ rounds). Architecture: - 3 Discord bots: owner (codex), reviewer (claude), arbiter (claude/codex) - Arbiter is NOT always-on — only invoked when deadlock detected - Arbiter renders binding verdict: PROCEED/REVISE/RESET/ESCALATE - Non-escalate verdicts reset round_trip_count and resume ping-pong - Backward compatible: ARBITER_AGENT_TYPE unset = existing 2-agent mode Changes across 13 source files + 7 test files: - types.ts: PairedRoomRole += 'arbiter', new statuses, ArbiterVerdict type - config.ts: ARBITER_AGENT_TYPE, ARBITER_SERVICE_ID, ARBITER_DEADLOCK_THRESHOLD - db.ts: schema migration (arbiter columns in channel_owner + paired_tasks) - service-routing.ts: arbiter_service_id in lease - room-role-context.ts: arbiter role detection - paired-execution-context.ts: deadlock->arbiter, verdict handling - message-runtime.ts: arbiter turn routing, cursor, sender labeling - message-agent-executor.ts: arbiter mode, failover - agent-runner.ts + environment.ts: arbiter container mode - platform-prompts.ts: arbiter prompt loading New files: - prompts/arbiter-paired-room.md: arbiter system prompt - src/arbiter-context.ts: builds conversation context for arbiter judgment
This commit is contained in:
@@ -3,7 +3,12 @@ import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, PAIRED_MAX_ROUND_TRIPS } from './config.js';
|
||||
import {
|
||||
ARBITER_DEADLOCK_THRESHOLD,
|
||||
DATA_DIR,
|
||||
PAIRED_MAX_ROUND_TRIPS,
|
||||
isArbiterEnabled,
|
||||
} from './config.js';
|
||||
import {
|
||||
createPairedTask,
|
||||
getLatestPairedTaskForChat,
|
||||
@@ -21,6 +26,7 @@ import {
|
||||
provisionOwnerWorkspaceForPairedTask,
|
||||
} from './paired-workspace-manager.js';
|
||||
import type {
|
||||
PairedRoomRole,
|
||||
PairedTask,
|
||||
PairedWorkspace,
|
||||
RegisteredGroup,
|
||||
@@ -57,6 +63,24 @@ function classifyReviewerVerdict(
|
||||
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(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
if (!cleaned) return 'unknown';
|
||||
const firstLine = cleaned.split('\n')[0].trim();
|
||||
if (/^\*{0,2}PROCEED\*{0,2}\b/i.test(firstLine)) return 'proceed';
|
||||
if (/^\*{0,2}REVISE\*{0,2}\b/i.test(firstLine)) return 'revise';
|
||||
if (/^\*{0,2}RESET\*{0,2}\b/i.test(firstLine)) return 'reset';
|
||||
if (/^\*{0,2}ESCALATE\*{0,2}\b/i.test(firstLine)) return 'escalate';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -156,6 +180,8 @@ function ensureActiveTask(
|
||||
review_requested_at: null,
|
||||
round_trip_count: 0,
|
||||
status: 'active',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -239,7 +265,7 @@ export function preparePairedExecutionContext(args: {
|
||||
// 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);
|
||||
} else {
|
||||
} else if (roomRoleContext.role === 'reviewer') {
|
||||
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
|
||||
workspace = reviewerWorkspace.workspace;
|
||||
blockMessage = reviewerWorkspace.blockMessage;
|
||||
@@ -250,6 +276,18 @@ export function preparePairedExecutionContext(args: {
|
||||
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<string, string> = {
|
||||
@@ -272,6 +310,15 @@ export function preparePairedExecutionContext(args: {
|
||||
);
|
||||
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`,
|
||||
);
|
||||
fs.mkdirSync(arbiterSessionDir, { recursive: true });
|
||||
envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -288,7 +335,7 @@ export function preparePairedExecutionContext(args: {
|
||||
|
||||
export function completePairedExecutionContext(args: {
|
||||
taskId: string;
|
||||
role: 'owner' | 'reviewer';
|
||||
role: PairedRoomRole;
|
||||
status: 'succeeded' | 'failed';
|
||||
summary?: string | null;
|
||||
}): void {
|
||||
@@ -487,13 +534,25 @@ export function completePairedExecutionContext(args: {
|
||||
case 'continue':
|
||||
default:
|
||||
// If both sides keep echoing DONE_WITH_CONCERNS without progress,
|
||||
// stop the loop after 3 round trips to prevent infinite ping-pong.
|
||||
if (task.round_trip_count >= 3) {
|
||||
updatePairedTask(taskId, { status: 'completed', updated_at: now });
|
||||
logger.info(
|
||||
{ taskId, verdict, roundTrips: task.round_trip_count },
|
||||
'Stopped ping-pong after repeated DONE_WITH_CONCERNS — escalating to user',
|
||||
);
|
||||
// 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', 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
|
||||
@@ -505,4 +564,52 @@ export function completePairedExecutionContext(args: {
|
||||
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: reset counter, continue ping-pong
|
||||
updatePairedTask(taskId, {
|
||||
status: 'active',
|
||||
round_trip_count: 0,
|
||||
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',
|
||||
updated_at: now,
|
||||
});
|
||||
logger.info(
|
||||
{ taskId },
|
||||
'Arbiter escalated to user — task completed',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Unknown verdict — treat as escalate
|
||||
updatePairedTask(taskId, {
|
||||
status: 'completed',
|
||||
arbiter_verdict: 'unknown',
|
||||
updated_at: now,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user