diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 4522e53..5af174a 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -1485,6 +1485,83 @@ describe('runAgentForGroup Claude rotation', () => { expect(outputs).toEqual(['force refresh 뒤 Claude 응답입니다.']); }); + it('marks paired execution as succeeded when Claude rotation recovers after a streamed auth-expired trigger', async () => { + vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2); + vi.mocked(tokenRotation.rotateToken).mockReturnValueOnce(true); + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-claude-rotation-success', + chat_jid: 'group@test', + group_folder: 'test-claude', + owner_service_id: 'claude', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-06T00:00:00.000Z', + updated_at: '2026-04-06T00:00:00.000Z', + }, + workspace: null, + envOverrides: {}, + }); + + vi.mocked(agentRunner.runAgentProcess) + .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'intermediate', + result: + 'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}', + }); + await onOutput?.({ + status: 'success', + phase: 'final', + result: + 'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}', + }); + return { + status: 'success', + result: null, + }; + }) + .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'final', + result: '회전 복구 후 paired success', + }); + return { + status: 'success', + result: null, + }; + }); + + const result = await runAgentForGroup(makeDeps(), { + group: makeGroup(), + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-paired-auth-expired-success', + onOutput: async () => {}, + }); + + expect(result).toBe('success'); + expect(pairedExecutionContext.completePairedExecutionContext).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'paired-task-claude-rotation-success', + role: 'owner', + status: 'succeeded', + }), + ); + }); + it('suppresses Claude 502 HTML and returns error when no rotation is available', async () => { const outputs: string[] = []; diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 3d207f1..bfd7bc4 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -774,6 +774,8 @@ export async function runAgentForGroup( return 'error'; }; + type AgentAttempt = Awaited>; + const retryClaudeWithRotation = async ( initialTrigger: { reason: AgentTriggerReason; @@ -813,9 +815,90 @@ export async function runAgentForGroup( } }; + const resolveClaudeRetryTrigger = ( + attempt: AgentAttempt, + fallbackMessage?: string | null, + ): { reason: AgentTriggerReason; retryAfterMs?: number } | null => { + if ( + !( + canRetryClaudeCredentials && + provider === 'claude' && + !attempt.sawOutput + ) + ) { + return null; + } + + if (attempt.streamedTriggerReason) { + return { + reason: attempt.streamedTriggerReason.reason, + retryAfterMs: attempt.streamedTriggerReason.retryAfterMs, + }; + } + + const trigger = classifyRotationTrigger(fallbackMessage); + if (!trigger.shouldRetry) { + return null; + } + + return { + reason: trigger.reason, + retryAfterMs: trigger.retryAfterMs, + }; + }; + + const retryClaudeAttemptIfNeeded = async ( + attempt: AgentAttempt, + rotationMessage?: string | null, + ): Promise<'success' | 'error' | null> => { + const trigger = resolveClaudeRetryTrigger(attempt, rotationMessage); + if (!trigger) { + return null; + } + + const result = await retryClaudeWithRotation( + trigger, + rotationMessage ?? undefined, + ); + if (result === 'error') { + return maybeHandoffAfterError(trigger.reason, attempt); + } + + pairedExecutionStatus = 'succeeded'; + return result; + }; + + const retryCodexAttemptIfNeeded = async ( + attempt: AgentAttempt, + rotationMessage?: string | null, + ): Promise<'success' | 'error' | null> => { + if (isClaudeCodeAgent || getCodexAccountCount() <= 1) { + return null; + } + + const trigger = attempt.streamedTriggerReason + ? { + shouldRotate: true, + reason: attempt.streamedTriggerReason.reason as CodexRotationReason, + } + : detectCodexRotationTrigger(rotationMessage); + if (!trigger.shouldRotate) { + return null; + } + + const result = await retryCodexWithRotation( + { reason: trigger.reason }, + rotationMessage ?? undefined, + ); + if (result === 'success') { + pairedExecutionStatus = 'succeeded'; + } + return result; + }; + const maybeHandoffAfterError = ( reason: AgentTriggerReason, - attempt: Awaited>, + attempt: AgentAttempt, ): 'success' | 'error' => { if (maybeHandoffToCodex(reason, attempt.sawVisibleOutput)) { return 'success'; @@ -823,6 +906,38 @@ export async function runAgentForGroup( return 'error'; }; + type PairedFollowUpQueueAction = + | 'generic' + | 'pending' + | 'skip-inline-finalize' + | 'none'; + type PairedTaskStatus = + NonNullable>['status']; + + const resolvePairedFollowUpQueueAction = ( + completedRole: PairedRoomRole, + executionStatus: 'succeeded' | 'failed', + sawOutput: boolean, + taskStatus: PairedTaskStatus | null, + ): PairedFollowUpQueueAction => { + if (executionStatus === 'succeeded' && sawOutput) { + return completedRole === 'reviewer' && taskStatus === 'merge_ready' + ? 'skip-inline-finalize' + : taskStatus !== 'completed' + ? 'generic' + : 'none'; + } + + const shouldRequeuePendingPairedTurn = + (completedRole === 'reviewer' || completedRole === 'arbiter') && + (taskStatus === 'review_ready' || + taskStatus === 'in_review' || + taskStatus === 'arbiter_requested' || + taskStatus === 'in_arbitration' || + taskStatus === 'merge_ready'); + return shouldRequeuePendingPairedTurn ? 'pending' : 'none'; + }; + const provider = 'claude'; try { @@ -864,50 +979,21 @@ export async function runAgentForGroup( } if (primaryAttempt.error) { - if ( - canRetryClaudeCredentials && - provider === 'claude' && - !primaryAttempt.sawOutput - ) { - const errMsg = getErrorMessage(primaryAttempt.error); - const trigger = primaryAttempt.streamedTriggerReason - ? { - shouldRetry: true, - reason: primaryAttempt.streamedTriggerReason.reason, - retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, - } - : classifyRotationTrigger(errMsg); - if (trigger.shouldRetry) { - const result = await retryClaudeWithRotation( - { - reason: trigger.reason, - retryAfterMs: trigger.retryAfterMs, - }, - errMsg, - ); - if (result === 'error') { - return maybeHandoffAfterError(trigger.reason, primaryAttempt); - } - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } + const errMsg = getErrorMessage(primaryAttempt.error); + const claudeRetryResult = await retryClaudeAttemptIfNeeded( + primaryAttempt, + errMsg, + ); + if (claudeRetryResult) { + return claudeRetryResult; } - if (!isClaudeCodeAgent) { - const errMsg = getErrorMessage(primaryAttempt.error); - const trigger = detectCodexRotationTrigger(errMsg); - if (trigger.shouldRotate && getCodexAccountCount() > 1) { - const result = await retryCodexWithRotation( - { reason: trigger.reason }, - errMsg, - ); - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } + const codexRetryResult = await retryCodexAttemptIfNeeded( + primaryAttempt, + errMsg, + ); + if (codexRetryResult) { + return codexRetryResult; } log.error( @@ -937,24 +1023,11 @@ export async function runAgentForGroup( : null); } - if ( - canRetryClaudeCredentials && - provider === 'claude' && - !primaryAttempt.sawOutput && - primaryAttempt.streamedTriggerReason && - output.status !== 'error' - ) { - const result = await retryClaudeWithRotation({ - reason: primaryAttempt.streamedTriggerReason.reason, - retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, - }); - if (result === 'error') { - return maybeHandoffAfterError( - primaryAttempt.streamedTriggerReason.reason, - primaryAttempt, - ); + if (!primaryAttempt.sawOutput && output.status !== 'error') { + const claudeRetryResult = await retryClaudeAttemptIfNeeded(primaryAttempt); + if (claudeRetryResult) { + return claudeRetryResult; } - return result; } if ( @@ -969,48 +1042,20 @@ export async function runAgentForGroup( } if (output.status === 'error') { - if ( - canRetryClaudeCredentials && - provider === 'claude' && - !primaryAttempt.sawOutput - ) { - const trigger = primaryAttempt.streamedTriggerReason - ? { - shouldRetry: true, - reason: primaryAttempt.streamedTriggerReason.reason, - retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, - } - : classifyRotationTrigger(output.error); - if (trigger.shouldRetry) { - const result = await retryClaudeWithRotation( - { - reason: trigger.reason, - retryAfterMs: trigger.retryAfterMs, - }, - output.error ?? undefined, - ); - if (result === 'error') { - return maybeHandoffAfterError(trigger.reason, primaryAttempt); - } - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } + const claudeRetryResult = await retryClaudeAttemptIfNeeded( + primaryAttempt, + output.error, + ); + if (claudeRetryResult) { + return claudeRetryResult; } - if (!isClaudeCodeAgent && getCodexAccountCount() > 1) { - const trigger = detectCodexRotationTrigger(output.error); - if (trigger.shouldRotate) { - const result = await retryCodexWithRotation( - { reason: trigger.reason }, - output.error ?? undefined, - ); - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } + const codexRetryResult = await retryCodexAttemptIfNeeded( + primaryAttempt, + output.error, + ); + if (codexRetryResult) { + return codexRetryResult; } log.error( @@ -1023,22 +1068,12 @@ export async function runAgentForGroup( return 'error'; } - if ( - !isClaudeCodeAgent && - primaryAttempt.streamedTriggerReason && - getCodexAccountCount() > 1 - ) { - const result = await retryCodexWithRotation( - { - reason: primaryAttempt.streamedTriggerReason - .reason as CodexRotationReason, - }, - output.error ?? output.result ?? undefined, - ); - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; + const codexRetryResult = await retryCodexAttemptIfNeeded( + primaryAttempt, + output.error ?? output.result, + ); + if (codexRetryResult) { + return codexRetryResult; } // Unresolved streamed trigger — rotation was unavailable or output was @@ -1144,16 +1179,18 @@ export async function runAgentForGroup( // After owner/reviewer completes, enqueue the next turn so // the message loop picks it up without waiting for a new message. // Skip if: no output (interrupted), or task already completed (ESCALATE, done, etc.) - if ( - pairedExecutionContext && - pairedExecutionStatus === 'succeeded' && - pairedSawOutput - ) { + if (pairedExecutionContext) { const completedRole = roomRoleContext?.role ?? 'owner'; const finishedCheck = getPairedTaskById(pairedExecutionContext.task.id); - const skipGenericFollowUpForInlineFinalize = - completedRole === 'reviewer' && finishedCheck?.status === 'merge_ready'; - if (skipGenericFollowUpForInlineFinalize) { + const queueAction = resolvePairedFollowUpQueueAction( + completedRole, + pairedExecutionStatus, + pairedSawOutput, + finishedCheck?.status ?? null, + ); + if (queueAction === 'generic') { + deps.queue.enqueueMessageCheck(chatJid); + } else if (queueAction === 'skip-inline-finalize') { log.info( { taskId: pairedExecutionContext.task.id, @@ -1162,20 +1199,7 @@ export async function runAgentForGroup( }, 'Skipping generic follow-up after reviewer approval because inline finalize will handle the owner handoff', ); - } else if (finishedCheck?.status !== 'completed') { - deps.queue.enqueueMessageCheck(chatJid); - } - } else if (pairedExecutionContext) { - const completedRole = roomRoleContext?.role ?? 'owner'; - const finishedCheck = getPairedTaskById(pairedExecutionContext.task.id); - const shouldRequeuePendingPairedTurn = - (completedRole === 'reviewer' || completedRole === 'arbiter') && - (finishedCheck?.status === 'review_ready' || - finishedCheck?.status === 'in_review' || - finishedCheck?.status === 'arbiter_requested' || - finishedCheck?.status === 'in_arbitration' || - finishedCheck?.status === 'merge_ready'); - if (shouldRequeuePendingPairedTurn) { + } else if (queueAction === 'pending') { deps.queue.enqueueMessageCheck(chatJid); log.info( {