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:
Eyejoker
2026-03-30 22:20:42 +09:00
parent 58ed721d6e
commit a043f2b79a
20 changed files with 587 additions and 53 deletions

View File

@@ -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;
}
}
}