From a043f2b79a4b7eacea759a82523fdd7f106bcbca Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 30 Mar 2026 22:20:42 +0900 Subject: [PATCH] feat: implement MAGI 3-agent arbiter system for deadlock resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- prompts/arbiter-paired-room.md | 28 ++++++ src/agent-runner-environment.test.ts | 3 + src/agent-runner-environment.ts | 10 ++- src/agent-runner.ts | 4 +- src/arbiter-context.ts | 29 ++++++ src/config.ts | 22 +++++ src/db.test.ts | 4 + src/db.ts | 92 +++++++++++++++++-- src/message-agent-executor.test.ts | 10 +++ src/message-agent-executor.ts | 63 ++++++++++--- src/message-runtime.test.ts | 3 + src/message-runtime.ts | 119 ++++++++++++++++++++++--- src/paired-execution-context.test.ts | 10 +++ src/paired-execution-context.ts | 127 ++++++++++++++++++++++++--- src/paired-workspace-manager.test.ts | 14 +++ src/platform-prompts.ts | 21 +++++ src/room-role-context.test.ts | 29 ++++++ src/room-role-context.ts | 19 ++-- src/service-routing.ts | 22 +++++ src/types.ts | 11 ++- 20 files changed, 587 insertions(+), 53 deletions(-) create mode 100644 prompts/arbiter-paired-room.md create mode 100644 src/arbiter-context.ts diff --git a/prompts/arbiter-paired-room.md b/prompts/arbiter-paired-room.md new file mode 100644 index 0000000..95c8232 --- /dev/null +++ b/prompts/arbiter-paired-room.md @@ -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 diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index 4f5b32f..d98b814 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -44,6 +44,7 @@ vi.mock('./service-routing.js', () => ({ chat_jid: 'dc:test', owner_service_id: 'claude', reviewer_service_id: 'codex-main', + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -206,6 +207,7 @@ describe('prepareGroupEnvironment codex auth handling', () => { chat_jid: 'dc:test', owner_service_id: 'codex-review', reviewer_service_id: 'codex-main', + arbiter_service_id: null, activated_at: '2026-03-28T00:00:00.000Z', reason: 'claude-429', explicit: true, @@ -310,6 +312,7 @@ describe('prepareGroupEnvironment codex auth handling', () => { chat_jid: 'dc:test', owner_service_id: 'claude', reviewer_service_id: 'codex-main', + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 174cfcb..b2452d7 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -23,6 +23,7 @@ import { resolveServiceTaskSessionsPath, } from './group-folder.js'; import { + readArbiterPrompt, readPairedRoomPrompt, readPlatformPrompt, } from './platform-prompts.js'; @@ -527,8 +528,9 @@ export function prepareContainerSessionEnvironment(args: { chatJid: string; isMain: boolean; memoryBriefing?: string; + role?: 'reviewer' | 'arbiter'; }): void { - const { sessionDir, chatJid, isMain, memoryBriefing } = args; + const { sessionDir, chatJid, isMain, memoryBriefing, role = 'reviewer' } = args; const projectRoot = process.cwd(); fs.mkdirSync(sessionDir, { recursive: true }); @@ -541,10 +543,12 @@ export function prepareContainerSessionEnvironment(args: { ]; 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 claudePairedRoomPrompt = isPairedRoomJid(chatJid) - ? readPairedRoomPrompt('claude-code', projectRoot) + ? (role === 'arbiter' + ? readArbiterPrompt(projectRoot) + : readPairedRoomPrompt('claude-code', projectRoot)) : undefined; const globalDir = path.join(GROUPS_DIR, 'global'); const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md'); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index e8bdc9c..1421e7a 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -75,7 +75,7 @@ export async function runAgentProcess( // ── Reviewer container mode ───────────────────────────────────── // Reviewers always run inside a Docker container with read-only source // 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 = 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. const sessionDir = envOverrides?.CLAUDE_CONFIG_DIR; if (sessionDir) { + const containerRole = envOverrides?.EJCLAW_ARBITER_RUNTIME === '1' ? 'arbiter' as const : 'reviewer' as const; prepareContainerSessionEnvironment({ sessionDir, chatJid: input.chatJid, isMain: input.isMain, memoryBriefing: input.memoryBriefing, + role: containerRole, }); } diff --git a/src/arbiter-context.ts b/src/arbiter-context.ts new file mode 100644 index 0000000..7a7bea2 --- /dev/null +++ b/src/arbiter-context.ts @@ -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 [ + ``, + `${taskId}`, + `${roundTripCount}`, + `Deadlock detected: owner and reviewer exchanged ${roundTripCount} rounds without resolution`, + ``, + ``, + ``, + conversationContext, + ``, + ``, + `Review the conversation above and render your verdict.`, + ].join('\n'); +} diff --git a/src/config.ts b/src/config.ts index d5e7fb9..1986f38 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,6 +110,28 @@ export const REVIEWER_SERVICE_ID_FOR_TYPE = ? CLAUDE_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. const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10'; export const PAIRED_MAX_ROUND_TRIPS = diff --git a/src/db.test.ts b/src/db.test.ts index 85501d3..610f708 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -559,6 +559,8 @@ describe('paired task state', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_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, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); diff --git a/src/db.ts b/src/db.ts index 3ca44f5..5987bcd 100644 --- a/src/db.ts +++ b/src/db.ts @@ -58,6 +58,7 @@ export interface ChannelOwnerLeaseRow { chat_jid: string; owner_service_id: string; reviewer_service_id: string | null; + arbiter_service_id: string | null; activated_at: string | null; reason: string | null; } @@ -252,9 +253,11 @@ function createSchema(database: Database.Database): void { 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')) + 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 ON paired_tasks(chat_jid, status, updated_at); @@ -278,6 +281,7 @@ function createSchema(database: Database.Database): void { chat_jid TEXT PRIMARY KEY, owner_service_id TEXT NOT NULL, reviewer_service_id TEXT, + arbiter_service_id TEXT, activated_at TEXT, reason TEXT ); @@ -580,15 +584,74 @@ function createSchema(database: Database.Database): void { 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')) + 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 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 for (const table of [ 'paired_executions', @@ -1871,10 +1934,12 @@ export function createPairedTask(task: PairedTask): void { review_requested_at, round_trip_count, status, + arbiter_verdict, + arbiter_requested_at, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, @@ -1888,6 +1953,8 @@ export function createPairedTask(task: PairedTask): void { task.review_requested_at, task.round_trip_count, task.status, + task.arbiter_verdict, + task.arbiter_requested_at, task.created_at, task.updated_at, ); @@ -1943,6 +2010,8 @@ export function updatePairedTask( | 'review_requested_at' | 'round_trip_count' | 'status' + | 'arbiter_verdict' + | 'arbiter_requested_at' | 'updated_at' > >, @@ -1974,6 +2043,14 @@ export function updatePairedTask( fields.push('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) { fields.push('updated_at = ?'); values.push(updates.updated_at); @@ -2070,7 +2147,7 @@ export function getChannelOwnerLease( ): ChannelOwnerLeaseRow | undefined { return db .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 WHERE chat_jid = ?`, ) @@ -2080,7 +2157,7 @@ export function getChannelOwnerLease( export function getAllChannelOwnerLeases(): ChannelOwnerLeaseRow[] { return db .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`, ) .all() as ChannelOwnerLeaseRow[]; @@ -2090,6 +2167,7 @@ export function setChannelOwnerLease(input: { chat_jid: string; owner_service_id: string; reviewer_service_id?: string | null; + arbiter_service_id?: string | null; activated_at?: string | null; reason?: string | null; }): void { @@ -2098,13 +2176,15 @@ export function setChannelOwnerLease(input: { chat_jid, owner_service_id, reviewer_service_id, + arbiter_service_id, activated_at, reason - ) VALUES (?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?)`, ).run( input.chat_jid, input.owner_service_id, input.reviewer_service_id ?? null, + input.arbiter_service_id ?? null, input.activated_at ?? new Date().toISOString(), input.reason ?? null, ); diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 28f8159..1a355cc 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -34,6 +34,7 @@ vi.mock('./service-routing.js', () => ({ chat_jid: 'group@test', owner_service_id: 'claude', reviewer_service_id: 'codex-main', + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -278,6 +279,7 @@ describe('runAgentForGroup room memory', () => { chat_jid: 'group@test', owner_service_id: 'claude', reviewer_service_id: 'claude', + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -307,6 +309,7 @@ describe('runAgentForGroup room memory', () => { chat_jid: 'group@test', owner_service_id: 'codex-main', reviewer_service_id: 'claude', + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -326,6 +329,8 @@ describe('runAgentForGroup room memory', () => { plan_notes: null, round_trip_count: 0, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_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, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_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', owner_service_id: 'codex-main', reviewer_service_id: 'claude', + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -471,6 +479,8 @@ describe('runAgentForGroup room memory', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }, diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 1c0f22d..4604a19 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -34,6 +34,7 @@ import { shouldRetryFreshSessionOnAgentFailure, } from './session-recovery.js'; import { + ARBITER_AGENT_TYPE, CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, SERVICE_SESSION_SCOPE, @@ -96,29 +97,41 @@ export async function runAgentForGroup( : null; const effectiveServiceId = pairedTask && - (pairedTask.status === 'review_ready' || pairedTask.status === 'in_review') - ? currentLease.reviewer_service_id! - : currentLease.owner_service_id; + (pairedTask.status === 'arbiter_requested' || pairedTask.status === 'in_arbitration') + ? currentLease.arbiter_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 arbiterMode = effectiveServiceId === currentLease.arbiter_service_id; // 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, // swap in the configured type for the duration of this execution. const reviewerAgentTypeOverride = reviewerMode && REVIEWER_AGENT_TYPE !== (group.agentType || 'claude-code'); - const effectiveAgentType = reviewerAgentTypeOverride - ? REVIEWER_AGENT_TYPE - : group.agentType || 'claude-code'; - const effectiveGroup = reviewerAgentTypeOverride - ? { ...group, agentType: REVIEWER_AGENT_TYPE } - : group; + const arbiterAgentTypeOverride = + arbiterMode && ARBITER_AGENT_TYPE != null && ARBITER_AGENT_TYPE !== (group.agentType || 'claude-code'); + const effectiveAgentType = arbiterAgentTypeOverride + ? ARBITER_AGENT_TYPE! + : reviewerAgentTypeOverride + ? 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'; - // When the reviewer uses a different agent type than the group default, - // use a separate session key so owner and reviewer sessions don't collide. - const sessionFolder = reviewerAgentTypeOverride - ? `${group.folder}:reviewer` - : group.folder; + // When the reviewer/arbiter uses a different agent type than the group default, + // use a separate session key so owner, reviewer, and arbiter sessions don't collide. + const sessionFolder = arbiterAgentTypeOverride + ? `${group.folder}:arbiter` + : reviewerAgentTypeOverride + ? `${group.folder}:reviewer` + : group.folder; const sessionId = sessions[sessionFolder]; const memoryBriefing = sessionId ? undefined @@ -194,6 +207,26 @@ export async function runAgentForGroup( 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) { // Reviewer failed (e.g. Claude 401/429) — re-trigger review with codex // instead of swapping owner/reviewer roles. @@ -421,7 +454,9 @@ export async function runAgentForGroup( runId, provider: effectiveAgentType, reviewerMode, + arbiterMode, reviewerAgentTypeOverride, + arbiterAgentTypeOverride, }, `Using provider: ${effectiveAgentType}`, ); diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 6254356..8c450fd 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -19,6 +19,7 @@ vi.mock('./config.js', () => ({ CODEX_MAIN_SERVICE_ID: 'codex-main', CODEX_REVIEW_SERVICE_ID: 'codex-review', REVIEWER_AGENT_TYPE: 'claude-code', + ARBITER_AGENT_TYPE: undefined, normalizeServiceId: vi.fn((serviceId: string) => serviceId), isClaudeService: vi.fn(() => true), isReviewService: vi.fn(() => false), @@ -556,6 +557,8 @@ describe('createMessageRuntime', () => { review_requested_at: '2026-03-30T00:00:00.000Z', round_trip_count: 0, status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: '2026-03-30T00:00:00.000Z', updated_at: '2026-03-30T00:00:00.000Z', }); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 3b328e0..8de8e1a 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -21,6 +21,7 @@ import { type WorkItem, } from './db.js'; import { + ARBITER_AGENT_TYPE, CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, @@ -46,6 +47,7 @@ import { hasAllowedTrigger, shouldSkipBotOnlyCollaboration, } from './message-runtime-rules.js'; +import { buildArbiterContextPrompt } from './arbiter-context.js'; import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; import { @@ -152,7 +154,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ? 'owner' : serviceId === lease.reviewer_service_id ? 'reviewer' - : msg.sender_name; + : serviceId === lease.arbiter_service_id + ? 'arbiter' + : msg.sender_name; return { ...msg, sender_name: role }; }); }; @@ -435,14 +439,19 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { return; } - // Reviewer failover handoffs (reason starts with "reviewer-") should - // run via the reviewer channel so they execute in reviewer mode. + // Reviewer/arbiter failover handoffs should run via the appropriate + // channel so they execute in the correct role mode. const isReviewerHandoff = handoff.reason?.startsWith('reviewer-'); + const isArbiterHandoff = handoff.reason?.startsWith('arbiter-'); let handoffChannel = channel; if (isReviewerHandoff) { const revChName = REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; 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}`; @@ -517,6 +526,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { reviewerChannelName, ); 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)) { logger.info( { @@ -524,9 +545,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { reviewerChannelName, foundChannel: foundReviewerChannel?.name ?? null, usingChannel: reviewerChannel.name, + arbiterChannelName, + foundArbiterChannel: foundArbiterChannel?.name ?? null, + usingArbiterChannel: arbiterChannel.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.status === 'review_ready' || 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( deliveryChannel, openWorkItem, @@ -619,6 +651,42 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { 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 if (pendingReviewTask && pendingReviewTask.status === 'merge_ready') { const lastRaw = rawMissedMessages[rawMissedMessages.length - 1]; @@ -756,13 +824,34 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { pendingTaskForChannel && (pendingTaskForChannel.status === 'review_ready' || pendingTaskForChannel.status === 'in_review'); - const turnChannel = useReviewerChannel ? reviewerChannel : channel; - const cursorKey = pairedCursorKey(chatJid, !!useReviewerChannel); + const useArbiterChannel = + 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( - labelPairedSenders(chatJid, missedMessages), - deps.timezone, - ); + // Arbiter turns use a dedicated context prompt; regular turns use formatted messages. + let prompt: string; + 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 endSeq = missedMessages[missedMessages.length - 1].seq ?? null; if (endSeq !== null) { @@ -953,7 +1042,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { !!loopPendingTask && (loopPendingTask.status === 'review_ready' || 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( chatJid, diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 3991069..9df010f 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -110,6 +110,8 @@ function buildPairedTask(overrides: Partial = {}): PairedTask { review_requested_at: null, round_trip_count: 0, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', ...overrides, @@ -198,6 +200,8 @@ describe('paired execution context', () => { round_trip_count: 0, review_requested_at: '2026-03-28T00:00:00.000Z', status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, created_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, review_requested_at: '2026-03-28T00:00:00.000Z', status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, created_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, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_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, review_requested_at: '2026-03-28T00:00:00.000Z', status: 'in_review', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index faf6168..4fd8e27 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -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(/[\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 = { @@ -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; + } + } } diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index 6ba0043..e92f7b7 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -78,6 +78,8 @@ describe('paired workspace manager', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: now, updated_at: now, }); @@ -167,6 +169,8 @@ describe('paired workspace manager', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: now, updated_at: now, }); @@ -243,6 +247,8 @@ describe('paired workspace manager', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: now, updated_at: now, }); @@ -318,6 +324,8 @@ describe('paired workspace manager', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: now, updated_at: now, }); @@ -459,6 +467,8 @@ describe('paired workspace manager', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: now, updated_at: now, }); @@ -521,6 +531,8 @@ describe('paired workspace manager', () => { round_trip_count: 0, review_requested_at: '2026-03-28T00:01:00.000Z', status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: now, updated_at: '2026-03-28T00:01:00.000Z', }); @@ -587,6 +599,8 @@ describe('paired workspace manager', () => { round_trip_count: 0, review_requested_at: null, status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, created_at: now, updated_at: now, }); diff --git a/src/platform-prompts.ts b/src/platform-prompts.ts index 38e64bb..038a2c8 100644 --- a/src/platform-prompts.ts +++ b/src/platform-prompts.ts @@ -15,6 +15,8 @@ const PAIRED_ROOM_PROMPT_FILES: Record = { codex: 'claude-paired-room.md', }; +const ARBITER_PROMPT_FILE = 'arbiter-paired-room.md'; + export function getPlatformPromptsDir(projectRoot = process.cwd()): string { return path.join(projectRoot, 'prompts'); } @@ -60,3 +62,22 @@ export function readPairedRoomPrompt( const prompt = fs.readFileSync(promptPath, 'utf-8').trim(); 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; +} diff --git a/src/room-role-context.test.ts b/src/room-role-context.test.ts index f40f3a2..44e8fae 100644 --- a/src/room-role-context.test.ts +++ b/src/room-role-context.test.ts @@ -10,6 +10,7 @@ describe('buildRoomRoleContext', () => { chat_jid: 'group@test', owner_service_id: 'claude', reviewer_service_id: 'codex-main', + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -22,6 +23,7 @@ describe('buildRoomRoleContext', () => { ownerServiceId: 'claude', reviewerServiceId: 'codex-main', failoverOwner: false, + arbiterServiceId: undefined, }); }); @@ -32,6 +34,7 @@ describe('buildRoomRoleContext', () => { chat_jid: 'group@test', owner_service_id: 'codex-review', reviewer_service_id: 'codex-main', + arbiter_service_id: null, activated_at: '2026-03-28T10:00:00.000Z', reason: 'claude-429', explicit: true, @@ -44,6 +47,31 @@ describe('buildRoomRoleContext', () => { ownerServiceId: 'codex-review', reviewerServiceId: 'codex-main', 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', owner_service_id: 'codex-main', reviewer_service_id: null, + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, diff --git a/src/room-role-context.ts b/src/room-role-context.ts index eadb001..9df9880 100644 --- a/src/room-role-context.ts +++ b/src/room-role-context.ts @@ -20,12 +20,20 @@ export function buildRoomRoleContext( } 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 = - ownerServiceId === normalizedServiceId - ? 'owner' - : reviewerServiceId === normalizedServiceId - ? 'reviewer' - : null; + arbiterServiceId && arbiterServiceId === normalizedServiceId + ? 'arbiter' + : ownerServiceId === normalizedServiceId + ? 'owner' + : reviewerServiceId === normalizedServiceId + ? 'reviewer' + : null; if (!role) { return undefined; @@ -39,5 +47,6 @@ export function buildRoomRoleContext( failoverOwner: ownerServiceId === CODEX_REVIEW_SERVICE_ID && reviewerServiceId === CODEX_MAIN_SERVICE_ID, + arbiterServiceId, }; } diff --git a/src/service-routing.ts b/src/service-routing.ts index ae907ce..5238070 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -1,4 +1,5 @@ import { + ARBITER_SERVICE_ID, CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, @@ -6,6 +7,7 @@ import { REVIEWER_SERVICE_ID_FOR_TYPE, SERVICE_AGENT_TYPE, SERVICE_ID, + isArbiterEnabled, normalizeServiceId, } from './config.js'; import { @@ -20,6 +22,7 @@ export interface EffectiveChannelLease { chat_jid: string; owner_service_id: string; reviewer_service_id: string | null; + arbiter_service_id: string | null; activated_at: string | null; reason: string | null; explicit: boolean; @@ -40,6 +43,9 @@ function normalizeLeaseRow( reviewer_service_id: row.reviewer_service_id ? normalizeServiceId(row.reviewer_service_id) : null, + arbiter_service_id: row.arbiter_service_id + ? normalizeServiceId(row.arbiter_service_id) + : null, activated_at: row.activated_at, reason: row.reason, explicit, @@ -59,6 +65,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease { chat_jid: chatJid, owner_service_id: ownerServiceId, reviewer_service_id: REVIEWER_SERVICE_ID_FOR_TYPE, + arbiter_service_id: isArbiterEnabled() ? ARBITER_SERVICE_ID : null, activated_at: null, reason: null, explicit: false, @@ -70,6 +77,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease { chat_jid: chatJid, owner_service_id: CODEX_MAIN_SERVICE_ID, reviewer_service_id: null, + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -81,6 +89,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease { chat_jid: chatJid, owner_service_id: CLAUDE_SERVICE_ID, reviewer_service_id: null, + arbiter_service_id: null, activated_at: null, reason: null, explicit: false, @@ -94,6 +103,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease { ? CODEX_MAIN_SERVICE_ID : CLAUDE_SERVICE_ID, reviewer_service_id: null, + arbiter_service_id: null, activated_at: null, reason: null, 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( _chatJid: string, _serviceId: string = SERVICE_ID, @@ -156,6 +177,7 @@ export function activateCodexFailover(chatJid: string, reason: string): void { chat_jid: chatJid, owner_service_id: CODEX_REVIEW_SERVICE_ID, reviewer_service_id: CODEX_MAIN_SERVICE_ID, + arbiter_service_id: null, activated_at: now, reason, }; diff --git a/src/types.ts b/src/types.ts index 25fde0f..f8bad91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,14 +23,18 @@ export type VisiblePhase = 'silent' | 'progress' | 'final'; export type AgentVisibility = 'public' | 'silent'; -export type PairedRoomRole = 'owner' | 'reviewer'; +export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedTaskStatus = | 'active' | 'review_ready' | 'in_review' | 'merge_ready' - | 'completed'; + | 'completed' + | 'arbiter_requested' + | 'in_arbitration'; + +export type ArbiterVerdict = 'proceed' | 'revise' | 'reset' | 'escalate'; export type PairedWorkspaceRole = 'owner' | 'reviewer'; @@ -42,6 +46,7 @@ export interface RoomRoleContext { ownerServiceId: string; reviewerServiceId: string; failoverOwner: boolean; + arbiterServiceId?: string; } export interface PairedProject { @@ -64,6 +69,8 @@ export interface PairedTask { review_requested_at: string | null; round_trip_count: number; status: PairedTaskStatus; + arbiter_verdict: string | null; + arbiter_requested_at: string | null; created_at: string; updated_at: string; }