From a59813dee6af911e0a3d00d64b9f9497191b5c1b Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 15 Apr 2026 23:47:46 +0900 Subject: [PATCH] feat: support room-level reviewer and arbiter agent selection --- runners/agent-runner/src/ipc-mcp-stdio.ts | 12 +++ src/db.test.ts | 23 +++++ src/db.ts | 1 + src/db/room-registration.ts | 115 ++++++++++++++++++---- src/db/rooms.ts | 13 +++ src/db/runtime-tasks-rooms.ts | 9 ++ src/ipc-auth.test.ts | 100 +++++++++++++++++++ src/ipc-task-processor.ts | 88 +++++++++++++++++ src/ipc-types.ts | 75 +++++++++++++- src/message-runtime-rules.test.ts | 50 +++++++++- src/message-runtime-rules.ts | 40 +++++--- src/paired-execution-context.test.ts | 69 +++++++++++++ src/paired-execution-context.ts | 23 +++-- src/service-routing.test.ts | 24 +++++ src/service-routing.ts | 28 +++--- 15 files changed, 618 insertions(+), 52 deletions(-) diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 2840066..b47c67f 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -798,6 +798,14 @@ If folder is omitted, the host generates one automatically.`, .enum(['claude-code', 'codex']) .optional() .describe('Preferred owner agent type for the room'), + reviewer_agent_type: z + .enum(['claude-code', 'codex']) + .optional() + .describe('Optional reviewer agent type override for tribunal rooms'), + arbiter_agent_type: z + .enum(['claude-code', 'codex']) + .optional() + .describe('Optional arbiter agent type override for tribunal rooms'), folder: z .string() .optional() @@ -826,6 +834,8 @@ If folder is omitted, the host generates one automatically.`, name: string; room_mode?: 'single' | 'tribunal'; owner_agent_type?: 'claude-code' | 'codex'; + reviewer_agent_type?: 'claude-code' | 'codex'; + arbiter_agent_type?: 'claude-code' | 'codex'; folder?: string; trigger?: string; requires_trigger?: boolean; @@ -850,6 +860,8 @@ If folder is omitted, the host generates one automatically.`, name: args.name, room_mode: args.room_mode || 'single', owner_agent_type: args.owner_agent_type, + reviewer_agent_type: args.reviewer_agent_type, + arbiter_agent_type: args.arbiter_agent_type, folder: args.folder, trigger: args.trigger, requiresTrigger: args.requires_trigger, diff --git a/src/db.test.ts b/src/db.test.ts index af0efa6..ceacaa9 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -2396,6 +2396,29 @@ describe('room assignment writes', () => { }); }); + it('includes arbiter-only room agent overrides in capability views', () => { + assignRoom('dc:arbiter-only-capability', { + name: 'Arbiter Only Capability', + roomMode: 'tribunal', + ownerAgentType: 'claude-code', + reviewerAgentType: 'claude-code', + arbiterAgentType: 'codex', + folder: 'arbiter-only-capability', + }); + + expect(getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort()).toEqual([ + 'claude-code', + 'codex', + ]); + expect( + getAllRoomBindings('codex')['dc:arbiter-only-capability'], + ).toMatchObject({ + name: 'Arbiter Only Capability', + folder: 'arbiter-only-capability', + agentType: 'codex', + }); + }); + it('updates room_settings-backed metadata across tribunal capability views', () => { assignRoom('dc:projection-room', { name: 'Projection Room', diff --git a/src/db.ts b/src/db.ts index aef3bbb..28a3e81 100644 --- a/src/db.ts +++ b/src/db.ts @@ -57,6 +57,7 @@ export { getRouterStateForService, getSession, getSessionForAgentType, + getStoredRoomRoleAgentPlan, getStoredRoomSettings, getTaskById, getTasksForGroup, diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index 775bebb..42008a2 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -1,8 +1,13 @@ import { Database } from 'bun:sqlite'; -import { OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js'; +import { + ARBITER_AGENT_TYPE, + OWNER_AGENT_TYPE, + REVIEWER_AGENT_TYPE, +} from '../config.js'; import { isValidGroupFolder } from '../group-folder.js'; import { logger } from '../logger.js'; +import { type RoleAgentPlan, resolveRoleAgentPlan } from '../role-agent-plan.js'; import type { AgentType, RegisteredGroup, RoomMode } from '../types.js'; export type RoomModeSource = 'explicit' | 'inferred'; @@ -58,8 +63,12 @@ export interface LegacyRoomMigrationPlan { export interface SyncRoomRoleOverridesOptions { ownerAgentConfig?: RegisteredGroup['agentConfig']; ownerCreatedAt?: string; + reviewerAgentType?: AgentType; reviewerAgentConfig?: RegisteredGroup['agentConfig']; reviewerCreatedAt?: string; + arbiterAgentType?: AgentType | null; + arbiterAgentConfig?: RegisteredGroup['agentConfig']; + arbiterCreatedAt?: string; updatedAt?: string; } @@ -207,14 +216,32 @@ export function getStoredRoomRowsFromDatabase( .filter((row): row is StoredRoomSettings => Boolean(row)); } +export function resolveStoredRoomRoleAgentPlan( + database: Database, + stored: StoredRoomSettings, +): RoleAgentPlan { + const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); + const ownerAgentType = + stored.ownerAgentType ?? overrides.get('owner')?.agentType ?? OWNER_AGENT_TYPE; + + return resolveRoleAgentPlan({ + paired: stored.roomMode === 'tribunal', + groupAgentType: ownerAgentType, + configuredReviewer: + overrides.get('reviewer')?.agentType ?? REVIEWER_AGENT_TYPE, + configuredArbiter: overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE, + }); +} + export function buildRegisteredGroupFromStoredSettings( database: Database, stored: StoredRoomSettings, requestedAgentType?: AgentType, ): (RegisteredGroup & { jid: string }) | undefined { const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored); + const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored); const ownerAgentType = - stored.ownerAgentType || + rolePlan.ownerAgentType || (capabilityTypes.length > 0 ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) : undefined); @@ -233,8 +260,12 @@ export function buildRegisteredGroupFromStoredSettings( return undefined; } - const role: 'owner' | 'reviewer' = - ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer'; + const role: 'owner' | 'reviewer' | 'arbiter' = + ownerAgentType === resolvedAgentType + ? 'owner' + : rolePlan.reviewerAgentType === resolvedAgentType + ? 'reviewer' + : 'arbiter'; const capabilityMetadata = getStoredRoomRoleOverrideRows( database, stored.chatJid, @@ -273,24 +304,17 @@ export function resolveStoredRoomCapabilityTypes( database: Database, stored: StoredRoomSettings, ): AgentType[] { - const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); - const ownerAgentType = - stored.ownerAgentType ?? - overrides.get('owner')?.agentType ?? - OWNER_AGENT_TYPE; - if (stored.modeSource !== 'explicit') { return inferStoredRoomCapabilityTypes(database, stored); } - const types = new Set([ownerAgentType]); - if (stored.roomMode === 'tribunal') { - const reviewerAgentType = - overrides.get('reviewer')?.agentType ?? - (ownerAgentType === REVIEWER_AGENT_TYPE - ? OWNER_AGENT_TYPE - : REVIEWER_AGENT_TYPE); - types.add(reviewerAgentType); + const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored); + const types = new Set([rolePlan.ownerAgentType]); + if (stored.roomMode === 'tribunal' && rolePlan.reviewerAgentType) { + types.add(rolePlan.reviewerAgentType); + } + if (stored.roomMode === 'tribunal' && rolePlan.arbiterAgentType) { + types.add(rolePlan.arbiterAgentType); } return [...types]; } @@ -526,11 +550,19 @@ export function syncRoomRoleOverridesForRoom( }); if (roomMode === 'tribunal') { - const reviewerAgentType = - ownerAgentType === REVIEWER_AGENT_TYPE + const existingReviewerOverride = existingOverrides.get('reviewer'); + const previousDefaultReviewerAgentType = + existingOwnerOverride?.agentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; - const existingReviewerOverride = existingOverrides.get('reviewer'); + const computedReviewerAgentType = + ownerAgentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; + const reviewerAgentType = + options.reviewerAgentType ?? + (existingReviewerOverride && + existingReviewerOverride.agentType !== previousDefaultReviewerAgentType + ? existingReviewerOverride.agentType + : computedReviewerAgentType); upsertRoomRoleOverride(database, chatJid, { role: 'reviewer', agentType: reviewerAgentType, @@ -547,6 +579,40 @@ export function syncRoomRoleOverridesForRoom( now, updatedAt: now, }); + + const existingArbiterOverride = existingOverrides.get('arbiter'); + const arbiterAgentType = + options.arbiterAgentType ?? + existingArbiterOverride?.agentType ?? + ARBITER_AGENT_TYPE ?? + null; + + if (arbiterAgentType) { + upsertRoomRoleOverride(database, chatJid, { + role: 'arbiter', + agentType: arbiterAgentType, + agentConfig: + options.arbiterAgentConfig ?? + (existingArbiterOverride?.agentType === arbiterAgentType + ? existingArbiterOverride.agentConfig + : undefined), + createdAt: + (existingArbiterOverride?.agentType === arbiterAgentType + ? existingArbiterOverride.createdAt + : undefined) ?? + options.arbiterCreatedAt ?? + now, + updatedAt: now, + }); + } else { + database + .prepare( + `DELETE FROM room_role_overrides + WHERE chat_jid = ? + AND role = 'arbiter'`, + ) + .run(chatJid); + } } else { database .prepare( @@ -555,6 +621,13 @@ export function syncRoomRoleOverridesForRoom( AND role = 'reviewer'`, ) .run(chatJid); + database + .prepare( + `DELETE FROM room_role_overrides + WHERE chat_jid = ? + AND role = 'arbiter'`, + ) + .run(chatJid); } } diff --git a/src/db/rooms.ts b/src/db/rooms.ts index 1f069ec..f50f933 100644 --- a/src/db/rooms.ts +++ b/src/db/rooms.ts @@ -17,6 +17,7 @@ import { normalizeStoredAgentType, resolveAssignedRoomFolder, resolveStoredRoomCapabilityTypes, + resolveStoredRoomRoleAgentPlan, syncRoomRoleOverridesForRoom, updateStoredRoomMetadata, upsertRoomRoleOverride, @@ -31,6 +32,8 @@ export interface AssignRoomInput { name: string; roomMode?: RoomMode; ownerAgentType?: AgentType; + reviewerAgentType?: AgentType; + arbiterAgentType?: AgentType | null; folder?: string; isMain?: boolean; workDir?: string; @@ -233,6 +236,8 @@ export function assignRoomInDatabase( syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, { ownerAgentConfig: input.ownerAgentConfig, ownerCreatedAt: input.addedAt ?? now, + reviewerAgentType: input.reviewerAgentType, + arbiterAgentType: input.arbiterAgentType, updatedAt: input.addedAt ?? now, }); })(); @@ -294,6 +299,14 @@ export function getStoredRoomSettingsFromDatabase( return getStoredRoomSettingsRowFromDatabase(database, chatJid); } +export function getStoredRoomRoleAgentPlanFromDatabase( + database: Database, + chatJid: string, +): ReturnType | undefined { + const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid); + return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined; +} + function getStoredRoomModeRowFromDatabase( database: Database, chatJid: string, diff --git a/src/db/runtime-tasks-rooms.ts b/src/db/runtime-tasks-rooms.ts index 6eac8f2..6f94077 100644 --- a/src/db/runtime-tasks-rooms.ts +++ b/src/db/runtime-tasks-rooms.ts @@ -34,6 +34,7 @@ import { getExplicitRoomModeFromDatabase, getRegisteredAgentTypesForJidFromDatabase, getRegisteredGroupFromDatabase, + getStoredRoomRoleAgentPlanFromDatabase, getStoredRoomSettingsFromDatabase, setExplicitRoomModeInDatabase, setRegisteredGroupForTestsInDatabase, @@ -354,6 +355,14 @@ export function getStoredRoomSettings( return getStoredRoomSettingsFromDatabase(db, chatJid); } +export function getStoredRoomRoleAgentPlan( + chatJid: string, +): ReturnType { + const db = getDatabaseIfInitialized(); + if (!db) return undefined; + return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid); +} + export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); } diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index bebf3db..70314b3 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -565,6 +565,73 @@ describe('IPC message authorization', () => { ); }); + it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => { + const sendMessage = vi.fn(async () => {}); + const injectInboundMessage = vi.fn(async () => {}); + + const result = await (forwardAuthorizedIpcMessage as any)( + { + type: 'inject_inbound_message', + chatJid: 'other@g.us', + text: 'act like a human input', + senderRole: 'owner', + sender: '1480575043007418420', + senderName: 'algo', + treatAsHuman: true, + sourceKind: 'trusted_external_bot', + }, + 'other-group', + false, + groups, + sendMessage, + injectInboundMessage, + ); + + expect(sendMessage).not.toHaveBeenCalled(); + expect(injectInboundMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid: 'other@g.us', + text: 'act like a human input', + sender: '1480575043007418420', + senderName: 'algo', + treatAsHuman: true, + sourceKind: 'trusted_external_bot', + }), + ); + expect(result).toEqual( + expect.objectContaining({ + outcome: 'sent', + chatJid: 'other@g.us', + }), + ); + }); + + it('does not inject unauthorized inbound messages for another group', async () => { + const sendMessage = vi.fn(async () => {}); + const injectInboundMessage = vi.fn(async () => {}); + + const result = await (forwardAuthorizedIpcMessage as any)( + { + type: 'inject_inbound_message', + chatJid: 'third@g.us', + text: 'should be blocked', + sender: '1480575043007418420', + senderName: 'algo', + treatAsHuman: true, + sourceKind: 'trusted_external_bot', + }, + 'other-group', + false, + groups, + sendMessage, + injectInboundMessage, + ); + + expect(result.outcome).toBe('blocked'); + expect(sendMessage).not.toHaveBeenCalled(); + expect(injectInboundMessage).not.toHaveBeenCalled(); + }); + it('does not forward unauthorized IPC messages even when senderRole exists', async () => { const sendMessage = vi.fn(async () => {}); @@ -975,6 +1042,39 @@ describe('assign_room success', () => { ]); }); + it('main group can assign per-room reviewer and arbiter agent types', async () => { + await processTaskIpc( + { + type: 'assign_room', + jid: 'tribunal-role-overrides@g.us', + name: 'Tribunal Role Overrides', + room_mode: 'tribunal', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: 'claude-code', + }, + 'whatsapp_main', + true, + deps, + ); + + expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject({ + chatJid: 'tribunal-role-overrides@g.us', + roomMode: 'tribunal', + modeSource: 'explicit', + name: 'Tribunal Role Overrides', + ownerAgentType: 'claude-code', + }); + expect( + getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(), + ).toEqual(['claude-code', 'codex']); + expect( + getRegisteredGroup('tribunal-role-overrides@g.us', 'codex'), + ).toMatchObject({ + agentType: 'codex', + }); + }); + it('assign_room rejects request with missing fields', async () => { await processTaskIpc( { diff --git a/src/ipc-task-processor.ts b/src/ipc-task-processor.ts index caf69c8..4fa0db9 100644 --- a/src/ipc-task-processor.ts +++ b/src/ipc-task-processor.ts @@ -241,6 +241,69 @@ export async function processTaskIpc( break; } + if (data.action === 'ejclaw_room_runtime') { + if (!data.chatJid) { + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ok: false, + action: 'ejclaw_room_runtime', + command: 'internal:ejclaw_room_runtime', + stdout: '', + stderr: '', + exitCode: 1, + error: 'Missing chatJid for ejclaw_room_runtime request', + }); + logger.warn( + { sourceGroup, requestId: data.requestId }, + 'Rejected ejclaw_room_runtime request without chatJid', + ); + break; + } + + if (!deps.getRoomRuntimeReport) { + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ok: false, + action: 'ejclaw_room_runtime', + command: 'internal:ejclaw_room_runtime', + stdout: '', + stderr: '', + exitCode: 1, + error: 'Room runtime reporting is not configured', + }); + logger.warn( + { sourceGroup, requestId: data.requestId, chatJid: data.chatJid }, + 'Rejected ejclaw_room_runtime request because runtime reporter is unavailable', + ); + break; + } + + const report = deps.getRoomRuntimeReport({ + chatJid: data.chatJid, + sourceGroup, + isMain, + }); + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ok: true, + action: 'ejclaw_room_runtime', + command: 'internal:ejclaw_room_runtime', + stdout: JSON.stringify(report, null, 2), + stderr: '', + exitCode: 0, + }); + logger.info( + { + sourceGroup, + requestId: data.requestId, + action: data.action, + chatJid: data.chatJid, + }, + 'Processed room runtime report request via IPC', + ); + break; + } + if (!isHostEvidenceAction(data.action)) { writeHostEvidenceResponse(sourceGroup, { requestId: data.requestId, @@ -405,11 +468,36 @@ export async function processTaskIpc( ); break; } + if ( + data.reviewer_agent_type !== undefined && + data.reviewer_agent_type !== 'claude-code' && + data.reviewer_agent_type !== 'codex' + ) { + logger.warn( + { sourceGroup, reviewerAgentType: data.reviewer_agent_type }, + 'Invalid assign_room request - unknown reviewer_agent_type', + ); + break; + } + if ( + data.arbiter_agent_type !== undefined && + data.arbiter_agent_type !== null && + data.arbiter_agent_type !== 'claude-code' && + data.arbiter_agent_type !== 'codex' + ) { + logger.warn( + { sourceGroup, arbiterAgentType: data.arbiter_agent_type }, + 'Invalid assign_room request - unknown arbiter_agent_type', + ); + break; + } deps.assignRoom(data.jid, { name: data.name, roomMode: data.room_mode, ownerAgentType: data.owner_agent_type, + reviewerAgentType: data.reviewer_agent_type, + arbiterAgentType: data.arbiter_agent_type, folder: data.folder, isMain: data.isMain, workDir: data.workDir, diff --git a/src/ipc-types.ts b/src/ipc-types.ts index 853789d..af9916c 100644 --- a/src/ipc-types.ts +++ b/src/ipc-types.ts @@ -1,6 +1,65 @@ import type { AvailableGroup } from './agent-runner.js'; import type { AssignRoomInput } from './db.js'; -import type { AgentType, RegisteredGroup, RoomMode } from './types.js'; +import type { + AgentType, + MessageSourceKind, + PairedTask, + RegisteredGroup, + RoomMode, +} from './types.js'; + +export interface InjectInboundMessagePayload { + chatJid: string; + text: string; + sender?: string; + senderName?: string; + messageId?: string; + timestamp?: string; + treatAsHuman: boolean; + sourceKind?: MessageSourceKind; +} + +export interface RoomRuntimeReport { + chatJid: string; + groupFolder: string; + room: RegisteredGroup | null; + queue: { + status: 'processing' | 'waiting' | 'inactive'; + runPhase: string; + elapsedMs: number | null; + pendingMessages: boolean; + pendingTasks: number; + currentRunId: string | null; + runningTaskId: string | null; + processName: string | null; + ipcDir: string | null; + retryCount: number; + retryScheduledAt: number | null; + waiting: boolean; + }; + latestOpenTask: Pick< + PairedTask, + | 'id' + | 'status' + | 'title' + | 'round_trip_count' + | 'updated_at' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + > | null; + recentMessages: Array<{ + id: string; + seq?: number; + timestamp: string; + sender: string; + senderName: string; + isFromMe: boolean; + isBotMessage: boolean; + sourceKind: MessageSourceKind; + contentPreview: string; + }>; +} export interface IpcDeps { sendMessage: ( @@ -19,6 +78,12 @@ export interface IpcDeps { isMain: boolean, availableGroups: AvailableGroup[], ) => void; + injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise; + getRoomRuntimeReport?: (args: { + chatJid: string; + sourceGroup: string; + isMain: boolean; + }) => RoomRuntimeReport; } export interface IpcMessagePayload { @@ -27,6 +92,12 @@ export interface IpcMessagePayload { text?: string; senderRole?: string; runId?: string; + sender?: string; + senderName?: string; + messageId?: string; + timestamp?: string; + treatAsHuman?: boolean; + sourceKind?: MessageSourceKind; } export interface IpcMessageForwardResult { @@ -54,6 +125,8 @@ export interface TaskIpcPayload { folder?: string; room_mode?: RoomMode; owner_agent_type?: AgentType; + reviewer_agent_type?: AgentType; + arbiter_agent_type?: AgentType | null; isMain?: boolean; workDir?: string; scopeKind?: string; diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts index b0692de..7c78c6b 100644 --- a/src/message-runtime-rules.test.ts +++ b/src/message-runtime-rules.test.ts @@ -315,6 +315,28 @@ describe('message-runtime-rules', () => { expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId); }); + it('uses lease reviewer metadata when the room reviewer differs from the owner agent type', () => { + const resolution = resolveExecutionTarget({ + lease: { + ...baseLease, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + reviewer_service_id: 'codex-review', + }, + pairedTaskStatus: 'review_ready', + groupFolder: 'group-1', + groupAgentType: 'claude-code', + }); + + expect(resolution).toMatchObject({ + activeRole: 'reviewer', + configuredAgentType: 'codex', + effectiveAgentType: 'codex', + sessionFolder: 'group-1:reviewer', + }); + expect(resolution.effectiveServiceId).toBe('codex-review'); + }); + it('resolves merge_ready execution target back to the owner/finalize path', () => { const resolution = resolveExecutionTarget({ lease: baseLease, @@ -346,8 +368,8 @@ describe('message-runtime-rules', () => { expect(resolution).toMatchObject({ inferredRole: 'arbiter', activeRole: 'arbiter', - configuredAgentType: 'claude-code', - effectiveAgentType: 'claude-code', + configuredAgentType: 'codex', + effectiveAgentType: 'codex', sessionFolder: 'group-1:arbiter', }); expect(resolution.effectiveServiceId).toBe( @@ -355,6 +377,30 @@ describe('message-runtime-rules', () => { ); }); + it('uses lease arbiter metadata when the room arbiter differs from the owner agent type', () => { + const resolution = resolveExecutionTarget({ + lease: { + ...baseLease, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + reviewer_service_id: 'claude', + arbiter_agent_type: 'claude-code', + arbiter_service_id: 'claude', + }, + pairedTaskStatus: 'arbiter_requested', + groupFolder: 'group-1', + groupAgentType: 'codex', + }); + + expect(resolution).toMatchObject({ + activeRole: 'arbiter', + configuredAgentType: 'claude-code', + effectiveAgentType: 'claude-code', + sessionFolder: 'group-1:arbiter', + }); + expect(resolution.effectiveServiceId).toBe('claude'); + }); + it('honors an arbiter forced role only when arbiter is configured', () => { const resolution = resolveExecutionTarget({ lease: { diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 7a284f9..af96408 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -248,12 +248,14 @@ export function resolveCursorKeyForRole( export function resolveConfiguredRoleAgentPlan( paired: boolean, groupAgentType: AgentType | undefined, + configuredReviewer: AgentType = REVIEWER_AGENT_TYPE, + configuredArbiter: AgentType | null | undefined = ARBITER_AGENT_TYPE, ): RoleAgentPlan { return resolveRoleAgentPlan({ paired, groupAgentType, - configuredReviewer: REVIEWER_AGENT_TYPE, - configuredArbiter: ARBITER_AGENT_TYPE, + configuredReviewer, + configuredArbiter, }); } @@ -261,8 +263,15 @@ export function resolveConfiguredRoleAgentPlan( export function resolveEffectiveAgentType( role: 'owner' | 'reviewer' | 'arbiter', groupAgentType: AgentType | undefined, + reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE, + arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE, ): AgentType { - const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType); + const plan = resolveConfiguredRoleAgentPlan( + role !== 'owner', + groupAgentType, + reviewerAgentType, + arbiterAgentType, + ); return resolveAgentTypeForRole(plan, role); } @@ -271,10 +280,17 @@ export function resolveSessionFolder( groupFolder: string, role: 'owner' | 'reviewer' | 'arbiter', groupAgentType: AgentType | undefined, + reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE, + arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE, ): string { // Arbiter always gets a separate session — must never share with owner/reviewer if (role === 'arbiter') return `${groupFolder}:arbiter`; - const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType); + const plan = resolveConfiguredRoleAgentPlan( + role !== 'owner', + groupAgentType, + reviewerAgentType, + arbiterAgentType, + ); const effectiveType = resolveAgentTypeForRole(plan, role); const groupDefault = plan.ownerAgentType; if (effectiveType === groupDefault) return groupFolder; @@ -316,14 +332,14 @@ export function resolveExecutionTarget(args: { const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer'); const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter'); + const ownerAgentType = args.lease.owner_agent_type ?? args.groupAgentType; const roleAgentPlan = resolveConfiguredRoleAgentPlan( - args.lease.reviewer_agent_type != null, - args.groupAgentType, - ); - const configuredAgentType = resolveEffectiveAgentType( - activeRole, - args.groupAgentType, + reviewerServiceId != null, + ownerAgentType, + args.lease.reviewer_agent_type ?? REVIEWER_AGENT_TYPE, + args.lease.arbiter_agent_type ?? ARBITER_AGENT_TYPE, ); + const configuredAgentType = resolveAgentTypeForRole(roleAgentPlan, activeRole); const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; return { @@ -339,7 +355,9 @@ export function resolveExecutionTarget(args: { sessionFolder: resolveSessionFolder( args.groupFolder, activeRole, - args.groupAgentType, + roleAgentPlan.ownerAgentType, + roleAgentPlan.reviewerAgentType ?? REVIEWER_AGENT_TYPE, + roleAgentPlan.arbiterAgentType ?? ARBITER_AGENT_TYPE, ), }; } diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 47c1520..bd5c662 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -250,6 +250,32 @@ describe('paired execution context', () => { }); }); + it('uses room role context agent overrides when creating a paired task', () => { + preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-room-overrides', + roomRoleContext: { + ...ownerContext, + ownerAgentType: 'codex', + reviewerAgentType: 'codex', + reviewerServiceId: config.CODEX_REVIEW_SERVICE_ID, + arbiterAgentType: 'claude-code', + }, + hasHumanMessage: true, + }); + + expect(db.createPairedTask).toHaveBeenCalledWith( + expect.objectContaining({ + owner_service_id: config.CODEX_MAIN_SERVICE_ID, + reviewer_service_id: config.CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'codex', + arbiter_agent_type: 'claude-code', + }), + ); + }); + it('persists stable role-slot service shadow instead of the transient failover owner lease', () => { preparePairedExecutionContext({ group, @@ -562,6 +588,49 @@ describe('paired execution context', () => { delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; }); + it('honors room-level reviewer agent overrides for unsafe host reviewer runtime env', () => { + process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue( + buildPairedTask({ + status: 'review_ready', + review_requested_at: '2026-03-28T00:00:00.000Z', + reviewer_agent_type: 'codex', + }), + ); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'review_ready', + review_requested_at: '2026-03-28T00:00:00.000Z', + reviewer_agent_type: 'codex', + }), + ); + vi.mocked( + pairedWorkspaceManager.prepareReviewerWorkspaceForExecution, + ).mockReturnValue({ + workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'), + autoRefreshed: false, + }); + + const result = preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-host-reviewer-room-override', + roomRoleContext: { + ...reviewerContext, + reviewerAgentType: 'codex', + }, + }); + + expect(result?.envOverrides).toMatchObject({ + EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer', + EJCLAW_PAIRED_ROLE: 'reviewer', + EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', + }); + expect(result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY).toBeUndefined(); + expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined(); + delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; + }); + it('completePairedExecutionContext logs without error', () => { completePairedExecutionContext({ taskId: 'task-1', diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 46b0fcb..41ec9dc 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -106,14 +106,17 @@ function createActiveTaskForRoom(args: { group: RegisteredGroup; chatJid: string; canonicalWorkDir: string; + roomRoleContext?: RoomRoleContext; }): PairedTask { - const { group, chatJid, canonicalWorkDir } = args; + const { group, chatJid, canonicalWorkDir, roomRoleContext } = args; const now = new Date().toISOString(); const rolePlan = resolveRoleAgentPlan({ paired: true, - groupAgentType: group.agentType, - configuredReviewer: REVIEWER_AGENT_TYPE, - configuredArbiter: ARBITER_AGENT_TYPE, + groupAgentType: roomRoleContext?.ownerAgentType ?? group.agentType, + configuredReviewer: + roomRoleContext?.reviewerAgentType ?? REVIEWER_AGENT_TYPE, + configuredArbiter: + roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE, }); const ownerServiceShadow = resolvePairedTaskServiceShadow( 'owner', @@ -195,6 +198,7 @@ export interface ResolvedOwnerHumanTask { export function resolveOwnerTaskForHumanMessage(args: { group: RegisteredGroup; chatJid: string; + roomRoleContext?: RoomRoleContext; existingTask?: PairedTask | null; }): ResolvedOwnerHumanTask { const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid); @@ -208,6 +212,7 @@ export function resolveOwnerTaskForHumanMessage(args: { group: args.group, chatJid: args.chatJid, canonicalWorkDir, + roomRoleContext: args.roomRoleContext, }) : null, supersededTask: null, @@ -247,6 +252,7 @@ export function resolveOwnerTaskForHumanMessage(args: { group: args.group, chatJid: args.chatJid, canonicalWorkDir, + roomRoleContext: args.roomRoleContext, }), supersededTask: existing, }; @@ -267,6 +273,7 @@ function ensureActiveTask( return resolveOwnerTaskForHumanMessage({ group, chatJid, + roomRoleContext, existingTask: existing ?? null, }).task; } @@ -287,6 +294,7 @@ function ensureActiveTask( group, chatJid, canonicalWorkDir, + roomRoleContext, }); } @@ -470,7 +478,7 @@ export function preparePairedExecutionContext(args: { envOverrides, buildPairedReadonlyRuntimeEnvOverrides({ role: 'reviewer', - agentType: REVIEWER_AGENT_TYPE, + agentType: roomRoleContext.reviewerAgentType ?? REVIEWER_AGENT_TYPE, unsafeHostPairedMode, }), ); @@ -489,7 +497,10 @@ export function preparePairedExecutionContext(args: { envOverrides, buildPairedReadonlyRuntimeEnvOverrides({ role: 'arbiter', - agentType: ARBITER_AGENT_TYPE ?? REVIEWER_AGENT_TYPE, + agentType: + roomRoleContext.arbiterAgentType ?? + ARBITER_AGENT_TYPE ?? + REVIEWER_AGENT_TYPE, unsafeHostPairedMode, }), ); diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index 2f7dc92..3c86cba 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -5,6 +5,7 @@ import { _setRegisteredGroupForTests, _setStoredRoomOwnerAgentTypeForTests, _initTestDatabase, + assignRoom, setChannelOwnerLease, setExplicitRoomMode, } from './db.js'; @@ -236,6 +237,29 @@ describe('service-routing global failover', () => { }); }); + it('uses room-level reviewer and arbiter agent overrides from assign_room', () => { + assignRoom('dc:room-role-overrides', { + name: 'Room Role Overrides', + roomMode: 'tribunal', + ownerAgentType: 'claude-code', + reviewerAgentType: 'codex', + arbiterAgentType: 'claude-code', + folder: 'room-role-overrides', + }); + + expect(getEffectiveChannelLease('dc:room-role-overrides')).toMatchObject({ + chat_jid: 'dc:room-role-overrides', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: 'claude-code', + owner_service_id: 'claude', + reviewer_service_id: 'codex-review', + arbiter_service_id: 'claude', + owner_failover_active: false, + explicit: false, + }); + }); + it('defaults to the configured owner service for chats without canonical room settings', () => { expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({ chat_jid: 'dc:unregistered', diff --git a/src/service-routing.ts b/src/service-routing.ts index 1eb974d..35c53d9 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -12,6 +12,7 @@ import { clearChannelOwnerLease, getAllChannelOwnerLeases, getEffectiveRuntimeRoomMode, + getStoredRoomRoleAgentPlan, getStoredRoomSettings, type ChannelOwnerLeaseRow, } from './db.js'; @@ -46,8 +47,10 @@ function normalizeLeaseRow( row: ChannelOwnerLeaseRow, explicit: boolean, ): EffectiveChannelLease { + const storedRolePlan = getStoredRoomRoleAgentPlan(row.chat_jid); const ownerAgentType = row.owner_agent_type ?? + storedRolePlan?.ownerAgentType ?? getStoredRoomSettings(row.chat_jid)?.ownerAgentType ?? inferAgentTypeFromServiceShadow(row.owner_service_id) ?? 'claude-code'; @@ -56,6 +59,7 @@ function normalizeLeaseRow( ? null : (row.reviewer_agent_type ?? inferAgentTypeFromServiceShadow(row.reviewer_service_id) ?? + storedRolePlan?.reviewerAgentType ?? resolveRoleAgentPlan({ paired: true, groupAgentType: ownerAgentType, @@ -67,6 +71,7 @@ function normalizeLeaseRow( (row.arbiter_service_id ? inferAgentTypeFromServiceShadow(row.arbiter_service_id) : undefined) ?? + storedRolePlan?.arbiterAgentType ?? null; return { @@ -102,20 +107,21 @@ function normalizeLeaseRow( }; } -function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined { - return getStoredRoomSettings(chatJid)?.ownerAgentType; -} - function getDefaultLease(chatJid: string): EffectiveChannelLease { const roomMode = getEffectiveRuntimeRoomMode(chatJid); + const storedRolePlan = getStoredRoomRoleAgentPlan(chatJid); const ownerAgentType = - resolveDefaultOwnerAgentType(chatJid) ?? OWNER_AGENT_TYPE; - const rolePlan = resolveRoleAgentPlan({ - paired: roomMode === 'tribunal', - groupAgentType: ownerAgentType, - configuredReviewer: REVIEWER_AGENT_TYPE, - configuredArbiter: ARBITER_AGENT_TYPE, - }); + storedRolePlan?.ownerAgentType ?? + getStoredRoomSettings(chatJid)?.ownerAgentType ?? + OWNER_AGENT_TYPE; + const rolePlan = + storedRolePlan ?? + resolveRoleAgentPlan({ + paired: roomMode === 'tribunal', + groupAgentType: ownerAgentType, + configuredReviewer: REVIEWER_AGENT_TYPE, + configuredArbiter: ARBITER_AGENT_TYPE, + }); return { chat_jid: chatJid,