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',
|
chat_jid: 'dc:test',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -206,6 +207,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
owner_service_id: 'codex-review',
|
owner_service_id: 'codex-review',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: '2026-03-28T00:00:00.000Z',
|
activated_at: '2026-03-28T00:00:00.000Z',
|
||||||
reason: 'claude-429',
|
reason: 'claude-429',
|
||||||
explicit: true,
|
explicit: true,
|
||||||
@@ -310,6 +312,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
resolveServiceTaskSessionsPath,
|
resolveServiceTaskSessionsPath,
|
||||||
} from './group-folder.js';
|
} from './group-folder.js';
|
||||||
import {
|
import {
|
||||||
|
readArbiterPrompt,
|
||||||
readPairedRoomPrompt,
|
readPairedRoomPrompt,
|
||||||
readPlatformPrompt,
|
readPlatformPrompt,
|
||||||
} from './platform-prompts.js';
|
} from './platform-prompts.js';
|
||||||
@@ -527,8 +528,9 @@ export function prepareContainerSessionEnvironment(args: {
|
|||||||
chatJid: string;
|
chatJid: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
memoryBriefing?: string;
|
memoryBriefing?: string;
|
||||||
|
role?: 'reviewer' | 'arbiter';
|
||||||
}): void {
|
}): void {
|
||||||
const { sessionDir, chatJid, isMain, memoryBriefing } = args;
|
const { sessionDir, chatJid, isMain, memoryBriefing, role = 'reviewer' } = args;
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
|
|
||||||
fs.mkdirSync(sessionDir, { recursive: true });
|
fs.mkdirSync(sessionDir, { recursive: true });
|
||||||
@@ -541,10 +543,12 @@ export function prepareContainerSessionEnvironment(args: {
|
|||||||
];
|
];
|
||||||
syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills'));
|
syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills'));
|
||||||
|
|
||||||
// Build CLAUDE.md with reviewer-appropriate prompts
|
// Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter)
|
||||||
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
|
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
|
||||||
const claudePairedRoomPrompt = isPairedRoomJid(chatJid)
|
const claudePairedRoomPrompt = isPairedRoomJid(chatJid)
|
||||||
? readPairedRoomPrompt('claude-code', projectRoot)
|
? (role === 'arbiter'
|
||||||
|
? readArbiterPrompt(projectRoot)
|
||||||
|
: readPairedRoomPrompt('claude-code', projectRoot))
|
||||||
: undefined;
|
: undefined;
|
||||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||||
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
|
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function runAgentProcess(
|
|||||||
// ── Reviewer container mode ─────────────────────────────────────
|
// ── Reviewer container mode ─────────────────────────────────────
|
||||||
// Reviewers always run inside a Docker container with read-only source
|
// Reviewers always run inside a Docker container with read-only source
|
||||||
// mount for kernel-level write protection. Docker is required.
|
// mount for kernel-level write protection. Docker is required.
|
||||||
if (envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1') {
|
if (envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1' || envOverrides?.EJCLAW_ARBITER_RUNTIME === '1') {
|
||||||
const ownerWorkspaceDir =
|
const ownerWorkspaceDir =
|
||||||
envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd();
|
envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd();
|
||||||
|
|
||||||
@@ -83,11 +83,13 @@ export async function runAgentProcess(
|
|||||||
// so the Claude SDK inside the container has platform & paired room prompts.
|
// so the Claude SDK inside the container has platform & paired room prompts.
|
||||||
const sessionDir = envOverrides?.CLAUDE_CONFIG_DIR;
|
const sessionDir = envOverrides?.CLAUDE_CONFIG_DIR;
|
||||||
if (sessionDir) {
|
if (sessionDir) {
|
||||||
|
const containerRole = envOverrides?.EJCLAW_ARBITER_RUNTIME === '1' ? 'arbiter' as const : 'reviewer' as const;
|
||||||
prepareContainerSessionEnvironment({
|
prepareContainerSessionEnvironment({
|
||||||
sessionDir,
|
sessionDir,
|
||||||
chatJid: input.chatJid,
|
chatJid: input.chatJid,
|
||||||
isMain: input.isMain,
|
isMain: input.isMain,
|
||||||
memoryBriefing: input.memoryBriefing,
|
memoryBriefing: input.memoryBriefing,
|
||||||
|
role: containerRole,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
src/arbiter-context.ts
Normal file
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
|
? CLAUDE_SERVICE_ID
|
||||||
: CODEX_REVIEW_SERVICE_ID;
|
: CODEX_REVIEW_SERVICE_ID;
|
||||||
|
|
||||||
|
/** Arbiter agent type. Disabled by default. Set ARBITER_AGENT_TYPE=codex or claude-code to enable. */
|
||||||
|
const rawArbiterAgentType = getEnv('ARBITER_AGENT_TYPE');
|
||||||
|
export const ARBITER_AGENT_TYPE: AgentType | undefined =
|
||||||
|
rawArbiterAgentType === 'codex' || rawArbiterAgentType === 'claude-code'
|
||||||
|
? rawArbiterAgentType
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/** Service ID for the arbiter. Re-uses codex-review bot by default when arbiter is enabled. */
|
||||||
|
export const ARBITER_SERVICE_ID = ARBITER_AGENT_TYPE
|
||||||
|
? (getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/** Number of consecutive owner↔reviewer round trips before arbiter is auto-requested. */
|
||||||
|
export const ARBITER_DEADLOCK_THRESHOLD = parseInt(
|
||||||
|
getEnv('ARBITER_DEADLOCK_THRESHOLD') || '3',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isArbiterEnabled(): boolean {
|
||||||
|
return ARBITER_AGENT_TYPE !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Max owner↔reviewer round trips per task. 0 = unlimited.
|
// Max owner↔reviewer round trips per task. 0 = unlimited.
|
||||||
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10';
|
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10';
|
||||||
export const PAIRED_MAX_ROUND_TRIPS =
|
export const PAIRED_MAX_ROUND_TRIPS =
|
||||||
|
|||||||
@@ -559,6 +559,8 @@ describe('paired task state', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -598,6 +600,8 @@ describe('paired task state', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|||||||
92
src/db.ts
92
src/db.ts
@@ -58,6 +58,7 @@ export interface ChannelOwnerLeaseRow {
|
|||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
owner_service_id: string;
|
owner_service_id: string;
|
||||||
reviewer_service_id: string | null;
|
reviewer_service_id: string | null;
|
||||||
|
arbiter_service_id: string | null;
|
||||||
activated_at: string | null;
|
activated_at: string | null;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
}
|
}
|
||||||
@@ -252,9 +253,11 @@ function createSchema(database: Database.Database): void {
|
|||||||
review_requested_at TEXT,
|
review_requested_at TEXT,
|
||||||
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
arbiter_verdict TEXT,
|
||||||
|
arbiter_requested_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed'))
|
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration'))
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||||
ON paired_tasks(chat_jid, status, updated_at);
|
ON paired_tasks(chat_jid, status, updated_at);
|
||||||
@@ -278,6 +281,7 @@ function createSchema(database: Database.Database): void {
|
|||||||
chat_jid TEXT PRIMARY KEY,
|
chat_jid TEXT PRIMARY KEY,
|
||||||
owner_service_id TEXT NOT NULL,
|
owner_service_id TEXT NOT NULL,
|
||||||
reviewer_service_id TEXT,
|
reviewer_service_id TEXT,
|
||||||
|
arbiter_service_id TEXT,
|
||||||
activated_at TEXT,
|
activated_at TEXT,
|
||||||
reason TEXT
|
reason TEXT
|
||||||
);
|
);
|
||||||
@@ -580,15 +584,74 @@ function createSchema(database: Database.Database): void {
|
|||||||
review_requested_at TEXT,
|
review_requested_at TEXT,
|
||||||
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
arbiter_verdict TEXT,
|
||||||
|
arbiter_requested_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed'))
|
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration'))
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||||
ON paired_tasks(chat_jid, status, updated_at);
|
ON paired_tasks(chat_jid, status, updated_at);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: add arbiter columns to paired_tasks and rebuild CHECK constraint
|
||||||
|
// if it doesn't include arbiter statuses
|
||||||
|
{
|
||||||
|
const ptSqlRow = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
||||||
|
)
|
||||||
|
.get() as { sql?: string } | undefined;
|
||||||
|
const ptSql = ptSqlRow?.sql || '';
|
||||||
|
if (ptSql && !ptSql.includes('arbiter_requested')) {
|
||||||
|
// CHECK constraint cannot be altered in SQLite — rebuild table
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE paired_tasks_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
chat_jid TEXT NOT NULL,
|
||||||
|
group_folder TEXT NOT NULL,
|
||||||
|
owner_service_id TEXT NOT NULL,
|
||||||
|
reviewer_service_id TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
source_ref TEXT,
|
||||||
|
plan_notes TEXT,
|
||||||
|
review_requested_at TEXT,
|
||||||
|
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
arbiter_verdict TEXT,
|
||||||
|
arbiter_requested_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration'))
|
||||||
|
);
|
||||||
|
INSERT INTO paired_tasks_new (
|
||||||
|
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
|
||||||
|
title, source_ref, plan_notes, review_requested_at,
|
||||||
|
round_trip_count, status, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
|
||||||
|
title, source_ref, plan_notes, review_requested_at,
|
||||||
|
round_trip_count, status, created_at, updated_at
|
||||||
|
FROM paired_tasks;
|
||||||
|
DROP TABLE paired_tasks;
|
||||||
|
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||||
|
ON paired_tasks(chat_jid, status, updated_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: add arbiter_service_id column to channel_owner if it doesn't exist
|
||||||
|
try {
|
||||||
|
database.exec(
|
||||||
|
`ALTER TABLE channel_owner ADD COLUMN arbiter_service_id TEXT`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* column already exists */
|
||||||
|
}
|
||||||
|
|
||||||
// Drop legacy tables that are no longer used
|
// Drop legacy tables that are no longer used
|
||||||
for (const table of [
|
for (const table of [
|
||||||
'paired_executions',
|
'paired_executions',
|
||||||
@@ -1871,10 +1934,12 @@ export function createPairedTask(task: PairedTask): void {
|
|||||||
review_requested_at,
|
review_requested_at,
|
||||||
round_trip_count,
|
round_trip_count,
|
||||||
status,
|
status,
|
||||||
|
arbiter_verdict,
|
||||||
|
arbiter_requested_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
@@ -1888,6 +1953,8 @@ export function createPairedTask(task: PairedTask): void {
|
|||||||
task.review_requested_at,
|
task.review_requested_at,
|
||||||
task.round_trip_count,
|
task.round_trip_count,
|
||||||
task.status,
|
task.status,
|
||||||
|
task.arbiter_verdict,
|
||||||
|
task.arbiter_requested_at,
|
||||||
task.created_at,
|
task.created_at,
|
||||||
task.updated_at,
|
task.updated_at,
|
||||||
);
|
);
|
||||||
@@ -1943,6 +2010,8 @@ export function updatePairedTask(
|
|||||||
| 'review_requested_at'
|
| 'review_requested_at'
|
||||||
| 'round_trip_count'
|
| 'round_trip_count'
|
||||||
| 'status'
|
| 'status'
|
||||||
|
| 'arbiter_verdict'
|
||||||
|
| 'arbiter_requested_at'
|
||||||
| 'updated_at'
|
| 'updated_at'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
@@ -1974,6 +2043,14 @@ export function updatePairedTask(
|
|||||||
fields.push('status = ?');
|
fields.push('status = ?');
|
||||||
values.push(updates.status);
|
values.push(updates.status);
|
||||||
}
|
}
|
||||||
|
if (updates.arbiter_verdict !== undefined) {
|
||||||
|
fields.push('arbiter_verdict = ?');
|
||||||
|
values.push(updates.arbiter_verdict);
|
||||||
|
}
|
||||||
|
if (updates.arbiter_requested_at !== undefined) {
|
||||||
|
fields.push('arbiter_requested_at = ?');
|
||||||
|
values.push(updates.arbiter_requested_at);
|
||||||
|
}
|
||||||
if (updates.updated_at !== undefined) {
|
if (updates.updated_at !== undefined) {
|
||||||
fields.push('updated_at = ?');
|
fields.push('updated_at = ?');
|
||||||
values.push(updates.updated_at);
|
values.push(updates.updated_at);
|
||||||
@@ -2070,7 +2147,7 @@ export function getChannelOwnerLease(
|
|||||||
): ChannelOwnerLeaseRow | undefined {
|
): ChannelOwnerLeaseRow | undefined {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT chat_jid, owner_service_id, reviewer_service_id, activated_at, reason
|
`SELECT chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, activated_at, reason
|
||||||
FROM channel_owner
|
FROM channel_owner
|
||||||
WHERE chat_jid = ?`,
|
WHERE chat_jid = ?`,
|
||||||
)
|
)
|
||||||
@@ -2080,7 +2157,7 @@ export function getChannelOwnerLease(
|
|||||||
export function getAllChannelOwnerLeases(): ChannelOwnerLeaseRow[] {
|
export function getAllChannelOwnerLeases(): ChannelOwnerLeaseRow[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT chat_jid, owner_service_id, reviewer_service_id, activated_at, reason
|
`SELECT chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, activated_at, reason
|
||||||
FROM channel_owner`,
|
FROM channel_owner`,
|
||||||
)
|
)
|
||||||
.all() as ChannelOwnerLeaseRow[];
|
.all() as ChannelOwnerLeaseRow[];
|
||||||
@@ -2090,6 +2167,7 @@ export function setChannelOwnerLease(input: {
|
|||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
owner_service_id: string;
|
owner_service_id: string;
|
||||||
reviewer_service_id?: string | null;
|
reviewer_service_id?: string | null;
|
||||||
|
arbiter_service_id?: string | null;
|
||||||
activated_at?: string | null;
|
activated_at?: string | null;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
@@ -2098,13 +2176,15 @@ export function setChannelOwnerLease(input: {
|
|||||||
chat_jid,
|
chat_jid,
|
||||||
owner_service_id,
|
owner_service_id,
|
||||||
reviewer_service_id,
|
reviewer_service_id,
|
||||||
|
arbiter_service_id,
|
||||||
activated_at,
|
activated_at,
|
||||||
reason
|
reason
|
||||||
) VALUES (?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.chat_jid,
|
input.chat_jid,
|
||||||
input.owner_service_id,
|
input.owner_service_id,
|
||||||
input.reviewer_service_id ?? null,
|
input.reviewer_service_id ?? null,
|
||||||
|
input.arbiter_service_id ?? null,
|
||||||
input.activated_at ?? new Date().toISOString(),
|
input.activated_at ?? new Date().toISOString(),
|
||||||
input.reason ?? null,
|
input.reason ?? null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ vi.mock('./service-routing.js', () => ({
|
|||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -278,6 +279,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -307,6 +309,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -326,6 +329,8 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
plan_notes: null,
|
plan_notes: null,
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-29T00:00:00.000Z',
|
created_at: '2026-03-29T00:00:00.000Z',
|
||||||
updated_at: '2026-03-29T00:00:00.000Z',
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -388,6 +393,8 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -452,6 +459,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -471,6 +479,8 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
shouldRetryFreshSessionOnAgentFailure,
|
shouldRetryFreshSessionOnAgentFailure,
|
||||||
} from './session-recovery.js';
|
} from './session-recovery.js';
|
||||||
import {
|
import {
|
||||||
|
ARBITER_AGENT_TYPE,
|
||||||
CODEX_MAIN_SERVICE_ID,
|
CODEX_MAIN_SERVICE_ID,
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
@@ -96,29 +97,41 @@ export async function runAgentForGroup(
|
|||||||
: null;
|
: null;
|
||||||
const effectiveServiceId =
|
const effectiveServiceId =
|
||||||
pairedTask &&
|
pairedTask &&
|
||||||
(pairedTask.status === 'review_ready' || pairedTask.status === 'in_review')
|
(pairedTask.status === 'arbiter_requested' || pairedTask.status === 'in_arbitration')
|
||||||
? currentLease.reviewer_service_id!
|
? currentLease.arbiter_service_id!
|
||||||
: currentLease.owner_service_id;
|
: pairedTask &&
|
||||||
|
(pairedTask.status === 'review_ready' || pairedTask.status === 'in_review')
|
||||||
|
? currentLease.reviewer_service_id!
|
||||||
|
: currentLease.owner_service_id;
|
||||||
const reviewerMode = effectiveServiceId === currentLease.reviewer_service_id;
|
const reviewerMode = effectiveServiceId === currentLease.reviewer_service_id;
|
||||||
|
const arbiterMode = effectiveServiceId === currentLease.arbiter_service_id;
|
||||||
|
|
||||||
// Override agent type based on role config (OWNER_AGENT_TYPE / REVIEWER_AGENT_TYPE).
|
// Override agent type based on role config (OWNER_AGENT_TYPE / REVIEWER_AGENT_TYPE).
|
||||||
// When the reviewer uses a different agent type than the group default,
|
// When the reviewer uses a different agent type than the group default,
|
||||||
// swap in the configured type for the duration of this execution.
|
// swap in the configured type for the duration of this execution.
|
||||||
const reviewerAgentTypeOverride =
|
const reviewerAgentTypeOverride =
|
||||||
reviewerMode && REVIEWER_AGENT_TYPE !== (group.agentType || 'claude-code');
|
reviewerMode && REVIEWER_AGENT_TYPE !== (group.agentType || 'claude-code');
|
||||||
const effectiveAgentType = reviewerAgentTypeOverride
|
const arbiterAgentTypeOverride =
|
||||||
? REVIEWER_AGENT_TYPE
|
arbiterMode && ARBITER_AGENT_TYPE != null && ARBITER_AGENT_TYPE !== (group.agentType || 'claude-code');
|
||||||
: group.agentType || 'claude-code';
|
const effectiveAgentType = arbiterAgentTypeOverride
|
||||||
const effectiveGroup = reviewerAgentTypeOverride
|
? ARBITER_AGENT_TYPE!
|
||||||
? { ...group, agentType: REVIEWER_AGENT_TYPE }
|
: reviewerAgentTypeOverride
|
||||||
: group;
|
? REVIEWER_AGENT_TYPE
|
||||||
|
: group.agentType || 'claude-code';
|
||||||
|
const effectiveGroup = arbiterAgentTypeOverride
|
||||||
|
? { ...group, agentType: ARBITER_AGENT_TYPE! }
|
||||||
|
: reviewerAgentTypeOverride
|
||||||
|
? { ...group, agentType: REVIEWER_AGENT_TYPE }
|
||||||
|
: group;
|
||||||
const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
|
const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
|
||||||
|
|
||||||
// When the reviewer uses a different agent type than the group default,
|
// When the reviewer/arbiter uses a different agent type than the group default,
|
||||||
// use a separate session key so owner and reviewer sessions don't collide.
|
// use a separate session key so owner, reviewer, and arbiter sessions don't collide.
|
||||||
const sessionFolder = reviewerAgentTypeOverride
|
const sessionFolder = arbiterAgentTypeOverride
|
||||||
? `${group.folder}:reviewer`
|
? `${group.folder}:arbiter`
|
||||||
: group.folder;
|
: reviewerAgentTypeOverride
|
||||||
|
? `${group.folder}:reviewer`
|
||||||
|
: group.folder;
|
||||||
const sessionId = sessions[sessionFolder];
|
const sessionId = sessions[sessionFolder];
|
||||||
const memoryBriefing = sessionId
|
const memoryBriefing = sessionId
|
||||||
? undefined
|
? undefined
|
||||||
@@ -194,6 +207,26 @@ export async function runAgentForGroup(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (arbiterMode) {
|
||||||
|
// Arbiter failed (e.g. Claude 401/429) — re-trigger arbitration with codex
|
||||||
|
createServiceHandoff({
|
||||||
|
chat_jid: chatJid,
|
||||||
|
group_folder: group.folder,
|
||||||
|
source_service_id: SERVICE_SESSION_SCOPE,
|
||||||
|
target_service_id: CODEX_REVIEW_SERVICE_ID,
|
||||||
|
target_agent_type: 'codex',
|
||||||
|
prompt,
|
||||||
|
start_seq: startSeq ?? null,
|
||||||
|
end_seq: endSeq ?? null,
|
||||||
|
reason: `arbiter-claude-${reason}`,
|
||||||
|
});
|
||||||
|
logger.warn(
|
||||||
|
{ chatJid, group: group.name, runId, reason },
|
||||||
|
'Claude arbiter unavailable, handed off arbiter turn to codex',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (reviewerMode) {
|
if (reviewerMode) {
|
||||||
// Reviewer failed (e.g. Claude 401/429) — re-trigger review with codex
|
// Reviewer failed (e.g. Claude 401/429) — re-trigger review with codex
|
||||||
// instead of swapping owner/reviewer roles.
|
// instead of swapping owner/reviewer roles.
|
||||||
@@ -421,7 +454,9 @@ export async function runAgentForGroup(
|
|||||||
runId,
|
runId,
|
||||||
provider: effectiveAgentType,
|
provider: effectiveAgentType,
|
||||||
reviewerMode,
|
reviewerMode,
|
||||||
|
arbiterMode,
|
||||||
reviewerAgentTypeOverride,
|
reviewerAgentTypeOverride,
|
||||||
|
arbiterAgentTypeOverride,
|
||||||
},
|
},
|
||||||
`Using provider: ${effectiveAgentType}`,
|
`Using provider: ${effectiveAgentType}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ vi.mock('./config.js', () => ({
|
|||||||
CODEX_MAIN_SERVICE_ID: 'codex-main',
|
CODEX_MAIN_SERVICE_ID: 'codex-main',
|
||||||
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
||||||
REVIEWER_AGENT_TYPE: 'claude-code',
|
REVIEWER_AGENT_TYPE: 'claude-code',
|
||||||
|
ARBITER_AGENT_TYPE: undefined,
|
||||||
normalizeServiceId: vi.fn((serviceId: string) => serviceId),
|
normalizeServiceId: vi.fn((serviceId: string) => serviceId),
|
||||||
isClaudeService: vi.fn(() => true),
|
isClaudeService: vi.fn(() => true),
|
||||||
isReviewService: vi.fn(() => false),
|
isReviewService: vi.fn(() => false),
|
||||||
@@ -556,6 +557,8 @@ describe('createMessageRuntime', () => {
|
|||||||
review_requested_at: '2026-03-30T00:00:00.000Z',
|
review_requested_at: '2026-03-30T00:00:00.000Z',
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-30T00:00:00.000Z',
|
created_at: '2026-03-30T00:00:00.000Z',
|
||||||
updated_at: '2026-03-30T00:00:00.000Z',
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type WorkItem,
|
type WorkItem,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
|
ARBITER_AGENT_TYPE,
|
||||||
CLAUDE_SERVICE_ID,
|
CLAUDE_SERVICE_ID,
|
||||||
CODEX_MAIN_SERVICE_ID,
|
CODEX_MAIN_SERVICE_ID,
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
hasAllowedTrigger,
|
hasAllowedTrigger,
|
||||||
shouldSkipBotOnlyCollaboration,
|
shouldSkipBotOnlyCollaboration,
|
||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
|
import { buildArbiterContextPrompt } from './arbiter-context.js';
|
||||||
import { runAgentForGroup } from './message-agent-executor.js';
|
import { runAgentForGroup } from './message-agent-executor.js';
|
||||||
import { MessageTurnController } from './message-turn-controller.js';
|
import { MessageTurnController } from './message-turn-controller.js';
|
||||||
import {
|
import {
|
||||||
@@ -152,7 +154,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
? 'owner'
|
? 'owner'
|
||||||
: serviceId === lease.reviewer_service_id
|
: serviceId === lease.reviewer_service_id
|
||||||
? 'reviewer'
|
? 'reviewer'
|
||||||
: msg.sender_name;
|
: serviceId === lease.arbiter_service_id
|
||||||
|
? 'arbiter'
|
||||||
|
: msg.sender_name;
|
||||||
return { ...msg, sender_name: role };
|
return { ...msg, sender_name: role };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -435,14 +439,19 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reviewer failover handoffs (reason starts with "reviewer-") should
|
// Reviewer/arbiter failover handoffs should run via the appropriate
|
||||||
// run via the reviewer channel so they execute in reviewer mode.
|
// channel so they execute in the correct role mode.
|
||||||
const isReviewerHandoff = handoff.reason?.startsWith('reviewer-');
|
const isReviewerHandoff = handoff.reason?.startsWith('reviewer-');
|
||||||
|
const isArbiterHandoff = handoff.reason?.startsWith('arbiter-');
|
||||||
let handoffChannel = channel;
|
let handoffChannel = channel;
|
||||||
if (isReviewerHandoff) {
|
if (isReviewerHandoff) {
|
||||||
const revChName =
|
const revChName =
|
||||||
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
|
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
|
||||||
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
|
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
|
||||||
|
} else if (isArbiterHandoff) {
|
||||||
|
const arbChName =
|
||||||
|
ARBITER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
|
||||||
|
handoffChannel = findChannelByName(deps.channels, arbChName) || channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runId = `handoff-${handoff.id}`;
|
const runId = `handoff-${handoff.id}`;
|
||||||
@@ -517,6 +526,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
reviewerChannelName,
|
reviewerChannelName,
|
||||||
);
|
);
|
||||||
const reviewerChannel = foundReviewerChannel || channel;
|
const reviewerChannel = foundReviewerChannel || channel;
|
||||||
|
|
||||||
|
// Arbiter channel: route arbiter output through the appropriate bot.
|
||||||
|
const arbiterChannelName =
|
||||||
|
isPairedRoomJid(chatJid) && ARBITER_AGENT_TYPE === 'claude-code'
|
||||||
|
? 'discord'
|
||||||
|
: 'discord-review';
|
||||||
|
const foundArbiterChannel = findChannelByName(
|
||||||
|
deps.channels,
|
||||||
|
arbiterChannelName,
|
||||||
|
);
|
||||||
|
const arbiterChannel = foundArbiterChannel || channel;
|
||||||
|
|
||||||
if (isPairedRoomJid(chatJid)) {
|
if (isPairedRoomJid(chatJid)) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -524,9 +545,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
reviewerChannelName,
|
reviewerChannelName,
|
||||||
foundChannel: foundReviewerChannel?.name ?? null,
|
foundChannel: foundReviewerChannel?.name ?? null,
|
||||||
usingChannel: reviewerChannel.name,
|
usingChannel: reviewerChannel.name,
|
||||||
|
arbiterChannelName,
|
||||||
|
foundArbiterChannel: foundArbiterChannel?.name ?? null,
|
||||||
|
usingArbiterChannel: arbiterChannel.name,
|
||||||
availableChannels: deps.channels.map((c) => c.name),
|
availableChannels: deps.channels.map((c) => c.name),
|
||||||
},
|
},
|
||||||
'Paired room reviewer channel resolution',
|
'Paired room reviewer/arbiter channel resolution',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +569,15 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
pendingTask &&
|
pendingTask &&
|
||||||
(pendingTask.status === 'review_ready' ||
|
(pendingTask.status === 'review_ready' ||
|
||||||
pendingTask.status === 'in_review');
|
pendingTask.status === 'in_review');
|
||||||
const deliveryChannel = isReviewerWorkItem ? reviewerChannel : channel;
|
const isArbiterWorkItem =
|
||||||
|
pendingTask &&
|
||||||
|
(pendingTask.status === 'arbiter_requested' ||
|
||||||
|
pendingTask.status === 'in_arbitration');
|
||||||
|
const deliveryChannel = isArbiterWorkItem
|
||||||
|
? arbiterChannel
|
||||||
|
: isReviewerWorkItem
|
||||||
|
? reviewerChannel
|
||||||
|
: channel;
|
||||||
const delivered = await deliverOpenWorkItem(
|
const delivered = await deliverOpenWorkItem(
|
||||||
deliveryChannel,
|
deliveryChannel,
|
||||||
openWorkItem,
|
openWorkItem,
|
||||||
@@ -619,6 +651,42 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
return deliverySucceeded;
|
return deliverySucceeded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// arbiter_requested / in_arbitration: run arbiter turn
|
||||||
|
if (
|
||||||
|
pendingReviewTask &&
|
||||||
|
(pendingReviewTask.status === 'arbiter_requested' ||
|
||||||
|
pendingReviewTask.status === 'in_arbitration')
|
||||||
|
) {
|
||||||
|
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
|
||||||
|
const cursor = lastRaw?.seq ?? lastRaw?.timestamp;
|
||||||
|
if (cursor != null) {
|
||||||
|
advanceLastAgentCursor(
|
||||||
|
deps.getLastAgentTimestamps(),
|
||||||
|
deps.saveState,
|
||||||
|
chatJid,
|
||||||
|
cursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arbiterPrompt = buildArbiterContextPrompt({
|
||||||
|
chatJid,
|
||||||
|
taskId: pendingReviewTask.id,
|
||||||
|
roundTripCount: pendingReviewTask.round_trip_count,
|
||||||
|
timezone: deps.timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { deliverySucceeded } = await executeTurn({
|
||||||
|
group,
|
||||||
|
prompt: arbiterPrompt,
|
||||||
|
chatJid,
|
||||||
|
runId,
|
||||||
|
channel: arbiterChannel,
|
||||||
|
startSeq: null,
|
||||||
|
endSeq: null,
|
||||||
|
});
|
||||||
|
return deliverySucceeded;
|
||||||
|
}
|
||||||
|
|
||||||
// merge_ready: reviewer approved, owner gets final turn to finalize
|
// merge_ready: reviewer approved, owner gets final turn to finalize
|
||||||
if (pendingReviewTask && pendingReviewTask.status === 'merge_ready') {
|
if (pendingReviewTask && pendingReviewTask.status === 'merge_ready') {
|
||||||
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
|
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
|
||||||
@@ -756,13 +824,34 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
pendingTaskForChannel &&
|
pendingTaskForChannel &&
|
||||||
(pendingTaskForChannel.status === 'review_ready' ||
|
(pendingTaskForChannel.status === 'review_ready' ||
|
||||||
pendingTaskForChannel.status === 'in_review');
|
pendingTaskForChannel.status === 'in_review');
|
||||||
const turnChannel = useReviewerChannel ? reviewerChannel : channel;
|
const useArbiterChannel =
|
||||||
const cursorKey = pairedCursorKey(chatJid, !!useReviewerChannel);
|
pendingTaskForChannel &&
|
||||||
|
(pendingTaskForChannel.status === 'arbiter_requested' ||
|
||||||
|
pendingTaskForChannel.status === 'in_arbitration');
|
||||||
|
const turnChannel = useArbiterChannel
|
||||||
|
? arbiterChannel
|
||||||
|
: useReviewerChannel
|
||||||
|
? reviewerChannel
|
||||||
|
: channel;
|
||||||
|
const cursorKey = useArbiterChannel
|
||||||
|
? `${chatJid}:arbiter`
|
||||||
|
: pairedCursorKey(chatJid, !!useReviewerChannel);
|
||||||
|
|
||||||
const prompt = formatMessages(
|
// Arbiter turns use a dedicated context prompt; regular turns use formatted messages.
|
||||||
labelPairedSenders(chatJid, missedMessages),
|
let prompt: string;
|
||||||
deps.timezone,
|
if (useArbiterChannel && pendingTaskForChannel) {
|
||||||
);
|
prompt = buildArbiterContextPrompt({
|
||||||
|
chatJid,
|
||||||
|
taskId: pendingTaskForChannel.id,
|
||||||
|
roundTripCount: pendingTaskForChannel.round_trip_count,
|
||||||
|
timezone: deps.timezone,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
prompt = formatMessages(
|
||||||
|
labelPairedSenders(chatJid, missedMessages),
|
||||||
|
deps.timezone,
|
||||||
|
);
|
||||||
|
}
|
||||||
const startSeq = missedMessages[0].seq ?? null;
|
const startSeq = missedMessages[0].seq ?? null;
|
||||||
const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
|
const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
|
||||||
if (endSeq !== null) {
|
if (endSeq !== null) {
|
||||||
@@ -953,7 +1042,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
!!loopPendingTask &&
|
!!loopPendingTask &&
|
||||||
(loopPendingTask.status === 'review_ready' ||
|
(loopPendingTask.status === 'review_ready' ||
|
||||||
loopPendingTask.status === 'in_review');
|
loopPendingTask.status === 'in_review');
|
||||||
const loopCursorKey = pairedCursorKey(chatJid, loopIsReviewerTurn);
|
const loopIsArbiterTurn =
|
||||||
|
!!loopPendingTask &&
|
||||||
|
(loopPendingTask.status === 'arbiter_requested' ||
|
||||||
|
loopPendingTask.status === 'in_arbitration');
|
||||||
|
const loopCursorKey = loopIsArbiterTurn
|
||||||
|
? `${chatJid}:arbiter`
|
||||||
|
: pairedCursorKey(chatJid, loopIsReviewerTurn);
|
||||||
|
|
||||||
const rawPendingMessages = getMessagesSinceSeq(
|
const rawPendingMessages = getMessagesSinceSeq(
|
||||||
chatJid,
|
chatJid,
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
|
|||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -198,6 +200,8 @@ describe('paired execution context', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -230,6 +234,8 @@ describe('paired execution context', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -265,6 +271,8 @@ describe('paired execution context', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -306,6 +314,8 @@ describe('paired execution context', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
status: 'in_review',
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import crypto from 'crypto';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { DATA_DIR, PAIRED_MAX_ROUND_TRIPS } from './config.js';
|
import {
|
||||||
|
ARBITER_DEADLOCK_THRESHOLD,
|
||||||
|
DATA_DIR,
|
||||||
|
PAIRED_MAX_ROUND_TRIPS,
|
||||||
|
isArbiterEnabled,
|
||||||
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
createPairedTask,
|
createPairedTask,
|
||||||
getLatestPairedTaskForChat,
|
getLatestPairedTaskForChat,
|
||||||
@@ -21,6 +26,7 @@ import {
|
|||||||
provisionOwnerWorkspaceForPairedTask,
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
} from './paired-workspace-manager.js';
|
} from './paired-workspace-manager.js';
|
||||||
import type {
|
import type {
|
||||||
|
PairedRoomRole,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
PairedWorkspace,
|
PairedWorkspace,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
@@ -57,6 +63,24 @@ function classifyReviewerVerdict(
|
|||||||
return 'continue';
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Arbiter verdict detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ArbiterVerdictResult = 'proceed' | 'revise' | 'reset' | 'escalate' | 'unknown';
|
||||||
|
|
||||||
|
function classifyArbiterVerdict(summary: string | null | undefined): ArbiterVerdictResult {
|
||||||
|
if (!summary) return 'unknown';
|
||||||
|
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||||
|
if (!cleaned) return 'unknown';
|
||||||
|
const firstLine = cleaned.split('\n')[0].trim();
|
||||||
|
if (/^\*{0,2}PROCEED\*{0,2}\b/i.test(firstLine)) return 'proceed';
|
||||||
|
if (/^\*{0,2}REVISE\*{0,2}\b/i.test(firstLine)) return 'revise';
|
||||||
|
if (/^\*{0,2}RESET\*{0,2}\b/i.test(firstLine)) return 'reset';
|
||||||
|
if (/^\*{0,2}ESCALATE\*{0,2}\b/i.test(firstLine)) return 'escalate';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -156,6 +180,8 @@ function ensureActiveTask(
|
|||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -239,7 +265,7 @@ export function preparePairedExecutionContext(args: {
|
|||||||
// Use a stable per-channel worktree (not per-task) so the Claude SDK
|
// Use a stable per-channel worktree (not per-task) so the Claude SDK
|
||||||
// session persists across tasks. Different channels still get isolation.
|
// session persists across tasks. Different channels still get isolation.
|
||||||
workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id);
|
workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id);
|
||||||
} else {
|
} else if (roomRoleContext.role === 'reviewer') {
|
||||||
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
|
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
|
||||||
workspace = reviewerWorkspace.workspace;
|
workspace = reviewerWorkspace.workspace;
|
||||||
blockMessage = reviewerWorkspace.blockMessage;
|
blockMessage = reviewerWorkspace.blockMessage;
|
||||||
@@ -250,6 +276,18 @@ export function preparePairedExecutionContext(args: {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (roomRoleContext.role === 'arbiter') {
|
||||||
|
// Arbiter uses same read-only workspace as reviewer
|
||||||
|
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
|
||||||
|
workspace = reviewerWorkspace.workspace;
|
||||||
|
blockMessage = reviewerWorkspace.blockMessage;
|
||||||
|
const refreshedTask = getPairedTaskById(latestTask.id) ?? latestTask;
|
||||||
|
if (workspace && refreshedTask.status === 'arbiter_requested') {
|
||||||
|
updatePairedTask(latestTask.id, {
|
||||||
|
status: 'in_arbitration',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const envOverrides: Record<string, string> = {
|
const envOverrides: Record<string, string> = {
|
||||||
@@ -272,6 +310,15 @@ export function preparePairedExecutionContext(args: {
|
|||||||
);
|
);
|
||||||
fs.mkdirSync(reviewerSessionDir, { recursive: true });
|
fs.mkdirSync(reviewerSessionDir, { recursive: true });
|
||||||
envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir;
|
envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir;
|
||||||
|
} else if (roomRoleContext.role === 'arbiter') {
|
||||||
|
envOverrides.EJCLAW_ARBITER_RUNTIME = '1';
|
||||||
|
const arbiterSessionDir = path.join(
|
||||||
|
DATA_DIR,
|
||||||
|
'sessions',
|
||||||
|
`${group.folder}-arbiter`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(arbiterSessionDir, { recursive: true });
|
||||||
|
envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -288,7 +335,7 @@ export function preparePairedExecutionContext(args: {
|
|||||||
|
|
||||||
export function completePairedExecutionContext(args: {
|
export function completePairedExecutionContext(args: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
role: 'owner' | 'reviewer';
|
role: PairedRoomRole;
|
||||||
status: 'succeeded' | 'failed';
|
status: 'succeeded' | 'failed';
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
@@ -487,13 +534,25 @@ export function completePairedExecutionContext(args: {
|
|||||||
case 'continue':
|
case 'continue':
|
||||||
default:
|
default:
|
||||||
// If both sides keep echoing DONE_WITH_CONCERNS without progress,
|
// If both sides keep echoing DONE_WITH_CONCERNS without progress,
|
||||||
// stop the loop after 3 round trips to prevent infinite ping-pong.
|
// request arbiter intervention (or escalate to user if arbiter not configured).
|
||||||
if (task.round_trip_count >= 3) {
|
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
|
||||||
updatePairedTask(taskId, { status: 'completed', updated_at: now });
|
if (isArbiterEnabled()) {
|
||||||
logger.info(
|
updatePairedTask(taskId, {
|
||||||
{ taskId, verdict, roundTrips: task.round_trip_count },
|
status: 'arbiter_requested',
|
||||||
'Stopped ping-pong after repeated DONE_WITH_CONCERNS — escalating to user',
|
arbiter_requested_at: now,
|
||||||
);
|
updated_at: now,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ taskId, verdict, roundTrips: task.round_trip_count },
|
||||||
|
'Deadlock detected — requesting arbiter intervention',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updatePairedTask(taskId, { status: 'completed', updated_at: now });
|
||||||
|
logger.info(
|
||||||
|
{ taskId, verdict, roundTrips: task.round_trip_count },
|
||||||
|
'Stopped ping-pong — escalating to user (arbiter not configured)',
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Owner needs to address feedback — ping-pong continues
|
// Owner needs to address feedback — ping-pong continues
|
||||||
@@ -505,4 +564,52 @@ export function completePairedExecutionContext(args: {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arbiter finished → classify verdict and route accordingly
|
||||||
|
if (role === 'arbiter') {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const arbiterVerdict = classifyArbiterVerdict(args.summary);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ taskId, arbiterVerdict, summary: args.summary?.slice(0, 200) },
|
||||||
|
'Arbiter verdict rendered',
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (arbiterVerdict) {
|
||||||
|
case 'proceed':
|
||||||
|
case 'revise':
|
||||||
|
case 'reset':
|
||||||
|
// Non-escalate: reset counter, continue ping-pong
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'active',
|
||||||
|
round_trip_count: 0,
|
||||||
|
arbiter_verdict: arbiterVerdict,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ taskId, arbiterVerdict },
|
||||||
|
'Arbiter resolved deadlock — resuming ping-pong',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'escalate':
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'completed',
|
||||||
|
arbiter_verdict: 'escalate',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ taskId },
|
||||||
|
'Arbiter escalated to user — task completed',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unknown verdict — treat as escalate
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'completed',
|
||||||
|
arbiter_verdict: 'unknown',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ describe('paired workspace manager', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -167,6 +169,8 @@ describe('paired workspace manager', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -243,6 +247,8 @@ describe('paired workspace manager', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -318,6 +324,8 @@ describe('paired workspace manager', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -459,6 +467,8 @@ describe('paired workspace manager', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -521,6 +531,8 @@ describe('paired workspace manager', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: '2026-03-28T00:01:00.000Z',
|
review_requested_at: '2026-03-28T00:01:00.000Z',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: '2026-03-28T00:01:00.000Z',
|
updated_at: '2026-03-28T00:01:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -587,6 +599,8 @@ describe('paired workspace manager', () => {
|
|||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const PAIRED_ROOM_PROMPT_FILES: Record<AgentType, string> = {
|
|||||||
codex: 'claude-paired-room.md',
|
codex: 'claude-paired-room.md',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ARBITER_PROMPT_FILE = 'arbiter-paired-room.md';
|
||||||
|
|
||||||
export function getPlatformPromptsDir(projectRoot = process.cwd()): string {
|
export function getPlatformPromptsDir(projectRoot = process.cwd()): string {
|
||||||
return path.join(projectRoot, 'prompts');
|
return path.join(projectRoot, 'prompts');
|
||||||
}
|
}
|
||||||
@@ -60,3 +62,22 @@ export function readPairedRoomPrompt(
|
|||||||
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
||||||
return prompt || undefined;
|
return prompt || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getArbiterPromptPath(
|
||||||
|
projectRoot = process.cwd(),
|
||||||
|
): string {
|
||||||
|
return path.join(
|
||||||
|
getPlatformPromptsDir(projectRoot),
|
||||||
|
ARBITER_PROMPT_FILE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readArbiterPrompt(
|
||||||
|
projectRoot = process.cwd(),
|
||||||
|
): string | undefined {
|
||||||
|
const promptPath = getArbiterPromptPath(projectRoot);
|
||||||
|
if (!fs.existsSync(promptPath)) return undefined;
|
||||||
|
|
||||||
|
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
||||||
|
return prompt || undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe('buildRoomRoleContext', () => {
|
|||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -22,6 +23,7 @@ describe('buildRoomRoleContext', () => {
|
|||||||
ownerServiceId: 'claude',
|
ownerServiceId: 'claude',
|
||||||
reviewerServiceId: 'codex-main',
|
reviewerServiceId: 'codex-main',
|
||||||
failoverOwner: false,
|
failoverOwner: false,
|
||||||
|
arbiterServiceId: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ describe('buildRoomRoleContext', () => {
|
|||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
owner_service_id: 'codex-review',
|
owner_service_id: 'codex-review',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: '2026-03-28T10:00:00.000Z',
|
activated_at: '2026-03-28T10:00:00.000Z',
|
||||||
reason: 'claude-429',
|
reason: 'claude-429',
|
||||||
explicit: true,
|
explicit: true,
|
||||||
@@ -44,6 +47,31 @@ describe('buildRoomRoleContext', () => {
|
|||||||
ownerServiceId: 'codex-review',
|
ownerServiceId: 'codex-review',
|
||||||
reviewerServiceId: 'codex-main',
|
reviewerServiceId: 'codex-main',
|
||||||
failoverOwner: true,
|
failoverOwner: true,
|
||||||
|
arbiterServiceId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns arbiter context when service matches arbiter_service_id', () => {
|
||||||
|
expect(
|
||||||
|
buildRoomRoleContext(
|
||||||
|
{
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: 'codex-review',
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
},
|
||||||
|
'codex-review',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
serviceId: 'codex-review',
|
||||||
|
role: 'arbiter',
|
||||||
|
ownerServiceId: 'codex-main',
|
||||||
|
reviewerServiceId: 'claude',
|
||||||
|
failoverOwner: false,
|
||||||
|
arbiterServiceId: 'codex-review',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +82,7 @@ describe('buildRoomRoleContext', () => {
|
|||||||
chat_jid: 'solo@test',
|
chat_jid: 'solo@test',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ export function buildRoomRoleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ownerServiceId = normalizeServiceId(lease.owner_service_id);
|
const ownerServiceId = normalizeServiceId(lease.owner_service_id);
|
||||||
|
const arbiterServiceId = lease.arbiter_service_id
|
||||||
|
? normalizeServiceId(lease.arbiter_service_id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Check arbiter role first: if this service matches the arbiter_service_id,
|
||||||
|
// it takes the arbiter role (even if it also matches owner or reviewer)
|
||||||
const role =
|
const role =
|
||||||
ownerServiceId === normalizedServiceId
|
arbiterServiceId && arbiterServiceId === normalizedServiceId
|
||||||
? 'owner'
|
? 'arbiter'
|
||||||
: reviewerServiceId === normalizedServiceId
|
: ownerServiceId === normalizedServiceId
|
||||||
? 'reviewer'
|
? 'owner'
|
||||||
: null;
|
: reviewerServiceId === normalizedServiceId
|
||||||
|
? 'reviewer'
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -39,5 +47,6 @@ export function buildRoomRoleContext(
|
|||||||
failoverOwner:
|
failoverOwner:
|
||||||
ownerServiceId === CODEX_REVIEW_SERVICE_ID &&
|
ownerServiceId === CODEX_REVIEW_SERVICE_ID &&
|
||||||
reviewerServiceId === CODEX_MAIN_SERVICE_ID,
|
reviewerServiceId === CODEX_MAIN_SERVICE_ID,
|
||||||
|
arbiterServiceId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ARBITER_SERVICE_ID,
|
||||||
CLAUDE_SERVICE_ID,
|
CLAUDE_SERVICE_ID,
|
||||||
CODEX_MAIN_SERVICE_ID,
|
CODEX_MAIN_SERVICE_ID,
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
REVIEWER_SERVICE_ID_FOR_TYPE,
|
REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||||
SERVICE_AGENT_TYPE,
|
SERVICE_AGENT_TYPE,
|
||||||
SERVICE_ID,
|
SERVICE_ID,
|
||||||
|
isArbiterEnabled,
|
||||||
normalizeServiceId,
|
normalizeServiceId,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ export interface EffectiveChannelLease {
|
|||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
owner_service_id: string;
|
owner_service_id: string;
|
||||||
reviewer_service_id: string | null;
|
reviewer_service_id: string | null;
|
||||||
|
arbiter_service_id: string | null;
|
||||||
activated_at: string | null;
|
activated_at: string | null;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
explicit: boolean;
|
explicit: boolean;
|
||||||
@@ -40,6 +43,9 @@ function normalizeLeaseRow(
|
|||||||
reviewer_service_id: row.reviewer_service_id
|
reviewer_service_id: row.reviewer_service_id
|
||||||
? normalizeServiceId(row.reviewer_service_id)
|
? normalizeServiceId(row.reviewer_service_id)
|
||||||
: null,
|
: null,
|
||||||
|
arbiter_service_id: row.arbiter_service_id
|
||||||
|
? normalizeServiceId(row.arbiter_service_id)
|
||||||
|
: null,
|
||||||
activated_at: row.activated_at,
|
activated_at: row.activated_at,
|
||||||
reason: row.reason,
|
reason: row.reason,
|
||||||
explicit,
|
explicit,
|
||||||
@@ -59,6 +65,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
owner_service_id: ownerServiceId,
|
owner_service_id: ownerServiceId,
|
||||||
reviewer_service_id: REVIEWER_SERVICE_ID_FOR_TYPE,
|
reviewer_service_id: REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||||
|
arbiter_service_id: isArbiterEnabled() ? ARBITER_SERVICE_ID : null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -70,6 +77,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
owner_service_id: CODEX_MAIN_SERVICE_ID,
|
owner_service_id: CODEX_MAIN_SERVICE_ID,
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -81,6 +89,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
owner_service_id: CLAUDE_SERVICE_ID,
|
owner_service_id: CLAUDE_SERVICE_ID,
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -94,6 +103,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|||||||
? CODEX_MAIN_SERVICE_ID
|
? CODEX_MAIN_SERVICE_ID
|
||||||
: CLAUDE_SERVICE_ID,
|
: CLAUDE_SERVICE_ID,
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -143,6 +153,17 @@ export function isReviewerServiceForChat(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isArbiterServiceForChat(
|
||||||
|
chatJid: string,
|
||||||
|
serviceId: string = SERVICE_ID,
|
||||||
|
): boolean {
|
||||||
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
|
return (
|
||||||
|
lease.arbiter_service_id !== null &&
|
||||||
|
normalizeServiceId(serviceId) === lease.arbiter_service_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldServiceProcessChat(
|
export function shouldServiceProcessChat(
|
||||||
_chatJid: string,
|
_chatJid: string,
|
||||||
_serviceId: string = SERVICE_ID,
|
_serviceId: string = SERVICE_ID,
|
||||||
@@ -156,6 +177,7 @@ export function activateCodexFailover(chatJid: string, reason: string): void {
|
|||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
owner_service_id: CODEX_REVIEW_SERVICE_ID,
|
owner_service_id: CODEX_REVIEW_SERVICE_ID,
|
||||||
reviewer_service_id: CODEX_MAIN_SERVICE_ID,
|
reviewer_service_id: CODEX_MAIN_SERVICE_ID,
|
||||||
|
arbiter_service_id: null,
|
||||||
activated_at: now,
|
activated_at: now,
|
||||||
reason,
|
reason,
|
||||||
};
|
};
|
||||||
|
|||||||
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 AgentVisibility = 'public' | 'silent';
|
||||||
|
|
||||||
export type PairedRoomRole = 'owner' | 'reviewer';
|
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
|
||||||
|
|
||||||
export type PairedTaskStatus =
|
export type PairedTaskStatus =
|
||||||
| 'active'
|
| 'active'
|
||||||
| 'review_ready'
|
| 'review_ready'
|
||||||
| 'in_review'
|
| 'in_review'
|
||||||
| 'merge_ready'
|
| 'merge_ready'
|
||||||
| 'completed';
|
| 'completed'
|
||||||
|
| 'arbiter_requested'
|
||||||
|
| 'in_arbitration';
|
||||||
|
|
||||||
|
export type ArbiterVerdict = 'proceed' | 'revise' | 'reset' | 'escalate';
|
||||||
|
|
||||||
export type PairedWorkspaceRole = 'owner' | 'reviewer';
|
export type PairedWorkspaceRole = 'owner' | 'reviewer';
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ export interface RoomRoleContext {
|
|||||||
ownerServiceId: string;
|
ownerServiceId: string;
|
||||||
reviewerServiceId: string;
|
reviewerServiceId: string;
|
||||||
failoverOwner: boolean;
|
failoverOwner: boolean;
|
||||||
|
arbiterServiceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairedProject {
|
export interface PairedProject {
|
||||||
@@ -64,6 +69,8 @@ export interface PairedTask {
|
|||||||
review_requested_at: string | null;
|
review_requested_at: string | null;
|
||||||
round_trip_count: number;
|
round_trip_count: number;
|
||||||
status: PairedTaskStatus;
|
status: PairedTaskStatus;
|
||||||
|
arbiter_verdict: string | null;
|
||||||
|
arbiter_requested_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user