feat: support room-level reviewer and arbiter agent selection

This commit is contained in:
ejclaw
2026-04-15 23:47:46 +09:00
parent 1846d59d2f
commit a59813dee6
15 changed files with 618 additions and 52 deletions

View File

@@ -798,6 +798,14 @@ If folder is omitted, the host generates one automatically.`,
.enum(['claude-code', 'codex']) .enum(['claude-code', 'codex'])
.optional() .optional()
.describe('Preferred owner agent type for the room'), .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 folder: z
.string() .string()
.optional() .optional()
@@ -826,6 +834,8 @@ If folder is omitted, the host generates one automatically.`,
name: string; name: string;
room_mode?: 'single' | 'tribunal'; room_mode?: 'single' | 'tribunal';
owner_agent_type?: 'claude-code' | 'codex'; owner_agent_type?: 'claude-code' | 'codex';
reviewer_agent_type?: 'claude-code' | 'codex';
arbiter_agent_type?: 'claude-code' | 'codex';
folder?: string; folder?: string;
trigger?: string; trigger?: string;
requires_trigger?: boolean; requires_trigger?: boolean;
@@ -850,6 +860,8 @@ If folder is omitted, the host generates one automatically.`,
name: args.name, name: args.name,
room_mode: args.room_mode || 'single', room_mode: args.room_mode || 'single',
owner_agent_type: args.owner_agent_type, owner_agent_type: args.owner_agent_type,
reviewer_agent_type: args.reviewer_agent_type,
arbiter_agent_type: args.arbiter_agent_type,
folder: args.folder, folder: args.folder,
trigger: args.trigger, trigger: args.trigger,
requiresTrigger: args.requires_trigger, requiresTrigger: args.requires_trigger,

View File

@@ -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', () => { it('updates room_settings-backed metadata across tribunal capability views', () => {
assignRoom('dc:projection-room', { assignRoom('dc:projection-room', {
name: 'Projection Room', name: 'Projection Room',

View File

@@ -57,6 +57,7 @@ export {
getRouterStateForService, getRouterStateForService,
getSession, getSession,
getSessionForAgentType, getSessionForAgentType,
getStoredRoomRoleAgentPlan,
getStoredRoomSettings, getStoredRoomSettings,
getTaskById, getTaskById,
getTasksForGroup, getTasksForGroup,

View File

@@ -1,8 +1,13 @@
import { Database } from 'bun:sqlite'; 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 { isValidGroupFolder } from '../group-folder.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { type RoleAgentPlan, resolveRoleAgentPlan } from '../role-agent-plan.js';
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js'; import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
export type RoomModeSource = 'explicit' | 'inferred'; export type RoomModeSource = 'explicit' | 'inferred';
@@ -58,8 +63,12 @@ export interface LegacyRoomMigrationPlan {
export interface SyncRoomRoleOverridesOptions { export interface SyncRoomRoleOverridesOptions {
ownerAgentConfig?: RegisteredGroup['agentConfig']; ownerAgentConfig?: RegisteredGroup['agentConfig'];
ownerCreatedAt?: string; ownerCreatedAt?: string;
reviewerAgentType?: AgentType;
reviewerAgentConfig?: RegisteredGroup['agentConfig']; reviewerAgentConfig?: RegisteredGroup['agentConfig'];
reviewerCreatedAt?: string; reviewerCreatedAt?: string;
arbiterAgentType?: AgentType | null;
arbiterAgentConfig?: RegisteredGroup['agentConfig'];
arbiterCreatedAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -207,14 +216,32 @@ export function getStoredRoomRowsFromDatabase(
.filter((row): row is StoredRoomSettings => Boolean(row)); .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( export function buildRegisteredGroupFromStoredSettings(
database: Database, database: Database,
stored: StoredRoomSettings, stored: StoredRoomSettings,
requestedAgentType?: AgentType, requestedAgentType?: AgentType,
): (RegisteredGroup & { jid: string }) | undefined { ): (RegisteredGroup & { jid: string }) | undefined {
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored); const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored);
const ownerAgentType = const ownerAgentType =
stored.ownerAgentType || rolePlan.ownerAgentType ||
(capabilityTypes.length > 0 (capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: undefined); : undefined);
@@ -233,8 +260,12 @@ export function buildRegisteredGroupFromStoredSettings(
return undefined; return undefined;
} }
const role: 'owner' | 'reviewer' = const role: 'owner' | 'reviewer' | 'arbiter' =
ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer'; ownerAgentType === resolvedAgentType
? 'owner'
: rolePlan.reviewerAgentType === resolvedAgentType
? 'reviewer'
: 'arbiter';
const capabilityMetadata = getStoredRoomRoleOverrideRows( const capabilityMetadata = getStoredRoomRoleOverrideRows(
database, database,
stored.chatJid, stored.chatJid,
@@ -273,24 +304,17 @@ export function resolveStoredRoomCapabilityTypes(
database: Database, database: Database,
stored: StoredRoomSettings, stored: StoredRoomSettings,
): AgentType[] { ): AgentType[] {
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
const ownerAgentType =
stored.ownerAgentType ??
overrides.get('owner')?.agentType ??
OWNER_AGENT_TYPE;
if (stored.modeSource !== 'explicit') { if (stored.modeSource !== 'explicit') {
return inferStoredRoomCapabilityTypes(database, stored); return inferStoredRoomCapabilityTypes(database, stored);
} }
const types = new Set<AgentType>([ownerAgentType]); const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored);
if (stored.roomMode === 'tribunal') { const types = new Set<AgentType>([rolePlan.ownerAgentType]);
const reviewerAgentType = if (stored.roomMode === 'tribunal' && rolePlan.reviewerAgentType) {
overrides.get('reviewer')?.agentType ?? types.add(rolePlan.reviewerAgentType);
(ownerAgentType === REVIEWER_AGENT_TYPE }
? OWNER_AGENT_TYPE if (stored.roomMode === 'tribunal' && rolePlan.arbiterAgentType) {
: REVIEWER_AGENT_TYPE); types.add(rolePlan.arbiterAgentType);
types.add(reviewerAgentType);
} }
return [...types]; return [...types];
} }
@@ -526,11 +550,19 @@ export function syncRoomRoleOverridesForRoom(
}); });
if (roomMode === 'tribunal') { if (roomMode === 'tribunal') {
const reviewerAgentType = const existingReviewerOverride = existingOverrides.get('reviewer');
ownerAgentType === REVIEWER_AGENT_TYPE const previousDefaultReviewerAgentType =
existingOwnerOverride?.agentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE ? OWNER_AGENT_TYPE
: REVIEWER_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, { upsertRoomRoleOverride(database, chatJid, {
role: 'reviewer', role: 'reviewer',
agentType: reviewerAgentType, agentType: reviewerAgentType,
@@ -547,6 +579,40 @@ export function syncRoomRoleOverridesForRoom(
now, now,
updatedAt: 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 { } else {
database database
.prepare( .prepare(
@@ -555,6 +621,13 @@ export function syncRoomRoleOverridesForRoom(
AND role = 'reviewer'`, AND role = 'reviewer'`,
) )
.run(chatJid); .run(chatJid);
database
.prepare(
`DELETE FROM room_role_overrides
WHERE chat_jid = ?
AND role = 'arbiter'`,
)
.run(chatJid);
} }
} }

View File

@@ -17,6 +17,7 @@ import {
normalizeStoredAgentType, normalizeStoredAgentType,
resolveAssignedRoomFolder, resolveAssignedRoomFolder,
resolveStoredRoomCapabilityTypes, resolveStoredRoomCapabilityTypes,
resolveStoredRoomRoleAgentPlan,
syncRoomRoleOverridesForRoom, syncRoomRoleOverridesForRoom,
updateStoredRoomMetadata, updateStoredRoomMetadata,
upsertRoomRoleOverride, upsertRoomRoleOverride,
@@ -31,6 +32,8 @@ export interface AssignRoomInput {
name: string; name: string;
roomMode?: RoomMode; roomMode?: RoomMode;
ownerAgentType?: AgentType; ownerAgentType?: AgentType;
reviewerAgentType?: AgentType;
arbiterAgentType?: AgentType | null;
folder?: string; folder?: string;
isMain?: boolean; isMain?: boolean;
workDir?: string; workDir?: string;
@@ -233,6 +236,8 @@ export function assignRoomInDatabase(
syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, { syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, {
ownerAgentConfig: input.ownerAgentConfig, ownerAgentConfig: input.ownerAgentConfig,
ownerCreatedAt: input.addedAt ?? now, ownerCreatedAt: input.addedAt ?? now,
reviewerAgentType: input.reviewerAgentType,
arbiterAgentType: input.arbiterAgentType,
updatedAt: input.addedAt ?? now, updatedAt: input.addedAt ?? now,
}); });
})(); })();
@@ -294,6 +299,14 @@ export function getStoredRoomSettingsFromDatabase(
return getStoredRoomSettingsRowFromDatabase(database, chatJid); return getStoredRoomSettingsRowFromDatabase(database, chatJid);
} }
export function getStoredRoomRoleAgentPlanFromDatabase(
database: Database,
chatJid: string,
): ReturnType<typeof resolveStoredRoomRoleAgentPlan> | undefined {
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined;
}
function getStoredRoomModeRowFromDatabase( function getStoredRoomModeRowFromDatabase(
database: Database, database: Database,
chatJid: string, chatJid: string,

View File

@@ -34,6 +34,7 @@ import {
getExplicitRoomModeFromDatabase, getExplicitRoomModeFromDatabase,
getRegisteredAgentTypesForJidFromDatabase, getRegisteredAgentTypesForJidFromDatabase,
getRegisteredGroupFromDatabase, getRegisteredGroupFromDatabase,
getStoredRoomRoleAgentPlanFromDatabase,
getStoredRoomSettingsFromDatabase, getStoredRoomSettingsFromDatabase,
setExplicitRoomModeInDatabase, setExplicitRoomModeInDatabase,
setRegisteredGroupForTestsInDatabase, setRegisteredGroupForTestsInDatabase,
@@ -354,6 +355,14 @@ export function getStoredRoomSettings(
return getStoredRoomSettingsFromDatabase(db, chatJid); return getStoredRoomSettingsFromDatabase(db, chatJid);
} }
export function getStoredRoomRoleAgentPlan(
chatJid: string,
): ReturnType<typeof getStoredRoomRoleAgentPlanFromDatabase> {
const db = getDatabaseIfInitialized();
if (!db) return undefined;
return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid);
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
} }

View File

@@ -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 () => { it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
const sendMessage = vi.fn(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 () => { it('assign_room rejects request with missing fields', async () => {
await processTaskIpc( await processTaskIpc(
{ {

View File

@@ -241,6 +241,69 @@ export async function processTaskIpc(
break; 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)) { if (!isHostEvidenceAction(data.action)) {
writeHostEvidenceResponse(sourceGroup, { writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId, requestId: data.requestId,
@@ -405,11 +468,36 @@ export async function processTaskIpc(
); );
break; 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, { deps.assignRoom(data.jid, {
name: data.name, name: data.name,
roomMode: data.room_mode, roomMode: data.room_mode,
ownerAgentType: data.owner_agent_type, ownerAgentType: data.owner_agent_type,
reviewerAgentType: data.reviewer_agent_type,
arbiterAgentType: data.arbiter_agent_type,
folder: data.folder, folder: data.folder,
isMain: data.isMain, isMain: data.isMain,
workDir: data.workDir, workDir: data.workDir,

View File

@@ -1,6 +1,65 @@
import type { AvailableGroup } from './agent-runner.js'; import type { AvailableGroup } from './agent-runner.js';
import type { AssignRoomInput } from './db.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 { export interface IpcDeps {
sendMessage: ( sendMessage: (
@@ -19,6 +78,12 @@ export interface IpcDeps {
isMain: boolean, isMain: boolean,
availableGroups: AvailableGroup[], availableGroups: AvailableGroup[],
) => void; ) => void;
injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise<void>;
getRoomRuntimeReport?: (args: {
chatJid: string;
sourceGroup: string;
isMain: boolean;
}) => RoomRuntimeReport;
} }
export interface IpcMessagePayload { export interface IpcMessagePayload {
@@ -27,6 +92,12 @@ export interface IpcMessagePayload {
text?: string; text?: string;
senderRole?: string; senderRole?: string;
runId?: string; runId?: string;
sender?: string;
senderName?: string;
messageId?: string;
timestamp?: string;
treatAsHuman?: boolean;
sourceKind?: MessageSourceKind;
} }
export interface IpcMessageForwardResult { export interface IpcMessageForwardResult {
@@ -54,6 +125,8 @@ export interface TaskIpcPayload {
folder?: string; folder?: string;
room_mode?: RoomMode; room_mode?: RoomMode;
owner_agent_type?: AgentType; owner_agent_type?: AgentType;
reviewer_agent_type?: AgentType;
arbiter_agent_type?: AgentType | null;
isMain?: boolean; isMain?: boolean;
workDir?: string; workDir?: string;
scopeKind?: string; scopeKind?: string;

View File

@@ -315,6 +315,28 @@ describe('message-runtime-rules', () => {
expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId); 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', () => { it('resolves merge_ready execution target back to the owner/finalize path', () => {
const resolution = resolveExecutionTarget({ const resolution = resolveExecutionTarget({
lease: baseLease, lease: baseLease,
@@ -346,8 +368,8 @@ describe('message-runtime-rules', () => {
expect(resolution).toMatchObject({ expect(resolution).toMatchObject({
inferredRole: 'arbiter', inferredRole: 'arbiter',
activeRole: 'arbiter', activeRole: 'arbiter',
configuredAgentType: 'claude-code', configuredAgentType: 'codex',
effectiveAgentType: 'claude-code', effectiveAgentType: 'codex',
sessionFolder: 'group-1:arbiter', sessionFolder: 'group-1:arbiter',
}); });
expect(resolution.effectiveServiceId).toBe( 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', () => { it('honors an arbiter forced role only when arbiter is configured', () => {
const resolution = resolveExecutionTarget({ const resolution = resolveExecutionTarget({
lease: { lease: {

View File

@@ -248,12 +248,14 @@ export function resolveCursorKeyForRole(
export function resolveConfiguredRoleAgentPlan( export function resolveConfiguredRoleAgentPlan(
paired: boolean, paired: boolean,
groupAgentType: AgentType | undefined, groupAgentType: AgentType | undefined,
configuredReviewer: AgentType = REVIEWER_AGENT_TYPE,
configuredArbiter: AgentType | null | undefined = ARBITER_AGENT_TYPE,
): RoleAgentPlan { ): RoleAgentPlan {
return resolveRoleAgentPlan({ return resolveRoleAgentPlan({
paired, paired,
groupAgentType, groupAgentType,
configuredReviewer: REVIEWER_AGENT_TYPE, configuredReviewer,
configuredArbiter: ARBITER_AGENT_TYPE, configuredArbiter,
}); });
} }
@@ -261,8 +263,15 @@ export function resolveConfiguredRoleAgentPlan(
export function resolveEffectiveAgentType( export function resolveEffectiveAgentType(
role: 'owner' | 'reviewer' | 'arbiter', role: 'owner' | 'reviewer' | 'arbiter',
groupAgentType: AgentType | undefined, groupAgentType: AgentType | undefined,
reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE,
arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE,
): AgentType { ): AgentType {
const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType); const plan = resolveConfiguredRoleAgentPlan(
role !== 'owner',
groupAgentType,
reviewerAgentType,
arbiterAgentType,
);
return resolveAgentTypeForRole(plan, role); return resolveAgentTypeForRole(plan, role);
} }
@@ -271,10 +280,17 @@ export function resolveSessionFolder(
groupFolder: string, groupFolder: string,
role: 'owner' | 'reviewer' | 'arbiter', role: 'owner' | 'reviewer' | 'arbiter',
groupAgentType: AgentType | undefined, groupAgentType: AgentType | undefined,
reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE,
arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE,
): string { ): string {
// Arbiter always gets a separate session — must never share with owner/reviewer // Arbiter always gets a separate session — must never share with owner/reviewer
if (role === 'arbiter') return `${groupFolder}:arbiter`; 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 effectiveType = resolveAgentTypeForRole(plan, role);
const groupDefault = plan.ownerAgentType; const groupDefault = plan.ownerAgentType;
if (effectiveType === groupDefault) return groupFolder; if (effectiveType === groupDefault) return groupFolder;
@@ -316,14 +332,14 @@ export function resolveExecutionTarget(args: {
const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer'); const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer');
const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter'); const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter');
const ownerAgentType = args.lease.owner_agent_type ?? args.groupAgentType;
const roleAgentPlan = resolveConfiguredRoleAgentPlan( const roleAgentPlan = resolveConfiguredRoleAgentPlan(
args.lease.reviewer_agent_type != null, reviewerServiceId != null,
args.groupAgentType, ownerAgentType,
); args.lease.reviewer_agent_type ?? REVIEWER_AGENT_TYPE,
const configuredAgentType = resolveEffectiveAgentType( args.lease.arbiter_agent_type ?? ARBITER_AGENT_TYPE,
activeRole,
args.groupAgentType,
); );
const configuredAgentType = resolveAgentTypeForRole(roleAgentPlan, activeRole);
const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; const effectiveAgentType = args.forcedAgentType ?? configuredAgentType;
return { return {
@@ -339,7 +355,9 @@ export function resolveExecutionTarget(args: {
sessionFolder: resolveSessionFolder( sessionFolder: resolveSessionFolder(
args.groupFolder, args.groupFolder,
activeRole, activeRole,
args.groupAgentType, roleAgentPlan.ownerAgentType,
roleAgentPlan.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
roleAgentPlan.arbiterAgentType ?? ARBITER_AGENT_TYPE,
), ),
}; };
} }

View File

@@ -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', () => { it('persists stable role-slot service shadow instead of the transient failover owner lease', () => {
preparePairedExecutionContext({ preparePairedExecutionContext({
group, group,
@@ -562,6 +588,49 @@ describe('paired execution context', () => {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; 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', () => { it('completePairedExecutionContext logs without error', () => {
completePairedExecutionContext({ completePairedExecutionContext({
taskId: 'task-1', taskId: 'task-1',

View File

@@ -106,14 +106,17 @@ function createActiveTaskForRoom(args: {
group: RegisteredGroup; group: RegisteredGroup;
chatJid: string; chatJid: string;
canonicalWorkDir: string; canonicalWorkDir: string;
roomRoleContext?: RoomRoleContext;
}): PairedTask { }): PairedTask {
const { group, chatJid, canonicalWorkDir } = args; const { group, chatJid, canonicalWorkDir, roomRoleContext } = args;
const now = new Date().toISOString(); const now = new Date().toISOString();
const rolePlan = resolveRoleAgentPlan({ const rolePlan = resolveRoleAgentPlan({
paired: true, paired: true,
groupAgentType: group.agentType, groupAgentType: roomRoleContext?.ownerAgentType ?? group.agentType,
configuredReviewer: REVIEWER_AGENT_TYPE, configuredReviewer:
configuredArbiter: ARBITER_AGENT_TYPE, roomRoleContext?.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
configuredArbiter:
roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE,
}); });
const ownerServiceShadow = resolvePairedTaskServiceShadow( const ownerServiceShadow = resolvePairedTaskServiceShadow(
'owner', 'owner',
@@ -195,6 +198,7 @@ export interface ResolvedOwnerHumanTask {
export function resolveOwnerTaskForHumanMessage(args: { export function resolveOwnerTaskForHumanMessage(args: {
group: RegisteredGroup; group: RegisteredGroup;
chatJid: string; chatJid: string;
roomRoleContext?: RoomRoleContext;
existingTask?: PairedTask | null; existingTask?: PairedTask | null;
}): ResolvedOwnerHumanTask { }): ResolvedOwnerHumanTask {
const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid); const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid);
@@ -208,6 +212,7 @@ export function resolveOwnerTaskForHumanMessage(args: {
group: args.group, group: args.group,
chatJid: args.chatJid, chatJid: args.chatJid,
canonicalWorkDir, canonicalWorkDir,
roomRoleContext: args.roomRoleContext,
}) })
: null, : null,
supersededTask: null, supersededTask: null,
@@ -247,6 +252,7 @@ export function resolveOwnerTaskForHumanMessage(args: {
group: args.group, group: args.group,
chatJid: args.chatJid, chatJid: args.chatJid,
canonicalWorkDir, canonicalWorkDir,
roomRoleContext: args.roomRoleContext,
}), }),
supersededTask: existing, supersededTask: existing,
}; };
@@ -267,6 +273,7 @@ function ensureActiveTask(
return resolveOwnerTaskForHumanMessage({ return resolveOwnerTaskForHumanMessage({
group, group,
chatJid, chatJid,
roomRoleContext,
existingTask: existing ?? null, existingTask: existing ?? null,
}).task; }).task;
} }
@@ -287,6 +294,7 @@ function ensureActiveTask(
group, group,
chatJid, chatJid,
canonicalWorkDir, canonicalWorkDir,
roomRoleContext,
}); });
} }
@@ -470,7 +478,7 @@ export function preparePairedExecutionContext(args: {
envOverrides, envOverrides,
buildPairedReadonlyRuntimeEnvOverrides({ buildPairedReadonlyRuntimeEnvOverrides({
role: 'reviewer', role: 'reviewer',
agentType: REVIEWER_AGENT_TYPE, agentType: roomRoleContext.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
unsafeHostPairedMode, unsafeHostPairedMode,
}), }),
); );
@@ -489,7 +497,10 @@ export function preparePairedExecutionContext(args: {
envOverrides, envOverrides,
buildPairedReadonlyRuntimeEnvOverrides({ buildPairedReadonlyRuntimeEnvOverrides({
role: 'arbiter', role: 'arbiter',
agentType: ARBITER_AGENT_TYPE ?? REVIEWER_AGENT_TYPE, agentType:
roomRoleContext.arbiterAgentType ??
ARBITER_AGENT_TYPE ??
REVIEWER_AGENT_TYPE,
unsafeHostPairedMode, unsafeHostPairedMode,
}), }),
); );

View File

@@ -5,6 +5,7 @@ import {
_setRegisteredGroupForTests, _setRegisteredGroupForTests,
_setStoredRoomOwnerAgentTypeForTests, _setStoredRoomOwnerAgentTypeForTests,
_initTestDatabase, _initTestDatabase,
assignRoom,
setChannelOwnerLease, setChannelOwnerLease,
setExplicitRoomMode, setExplicitRoomMode,
} from './db.js'; } 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', () => { it('defaults to the configured owner service for chats without canonical room settings', () => {
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({ expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
chat_jid: 'dc:unregistered', chat_jid: 'dc:unregistered',

View File

@@ -12,6 +12,7 @@ import {
clearChannelOwnerLease, clearChannelOwnerLease,
getAllChannelOwnerLeases, getAllChannelOwnerLeases,
getEffectiveRuntimeRoomMode, getEffectiveRuntimeRoomMode,
getStoredRoomRoleAgentPlan,
getStoredRoomSettings, getStoredRoomSettings,
type ChannelOwnerLeaseRow, type ChannelOwnerLeaseRow,
} from './db.js'; } from './db.js';
@@ -46,8 +47,10 @@ function normalizeLeaseRow(
row: ChannelOwnerLeaseRow, row: ChannelOwnerLeaseRow,
explicit: boolean, explicit: boolean,
): EffectiveChannelLease { ): EffectiveChannelLease {
const storedRolePlan = getStoredRoomRoleAgentPlan(row.chat_jid);
const ownerAgentType = const ownerAgentType =
row.owner_agent_type ?? row.owner_agent_type ??
storedRolePlan?.ownerAgentType ??
getStoredRoomSettings(row.chat_jid)?.ownerAgentType ?? getStoredRoomSettings(row.chat_jid)?.ownerAgentType ??
inferAgentTypeFromServiceShadow(row.owner_service_id) ?? inferAgentTypeFromServiceShadow(row.owner_service_id) ??
'claude-code'; 'claude-code';
@@ -56,6 +59,7 @@ function normalizeLeaseRow(
? null ? null
: (row.reviewer_agent_type ?? : (row.reviewer_agent_type ??
inferAgentTypeFromServiceShadow(row.reviewer_service_id) ?? inferAgentTypeFromServiceShadow(row.reviewer_service_id) ??
storedRolePlan?.reviewerAgentType ??
resolveRoleAgentPlan({ resolveRoleAgentPlan({
paired: true, paired: true,
groupAgentType: ownerAgentType, groupAgentType: ownerAgentType,
@@ -67,6 +71,7 @@ function normalizeLeaseRow(
(row.arbiter_service_id (row.arbiter_service_id
? inferAgentTypeFromServiceShadow(row.arbiter_service_id) ? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
: undefined) ?? : undefined) ??
storedRolePlan?.arbiterAgentType ??
null; null;
return { return {
@@ -102,15 +107,16 @@ function normalizeLeaseRow(
}; };
} }
function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
return getStoredRoomSettings(chatJid)?.ownerAgentType;
}
function getDefaultLease(chatJid: string): EffectiveChannelLease { function getDefaultLease(chatJid: string): EffectiveChannelLease {
const roomMode = getEffectiveRuntimeRoomMode(chatJid); const roomMode = getEffectiveRuntimeRoomMode(chatJid);
const storedRolePlan = getStoredRoomRoleAgentPlan(chatJid);
const ownerAgentType = const ownerAgentType =
resolveDefaultOwnerAgentType(chatJid) ?? OWNER_AGENT_TYPE; storedRolePlan?.ownerAgentType ??
const rolePlan = resolveRoleAgentPlan({ getStoredRoomSettings(chatJid)?.ownerAgentType ??
OWNER_AGENT_TYPE;
const rolePlan =
storedRolePlan ??
resolveRoleAgentPlan({
paired: roomMode === 'tribunal', paired: roomMode === 'tribunal',
groupAgentType: ownerAgentType, groupAgentType: ownerAgentType,
configuredReviewer: REVIEWER_AGENT_TYPE, configuredReviewer: REVIEWER_AGENT_TYPE,