Extract paired turn fallback planning

This commit is contained in:
ejclaw
2026-04-06 19:53:26 +09:00
parent 5b832042da
commit a3dac2b983
3 changed files with 267 additions and 94 deletions

View File

@@ -30,6 +30,7 @@ import {
completePairedExecutionContext, completePairedExecutionContext,
preparePairedExecutionContext, preparePairedExecutionContext,
} from './paired-execution-context.js'; } from './paired-execution-context.js';
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
import { import {
resolveActiveRole, resolveActiveRole,
resolveConfiguredRoleAgentPlan, resolveConfiguredRoleAgentPlan,
@@ -50,7 +51,6 @@ import {
ARBITER_AGENT_TYPE, ARBITER_AGENT_TYPE,
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
REVIEWER_AGENT_TYPE, REVIEWER_AGENT_TYPE,
SERVICE_SESSION_SCOPE,
TIMEZONE, TIMEZONE,
isClaudeService, isClaudeService,
getRoleModelConfig, getRoleModelConfig,
@@ -154,6 +154,10 @@ export async function runAgentForGroup(
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1'; process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1';
const forceFreshClaudeReviewerSession = const forceFreshClaudeReviewerSession =
reviewerMode && isClaudeCodeAgent && unsafeHostPairedMode; reviewerMode && isClaudeCodeAgent && unsafeHostPairedMode;
const shouldPersistSession =
activeRole !== 'arbiter' &&
!args.forcedAgentType &&
!forceFreshClaudeReviewerSession;
const storedSessionId = sessions[sessionFolder]; const storedSessionId = sessions[sessionFolder];
if (forceFreshClaudeReviewerSession && storedSessionId) { if (forceFreshClaudeReviewerSession && storedSessionId) {
deps.clearSession(sessionFolder); deps.clearSession(sessionFolder);
@@ -321,103 +325,44 @@ export async function runAgentForGroup(
let pairedExecutionCompleted = false; let pairedExecutionCompleted = false;
let pairedSawOutput = 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 = ( const maybeHandoffToCodex = (
reason: AgentTriggerReason, reason: AgentTriggerReason,
sawVisibleOutput: boolean, sawVisibleOutput: boolean,
): boolean => { ): boolean => {
if (!isClaudeCodeAgent) return false; if (!isClaudeCodeAgent) return false;
if (!shouldHandoffToCodex(reason, sawVisibleOutput)) { const handoffResolution = resolveCodexFallbackHandoff({
return false; activeRole,
} effectiveAgentType,
if (currentLease.reviewer_agent_type === null) { hasReviewer: currentLease.reviewer_agent_type !== null,
return false; fallbackEnabled: getRoleModelConfig(activeRole).fallbackEnabled,
} reason,
// Per-role fallback toggle sawVisibleOutput,
const roleConfig = getRoleModelConfig(activeRole); prompt,
if (!roleConfig.fallbackEnabled) { startSeq,
log.info({ reason }, 'Fallback disabled for role, skipping handoff'); endSeq,
});
if (handoffResolution.type === 'none') {
return false; return false;
} }
if (arbiterMode) { if (handoffResolution.type === 'skip') {
// Arbiter failed (e.g. Claude 401/429) — re-trigger arbitration with codex log.info({ reason }, handoffResolution.logMessage);
createServiceHandoff({ return false;
chat_jid: chatJid, }
group_folder: group.folder,
source_role: activeRole, if (handoffResolution.plan.activateOwnerFailoverReason) {
target_role: 'arbiter', activateCodexFailover(
source_agent_type: effectiveAgentType, chatJid,
target_agent_type: 'codex', handoffResolution.plan.activateOwnerFailoverReason,
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',
); );
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({ createServiceHandoff({
chat_jid: chatJid, chat_jid: chatJid,
group_folder: group.folder, group_folder: group.folder,
source_role: activeRole, ...handoffResolution.plan.handoff,
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,
}); });
log.warn( log.warn({ reason }, handoffResolution.plan.logMessage);
{ reason },
'Claude unavailable, handed off current owner turn to codex fallback',
);
return true; return true;
}; };
@@ -480,8 +425,7 @@ export async function runAgentForGroup(
sawVisibleOutput: false, sawVisibleOutput: false,
sawSuccessNullResultWithoutOutput: false, sawSuccessNullResultWithoutOutput: false,
}; };
const attemptSessionId = const attemptSessionId = currentSessionId;
provider === 'claude' ? currentSessionId : undefined;
const wrappedOnOutput = onOutput const wrappedOnOutput = onOutput
? async (output: AgentOutput) => { ? async (output: AgentOutput) => {
@@ -528,10 +472,9 @@ export async function runAgentForGroup(
resetSessionRequested = true; resetSessionRequested = true;
} }
if ( if (
provider === 'claude' &&
output.newSessionId && output.newSessionId &&
!resetSessionRequested && !resetSessionRequested &&
!forceFreshClaudeReviewerSession shouldPersistSession
) { ) {
deps.persistSession(sessionFolder, output.newSessionId); deps.persistSession(sessionFolder, output.newSessionId);
currentSessionId = output.newSessionId; currentSessionId = output.newSessionId;
@@ -642,11 +585,7 @@ export async function runAgentForGroup(
pairedExecutionContext?.envOverrides, pairedExecutionContext?.envOverrides,
); );
if ( if (output.newSessionId && shouldPersistSession) {
provider === 'claude' &&
output.newSessionId &&
!forceFreshClaudeReviewerSession
) {
deps.persistSession(sessionFolder, output.newSessionId); deps.persistSession(sessionFolder, output.newSessionId);
currentSessionId = output.newSessionId; currentSessionId = output.newSessionId;
} }
@@ -939,7 +878,7 @@ export async function runAgentForGroup(
return shouldRequeuePendingPairedTurn ? 'pending' : 'none'; return shouldRequeuePendingPairedTurn ? 'pending' : 'none';
}; };
const provider = 'claude'; const provider = isClaudeCodeAgent ? 'claude' : 'codex';
try { try {
let primaryAttempt = await runAttempt(provider); let primaryAttempt = await runAttempt(provider);

View File

@@ -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',
});
});
});

116
src/paired-turn-fallback.ts Normal file
View File

@@ -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<AgentTriggerReason>([
'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',
},
};
}