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 () => { it('allows silent reviewer outputs', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({

View File

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

View File

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