refactor: split message executor attempt lifecycle (#188)

This commit is contained in:
Eyejoker
2026-05-29 19:47:36 +09:00
committed by GitHub
parent 41f0460848
commit 6f82cea91e
2 changed files with 198 additions and 165 deletions

View File

@@ -114,10 +114,6 @@
"maxComplexity": 47, "maxComplexity": 47,
"owner": "message executor attempt runner hotspot" "owner": "message executor attempt runner hotspot"
}, },
"src/message-agent-executor-lifecycle.ts": {
"maxFunctionLines": 417,
"owner": "message executor lifecycle hotspot"
},
"src/message-agent-executor-target.ts": { "src/message-agent-executor-target.ts": {
"maxFunctionLines": 274, "maxFunctionLines": 274,
"maxComplexity": 48, "maxComplexity": 48,

View File

@@ -38,7 +38,7 @@ function isRetryableCodexSessionFailureAttempt(args: {
}); });
} }
export async function executeMessageAgentAttemptLifecycle(args: { interface ExecuteMessageAgentAttemptLifecycleArgs {
provider: 'claude' | 'codex'; provider: 'claude' | 'codex';
runAttempt: (provider: 'claude' | 'codex') => Promise<MessageAgentAttempt>; runAttempt: (provider: 'claude' | 'codex') => Promise<MessageAgentAttempt>;
isClaudeCodeAgent: boolean; isClaudeCodeAgent: boolean;
@@ -69,143 +69,166 @@ export async function executeMessageAgentAttemptLifecycle(args: {
warn: (obj: Record<string, unknown> | string, msg?: string) => void; warn: (obj: Record<string, unknown> | string, msg?: string) => void;
error: (obj: Record<string, unknown> | string, msg?: string) => void; error: (obj: Record<string, unknown> | string, msg?: string) => void;
}; };
}): Promise<AttemptResult> { }
const {
provider,
runAttempt,
isClaudeCodeAgent,
canRetryClaudeCredentials,
clearStoredSession,
clearRoleSdkSessions,
sessionFolder,
maybeHandoffToCodex,
hasDirectTerminalDelivery,
pairedExecutionLifecycle,
shouldRetryFreshSessionOnAgentFailure,
rotationLogContext,
log,
} = args;
let resetSessionRequested = false; interface RecoveryResult {
const rememberAttempt = ( attempt: MessageAgentAttempt;
attempt: MessageAgentAttempt, resolved: AttemptResult | null;
): MessageAgentAttempt => { }
class MessageAgentAttemptLifecycleRunner {
private resetSessionRequested = false;
constructor(private readonly args: ExecuteMessageAgentAttemptLifecycleArgs) {}
async execute(): Promise<AttemptResult> {
let primaryAttempt = await this.runTrackedAttempt(this.args.provider);
const recoveredSessionAttempt =
await this.recoverRetryableClaudeSessionFailure(primaryAttempt);
if (recoveredSessionAttempt.resolved) {
return recoveredSessionAttempt.resolved;
}
primaryAttempt = recoveredSessionAttempt.attempt;
const recoveredCodexSessionAttempt =
await this.recoverRetryableCodexSessionFailure(primaryAttempt);
if (recoveredCodexSessionAttempt.resolved) {
return recoveredCodexSessionAttempt.resolved;
}
primaryAttempt = recoveredCodexSessionAttempt.attempt;
if (primaryAttempt.error) {
return this.handlePrimaryAttemptFailure(
primaryAttempt,
getErrorMessage(primaryAttempt.error),
);
}
return this.finalizePrimaryAttempt(primaryAttempt);
}
private rememberAttempt(attempt: MessageAgentAttempt): MessageAgentAttempt {
if (attempt.resetSessionRequested) { if (attempt.resetSessionRequested) {
resetSessionRequested = true; this.resetSessionRequested = true;
} }
return attempt; return attempt;
}; }
const runTrackedAttempt = async ( private async runTrackedAttempt(
currentProvider: 'claude' | 'codex', currentProvider: 'claude' | 'codex',
): Promise<MessageAgentAttempt> => ): Promise<MessageAgentAttempt> {
rememberAttempt(await runAttempt(currentProvider)); return this.rememberAttempt(await this.args.runAttempt(currentProvider));
}
const retryCodexWithRotation = async ( private async retryCodexWithRotation(
initialTrigger: { reason: CodexRotationReason }, initialTrigger: { reason: CodexRotationReason },
rotationMessage?: string, rotationMessage?: string,
): Promise<AttemptResult> => { ): Promise<AttemptResult> {
return runCodexAttemptWithRotation({ return runCodexAttemptWithRotation({
initialTrigger, initialTrigger,
runAttempt: () => runTrackedAttempt('codex'), runAttempt: () => this.runTrackedAttempt('codex'),
logContext: rotationLogContext, logContext: this.args.rotationLogContext,
rotationMessage, rotationMessage,
}); });
}; }
const retryClaudeWithRotation = async ( private async retryClaudeWithRotation(
initialTrigger: { initialTrigger: {
reason: AgentTriggerReason; reason: AgentTriggerReason;
retryAfterMs?: number; retryAfterMs?: number;
}, },
rotationMessage?: string, rotationMessage?: string,
): Promise<AttemptResult> => { ): Promise<AttemptResult> {
return runClaudeAttemptWithRotation({ return runClaudeAttemptWithRotation({
initialTrigger, initialTrigger,
runAttempt: () => runTrackedAttempt('claude'), runAttempt: () => this.runTrackedAttempt('claude'),
logContext: rotationLogContext, logContext: this.args.rotationLogContext,
rotationMessage, rotationMessage,
onSuccess: ({ sawOutput }) => { onSuccess: ({ sawOutput }) => {
pairedExecutionLifecycle.markSawOutput(sawOutput); this.args.pairedExecutionLifecycle.markSawOutput(sawOutput);
}, },
}); });
}; }
const maybeHandoffAfterError = ( private maybeHandoffAfterError(
reason: AgentTriggerReason, reason: AgentTriggerReason,
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
): AttemptResult => { ): AttemptResult {
if (maybeHandoffToCodex(reason, attempt.sawVisibleOutput)) { if (this.args.maybeHandoffToCodex(reason, attempt.sawVisibleOutput)) {
return 'success'; return 'success';
} }
return 'error'; return 'error';
}; }
const retryClaudeAttemptIfNeeded = async ( private async retryClaudeAttemptIfNeeded(
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
rotationMessage?: string | null, rotationMessage?: string | null,
): Promise<AttemptResult | null> => { ): Promise<AttemptResult | null> {
const retryAction = await executeAttemptRetryAction({ const retryAction = await executeAttemptRetryAction({
provider, provider: this.args.provider,
canRetryClaudeCredentials, canRetryClaudeCredentials: this.args.canRetryClaudeCredentials,
canRetryCodex: false, canRetryCodex: false,
attempt, attempt,
rotationMessage, rotationMessage,
runClaude: retryClaudeWithRotation, runClaude: (trigger, message) =>
runCodex: retryCodexWithRotation, this.retryClaudeWithRotation(trigger, message),
runCodex: (trigger, message) =>
this.retryCodexWithRotation(trigger, message),
}); });
if (retryAction.kind !== 'claude') { if (retryAction.kind !== 'claude') {
return null; return null;
} }
if (retryAction.result === 'error') { if (retryAction.result === 'error') {
return maybeHandoffAfterError(retryAction.trigger.reason, attempt); return this.maybeHandoffAfterError(retryAction.trigger.reason, attempt);
} }
pairedExecutionLifecycle.markStatus('succeeded'); this.args.pairedExecutionLifecycle.markStatus('succeeded');
return retryAction.result; return retryAction.result;
}; }
const retryCodexAttemptIfNeeded = async ( private async retryCodexAttemptIfNeeded(
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
rotationMessage?: string | null, rotationMessage?: string | null,
): Promise<AttemptResult | null> => { ): Promise<AttemptResult | null> {
const retryAction = await executeAttemptRetryAction({ const retryAction = await executeAttemptRetryAction({
provider, provider: this.args.provider,
canRetryClaudeCredentials: false, canRetryClaudeCredentials: false,
canRetryCodex: !isClaudeCodeAgent && getCodexAccountCount() > 1, canRetryCodex: !this.args.isClaudeCodeAgent && getCodexAccountCount() > 1,
attempt, attempt,
rotationMessage, rotationMessage,
runClaude: retryClaudeWithRotation, runClaude: (trigger, message) =>
runCodex: retryCodexWithRotation, this.retryClaudeWithRotation(trigger, message),
runCodex: (trigger, message) =>
this.retryCodexWithRotation(trigger, message),
}); });
if (retryAction.kind !== 'codex') { if (retryAction.kind !== 'codex') {
return null; return null;
} }
if (retryAction.result === 'success') { if (retryAction.result === 'success') {
pairedExecutionLifecycle.markStatus('succeeded'); this.args.pairedExecutionLifecycle.markStatus('succeeded');
} }
return retryAction.result; return retryAction.result;
}; }
const isRetryableClaudeSessionFailure = ( private isRetryableClaudeSessionFailure(
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
): boolean => ): boolean {
isRetryableClaudeSessionFailureAttempt({ return isRetryableClaudeSessionFailureAttempt({
attempt, attempt,
isClaudeCodeAgent, isClaudeCodeAgent: this.args.isClaudeCodeAgent,
provider, provider: this.args.provider,
shouldRetryFreshSessionOnAgentFailure, shouldRetryFreshSessionOnAgentFailure:
this.args.shouldRetryFreshSessionOnAgentFailure,
}); });
}
const recoverRetryableClaudeSessionFailure = async ( private async recoverRetryableClaudeSessionFailure(
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
): Promise<{ ): Promise<RecoveryResult> {
attempt: MessageAgentAttempt; const { clearRoleSdkSessions, clearStoredSession, log } = this.args;
resolved: AttemptResult | null; if (!this.isRetryableClaudeSessionFailure(attempt)) {
}> => {
if (!isRetryableClaudeSessionFailure(attempt)) {
return { attempt, resolved: null }; return { attempt, resolved: null };
} }
@@ -215,8 +238,8 @@ export async function executeMessageAgentAttemptLifecycle(args: {
'Cleared poisoned Claude session before visible output, retrying fresh session', 'Cleared poisoned Claude session before visible output, retrying fresh session',
); );
const freshAttempt = await runTrackedAttempt('claude'); const freshAttempt = await this.runTrackedAttempt('claude');
if (!isRetryableClaudeSessionFailure(freshAttempt)) { if (!this.isRetryableClaudeSessionFailure(freshAttempt)) {
return { attempt: freshAttempt, resolved: null }; return { attempt: freshAttempt, resolved: null };
} }
@@ -225,16 +248,15 @@ export async function executeMessageAgentAttemptLifecycle(args: {
log.error('Retryable Claude session failure persisted after fresh retry'); log.error('Retryable Claude session failure persisted after fresh retry');
return { return {
attempt: freshAttempt, attempt: freshAttempt,
resolved: maybeHandoffAfterError('session-failure', freshAttempt), resolved: this.maybeHandoffAfterError('session-failure', freshAttempt),
}; };
}; }
const recoverRetryableCodexSessionFailure = async ( private async recoverRetryableCodexSessionFailure(
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
): Promise<{ ): Promise<RecoveryResult> {
attempt: MessageAgentAttempt; const { clearRoleSdkSessions, clearStoredSession, log, provider } =
resolved: AttemptResult | null; this.args;
}> => {
if (!isRetryableCodexSessionFailureAttempt({ provider, attempt })) { if (!isRetryableCodexSessionFailureAttempt({ provider, attempt })) {
return { attempt, resolved: null }; return { attempt, resolved: null };
} }
@@ -245,7 +267,7 @@ export async function executeMessageAgentAttemptLifecycle(args: {
'Cleared poisoned Codex session before visible output, retrying fresh session', 'Cleared poisoned Codex session before visible output, retrying fresh session',
); );
const freshAttempt = await runTrackedAttempt('codex'); const freshAttempt = await this.runTrackedAttempt('codex');
if ( if (
!isRetryableCodexSessionFailureAttempt({ !isRetryableCodexSessionFailureAttempt({
provider, provider,
@@ -262,13 +284,14 @@ export async function executeMessageAgentAttemptLifecycle(args: {
attempt: freshAttempt, attempt: freshAttempt,
resolved: 'error', resolved: 'error',
}; };
}; }
const handlePrimaryAttemptFailure = async ( private async handlePrimaryAttemptFailure(
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
rotationMessage: string, rotationMessage: string,
): Promise<AttemptResult> => { ): Promise<AttemptResult> {
const claudeRetryResult = await retryClaudeAttemptIfNeeded( const { log, provider } = this.args;
const claudeRetryResult = await this.retryClaudeAttemptIfNeeded(
attempt, attempt,
rotationMessage, rotationMessage,
); );
@@ -276,7 +299,7 @@ export async function executeMessageAgentAttemptLifecycle(args: {
return claudeRetryResult; return claudeRetryResult;
} }
const codexRetryResult = await retryCodexAttemptIfNeeded( const codexRetryResult = await this.retryCodexAttemptIfNeeded(
attempt, attempt,
rotationMessage, rotationMessage,
); );
@@ -303,74 +326,41 @@ export async function executeMessageAgentAttemptLifecycle(args: {
'Agent process error', 'Agent process error',
); );
return 'error'; return 'error';
}; }
const finalizePrimaryAttempt = async ( private async finalizePrimaryAttempt(
attempt: MessageAgentAttempt, attempt: MessageAgentAttempt,
): Promise<AttemptResult> => { ): Promise<AttemptResult> {
const { log, provider } = this.args;
const output = attempt.output; const output = attempt.output;
if (!output) { if (!output) {
log.error({ provider }, 'Agent produced no output object'); log.error({ provider }, 'Agent produced no output object');
return 'error'; return 'error';
} }
if (!pairedExecutionLifecycle.getSummary()) { this.updateSummaryFromOutputIfNeeded(output);
const finalOutputText = getAgentOutputText(output);
pairedExecutionLifecycle.updateSummary({
outputText:
typeof finalOutputText === 'string' && finalOutputText.length > 0
? finalOutputText
: null,
errorText:
typeof output.error === 'string' && output.error.length > 0
? output.error
: null,
});
}
if ( if (
!attempt.sawOutput && !attempt.sawOutput &&
!hasDirectTerminalDelivery() && !this.args.hasDirectTerminalDelivery() &&
output.status !== 'error' output.status !== 'error'
) { ) {
const claudeRetryResult = await retryClaudeAttemptIfNeeded(attempt); const claudeRetryResult = await this.retryClaudeAttemptIfNeeded(attempt);
if (claudeRetryResult) { if (claudeRetryResult) {
return claudeRetryResult; return claudeRetryResult;
} }
} }
if ( this.clearPoisonedSessionAfterFailure(output);
isClaudeCodeAgent &&
(resetSessionRequested || shouldResetSessionOnAgentFailure(output))
) {
clearStoredSession();
log.warn(
{ sessionFolder },
'Cleared poisoned agent session after unrecoverable error',
);
}
if (
!isClaudeCodeAgent &&
provider === 'codex' &&
(resetSessionRequested || shouldResetCodexSessionOnAgentFailure(output))
) {
clearStoredSession();
clearRoleSdkSessions();
log.warn(
{ sessionFolder },
'Cleared poisoned Codex session after unrecoverable error',
);
}
if (output.status === 'error') { if (output.status === 'error') {
return handlePrimaryAttemptFailure( return this.handlePrimaryAttemptFailure(
attempt, attempt,
output.error ?? 'Agent process error', output.error ?? 'Agent process error',
); );
} }
const codexRetryResult = await retryCodexAttemptIfNeeded( const codexRetryResult = await this.retryCodexAttemptIfNeeded(
attempt, attempt,
output.error ?? output.result, output.error ?? output.result,
); );
@@ -379,13 +369,7 @@ export async function executeMessageAgentAttemptLifecycle(args: {
} }
if (attempt.streamedTriggerReason) { if (attempt.streamedTriggerReason) {
if ( if (this.resolveStreamedTriggerHandoff(attempt)) {
isClaudeCodeAgent &&
maybeHandoffToCodex(
attempt.streamedTriggerReason.reason,
attempt.sawVisibleOutput,
)
) {
return 'success'; return 'success';
} }
log.error( log.error(
@@ -397,45 +381,98 @@ export async function executeMessageAgentAttemptLifecycle(args: {
return 'error'; return 'error';
} }
if ( if (this.isSuccessWithoutVisibleOutput(attempt)) {
attempt.sawSuccessNullResultWithoutOutput &&
!attempt.sawOutput &&
!hasDirectTerminalDelivery()
) {
log.error( log.error(
'Agent returned success with null result and no visible output', 'Agent returned success with null result and no visible output',
); );
return 'error'; return 'error';
} }
pairedExecutionLifecycle.markStatus('succeeded'); this.args.pairedExecutionLifecycle.markStatus('succeeded');
pairedExecutionLifecycle.markSawOutput( this.args.pairedExecutionLifecycle.markSawOutput(
attempt.sawOutput || hasDirectTerminalDelivery(), attempt.sawOutput || this.args.hasDirectTerminalDelivery(),
); );
return 'success'; return 'success';
};
let primaryAttempt = await runTrackedAttempt(provider);
const recoveredSessionAttempt =
await recoverRetryableClaudeSessionFailure(primaryAttempt);
if (recoveredSessionAttempt.resolved) {
return recoveredSessionAttempt.resolved;
} }
primaryAttempt = recoveredSessionAttempt.attempt;
const recoveredCodexSessionAttempt = private updateSummaryFromOutputIfNeeded(
await recoverRetryableCodexSessionFailure(primaryAttempt); output: NonNullable<MessageAgentAttempt['output']>,
if (recoveredCodexSessionAttempt.resolved) { ): void {
return recoveredCodexSessionAttempt.resolved; const { pairedExecutionLifecycle } = this.args;
if (pairedExecutionLifecycle.getSummary()) {
return;
}
const finalOutputText = getAgentOutputText(output);
pairedExecutionLifecycle.updateSummary({
outputText:
typeof finalOutputText === 'string' && finalOutputText.length > 0
? finalOutputText
: null,
errorText:
typeof output.error === 'string' && output.error.length > 0
? output.error
: null,
});
} }
primaryAttempt = recoveredCodexSessionAttempt.attempt;
if (primaryAttempt.error) { private clearPoisonedSessionAfterFailure(
return handlePrimaryAttemptFailure( output: NonNullable<MessageAgentAttempt['output']>,
primaryAttempt, ): void {
getErrorMessage(primaryAttempt.error), const {
clearRoleSdkSessions,
clearStoredSession,
isClaudeCodeAgent,
log,
provider,
sessionFolder,
} = this.args;
if (
isClaudeCodeAgent &&
(this.resetSessionRequested || shouldResetSessionOnAgentFailure(output))
) {
clearStoredSession();
log.warn(
{ sessionFolder },
'Cleared poisoned agent session after unrecoverable error',
);
return;
}
if (
!isClaudeCodeAgent &&
provider === 'codex' &&
(this.resetSessionRequested ||
shouldResetCodexSessionOnAgentFailure(output))
) {
clearStoredSession();
clearRoleSdkSessions();
log.warn(
{ sessionFolder },
'Cleared poisoned Codex session after unrecoverable error',
);
}
}
private resolveStreamedTriggerHandoff(attempt: MessageAgentAttempt): boolean {
if (!attempt.streamedTriggerReason || !this.args.isClaudeCodeAgent) {
return false;
}
return this.args.maybeHandoffToCodex(
attempt.streamedTriggerReason.reason,
attempt.sawVisibleOutput,
); );
} }
return finalizePrimaryAttempt(primaryAttempt); private isSuccessWithoutVisibleOutput(attempt: MessageAgentAttempt): boolean {
return (
attempt.sawSuccessNullResultWithoutOutput &&
!attempt.sawOutput &&
!this.args.hasDirectTerminalDelivery()
);
}
}
export async function executeMessageAgentAttemptLifecycle(
args: ExecuteMessageAgentAttemptLifecycleArgs,
): Promise<AttemptResult> {
return new MessageAgentAttemptLifecycleRunner(args).execute();
} }