fix: honor handoff agent type during failover

This commit is contained in:
Eyejoker
2026-04-01 03:13:05 +09:00
parent 60f360b5ea
commit ca58e8c8eb
3 changed files with 121 additions and 6 deletions

View File

@@ -452,6 +452,68 @@ describe('runAgentForGroup room memory', () => {
);
});
it('honors a forced agent type for reviewer failover handoffs', async () => {
const group: RegisteredGroup = {
...makeGroup(),
agentType: 'codex',
folder: 'test-group',
};
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'paired-task-reviewer-failover',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-31T00:00:00.000Z',
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-31T00:00:00.000Z',
updated_at: '2026-03-31T00:00:00.000Z',
});
await runAgentForGroup(
{
...makeDeps(),
getSessions: () => ({ 'test-group:reviewer': 'claude-review-session' }),
},
{
group,
prompt: 'please retry review with codex',
chatJid: 'group@test',
runId: 'run-forced-reviewer-codex',
forcedRole: 'reviewer',
forcedAgentType: 'codex',
},
);
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
expect.objectContaining({
agentType: 'codex',
}),
expect.objectContaining({
sessionId: undefined,
}),
expect.any(Function),
undefined,
undefined,
);
});
it('allows silent reviewer outputs', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({

View File

@@ -44,7 +44,9 @@ import {
shouldRetryFreshSessionOnAgentFailure,
} from './session-recovery.js';
import {
ARBITER_AGENT_TYPE,
CODEX_REVIEW_SERVICE_ID,
REVIEWER_AGENT_TYPE,
SERVICE_SESSION_SCOPE,
TIMEZONE,
isClaudeService,
@@ -69,7 +71,7 @@ import {
} from './codex-token-rotation.js';
import type { CodexRotationReason } from './agent-error-detection.js';
import { getTokenCount } from './token-rotation.js';
import type { PairedRoomRole, RegisteredGroup } from './types.js';
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
// ── Main executor ─────────────────────────────────────────────────
@@ -93,6 +95,7 @@ export async function runAgentForGroup(
endSeq?: number | null;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
onOutput?: (output: AgentOutput) => Promise<void>;
},
): Promise<'success' | 'error'> {
@@ -128,10 +131,11 @@ export async function runAgentForGroup(
group.agentType,
);
const effectiveAgentType = resolveEffectiveAgentType(
const configuredAgentType = resolveEffectiveAgentType(
activeRole,
group.agentType,
);
const effectiveAgentType = args.forcedAgentType ?? configuredAgentType;
const effectiveGroup =
effectiveAgentType !== roleAgentPlan.ownerAgentType
? { ...group, agentType: effectiveAgentType }
@@ -144,7 +148,9 @@ export async function runAgentForGroup(
);
// Arbiter always starts fresh — never resume a previous session
const sessionId =
activeRole === 'arbiter' ? undefined : sessions[sessionFolder];
activeRole === 'arbiter' || args.forcedAgentType
? undefined
: sessions[sessionFolder];
const memoryBriefing = sessionId
? undefined
: await buildRoomMemoryBriefing({
@@ -210,6 +216,29 @@ export async function runAgentForGroup(
role: activeRole,
serviceId: effectiveServiceId,
});
log.info(
{
forcedRole: args.forcedRole,
forcedAgentType: args.forcedAgentType ?? null,
inferredRole,
canHonorForcedRole,
pairedTaskId: pairedTask?.id,
pairedTaskStatus: pairedTask?.status,
configuredAgentType,
effectiveServiceId,
effectiveAgentType,
groupAgentType: group.agentType,
configuredReviewerAgentType: REVIEWER_AGENT_TYPE,
configuredArbiterAgentType: ARBITER_AGENT_TYPE,
reviewerServiceId: currentLease.reviewer_service_id,
arbiterServiceId: currentLease.arbiter_service_id,
reviewerMode,
arbiterMode,
sessionFolder,
resumedSession: sessionId ?? null,
},
'Resolved execution target for agent turn',
);
// ── MoA prompt enrichment ─────────────────────────────────────
// When MoA is enabled and we're in arbiter mode, query external API

View File

@@ -58,6 +58,7 @@ import {
import {
Channel,
NewMessage,
type AgentType,
type PairedRoomRole,
RegisteredGroup,
type PairedTask,
@@ -432,6 +433,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
endSeq?: number | null;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
},
): Promise<'success' | 'error'> =>
runAgentForGroup(
@@ -452,6 +454,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
endSeq: options?.endSeq,
hasHumanMessage: options?.hasHumanMessage,
forcedRole: options?.forcedRole,
forcedAgentType: options?.forcedAgentType,
onOutput,
},
);
@@ -466,6 +469,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
endSeq: number | null;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
}): Promise<{
outputStatus: 'success' | 'error';
deliverySucceeded: boolean;
@@ -479,7 +483,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}> => {
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
const isClaudeCodeAgent =
(group.agentType || 'claude-code') === 'claude-code';
(args.forcedAgentType ?? group.agentType ?? 'claude-code') ===
'claude-code';
const turnController = new MessageTurnController({
chatJid,
@@ -497,7 +502,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const workItem = createProducedWorkItem({
group_folder: group.folder,
chat_jid: chatJid,
agent_type: group.agentType || 'claude-code',
agent_type:
args.forcedAgentType ?? group.agentType ?? 'claude-code',
start_seq: startSeq,
end_seq: endSeq,
result_payload: text,
@@ -527,6 +533,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
endSeq,
hasHumanMessage: args.hasHumanMessage,
forcedRole: args.forcedRole,
forcedAgentType: args.forcedAgentType,
},
);
@@ -602,7 +609,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
let handoffChannel = channel;
if (handoffRole === 'reviewer') {
const revChName =
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
handoff.target_agent_type === 'claude-code'
? 'discord'
: 'discord-review';
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
} else if (handoffRole === 'arbiter') {
handoffChannel =
@@ -611,6 +620,20 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const runId = `handoff-${handoff.id}`;
try {
logger.info(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
runId,
handoffRole,
targetServiceId: handoff.target_service_id,
targetAgentType: handoff.target_agent_type,
reason: handoff.reason,
intendedRole: handoff.intended_role ?? null,
channelName: handoffChannel.name,
},
'Dispatching claimed service handoff',
);
const result = await executeTurn({
group,
prompt: handoff.prompt,
@@ -620,6 +643,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
startSeq: handoff.start_seq,
endSeq: handoff.end_seq,
forcedRole: handoffRole,
forcedAgentType: handoff.target_agent_type,
});
if (!result.deliverySucceeded) {