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

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

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

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

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

View File

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