Extract paired turn fallback planning
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
118
src/paired-turn-fallback.test.ts
Normal file
118
src/paired-turn-fallback.test.ts
Normal 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
116
src/paired-turn-fallback.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user