feat: implement MAGI 3-agent arbiter system for deadlock resolution

Introduces a third agent role (arbiter) that is summoned on-demand
when owner and reviewer reach a deadlock (same verdict 3+ rounds).

Architecture:
- 3 Discord bots: owner (codex), reviewer (claude), arbiter (claude/codex)
- Arbiter is NOT always-on — only invoked when deadlock detected
- Arbiter renders binding verdict: PROCEED/REVISE/RESET/ESCALATE
- Non-escalate verdicts reset round_trip_count and resume ping-pong
- Backward compatible: ARBITER_AGENT_TYPE unset = existing 2-agent mode

Changes across 13 source files + 7 test files:
- types.ts: PairedRoomRole += 'arbiter', new statuses, ArbiterVerdict type
- config.ts: ARBITER_AGENT_TYPE, ARBITER_SERVICE_ID, ARBITER_DEADLOCK_THRESHOLD
- db.ts: schema migration (arbiter columns in channel_owner + paired_tasks)
- service-routing.ts: arbiter_service_id in lease
- room-role-context.ts: arbiter role detection
- paired-execution-context.ts: deadlock->arbiter, verdict handling
- message-runtime.ts: arbiter turn routing, cursor, sender labeling
- message-agent-executor.ts: arbiter mode, failover
- agent-runner.ts + environment.ts: arbiter container mode
- platform-prompts.ts: arbiter prompt loading

New files:
- prompts/arbiter-paired-room.md: arbiter system prompt
- src/arbiter-context.ts: builds conversation context for arbiter judgment
This commit is contained in:
Eyejoker
2026-03-30 22:20:42 +09:00
parent 58ed721d6e
commit a043f2b79a
20 changed files with 587 additions and 53 deletions

View File

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

View File

@@ -44,6 +44,7 @@ vi.mock('./service-routing.js', () => ({
chat_jid: 'dc:test',
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,

View File

@@ -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');

View File

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

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

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

View File

@@ -110,6 +110,28 @@ export const REVIEWER_SERVICE_ID_FOR_TYPE =
? CLAUDE_SERVICE_ID
: 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 =

View File

@@ -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',
});

View File

@@ -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,
);

View File

@@ -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',
},

View File

@@ -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}`,
);

View File

@@ -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',
});

View File

@@ -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,

View File

@@ -110,6 +110,8 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): 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',
});

View File

@@ -3,7 +3,12 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { DATA_DIR, PAIRED_MAX_ROUND_TRIPS } from './config.js';
import {
ARBITER_DEADLOCK_THRESHOLD,
DATA_DIR,
PAIRED_MAX_ROUND_TRIPS,
isArbiterEnabled,
} from './config.js';
import {
createPairedTask,
getLatestPairedTaskForChat,
@@ -21,6 +26,7 @@ import {
provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js';
import type {
PairedRoomRole,
PairedTask,
PairedWorkspace,
RegisteredGroup,
@@ -57,6 +63,24 @@ function classifyReviewerVerdict(
return 'continue';
}
// ---------------------------------------------------------------------------
// Arbiter verdict detection
// ---------------------------------------------------------------------------
type ArbiterVerdictResult = 'proceed' | 'revise' | 'reset' | 'escalate' | 'unknown';
function classifyArbiterVerdict(summary: string | null | undefined): ArbiterVerdictResult {
if (!summary) return 'unknown';
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
if (!cleaned) return 'unknown';
const firstLine = cleaned.split('\n')[0].trim();
if (/^\*{0,2}PROCEED\*{0,2}\b/i.test(firstLine)) return 'proceed';
if (/^\*{0,2}REVISE\*{0,2}\b/i.test(firstLine)) return 'revise';
if (/^\*{0,2}RESET\*{0,2}\b/i.test(firstLine)) return 'reset';
if (/^\*{0,2}ESCALATE\*{0,2}\b/i.test(firstLine)) return 'escalate';
return 'unknown';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -156,6 +180,8 @@ function ensureActiveTask(
review_requested_at: null,
round_trip_count: 0,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
created_at: now,
updated_at: now,
};
@@ -239,7 +265,7 @@ export function preparePairedExecutionContext(args: {
// Use a stable per-channel worktree (not per-task) so the Claude SDK
// session persists across tasks. Different channels still get isolation.
workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id);
} else {
} else if (roomRoleContext.role === 'reviewer') {
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
workspace = reviewerWorkspace.workspace;
blockMessage = reviewerWorkspace.blockMessage;
@@ -250,6 +276,18 @@ export function preparePairedExecutionContext(args: {
updated_at: now,
});
}
} else if (roomRoleContext.role === 'arbiter') {
// Arbiter uses same read-only workspace as reviewer
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
workspace = reviewerWorkspace.workspace;
blockMessage = reviewerWorkspace.blockMessage;
const refreshedTask = getPairedTaskById(latestTask.id) ?? latestTask;
if (workspace && refreshedTask.status === 'arbiter_requested') {
updatePairedTask(latestTask.id, {
status: 'in_arbitration',
updated_at: now,
});
}
}
const envOverrides: Record<string, string> = {
@@ -272,6 +310,15 @@ export function preparePairedExecutionContext(args: {
);
fs.mkdirSync(reviewerSessionDir, { recursive: true });
envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir;
} else if (roomRoleContext.role === 'arbiter') {
envOverrides.EJCLAW_ARBITER_RUNTIME = '1';
const arbiterSessionDir = path.join(
DATA_DIR,
'sessions',
`${group.folder}-arbiter`,
);
fs.mkdirSync(arbiterSessionDir, { recursive: true });
envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir;
}
return {
@@ -288,7 +335,7 @@ export function preparePairedExecutionContext(args: {
export function completePairedExecutionContext(args: {
taskId: string;
role: 'owner' | 'reviewer';
role: PairedRoomRole;
status: 'succeeded' | 'failed';
summary?: string | null;
}): void {
@@ -487,13 +534,25 @@ export function completePairedExecutionContext(args: {
case 'continue':
default:
// If both sides keep echoing DONE_WITH_CONCERNS without progress,
// stop the loop after 3 round trips to prevent infinite ping-pong.
if (task.round_trip_count >= 3) {
updatePairedTask(taskId, { status: 'completed', updated_at: now });
logger.info(
{ taskId, verdict, roundTrips: task.round_trip_count },
'Stopped ping-pong after repeated DONE_WITH_CONCERNS — escalating to user',
);
// request arbiter intervention (or escalate to user if arbiter not configured).
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
if (isArbiterEnabled()) {
updatePairedTask(taskId, {
status: 'arbiter_requested',
arbiter_requested_at: now,
updated_at: now,
});
logger.info(
{ taskId, verdict, roundTrips: task.round_trip_count },
'Deadlock detected — requesting arbiter intervention',
);
} else {
updatePairedTask(taskId, { status: 'completed', updated_at: now });
logger.info(
{ taskId, verdict, roundTrips: task.round_trip_count },
'Stopped ping-pong — escalating to user (arbiter not configured)',
);
}
break;
}
// Owner needs to address feedback — ping-pong continues
@@ -505,4 +564,52 @@ export function completePairedExecutionContext(args: {
break;
}
}
// Arbiter finished → classify verdict and route accordingly
if (role === 'arbiter') {
const now = new Date().toISOString();
const arbiterVerdict = classifyArbiterVerdict(args.summary);
logger.info(
{ taskId, arbiterVerdict, summary: args.summary?.slice(0, 200) },
'Arbiter verdict rendered',
);
switch (arbiterVerdict) {
case 'proceed':
case 'revise':
case 'reset':
// Non-escalate: reset counter, continue ping-pong
updatePairedTask(taskId, {
status: 'active',
round_trip_count: 0,
arbiter_verdict: arbiterVerdict,
updated_at: now,
});
logger.info(
{ taskId, arbiterVerdict },
'Arbiter resolved deadlock — resuming ping-pong',
);
break;
case 'escalate':
updatePairedTask(taskId, {
status: 'completed',
arbiter_verdict: 'escalate',
updated_at: now,
});
logger.info(
{ taskId },
'Arbiter escalated to user — task completed',
);
break;
default:
// Unknown verdict — treat as escalate
updatePairedTask(taskId, {
status: 'completed',
arbiter_verdict: 'unknown',
updated_at: now,
});
break;
}
}
}

View File

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

View File

@@ -15,6 +15,8 @@ const PAIRED_ROOM_PROMPT_FILES: Record<AgentType, string> = {
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;
}

View File

@@ -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,

View File

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

View File

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

View File

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