From 6f82cea91e33f9fb136d20f93511cf0901066383 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Fri, 29 May 2026 19:47:36 +0900 Subject: [PATCH] refactor: split message executor attempt lifecycle (#188) --- quality/code-quality-budgets.json | 4 - src/message-agent-executor-lifecycle.ts | 359 +++++++++++++----------- 2 files changed, 198 insertions(+), 165 deletions(-) diff --git a/quality/code-quality-budgets.json b/quality/code-quality-budgets.json index e68f31b..935da99 100644 --- a/quality/code-quality-budgets.json +++ b/quality/code-quality-budgets.json @@ -114,10 +114,6 @@ "maxComplexity": 47, "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": { "maxFunctionLines": 274, "maxComplexity": 48, diff --git a/src/message-agent-executor-lifecycle.ts b/src/message-agent-executor-lifecycle.ts index 5aeff75..eebbbfa 100644 --- a/src/message-agent-executor-lifecycle.ts +++ b/src/message-agent-executor-lifecycle.ts @@ -38,7 +38,7 @@ function isRetryableCodexSessionFailureAttempt(args: { }); } -export async function executeMessageAgentAttemptLifecycle(args: { +interface ExecuteMessageAgentAttemptLifecycleArgs { provider: 'claude' | 'codex'; runAttempt: (provider: 'claude' | 'codex') => Promise; isClaudeCodeAgent: boolean; @@ -69,143 +69,166 @@ export async function executeMessageAgentAttemptLifecycle(args: { warn: (obj: Record | string, msg?: string) => void; error: (obj: Record | string, msg?: string) => void; }; -}): Promise { - const { - provider, - runAttempt, - isClaudeCodeAgent, - canRetryClaudeCredentials, - clearStoredSession, - clearRoleSdkSessions, - sessionFolder, - maybeHandoffToCodex, - hasDirectTerminalDelivery, - pairedExecutionLifecycle, - shouldRetryFreshSessionOnAgentFailure, - rotationLogContext, - log, - } = args; +} - let resetSessionRequested = false; - const rememberAttempt = ( - attempt: MessageAgentAttempt, - ): MessageAgentAttempt => { +interface RecoveryResult { + attempt: MessageAgentAttempt; + resolved: AttemptResult | null; +} + +class MessageAgentAttemptLifecycleRunner { + private resetSessionRequested = false; + + constructor(private readonly args: ExecuteMessageAgentAttemptLifecycleArgs) {} + + async execute(): Promise { + 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) { - resetSessionRequested = true; + this.resetSessionRequested = true; } return attempt; - }; + } - const runTrackedAttempt = async ( + private async runTrackedAttempt( currentProvider: 'claude' | 'codex', - ): Promise => - rememberAttempt(await runAttempt(currentProvider)); + ): Promise { + return this.rememberAttempt(await this.args.runAttempt(currentProvider)); + } - const retryCodexWithRotation = async ( + private async retryCodexWithRotation( initialTrigger: { reason: CodexRotationReason }, rotationMessage?: string, - ): Promise => { + ): Promise { return runCodexAttemptWithRotation({ initialTrigger, - runAttempt: () => runTrackedAttempt('codex'), - logContext: rotationLogContext, + runAttempt: () => this.runTrackedAttempt('codex'), + logContext: this.args.rotationLogContext, rotationMessage, }); - }; + } - const retryClaudeWithRotation = async ( + private async retryClaudeWithRotation( initialTrigger: { reason: AgentTriggerReason; retryAfterMs?: number; }, rotationMessage?: string, - ): Promise => { + ): Promise { return runClaudeAttemptWithRotation({ initialTrigger, - runAttempt: () => runTrackedAttempt('claude'), - logContext: rotationLogContext, + runAttempt: () => this.runTrackedAttempt('claude'), + logContext: this.args.rotationLogContext, rotationMessage, onSuccess: ({ sawOutput }) => { - pairedExecutionLifecycle.markSawOutput(sawOutput); + this.args.pairedExecutionLifecycle.markSawOutput(sawOutput); }, }); - }; + } - const maybeHandoffAfterError = ( + private maybeHandoffAfterError( reason: AgentTriggerReason, attempt: MessageAgentAttempt, - ): AttemptResult => { - if (maybeHandoffToCodex(reason, attempt.sawVisibleOutput)) { + ): AttemptResult { + if (this.args.maybeHandoffToCodex(reason, attempt.sawVisibleOutput)) { return 'success'; } return 'error'; - }; + } - const retryClaudeAttemptIfNeeded = async ( + private async retryClaudeAttemptIfNeeded( attempt: MessageAgentAttempt, rotationMessage?: string | null, - ): Promise => { + ): Promise { const retryAction = await executeAttemptRetryAction({ - provider, - canRetryClaudeCredentials, + provider: this.args.provider, + canRetryClaudeCredentials: this.args.canRetryClaudeCredentials, canRetryCodex: false, attempt, rotationMessage, - runClaude: retryClaudeWithRotation, - runCodex: retryCodexWithRotation, + runClaude: (trigger, message) => + this.retryClaudeWithRotation(trigger, message), + runCodex: (trigger, message) => + this.retryCodexWithRotation(trigger, message), }); if (retryAction.kind !== 'claude') { return null; } 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; - }; + } - const retryCodexAttemptIfNeeded = async ( + private async retryCodexAttemptIfNeeded( attempt: MessageAgentAttempt, rotationMessage?: string | null, - ): Promise => { + ): Promise { const retryAction = await executeAttemptRetryAction({ - provider, + provider: this.args.provider, canRetryClaudeCredentials: false, - canRetryCodex: !isClaudeCodeAgent && getCodexAccountCount() > 1, + canRetryCodex: !this.args.isClaudeCodeAgent && getCodexAccountCount() > 1, attempt, rotationMessage, - runClaude: retryClaudeWithRotation, - runCodex: retryCodexWithRotation, + runClaude: (trigger, message) => + this.retryClaudeWithRotation(trigger, message), + runCodex: (trigger, message) => + this.retryCodexWithRotation(trigger, message), }); if (retryAction.kind !== 'codex') { return null; } if (retryAction.result === 'success') { - pairedExecutionLifecycle.markStatus('succeeded'); + this.args.pairedExecutionLifecycle.markStatus('succeeded'); } return retryAction.result; - }; + } - const isRetryableClaudeSessionFailure = ( + private isRetryableClaudeSessionFailure( attempt: MessageAgentAttempt, - ): boolean => - isRetryableClaudeSessionFailureAttempt({ + ): boolean { + return isRetryableClaudeSessionFailureAttempt({ attempt, - isClaudeCodeAgent, - provider, - shouldRetryFreshSessionOnAgentFailure, + isClaudeCodeAgent: this.args.isClaudeCodeAgent, + provider: this.args.provider, + shouldRetryFreshSessionOnAgentFailure: + this.args.shouldRetryFreshSessionOnAgentFailure, }); + } - const recoverRetryableClaudeSessionFailure = async ( + private async recoverRetryableClaudeSessionFailure( attempt: MessageAgentAttempt, - ): Promise<{ - attempt: MessageAgentAttempt; - resolved: AttemptResult | null; - }> => { - if (!isRetryableClaudeSessionFailure(attempt)) { + ): Promise { + const { clearRoleSdkSessions, clearStoredSession, log } = this.args; + if (!this.isRetryableClaudeSessionFailure(attempt)) { return { attempt, resolved: null }; } @@ -215,8 +238,8 @@ export async function executeMessageAgentAttemptLifecycle(args: { 'Cleared poisoned Claude session before visible output, retrying fresh session', ); - const freshAttempt = await runTrackedAttempt('claude'); - if (!isRetryableClaudeSessionFailure(freshAttempt)) { + const freshAttempt = await this.runTrackedAttempt('claude'); + if (!this.isRetryableClaudeSessionFailure(freshAttempt)) { return { attempt: freshAttempt, resolved: null }; } @@ -225,16 +248,15 @@ export async function executeMessageAgentAttemptLifecycle(args: { log.error('Retryable Claude session failure persisted after fresh retry'); return { attempt: freshAttempt, - resolved: maybeHandoffAfterError('session-failure', freshAttempt), + resolved: this.maybeHandoffAfterError('session-failure', freshAttempt), }; - }; + } - const recoverRetryableCodexSessionFailure = async ( + private async recoverRetryableCodexSessionFailure( attempt: MessageAgentAttempt, - ): Promise<{ - attempt: MessageAgentAttempt; - resolved: AttemptResult | null; - }> => { + ): Promise { + const { clearRoleSdkSessions, clearStoredSession, log, provider } = + this.args; if (!isRetryableCodexSessionFailureAttempt({ provider, attempt })) { return { attempt, resolved: null }; } @@ -245,7 +267,7 @@ export async function executeMessageAgentAttemptLifecycle(args: { 'Cleared poisoned Codex session before visible output, retrying fresh session', ); - const freshAttempt = await runTrackedAttempt('codex'); + const freshAttempt = await this.runTrackedAttempt('codex'); if ( !isRetryableCodexSessionFailureAttempt({ provider, @@ -262,13 +284,14 @@ export async function executeMessageAgentAttemptLifecycle(args: { attempt: freshAttempt, resolved: 'error', }; - }; + } - const handlePrimaryAttemptFailure = async ( + private async handlePrimaryAttemptFailure( attempt: MessageAgentAttempt, rotationMessage: string, - ): Promise => { - const claudeRetryResult = await retryClaudeAttemptIfNeeded( + ): Promise { + const { log, provider } = this.args; + const claudeRetryResult = await this.retryClaudeAttemptIfNeeded( attempt, rotationMessage, ); @@ -276,7 +299,7 @@ export async function executeMessageAgentAttemptLifecycle(args: { return claudeRetryResult; } - const codexRetryResult = await retryCodexAttemptIfNeeded( + const codexRetryResult = await this.retryCodexAttemptIfNeeded( attempt, rotationMessage, ); @@ -303,74 +326,41 @@ export async function executeMessageAgentAttemptLifecycle(args: { 'Agent process error', ); return 'error'; - }; + } - const finalizePrimaryAttempt = async ( + private async finalizePrimaryAttempt( attempt: MessageAgentAttempt, - ): Promise => { + ): Promise { + const { log, provider } = this.args; const output = attempt.output; if (!output) { log.error({ provider }, 'Agent produced no output object'); return 'error'; } - if (!pairedExecutionLifecycle.getSummary()) { - 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, - }); - } + this.updateSummaryFromOutputIfNeeded(output); if ( !attempt.sawOutput && - !hasDirectTerminalDelivery() && + !this.args.hasDirectTerminalDelivery() && output.status !== 'error' ) { - const claudeRetryResult = await retryClaudeAttemptIfNeeded(attempt); + const claudeRetryResult = await this.retryClaudeAttemptIfNeeded(attempt); if (claudeRetryResult) { return claudeRetryResult; } } - if ( - 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', - ); - } + this.clearPoisonedSessionAfterFailure(output); if (output.status === 'error') { - return handlePrimaryAttemptFailure( + return this.handlePrimaryAttemptFailure( attempt, output.error ?? 'Agent process error', ); } - const codexRetryResult = await retryCodexAttemptIfNeeded( + const codexRetryResult = await this.retryCodexAttemptIfNeeded( attempt, output.error ?? output.result, ); @@ -379,13 +369,7 @@ export async function executeMessageAgentAttemptLifecycle(args: { } if (attempt.streamedTriggerReason) { - if ( - isClaudeCodeAgent && - maybeHandoffToCodex( - attempt.streamedTriggerReason.reason, - attempt.sawVisibleOutput, - ) - ) { + if (this.resolveStreamedTriggerHandoff(attempt)) { return 'success'; } log.error( @@ -397,45 +381,98 @@ export async function executeMessageAgentAttemptLifecycle(args: { return 'error'; } - if ( - attempt.sawSuccessNullResultWithoutOutput && - !attempt.sawOutput && - !hasDirectTerminalDelivery() - ) { + if (this.isSuccessWithoutVisibleOutput(attempt)) { log.error( 'Agent returned success with null result and no visible output', ); return 'error'; } - pairedExecutionLifecycle.markStatus('succeeded'); - pairedExecutionLifecycle.markSawOutput( - attempt.sawOutput || hasDirectTerminalDelivery(), + this.args.pairedExecutionLifecycle.markStatus('succeeded'); + this.args.pairedExecutionLifecycle.markSawOutput( + attempt.sawOutput || this.args.hasDirectTerminalDelivery(), ); return 'success'; - }; - - let primaryAttempt = await runTrackedAttempt(provider); - const recoveredSessionAttempt = - await recoverRetryableClaudeSessionFailure(primaryAttempt); - if (recoveredSessionAttempt.resolved) { - return recoveredSessionAttempt.resolved; } - primaryAttempt = recoveredSessionAttempt.attempt; - const recoveredCodexSessionAttempt = - await recoverRetryableCodexSessionFailure(primaryAttempt); - if (recoveredCodexSessionAttempt.resolved) { - return recoveredCodexSessionAttempt.resolved; + private updateSummaryFromOutputIfNeeded( + output: NonNullable, + ): void { + 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) { - return handlePrimaryAttemptFailure( - primaryAttempt, - getErrorMessage(primaryAttempt.error), + private clearPoisonedSessionAfterFailure( + output: NonNullable, + ): void { + 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 { + return new MessageAgentAttemptLifecycleRunner(args).execute(); }