diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 1ad0dc4..ecdcf9f 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -30,6 +30,7 @@ import { completePairedExecutionContext, preparePairedExecutionContext, } from './paired-execution-context.js'; +import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js'; import { resolveActiveRole, resolveConfiguredRoleAgentPlan, @@ -50,7 +51,6 @@ import { ARBITER_AGENT_TYPE, CODEX_REVIEW_SERVICE_ID, REVIEWER_AGENT_TYPE, - SERVICE_SESSION_SCOPE, TIMEZONE, isClaudeService, getRoleModelConfig, @@ -154,6 +154,10 @@ export async function runAgentForGroup( process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1'; const forceFreshClaudeReviewerSession = reviewerMode && isClaudeCodeAgent && unsafeHostPairedMode; + const shouldPersistSession = + activeRole !== 'arbiter' && + !args.forcedAgentType && + !forceFreshClaudeReviewerSession; const storedSessionId = sessions[sessionFolder]; if (forceFreshClaudeReviewerSession && storedSessionId) { deps.clearSession(sessionFolder); @@ -321,103 +325,44 @@ export async function runAgentForGroup( let pairedExecutionCompleted = false; let pairedSawOutput = false; - const shouldHandoffToCodex = ( - reason: AgentTriggerReason, - sawVisibleOutput: boolean, - ): boolean => { - if (sawVisibleOutput) { - return false; - } - return ( - reason === '429' || - reason === 'usage-exhausted' || - reason === 'auth-expired' || - reason === 'org-access-denied' || - reason === 'session-failure' - ); - }; - const maybeHandoffToCodex = ( reason: AgentTriggerReason, sawVisibleOutput: boolean, ): boolean => { if (!isClaudeCodeAgent) return false; - if (!shouldHandoffToCodex(reason, sawVisibleOutput)) { - return false; - } - if (currentLease.reviewer_agent_type === null) { - return false; - } - // Per-role fallback toggle - const roleConfig = getRoleModelConfig(activeRole); - if (!roleConfig.fallbackEnabled) { - log.info({ reason }, 'Fallback disabled for role, skipping handoff'); + const handoffResolution = resolveCodexFallbackHandoff({ + activeRole, + effectiveAgentType, + hasReviewer: currentLease.reviewer_agent_type !== null, + fallbackEnabled: getRoleModelConfig(activeRole).fallbackEnabled, + reason, + sawVisibleOutput, + prompt, + startSeq, + endSeq, + }); + + if (handoffResolution.type === 'none') { return false; } - if (arbiterMode) { - // Arbiter failed (e.g. Claude 401/429) — re-trigger arbitration with codex - createServiceHandoff({ - chat_jid: chatJid, - group_folder: group.folder, - source_role: activeRole, - target_role: 'arbiter', - source_agent_type: effectiveAgentType, - target_agent_type: 'codex', - prompt, - start_seq: startSeq ?? null, - end_seq: endSeq ?? null, - reason: `arbiter-claude-${reason}`, - intended_role: 'arbiter', - }); - log.warn( - { reason }, - 'Claude arbiter unavailable, handed off arbiter turn to codex', + if (handoffResolution.type === 'skip') { + log.info({ reason }, handoffResolution.logMessage); + return false; + } + + if (handoffResolution.plan.activateOwnerFailoverReason) { + activateCodexFailover( + chatJid, + handoffResolution.plan.activateOwnerFailoverReason, ); - return true; } - - if (reviewerMode) { - // Reviewer failed (e.g. Claude 401/429) — re-trigger review with codex - // instead of swapping owner/reviewer roles. - createServiceHandoff({ - chat_jid: chatJid, - group_folder: group.folder, - source_role: activeRole, - target_role: 'reviewer', - source_agent_type: effectiveAgentType, - target_agent_type: 'codex', - prompt, - start_seq: startSeq ?? null, - end_seq: endSeq ?? null, - reason: `reviewer-claude-${reason}`, - intended_role: 'reviewer', - }); - log.warn( - { reason }, - 'Claude reviewer unavailable, handed off review turn to codex-review', - ); - return true; - } - - activateCodexFailover(chatJid, `claude-${reason}`); createServiceHandoff({ chat_jid: chatJid, group_folder: group.folder, - source_role: activeRole, - target_role: activeRole, - source_agent_type: effectiveAgentType, - target_agent_type: 'codex', - prompt, - start_seq: startSeq ?? null, - end_seq: endSeq ?? null, - reason: `claude-${reason}`, - intended_role: activeRole, + ...handoffResolution.plan.handoff, }); - log.warn( - { reason }, - 'Claude unavailable, handed off current owner turn to codex fallback', - ); + log.warn({ reason }, handoffResolution.plan.logMessage); return true; }; @@ -480,8 +425,7 @@ export async function runAgentForGroup( sawVisibleOutput: false, sawSuccessNullResultWithoutOutput: false, }; - const attemptSessionId = - provider === 'claude' ? currentSessionId : undefined; + const attemptSessionId = currentSessionId; const wrappedOnOutput = onOutput ? async (output: AgentOutput) => { @@ -528,10 +472,9 @@ export async function runAgentForGroup( resetSessionRequested = true; } if ( - provider === 'claude' && output.newSessionId && !resetSessionRequested && - !forceFreshClaudeReviewerSession + shouldPersistSession ) { deps.persistSession(sessionFolder, output.newSessionId); currentSessionId = output.newSessionId; @@ -642,11 +585,7 @@ export async function runAgentForGroup( pairedExecutionContext?.envOverrides, ); - if ( - provider === 'claude' && - output.newSessionId && - !forceFreshClaudeReviewerSession - ) { + if (output.newSessionId && shouldPersistSession) { deps.persistSession(sessionFolder, output.newSessionId); currentSessionId = output.newSessionId; } @@ -939,7 +878,7 @@ export async function runAgentForGroup( return shouldRequeuePendingPairedTurn ? 'pending' : 'none'; }; - const provider = 'claude'; + const provider = isClaudeCodeAgent ? 'claude' : 'codex'; try { let primaryAttempt = await runAttempt(provider); diff --git a/src/paired-turn-fallback.test.ts b/src/paired-turn-fallback.test.ts new file mode 100644 index 0000000..4917b86 --- /dev/null +++ b/src/paired-turn-fallback.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js'; + +describe('resolveCodexFallbackHandoff', () => { + it('returns a reviewer codex handoff plan for reviewer auth failures', () => { + const result = resolveCodexFallbackHandoff({ + activeRole: 'reviewer', + effectiveAgentType: 'claude-code', + hasReviewer: true, + fallbackEnabled: true, + reason: 'auth-expired', + sawVisibleOutput: false, + prompt: 'please review', + startSeq: 10, + endSeq: 12, + }); + + expect(result).toEqual({ + type: 'handoff', + plan: { + handoff: { + source_role: 'reviewer', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'please review', + start_seq: 10, + end_seq: 12, + reason: 'reviewer-claude-auth-expired', + intended_role: 'reviewer', + }, + logMessage: + 'Claude reviewer unavailable, handed off review turn to codex-review', + }, + }); + }); + + it('returns an arbiter codex handoff plan for arbiter auth failures', () => { + const result = resolveCodexFallbackHandoff({ + activeRole: 'arbiter', + effectiveAgentType: 'claude-code', + hasReviewer: true, + fallbackEnabled: true, + reason: '429', + sawVisibleOutput: false, + prompt: 'please arbitrate', + }); + + expect(result).toEqual({ + type: 'handoff', + plan: { + handoff: { + source_role: 'arbiter', + target_role: 'arbiter', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'please arbitrate', + start_seq: null, + end_seq: null, + reason: 'arbiter-claude-429', + intended_role: 'arbiter', + }, + logMessage: + 'Claude arbiter unavailable, handed off arbiter turn to codex', + }, + }); + }); + + it('returns an owner codex handoff plan and failover activation for owner failures', () => { + const result = resolveCodexFallbackHandoff({ + activeRole: 'owner', + effectiveAgentType: 'claude-code', + hasReviewer: true, + fallbackEnabled: true, + reason: 'session-failure', + sawVisibleOutput: false, + prompt: 'please continue', + }); + + expect(result).toEqual({ + type: 'handoff', + plan: { + handoff: { + source_role: 'owner', + target_role: 'owner', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'please continue', + start_seq: null, + end_seq: null, + reason: 'claude-session-failure', + intended_role: 'owner', + }, + activateOwnerFailoverReason: 'claude-session-failure', + logMessage: + 'Claude unavailable, handed off current owner turn to codex fallback', + }, + }); + }); + + it('skips handoff when fallback is disabled for the role', () => { + const result = resolveCodexFallbackHandoff({ + activeRole: 'owner', + effectiveAgentType: 'claude-code', + hasReviewer: true, + fallbackEnabled: false, + reason: 'usage-exhausted', + sawVisibleOutput: false, + prompt: 'please continue', + }); + + expect(result).toEqual({ + type: 'skip', + logMessage: 'Fallback disabled for role, skipping handoff', + }); + }); +}); diff --git a/src/paired-turn-fallback.ts b/src/paired-turn-fallback.ts new file mode 100644 index 0000000..5edabbe --- /dev/null +++ b/src/paired-turn-fallback.ts @@ -0,0 +1,116 @@ +import type { AgentTriggerReason } from './agent-error-detection.js'; +import type { AgentType, PairedRoomRole } from './types.js'; + +const CODEX_HANDOFF_REASONS = new Set([ + '429', + 'usage-exhausted', + 'auth-expired', + 'org-access-denied', + 'session-failure', +]); + +export interface CodexFallbackHandoffRecord { + source_role: PairedRoomRole; + target_role: PairedRoomRole; + source_agent_type: AgentType; + target_agent_type: 'codex'; + prompt: string; + start_seq: number | null; + end_seq: number | null; + reason: string; + intended_role: PairedRoomRole; +} + +export interface CodexFallbackHandoffPlan { + handoff: CodexFallbackHandoffRecord; + activateOwnerFailoverReason?: string; + logMessage: string; +} + +export type CodexFallbackResolution = + | { type: 'none' } + | { type: 'skip'; logMessage: string } + | { type: 'handoff'; plan: CodexFallbackHandoffPlan }; + +export function resolveCodexFallbackHandoff(args: { + activeRole: PairedRoomRole; + effectiveAgentType: AgentType; + hasReviewer: boolean; + fallbackEnabled: boolean; + reason: AgentTriggerReason; + sawVisibleOutput: boolean; + prompt: string; + startSeq?: number | null; + endSeq?: number | null; +}): CodexFallbackResolution { + if (args.sawVisibleOutput || !CODEX_HANDOFF_REASONS.has(args.reason)) { + return { type: 'none' }; + } + + if (!args.hasReviewer) { + return { type: 'none' }; + } + + if (!args.fallbackEnabled) { + return { + type: 'skip', + logMessage: 'Fallback disabled for role, skipping handoff', + }; + } + + const baseHandoff = { + source_role: args.activeRole, + source_agent_type: args.effectiveAgentType, + target_agent_type: 'codex' as const, + prompt: args.prompt, + start_seq: args.startSeq ?? null, + end_seq: args.endSeq ?? null, + }; + + if (args.activeRole === 'arbiter') { + return { + type: 'handoff', + plan: { + handoff: { + ...baseHandoff, + target_role: 'arbiter', + intended_role: 'arbiter', + reason: `arbiter-claude-${args.reason}`, + }, + logMessage: + 'Claude arbiter unavailable, handed off arbiter turn to codex', + }, + }; + } + + if (args.activeRole === 'reviewer') { + return { + type: 'handoff', + plan: { + handoff: { + ...baseHandoff, + target_role: 'reviewer', + intended_role: 'reviewer', + reason: `reviewer-claude-${args.reason}`, + }, + logMessage: + 'Claude reviewer unavailable, handed off review turn to codex-review', + }, + }; + } + + return { + type: 'handoff', + plan: { + handoff: { + ...baseHandoff, + target_role: args.activeRole, + intended_role: args.activeRole, + reason: `claude-${args.reason}`, + }, + activateOwnerFailoverReason: `claude-${args.reason}`, + logMessage: + 'Claude unavailable, handed off current owner turn to codex fallback', + }, + }; +}