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

@@ -0,0 +1,28 @@
# Arbiter Paired Room Rules
You are the **arbiter** in a MAGI system with three agents: owner (implementer), reviewer (verifier), and you (judge).
You have been summoned because the owner and reviewer reached a deadlock after multiple rounds without progress.
## Your Role
- Read the conversation history between owner and reviewer
- Understand what each side is arguing
- Render a binding verdict based on evidence
## Verdict Format
**Start your first line** with one of these four verdicts. This is required.
- **PROCEED** — The owner's approach is correct. The reviewer should approve. Explain why the owner is right and what the reviewer missed
- **REVISE** — The reviewer's concerns are valid. Tell the owner exactly what to fix. Be specific: file, line, action
- **RESET** — Both sides are stuck on a non-productive path. Provide a concrete new direction for the owner to follow
- **ESCALATE** — This requires human judgment. Explain what decision only a human can make
## Rules
- Base your verdict on evidence (code, test output, logs), not on who said what first
- Your verdict is final for this deadlock cycle — after it, work resumes normally
- You do NOT implement or review code — you only judge the disagreement
- Keep your verdict concise — state the decision, the evidence, and the required action
- If both sides are saying the same thing but not acting on it, call it out and direct the owner to act

View File

@@ -44,6 +44,7 @@ vi.mock('./service-routing.js', () => ({
chat_jid: 'dc:test', chat_jid: 'dc:test',
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'codex-main', reviewer_service_id: 'codex-main',
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -206,6 +207,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
owner_service_id: 'codex-review', owner_service_id: 'codex-review',
reviewer_service_id: 'codex-main', reviewer_service_id: 'codex-main',
arbiter_service_id: null,
activated_at: '2026-03-28T00:00:00.000Z', activated_at: '2026-03-28T00:00:00.000Z',
reason: 'claude-429', reason: 'claude-429',
explicit: true, explicit: true,
@@ -310,6 +312,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'codex-main', reviewer_service_id: 'codex-main',
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,

View File

@@ -23,6 +23,7 @@ import {
resolveServiceTaskSessionsPath, resolveServiceTaskSessionsPath,
} from './group-folder.js'; } from './group-folder.js';
import { import {
readArbiterPrompt,
readPairedRoomPrompt, readPairedRoomPrompt,
readPlatformPrompt, readPlatformPrompt,
} from './platform-prompts.js'; } from './platform-prompts.js';
@@ -527,8 +528,9 @@ export function prepareContainerSessionEnvironment(args: {
chatJid: string; chatJid: string;
isMain: boolean; isMain: boolean;
memoryBriefing?: string; memoryBriefing?: string;
role?: 'reviewer' | 'arbiter';
}): void { }): void {
const { sessionDir, chatJid, isMain, memoryBriefing } = args; const { sessionDir, chatJid, isMain, memoryBriefing, role = 'reviewer' } = args;
const projectRoot = process.cwd(); const projectRoot = process.cwd();
fs.mkdirSync(sessionDir, { recursive: true }); fs.mkdirSync(sessionDir, { recursive: true });
@@ -541,10 +543,12 @@ export function prepareContainerSessionEnvironment(args: {
]; ];
syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills')); syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills'));
// Build CLAUDE.md with reviewer-appropriate prompts // Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter)
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot); const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
const claudePairedRoomPrompt = isPairedRoomJid(chatJid) const claudePairedRoomPrompt = isPairedRoomJid(chatJid)
? readPairedRoomPrompt('claude-code', projectRoot) ? (role === 'arbiter'
? readArbiterPrompt(projectRoot)
: readPairedRoomPrompt('claude-code', projectRoot))
: undefined; : undefined;
const globalDir = path.join(GROUPS_DIR, 'global'); const globalDir = path.join(GROUPS_DIR, 'global');
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md'); const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');

View File

