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