fix: honor handoff agent type during failover
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user