@@ -75,7 +75,7 @@ export async function runAgentProcess(
// ── Reviewer container mode ───────────────────────────────────── // ── Reviewer container mode ─────────────────────────────────────
// Reviewers always run inside a Docker container with read-only source // Reviewers always run inside a Docker container with read-only source
// mount for kernel-level write protection. Docker is required. // mount for kernel-level write protection. Docker is required.
if (envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1') { if (envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1' || envOverrides?.EJCLAW_ARBITER_RUNTIME === '1') {
const ownerWorkspaceDir = const ownerWorkspaceDir =
envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd(); envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd();
@@ -83,11 +83,13 @@ export async function runAgentProcess(
// so the Claude SDK inside the container has platform & paired room prompts. // so the Claude SDK inside the container has platform & paired room prompts.
const sessionDir = envOverrides?.CLAUDE_CONFIG_DIR; const sessionDir = envOverrides?.CLAUDE_CONFIG_DIR;
if (sessionDir) { if (sessionDir) {
const containerRole = envOverrides?.EJCLAW_ARBITER_RUNTIME === '1' ? 'arbiter' as const : 'reviewer' as const;
prepareContainerSessionEnvironment({ prepareContainerSessionEnvironment({
sessionDir, sessionDir,
chatJid: input.chatJid, chatJid: input.chatJid,
isMain: input.isMain, isMain: input.isMain,
memoryBriefing: input.memoryBriefing, memoryBriefing: input.memoryBriefing,
role: containerRole,
}); });
} }

29
src/arbiter-context.ts Normal file
View File

@@ -0,0 +1,29 @@
import { getRecentChatMessages } from './db.js';
import { formatMessages } from './router.js';
export function buildArbiterContextPrompt(args: {
chatJid: string;
taskId: string;
roundTripCount: number;
timezone: string;
recentTurnLimit?: number;
}): string {
const { chatJid, taskId, roundTripCount, timezone, recentTurnLimit = 20 } = args;
const recentMessages = getRecentChatMessages(chatJid, recentTurnLimit);
const conversationContext = formatMessages(recentMessages, timezone);
return [
`<arbiter-context>`,
`<task-id>${taskId}</task-id>`,
`<round-trips>${roundTripCount}</round-trips>`,
`<reason>Deadlock detected: owner and reviewer exchanged ${roundTripCount} rounds without resolution</reason>`,
`</arbiter-context>`,
``,
`<conversation-history>`,
conversationContext,
`</conversation-history>`,
``,
`Review the conversation above and render your verdict.`,
].join('\n');
}

View File

@@ -110,6 +110,28 @@ export const REVIEWER_SERVICE_ID_FOR_TYPE =
? CLAUDE_SERVICE_ID ? CLAUDE_SERVICE_ID
: CODEX_REVIEW_SERVICE_ID; : CODEX_REVIEW_SERVICE_ID;
/** Arbiter agent type. Disabled by default. Set ARBITER_AGENT_TYPE=codex or claude-code to enable. */
const rawArbiterAgentType = getEnv('ARBITER_AGENT_TYPE');
export const ARBITER_AGENT_TYPE: AgentType | undefined =
rawArbiterAgentType === 'codex' || rawArbiterAgentType === 'claude-code'
? rawArbiterAgentType
: undefined;
/** Service ID for the arbiter. Re-uses codex-review bot by default when arbiter is enabled. */
export const ARBITER_SERVICE_ID = ARBITER_AGENT_TYPE
? (getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID)
: null;
/** Number of consecutive owner↔reviewer round trips before arbiter is auto-requested. */
export const ARBITER_DEADLOCK_THRESHOLD = parseInt(
getEnv('ARBITER_DEADLOCK_THRESHOLD') || '3',
10,
);
export function isArbiterEnabled(): boolean {
return ARBITER_AGENT_TYPE !== undefined;
}
// Max owner↔reviewer round trips per task. 0 = unlimited. // Max owner↔reviewer round trips per task. 0 = unlimited.
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10'; const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10';
export const PAIRED_MAX_ROUND_TRIPS = export const PAIRED_MAX_ROUND_TRIPS =

View File

@@ -559,6 +559,8 @@ describe('paired task state', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
@@ -598,6 +600,8 @@ describe('paired task state', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });

View File

@@ -58,6 +58,7 @@ export interface ChannelOwnerLeaseRow {
chat_jid: string; chat_jid: string;
owner_service_id: string; owner_service_id: string;
reviewer_service_id: string | null; reviewer_service_id: string | null;
arbiter_service_id: string | null;
activated_at: string | null; activated_at: string | null;
reason: string | null; reason: string | null;
} }
@@ -252,9 +253,11 @@ function createSchema(database: Database.Database): void {
review_requested_at TEXT, review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0, round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed')) CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration'))
); );
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at); ON paired_tasks(chat_jid, status, updated_at);
@@ -278,6 +281,7 @@ function createSchema(database: Database.Database): void {
chat_jid TEXT PRIMARY KEY, chat_jid TEXT PRIMARY KEY,
owner_service_id TEXT NOT NULL, owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT, reviewer_service_id TEXT,
arbiter_service_id TEXT,
activated_at TEXT, activated_at TEXT,
reason TEXT reason TEXT
); );
@@ -580,15 +584,74 @@ function createSchema(database: Database.Database): void {
review_requested_at TEXT, review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0, round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed')) CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration'))
); );
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at); ON paired_tasks(chat_jid, status, updated_at);
`); `);
} }
// Migration: add arbiter columns to paired_tasks and rebuild CHECK constraint
// if it doesn't include arbiter statuses
{
const ptSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
)
.get() as { sql?: string } | undefined;
const ptSql = ptSqlRow?.sql || '';
if (ptSql && !ptSql.includes('arbiter_requested')) {
// CHECK constraint cannot be altered in SQLite — rebuild table
database.exec(`
CREATE TABLE paired_tasks_new (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration'))
);
INSERT INTO paired_tasks_new (
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
title, source_ref, plan_notes, review_requested_at,
round_trip_count, status, created_at, updated_at
)
SELECT
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
title, source_ref, plan_notes, review_requested_at,
round_trip_count, status, created_at, updated_at
FROM paired_tasks;
DROP TABLE paired_tasks;
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
`);
}
}
// Migration: add arbiter_service_id column to channel_owner if it doesn't exist
try {
database.exec(
`ALTER TABLE channel_owner ADD COLUMN arbiter_service_id TEXT`,
);
} catch {
/* column already exists */
}
// Drop legacy tables that are no longer used // Drop legacy tables that are no longer used
for (const table of [ for (const table of [
'paired_executions', 'paired_executions',
@@ -1871,10 +1934,12 @@ export function createPairedTask(task: PairedTask): void {
review_requested_at, review_requested_at,
round_trip_count, round_trip_count,
status, status,
arbiter_verdict,
arbiter_requested_at,
created_at, created_at,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
task.id, task.id,
@@ -1888,6 +1953,8 @@ export function createPairedTask(task: PairedTask): void {
task.review_requested_at, task.review_requested_at,
task.round_trip_count, task.round_trip_count,
task.status, task.status,
task.arbiter_verdict,
task.arbiter_requested_at,
task.created_at, task.created_at,
task.updated_at, task.updated_at,
); );
@@ -1943,6 +2010,8 @@ export function updatePairedTask(
| 'review_requested_at' | 'review_requested_at'
| 'round_trip_count' | 'round_trip_count'
| 'status' | 'status'
| 'arbiter_verdict'
| 'arbiter_requested_at'
| 'updated_at' | 'updated_at'
> >
>, >,
@@ -1974,6 +2043,14 @@ export function updatePairedTask(
fields.push('status = ?'); fields.push('status = ?');
values.push(updates.status); values.push(updates.status);
} }
if (updates.arbiter_verdict !== undefined) {
fields.push('arbiter_verdict = ?');
values.push(updates.arbiter_verdict);
}
if (updates.arbiter_requested_at !== undefined) {
fields.push('arbiter_requested_at = ?');
values.push(updates.arbiter_requested_at);
}
if (updates.updated_at !== undefined) { if (updates.updated_at !== undefined) {
fields.push('updated_at = ?'); fields.push('updated_at = ?');
values.push(updates.updated_at); values.push(updates.updated_at);
@@ -2070,7 +2147,7 @@ export function getChannelOwnerLease(
): ChannelOwnerLeaseRow | undefined { ): ChannelOwnerLeaseRow | undefined {
return db return db
.prepare( .prepare(
`SELECT chat_jid, owner_service_id, reviewer_service_id, activated_at, reason `SELECT chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, activated_at, reason
FROM channel_owner FROM channel_owner
WHERE chat_jid = ?`, WHERE chat_jid = ?`,
) )
@@ -2080,7 +2157,7 @@ export function getChannelOwnerLease(
export function getAllChannelOwnerLeases(): ChannelOwnerLeaseRow[] { export function getAllChannelOwnerLeases(): ChannelOwnerLeaseRow[] {
return db return db
.prepare( .prepare(
`SELECT chat_jid, owner_service_id, reviewer_service_id, activated_at, reason `SELECT chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, activated_at, reason
FROM channel_owner`, FROM channel_owner`,
) )
.all() as ChannelOwnerLeaseRow[]; .all() as ChannelOwnerLeaseRow[];
@@ -2090,6 +2167,7 @@ export function setChannelOwnerLease(input: {
chat_jid: string; chat_jid: string;
owner_service_id: string; owner_service_id: string;
reviewer_service_id?: string | null; reviewer_service_id?: string | null;
arbiter_service_id?: string | null;
activated_at?: string | null; activated_at?: string | null;
reason?: string | null; reason?: string | null;
}): void { }): void {
@@ -2098,13 +2176,15 @@ export function setChannelOwnerLease(input: {
chat_jid, chat_jid,
owner_service_id, owner_service_id,
reviewer_service_id, reviewer_service_id,
arbiter_service_id,
activated_at, activated_at,
reason reason
) VALUES (?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?)`,
).run( ).run(
input.chat_jid, input.chat_jid,
input.owner_service_id, input.owner_service_id,
input.reviewer_service_id ?? null, input.reviewer_service_id ?? null,
input.arbiter_service_id ?? null,
input.activated_at ?? new Date().toISOString(), input.activated_at ?? new Date().toISOString(),
input.reason ?? null, input.reason ?? null,
); );

View File

@@ -34,6 +34,7 @@ vi.mock('./service-routing.js', () => ({
chat_jid: 'group@test', chat_jid: 'group@test',
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'codex-main', reviewer_service_id: 'codex-main',
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -278,6 +279,7 @@ describe('runAgentForGroup room memory', () => {
chat_jid: 'group@test', chat_jid: 'group@test',
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'claude', reviewer_service_id: 'claude',
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -307,6 +309,7 @@ describe('runAgentForGroup room memory', () => {
chat_jid: 'group@test', chat_jid: 'group@test',
owner_service_id: 'codex-main', owner_service_id: 'codex-main',
reviewer_service_id: 'claude', reviewer_service_id: 'claude',
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -326,6 +329,8 @@ describe('runAgentForGroup room memory', () => {
plan_notes: null, plan_notes: null,
round_trip_count: 0, round_trip_count: 0,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-29T00:00:00.000Z', created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z', updated_at: '2026-03-29T00:00:00.000Z',
}, },
@@ -388,6 +393,8 @@ describe('runAgentForGroup room memory', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}, },
@@ -452,6 +459,7 @@ describe('runAgentForGroup room memory', () => {
chat_jid: 'group@test', chat_jid: 'group@test',
owner_service_id: 'codex-main', owner_service_id: 'codex-main',
reviewer_service_id: 'claude', reviewer_service_id: 'claude',
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -471,6 +479,8 @@ describe('runAgentForGroup room memory', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}, },

View File

@@ -34,6 +34,7 @@ import {
shouldRetryFreshSessionOnAgentFailure, shouldRetryFreshSessionOnAgentFailure,
} from './session-recovery.js'; } from './session-recovery.js';
import { import {
ARBITER_AGENT_TYPE,
CODEX_MAIN_SERVICE_ID, CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
SERVICE_SESSION_SCOPE, SERVICE_SESSION_SCOPE,
@@ -96,29 +97,41 @@ export async function runAgentForGroup(
: null; : null;
const effectiveServiceId = const effectiveServiceId =
pairedTask && pairedTask &&
(pairedTask.status === 'review_ready' || pairedTask.status === 'in_review') (pairedTask.status === 'arbiter_requested' || pairedTask.status === 'in_arbitration')
? currentLease.reviewer_service_id! ? currentLease.arbiter_service_id!
: currentLease.owner_service_id; : pairedTask &&
(pairedTask.status === 'review_ready' || pairedTask.status === 'in_review')
? currentLease.reviewer_service_id!
: currentLease.owner_service_id;
const reviewerMode = effectiveServiceId === currentLease.reviewer_service_id; const reviewerMode = effectiveServiceId === currentLease.reviewer_service_id;
const arbiterMode = effectiveServiceId === currentLease.arbiter_service_id;
// Override agent type based on role config (OWNER_AGENT_TYPE / REVIEWER_AGENT_TYPE). // Override agent type based on role config (OWNER_AGENT_TYPE / REVIEWER_AGENT_TYPE).
// When the reviewer uses a different agent type than the group default, // When the reviewer uses a different agent type than the group default,
// swap in the configured type for the duration of this execution. // swap in the configured type for the duration of this execution.
const reviewerAgentTypeOverride = const reviewerAgentTypeOverride =
reviewerMode && REVIEWER_AGENT_TYPE !== (group.agentType || 'claude-code'); reviewerMode && REVIEWER_AGENT_TYPE !== (group.agentType || 'claude-code');
const effectiveAgentType = reviewerAgentTypeOverride const arbiterAgentTypeOverride =
? REVIEWER_AGENT_TYPE arbiterMode && ARBITER_AGENT_TYPE != null && ARBITER_AGENT_TYPE !== (group.agentType || 'claude-code');
: group.agentType || 'claude-code'; const effectiveAgentType = arbiterAgentTypeOverride
const effectiveGroup = reviewerAgentTypeOverride ? ARBITER_AGENT_TYPE!
? { ...group, agentType: REVIEWER_AGENT_TYPE } : reviewerAgentTypeOverride
: group; ? REVIEWER_AGENT_TYPE
: group.agentType || 'claude-code';
const effectiveGroup = arbiterAgentTypeOverride
? { ...group, agentType: ARBITER_AGENT_TYPE! }
: reviewerAgentTypeOverride
? { ...group, agentType: REVIEWER_AGENT_TYPE }
: group;
const isClaudeCodeAgent = effectiveAgentType === 'claude-code'; const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
// When the reviewer uses a different agent type than the group default, // When the reviewer/arbiter uses a different agent type than the group default,
// use a separate session key so owner and reviewer sessions don't collide. // use a separate session key so owner, reviewer, and arbiter sessions don't collide.
const sessionFolder = reviewerAgentTypeOverride const sessionFolder = arbiterAgentTypeOverride
? `${group.folder}:reviewer` ? `${group.folder}:arbiter`
: group.folder; : reviewerAgentTypeOverride
? `${group.folder}:reviewer`
: group.folder;
const sessionId = sessions[sessionFolder]; const sessionId = sessions[sessionFolder];
const memoryBriefing = sessionId const memoryBriefing = sessionId
? undefined ? undefined
@@ -194,6 +207,26 @@ export async function runAgentForGroup(
return false; return false;
} }
if (arbiterMode) {
// Arbiter failed (e.g. Claude 401/429) — re-trigger arbitration with codex
createServiceHandoff({
chat_jid: chatJid,
group_folder: group.folder,
source_service_id: SERVICE_SESSION_SCOPE,
target_service_id: CODEX_REVIEW_SERVICE_ID,
target_agent_type: 'codex',
prompt,
start_seq: startSeq ?? null,
end_seq: endSeq ?? null,
reason: `arbiter-claude-${reason}`,
});
logger.warn(
{ chatJid, group: group.name, runId, reason },
'Claude arbiter unavailable, handed off arbiter turn to codex',
);
return true;
}
if (reviewerMode) { if (reviewerMode) {
// Reviewer failed (e.g. Claude 401/429) — re-trigger review with codex // Reviewer failed (e.g. Claude 401/429) — re-trigger review with codex
// instead of swapping owner/reviewer roles. // instead of swapping owner/reviewer roles.
@@ -421,7 +454,9 @@ export async function runAgentForGroup(
runId, runId,
provider: effectiveAgentType, provider: effectiveAgentType,
reviewerMode, reviewerMode,
arbiterMode,
reviewerAgentTypeOverride, reviewerAgentTypeOverride,
arbiterAgentTypeOverride,
}, },
`Using provider: ${effectiveAgentType}`, `Using provider: ${effectiveAgentType}`,
); );

View File

@@ -19,6 +19,7 @@ vi.mock('./config.js', () => ({
CODEX_MAIN_SERVICE_ID: 'codex-main', CODEX_MAIN_SERVICE_ID: 'codex-main',
CODEX_REVIEW_SERVICE_ID: 'codex-review', CODEX_REVIEW_SERVICE_ID: 'codex-review',
REVIEWER_AGENT_TYPE: 'claude-code', REVIEWER_AGENT_TYPE: 'claude-code',
ARBITER_AGENT_TYPE: undefined,
normalizeServiceId: vi.fn((serviceId: string) => serviceId), normalizeServiceId: vi.fn((serviceId: string) => serviceId),
isClaudeService: vi.fn(() => true), isClaudeService: vi.fn(() => true),
isReviewService: vi.fn(() => false), isReviewService: vi.fn(() => false),
@@ -556,6 +557,8 @@ describe('createMessageRuntime', () => {
review_requested_at: '2026-03-30T00:00:00.000Z', review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 0, round_trip_count: 0,
status: 'review_ready', status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-30T00:00:00.000Z', created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:00.000Z', updated_at: '2026-03-30T00:00:00.000Z',
}); });

View File

@@ -21,6 +21,7 @@ import {
type WorkItem, type WorkItem,
} from './db.js'; } from './db.js';
import { import {
ARBITER_AGENT_TYPE,
CLAUDE_SERVICE_ID, CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID, CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
@@ -46,6 +47,7 @@ import {
hasAllowedTrigger, hasAllowedTrigger,
shouldSkipBotOnlyCollaboration, shouldSkipBotOnlyCollaboration,
} from './message-runtime-rules.js'; } from './message-runtime-rules.js';
import { buildArbiterContextPrompt } from './arbiter-context.js';
import { runAgentForGroup } from './message-agent-executor.js'; import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js'; import { MessageTurnController } from './message-turn-controller.js';
import { import {
@@ -152,7 +154,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
? 'owner' ? 'owner'
: serviceId === lease.reviewer_service_id : serviceId === lease.reviewer_service_id
? 'reviewer' ? 'reviewer'
: msg.sender_name; : serviceId === lease.arbiter_service_id
? 'arbiter'
: msg.sender_name;
return { ...msg, sender_name: role }; return { ...msg, sender_name: role };
}); });
}; };
@@ -435,14 +439,19 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return; return;
} }
// Reviewer failover handoffs (reason starts with "reviewer-") should // Reviewer/arbiter failover handoffs should run via the appropriate
// run via the reviewer channel so they execute in reviewer mode. // channel so they execute in the correct role mode.
const isReviewerHandoff = handoff.reason?.startsWith('reviewer-'); const isReviewerHandoff = handoff.reason?.startsWith('reviewer-');
const isArbiterHandoff = handoff.reason?.startsWith('arbiter-');
let handoffChannel = channel; let handoffChannel = channel;
if (isReviewerHandoff) { if (isReviewerHandoff) {
const revChName = const revChName =
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
handoffChannel = findChannelByName(deps.channels, revChName) || channel; handoffChannel = findChannelByName(deps.channels, revChName) || channel;
} else if (isArbiterHandoff) {
const arbChName =
ARBITER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
handoffChannel = findChannelByName(deps.channels, arbChName) || channel;
} }
const runId = `handoff-${handoff.id}`; const runId = `handoff-${handoff.id}`;
@@ -517,6 +526,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
reviewerChannelName, reviewerChannelName,
); );
const reviewerChannel = foundReviewerChannel || channel; const reviewerChannel = foundReviewerChannel || channel;
// Arbiter channel: route arbiter output through the appropriate bot.
const arbiterChannelName =
isPairedRoomJid(chatJid) && ARBITER_AGENT_TYPE === 'claude-code'
? 'discord'
: 'discord-review';
const foundArbiterChannel = findChannelByName(
deps.channels,
arbiterChannelName,
);
const arbiterChannel = foundArbiterChannel || channel;
if (isPairedRoomJid(chatJid)) { if (isPairedRoomJid(chatJid)) {
logger.info( logger.info(
{ {
@@ -524,9 +545,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
reviewerChannelName, reviewerChannelName,
foundChannel: foundReviewerChannel?.name ?? null, foundChannel: foundReviewerChannel?.name ?? null,
usingChannel: reviewerChannel.name, usingChannel: reviewerChannel.name,
arbiterChannelName,
foundArbiterChannel: foundArbiterChannel?.name ?? null,
usingArbiterChannel: arbiterChannel.name,
availableChannels: deps.channels.map((c) => c.name), availableChannels: deps.channels.map((c) => c.name),
}, },
'Paired room reviewer channel resolution', 'Paired room reviewer/arbiter channel resolution',
); );
} }
@@ -545,7 +569,15 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
pendingTask && pendingTask &&
(pendingTask.status === 'review_ready' || (pendingTask.status === 'review_ready' ||
pendingTask.status === 'in_review'); pendingTask.status === 'in_review');
const deliveryChannel = isReviewerWorkItem ? reviewerChannel : channel; const isArbiterWorkItem =
pendingTask &&
(pendingTask.status === 'arbiter_requested' ||
pendingTask.status === 'in_arbitration');
const deliveryChannel = isArbiterWorkItem
? arbiterChannel
: isReviewerWorkItem
? reviewerChannel
: channel;
const delivered = await deliverOpenWorkItem( const delivered = await deliverOpenWorkItem(
deliveryChannel, deliveryChannel,
openWorkItem, openWorkItem,
@@ -619,6 +651,42 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return deliverySucceeded; return deliverySucceeded;
} }
// arbiter_requested / in_arbitration: run arbiter turn
if (
pendingReviewTask &&
(pendingReviewTask.status === 'arbiter_requested' ||
pendingReviewTask.status === 'in_arbitration')
) {
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
const cursor = lastRaw?.seq ?? lastRaw?.timestamp;
if (cursor != null) {
advanceLastAgentCursor(
deps.getLastAgentTimestamps(),
deps.saveState,
chatJid,
cursor,
);
}
const arbiterPrompt = buildArbiterContextPrompt({
chatJid,
taskId: pendingReviewTask.id,
roundTripCount: pendingReviewTask.round_trip_count,
timezone: deps.timezone,
});
const { deliverySucceeded } = await executeTurn({
group,
prompt: arbiterPrompt,
chatJid,
runId,
channel: arbiterChannel,
startSeq: null,
endSeq: null,
});
return deliverySucceeded;
}
// merge_ready: reviewer approved, owner gets final turn to finalize // merge_ready: reviewer approved, owner gets final turn to finalize
if (pendingReviewTask && pendingReviewTask.status === 'merge_ready') { if (pendingReviewTask && pendingReviewTask.status === 'merge_ready') {
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1]; const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
@@ -756,13 +824,34 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
pendingTaskForChannel && pendingTaskForChannel &&
(pendingTaskForChannel.status === 'review_ready' || (pendingTaskForChannel.status === 'review_ready' ||
pendingTaskForChannel.status === 'in_review'); pendingTaskForChannel.status === 'in_review');
const turnChannel = useReviewerChannel ? reviewerChannel : channel; const useArbiterChannel =
const cursorKey = pairedCursorKey(chatJid, !!useReviewerChannel); pendingTaskForChannel &&
(pendingTaskForChannel.status === 'arbiter_requested' ||
pendingTaskForChannel.status === 'in_arbitration');
const turnChannel = useArbiterChannel
? arbiterChannel
: useReviewerChannel
? reviewerChannel
: channel;
const cursorKey = useArbiterChannel
? `${chatJid}:arbiter`
: pairedCursorKey(chatJid, !!useReviewerChannel);
const prompt = formatMessages( // Arbiter turns use a dedicated context prompt; regular turns use formatted messages.
labelPairedSenders(chatJid, missedMessages), let prompt: string;
deps.timezone, if (useArbiterChannel && pendingTaskForChannel) {
); prompt = buildArbiterContextPrompt({
chatJid,
taskId: pendingTaskForChannel.id,
roundTripCount: pendingTaskForChannel.round_trip_count,
timezone: deps.timezone,
});
} else {
prompt = formatMessages(
labelPairedSenders(chatJid, missedMessages),
deps.timezone,
);
}
const startSeq = missedMessages[0].seq ?? null; const startSeq = missedMessages[0].seq ?? null;
const endSeq = missedMessages[missedMessages.length - 1].seq ?? null; const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
if (endSeq !== null) { if (endSeq !== null) {
@@ -953,7 +1042,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
!!loopPendingTask && !!loopPendingTask &&
(loopPendingTask.status === 'review_ready' || (loopPendingTask.status === 'review_ready' ||
loopPendingTask.status === 'in_review'); loopPendingTask.status === 'in_review');
const loopCursorKey = pairedCursorKey(chatJid, loopIsReviewerTurn); const loopIsArbiterTurn =
!!loopPendingTask &&
(loopPendingTask.status === 'arbiter_requested' ||
loopPendingTask.status === 'in_arbitration');
const loopCursorKey = loopIsArbiterTurn
? `${chatJid}:arbiter`
: pairedCursorKey(chatJid, loopIsReviewerTurn);
const rawPendingMessages = getMessagesSinceSeq( const rawPendingMessages = getMessagesSinceSeq(
chatJid, chatJid,

View File

@@ -110,6 +110,8 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
review_requested_at: null, review_requested_at: null,
round_trip_count: 0, round_trip_count: 0,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
...overrides, ...overrides,
@@ -198,6 +200,8 @@ describe('paired execution context', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z', review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready', status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
@@ -230,6 +234,8 @@ describe('paired execution context', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z', review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready', status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
@@ -265,6 +271,8 @@ describe('paired execution context', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
@@ -306,6 +314,8 @@ describe('paired execution context', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z', review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review', status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });

View File

@@ -3,7 +3,12 @@ import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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 { import {
createPairedTask, createPairedTask,
getLatestPairedTaskForChat, getLatestPairedTaskForChat,
@@ -21,6 +26,7 @@ import {
provisionOwnerWorkspaceForPairedTask, provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js'; } from './paired-workspace-manager.js';
import type { import type {
PairedRoomRole,
PairedTask, PairedTask,
PairedWorkspace, PairedWorkspace,
RegisteredGroup, RegisteredGroup,
@@ -57,6 +63,24 @@ function classifyReviewerVerdict(
return 'continue'; 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 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -156,6 +180,8 @@ function ensureActiveTask(
review_requested_at: null, review_requested_at: null,
round_trip_count: 0, round_trip_count: 0,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_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 // Use a stable per-channel worktree (not per-task) so the Claude SDK
// session persists across tasks. Different channels still get isolation. // session persists across tasks. Different channels still get isolation.
workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id); workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id);
} else { } else if (roomRoleContext.role === 'reviewer') {
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask); const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
workspace = reviewerWorkspace.workspace; workspace = reviewerWorkspace.workspace;
blockMessage = reviewerWorkspace.blockMessage; blockMessage = reviewerWorkspace.blockMessage;
@@ -250,6 +276,18 @@ export function preparePairedExecutionContext(args: {
updated_at: now, 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> = { const envOverrides: Record<string, string> = {
@@ -272,6 +310,15 @@ export function preparePairedExecutionContext(args: {
); );
fs.mkdirSync(reviewerSessionDir, { recursive: true }); fs.mkdirSync(reviewerSessionDir, { recursive: true });
envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir; 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 { return {
@@ -288,7 +335,7 @@ export function preparePairedExecutionContext(args: {
export function completePairedExecutionContext(args: { export function completePairedExecutionContext(args: {
taskId: string; taskId: string;
role: 'owner' | 'reviewer'; role: PairedRoomRole;
status: 'succeeded' | 'failed'; status: 'succeeded' | 'failed';
summary?: string | null; summary?: string | null;
}): void { }): void {
@@ -487,13 +534,25 @@ export function completePairedExecutionContext(args: {
case 'continue': case 'continue':
default: default:
// If both sides keep echoing DONE_WITH_CONCERNS without progress, // If both sides keep echoing DONE_WITH_CONCERNS without progress,
// stop the loop after 3 round trips to prevent infinite ping-pong. // request arbiter intervention (or escalate to user if arbiter not configured).
if (task.round_trip_count >= 3) { if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
updatePairedTask(taskId, { status: 'completed', updated_at: now }); if (isArbiterEnabled()) {
logger.info( updatePairedTask(taskId, {
{ taskId, verdict, roundTrips: task.round_trip_count }, status: 'arbiter_requested',
'Stopped ping-pong after repeated DONE_WITH_CONCERNS — escalating to user', 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; break;
} }
// Owner needs to address feedback — ping-pong continues // Owner needs to address feedback — ping-pong continues
@@ -505,4 +564,52 @@ export function completePairedExecutionContext(args: {
break; 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;
}
}
} }

View File

@@ -78,6 +78,8 @@ describe('paired workspace manager', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
@@ -167,6 +169,8 @@ describe('paired workspace manager', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
@@ -243,6 +247,8 @@ describe('paired workspace manager', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
@@ -318,6 +324,8 @@ describe('paired workspace manager', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
@@ -459,6 +467,8 @@ describe('paired workspace manager', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
@@ -521,6 +531,8 @@ describe('paired workspace manager', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: '2026-03-28T00:01:00.000Z', review_requested_at: '2026-03-28T00:01:00.000Z',
status: 'review_ready', status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_at: '2026-03-28T00:01:00.000Z', updated_at: '2026-03-28T00:01:00.000Z',
}); });
@@ -587,6 +599,8 @@ describe('paired workspace manager', () => {
round_trip_count: 0, round_trip_count: 0,
review_requested_at: null, review_requested_at: null,
status: 'active', status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });

View File

@@ -15,6 +15,8 @@ const PAIRED_ROOM_PROMPT_FILES: Record<AgentType, string> = {
codex: 'claude-paired-room.md', codex: 'claude-paired-room.md',
}; };
const ARBITER_PROMPT_FILE = 'arbiter-paired-room.md';
export function getPlatformPromptsDir(projectRoot = process.cwd()): string { export function getPlatformPromptsDir(projectRoot = process.cwd()): string {
return path.join(projectRoot, 'prompts'); return path.join(projectRoot, 'prompts');
} }
@@ -60,3 +62,22 @@ export function readPairedRoomPrompt(
const prompt = fs.readFileSync(promptPath, 'utf-8').trim(); const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined; return prompt || undefined;
} }
export function getArbiterPromptPath(
projectRoot = process.cwd(),
): string {
return path.join(
getPlatformPromptsDir(projectRoot),
ARBITER_PROMPT_FILE,
);
}
export function readArbiterPrompt(
projectRoot = process.cwd(),
): string | undefined {
const promptPath = getArbiterPromptPath(projectRoot);
if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined;
}

View File

@@ -10,6 +10,7 @@ describe('buildRoomRoleContext', () => {
chat_jid: 'group@test', chat_jid: 'group@test',
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'codex-main', reviewer_service_id: 'codex-main',
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -22,6 +23,7 @@ describe('buildRoomRoleContext', () => {
ownerServiceId: 'claude', ownerServiceId: 'claude',
reviewerServiceId: 'codex-main', reviewerServiceId: 'codex-main',
failoverOwner: false, failoverOwner: false,
arbiterServiceId: undefined,
}); });
}); });
@@ -32,6 +34,7 @@ describe('buildRoomRoleContext', () => {
chat_jid: 'group@test', chat_jid: 'group@test',
owner_service_id: 'codex-review', owner_service_id: 'codex-review',
reviewer_service_id: 'codex-main', reviewer_service_id: 'codex-main',
arbiter_service_id: null,
activated_at: '2026-03-28T10:00:00.000Z', activated_at: '2026-03-28T10:00:00.000Z',
reason: 'claude-429', reason: 'claude-429',
explicit: true, explicit: true,
@@ -44,6 +47,31 @@ describe('buildRoomRoleContext', () => {
ownerServiceId: 'codex-review', ownerServiceId: 'codex-review',
reviewerServiceId: 'codex-main', reviewerServiceId: 'codex-main',
failoverOwner: true, failoverOwner: true,
arbiterServiceId: undefined,
});
});
it('returns arbiter context when service matches arbiter_service_id', () => {
expect(
buildRoomRoleContext(
{
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: 'codex-review',
activated_at: null,
reason: null,
explicit: false,
},
'codex-review',
),
).toEqual({
serviceId: 'codex-review',
role: 'arbiter',
ownerServiceId: 'codex-main',
reviewerServiceId: 'claude',
failoverOwner: false,
arbiterServiceId: 'codex-review',
}); });
}); });
@@ -54,6 +82,7 @@ describe('buildRoomRoleContext', () => {
chat_jid: 'solo@test', chat_jid: 'solo@test',
owner_service_id: 'codex-main', owner_service_id: 'codex-main',
reviewer_service_id: null, reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,

View File

@@ -20,12 +20,20 @@ export function buildRoomRoleContext(
} }
const ownerServiceId = normalizeServiceId(lease.owner_service_id); const ownerServiceId = normalizeServiceId(lease.owner_service_id);
const arbiterServiceId = lease.arbiter_service_id
? normalizeServiceId(lease.arbiter_service_id)
: undefined;
// Check arbiter role first: if this service matches the arbiter_service_id,
// it takes the arbiter role (even if it also matches owner or reviewer)
const role = const role =
ownerServiceId === normalizedServiceId arbiterServiceId && arbiterServiceId === normalizedServiceId
? 'owner' ? 'arbiter'
: reviewerServiceId === normalizedServiceId : ownerServiceId === normalizedServiceId
? 'reviewer' ? 'owner'
: null; : reviewerServiceId === normalizedServiceId
? 'reviewer'
: null;
if (!role) { if (!role) {
return undefined; return undefined;
@@ -39,5 +47,6 @@ export function buildRoomRoleContext(
failoverOwner: failoverOwner:
ownerServiceId === CODEX_REVIEW_SERVICE_ID && ownerServiceId === CODEX_REVIEW_SERVICE_ID &&
reviewerServiceId === CODEX_MAIN_SERVICE_ID, reviewerServiceId === CODEX_MAIN_SERVICE_ID,
arbiterServiceId,
}; };
} }

View File

@@ -1,4 +1,5 @@
import { import {
ARBITER_SERVICE_ID,
CLAUDE_SERVICE_ID, CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID, CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
@@ -6,6 +7,7 @@ import {
REVIEWER_SERVICE_ID_FOR_TYPE, REVIEWER_SERVICE_ID_FOR_TYPE,
SERVICE_AGENT_TYPE, SERVICE_AGENT_TYPE,
SERVICE_ID, SERVICE_ID,
isArbiterEnabled,
normalizeServiceId, normalizeServiceId,
} from './config.js'; } from './config.js';
import { import {
@@ -20,6 +22,7 @@ export interface EffectiveChannelLease {
chat_jid: string; chat_jid: string;
owner_service_id: string; owner_service_id: string;
reviewer_service_id: string | null; reviewer_service_id: string | null;
arbiter_service_id: string | null;
activated_at: string | null; activated_at: string | null;
reason: string | null; reason: string | null;
explicit: boolean; explicit: boolean;
@@ -40,6 +43,9 @@ function normalizeLeaseRow(
reviewer_service_id: row.reviewer_service_id reviewer_service_id: row.reviewer_service_id
? normalizeServiceId(row.reviewer_service_id) ? normalizeServiceId(row.reviewer_service_id)
: null, : null,
arbiter_service_id: row.arbiter_service_id
? normalizeServiceId(row.arbiter_service_id)
: null,
activated_at: row.activated_at, activated_at: row.activated_at,
reason: row.reason, reason: row.reason,
explicit, explicit,
@@ -59,6 +65,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
chat_jid: chatJid, chat_jid: chatJid,
owner_service_id: ownerServiceId, owner_service_id: ownerServiceId,
reviewer_service_id: REVIEWER_SERVICE_ID_FOR_TYPE, reviewer_service_id: REVIEWER_SERVICE_ID_FOR_TYPE,
arbiter_service_id: isArbiterEnabled() ? ARBITER_SERVICE_ID : null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -70,6 +77,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
chat_jid: chatJid, chat_jid: chatJid,
owner_service_id: CODEX_MAIN_SERVICE_ID, owner_service_id: CODEX_MAIN_SERVICE_ID,
reviewer_service_id: null, reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -81,6 +89,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
chat_jid: chatJid, chat_jid: chatJid,
owner_service_id: CLAUDE_SERVICE_ID, owner_service_id: CLAUDE_SERVICE_ID,
reviewer_service_id: null, reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -94,6 +103,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
? CODEX_MAIN_SERVICE_ID ? CODEX_MAIN_SERVICE_ID
: CLAUDE_SERVICE_ID, : CLAUDE_SERVICE_ID,
reviewer_service_id: null, reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -143,6 +153,17 @@ export function isReviewerServiceForChat(
); );
} }
export function isArbiterServiceForChat(
chatJid: string,
serviceId: string = SERVICE_ID,
): boolean {
const lease = getEffectiveChannelLease(chatJid);
return (
lease.arbiter_service_id !== null &&
normalizeServiceId(serviceId) === lease.arbiter_service_id
);
}
export function shouldServiceProcessChat( export function shouldServiceProcessChat(
_chatJid: string, _chatJid: string,
_serviceId: string = SERVICE_ID, _serviceId: string = SERVICE_ID,
@@ -156,6 +177,7 @@ export function activateCodexFailover(chatJid: string, reason: string): void {
chat_jid: chatJid, chat_jid: chatJid,
owner_service_id: CODEX_REVIEW_SERVICE_ID, owner_service_id: CODEX_REVIEW_SERVICE_ID,
reviewer_service_id: CODEX_MAIN_SERVICE_ID, reviewer_service_id: CODEX_MAIN_SERVICE_ID,
arbiter_service_id: null,
activated_at: now, activated_at: now,
reason, reason,
}; };

View File

@@ -23,14 +23,18 @@ export type VisiblePhase = 'silent' | 'progress' | 'final';
export type AgentVisibility = 'public' | 'silent'; export type AgentVisibility = 'public' | 'silent';
export type PairedRoomRole = 'owner' | 'reviewer'; export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
export type PairedTaskStatus = export type PairedTaskStatus =
| 'active' | 'active'
| 'review_ready' | 'review_ready'
| 'in_review' | 'in_review'
| 'merge_ready' | 'merge_ready'
| 'completed'; | 'completed'
| 'arbiter_requested'
| 'in_arbitration';
export type ArbiterVerdict = 'proceed' | 'revise' | 'reset' | 'escalate';
export type PairedWorkspaceRole = 'owner' | 'reviewer'; export type PairedWorkspaceRole = 'owner' | 'reviewer';
@@ -42,6 +46,7 @@ export interface RoomRoleContext {
ownerServiceId: string; ownerServiceId: string;
reviewerServiceId: string; reviewerServiceId: string;
failoverOwner: boolean; failoverOwner: boolean;
arbiterServiceId?: string;
} }
export interface PairedProject { export interface PairedProject {
@@ -64,6 +69,8 @@ export interface PairedTask {
review_requested_at: string | null; review_requested_at: string | null;
round_trip_count: number; round_trip_count: number;
status: PairedTaskStatus; status: PairedTaskStatus;
arbiter_verdict: string | null;
arbiter_requested_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }