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:
28
prompts/arbiter-paired-room.md
Normal file
28
prompts/arbiter-paired-room.md
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
29
src/arbiter-context.ts
Normal 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');
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
92
src/db.ts
92
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,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
11
src/types.ts
11
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user