Extract paired turn fallback planning

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

View File

@@ -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);

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