diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index b45b4976b..acc6b6fdd 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -1,6 +1,44 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { logger } from '@/ui/logger'; import { killProcessByChildProcess } from '@/utils/process'; + +const MAX_STDERR_TAIL_CHARS = 4000; + +export type CodexErrorStage = 'app-server-spawn' | 'app-server-request' | 'process-exit' | 'protocol' | 'response-error'; + +export class CodexAppServerError extends Error { + readonly stage: CodexErrorStage; + readonly stderrTail?: string; + readonly exitCode?: number | null; + readonly signal?: NodeJS.Signals | null; + readonly errorCode?: number; + readonly retryable?: boolean; + + constructor(message: string, options: { + stage: CodexErrorStage; + stderrTail?: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + errorCode?: number; + retryable?: boolean; + cause?: unknown; + }) { + super(message, options.cause !== undefined ? { cause: options.cause } : undefined); + this.name = 'CodexAppServerError'; + this.stage = options.stage; + this.stderrTail = options.stderrTail; + this.exitCode = options.exitCode; + this.signal = options.signal; + this.errorCode = options.errorCode; + this.retryable = options.retryable; + } +} + +function appendTail(current: string, chunk: string): string { + if (!chunk) return current; + const combined = current + chunk; + return combined.length > MAX_STDERR_TAIL_CHARS ? combined.slice(-MAX_STDERR_TAIL_CHARS) : combined; +} import type { InitializeParams, InitializeResponse, @@ -65,6 +103,7 @@ export class CodexAppServerClient { private readonly requestHandlers = new Map(); private notificationHandler: ((method: string, params: unknown) => void) | null = null; private protocolError: Error | null = null; + private stderrTail = ''; static readonly DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; @@ -88,16 +127,23 @@ export class CodexAppServerClient { this.process.stderr.setEncoding('utf8'); this.process.stderr.on('data', (chunk) => { - const text = chunk.toString().trim(); - if (text.length > 0) { - logger.debug(`[CodexAppServer][stderr] ${text}`); + const text = chunk.toString(); + this.stderrTail = appendTail(this.stderrTail, text); + const trimmed = text.trim(); + if (trimmed.length > 0) { + logger.debug(`[CodexAppServer][stderr] ${trimmed}`); } }); this.process.on('exit', (code, signal) => { const message = `Codex app-server exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`; logger.debug(message); - this.rejectAllPending(new Error(message)); + this.rejectAllPending(new CodexAppServerError(message, { + stage: 'process-exit', + stderrTail: this.getStderrTail(), + exitCode: code, + signal + })); this.connected = false; this.resetParserState(); this.process = null; @@ -106,9 +152,13 @@ export class CodexAppServerClient { this.process.on('error', (error) => { logger.debug('[CodexAppServer] Process error', error); const message = error instanceof Error ? error.message : String(error); - this.rejectAllPending(new Error( + this.rejectAllPending(new CodexAppServerError( `Failed to spawn codex app-server: ${message}. Is it installed and on PATH?`, - { cause: error } + { + stage: 'app-server-spawn', + stderrTail: this.getStderrTail(), + cause: error + } )); this.connected = false; this.resetParserState(); @@ -180,7 +230,10 @@ export class CodexAppServerClient { } catch (error) { logger.debug('[CodexAppServer] Error while stopping process', error); } finally { - this.rejectAllPending(new Error('Codex app-server disconnected')); + this.rejectAllPending(new CodexAppServerError('Codex app-server disconnected', { + stage: 'process-exit', + stderrTail: this.getStderrTail() + })); this.connected = false; this.resetParserState(); } @@ -240,7 +293,10 @@ export class CodexAppServerClient { if (this.pending.has(id)) { this.pending.delete(id); cleanup(); - reject(new Error(`Codex app-server request '${method}' timed out after ${timeoutMs}ms`)); + reject(new CodexAppServerError(`Codex app-server request '${method}' timed out after ${timeoutMs}ms`, { + stage: 'app-server-request', + stderrTail: this.getStderrTail() + })); } }, timeoutMs); timeout.unref(); @@ -297,7 +353,10 @@ export class CodexAppServerClient { return; } } catch (error) { - const protocolError = new Error('Failed to parse JSON from codex app-server'); + const protocolError = new CodexAppServerError('Failed to parse JSON from codex app-server', { + stage: 'protocol', + stderrTail: this.getStderrTail() + }); this.protocolError = protocolError; logger.debug('[CodexAppServer] Failed to parse JSON line', { line, error }); this.rejectAllPending(protocolError); @@ -382,7 +441,12 @@ export class CodexAppServerClient { this.pending.delete(response.id); if (response.error) { - pending.reject(new Error(response.error.message)); + pending.reject(new CodexAppServerError(response.error.message, { + stage: 'response-error', + stderrTail: this.getStderrTail(), + errorCode: typeof response.error.code === 'number' ? response.error.code : undefined, + retryable: this.isRetryableError(response.error) + })); return; } @@ -397,6 +461,17 @@ export class CodexAppServerClient { private resetParserState(): void { this.buffer = ''; this.protocolError = null; + this.stderrTail = ''; + } + + getStderrTail(): string | undefined { + const trimmed = this.stderrTail.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + private isRetryableError(error: { code?: number; message: string; data?: unknown }): boolean { + const haystack = [error.message, typeof error.data === 'string' ? error.data : ''].join('\n').toLowerCase(); + return haystack.includes('high demand') || haystack.includes('rate limit') || haystack.includes('temporar'); } private rejectAllPending(error: Error): void { diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index 5e84eb432..87c6d1e92 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -84,6 +84,7 @@ export async function codexLocal(opts: { installHint: 'Codex CLI', includeCause: true, logExit: true, - shell: process.platform === 'win32' + shell: process.platform === 'win32', + stdio: ['inherit', 'inherit', 'pipe'] }); } diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 53024deec..556a40a7c 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -1,7 +1,7 @@ import React from 'react'; import { randomUUID } from 'node:crypto'; -import { CodexAppServerClient } from './codexAppServerClient'; +import { CodexAppServerClient, CodexAppServerError } from './codexAppServerClient'; import { CodexPermissionHandler } from './utils/permissionHandler'; import { ReasoningProcessor } from './utils/reasoningProcessor'; import { DiffProcessor } from './utils/diffProcessor'; @@ -25,6 +25,25 @@ import { type HappyServer = Awaited>['server']; type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string }; +function formatCodexErrorForUser(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + + const lines: string[] = [error.message]; + if (error instanceof CodexAppServerError) { + const details: string[] = []; + details.push(`stage=${error.stage}`); + if (typeof error.errorCode === 'number') details.push(`errorCode=${error.errorCode}`); + if (typeof error.exitCode === 'number') details.push(`exitCode=${error.exitCode}`); + if (error.signal) details.push(`signal=${error.signal}`); + if (error.retryable) details.push('retryable=true'); + if (details.length > 0) lines.push(`(${details.join(', ')})`); + if (error.stderrTail) lines.push(`stderr:\n${error.stderrTail}`); + } + return lines.join('\n'); +} + class CodexRemoteLauncher extends RemoteLauncherBase { private readonly session: CodexSession; private readonly appServerClient: CodexAppServerClient; @@ -268,11 +287,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } if (isTerminalEvent) { + const hasFailureDetails = msgType === 'task_failed' && Boolean( + asString(msg.error) || + asString(msg.stderr) || + typeof msg.exit_code === 'number' + ); if (shouldIgnoreTerminalEvent({ eventTurnId, currentTurnId: this.currentTurnId, turnInFlight, - allowAnonymousTerminalEvent + allowAnonymousTerminalEvent, + acceptAnonymousFailureWithDetails: hasFailureDetails })) { logger.debug( `[Codex] Ignoring terminal event ${msgType} without matching turn context; ` + @@ -314,7 +339,15 @@ class CodexRemoteLauncher extends RemoteLauncherBase { messageBuffer.addMessage('Turn aborted', 'status'); } else if (msgType === 'task_failed') { const error = asString(msg.error); - messageBuffer.addMessage(error ? `Task failed: ${error}` : 'Task failed', 'status'); + const stderr = asString(msg.stderr); + const exitCode = typeof msg.exit_code === 'number' ? msg.exit_code : null; + const retryable = msg.retryable === true; + const detail = [error, stderr ? `stderr: ${stderr}` : null, exitCode !== null ? `exitCode=${exitCode}` : null, retryable ? 'retryable=true' : null] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join(' | '); + const taskFailedMessage = detail ? `Task failed: ${detail}` : 'Task failed'; + messageBuffer.addMessage(taskFailedMessage, 'status'); + session.sendSessionEvent({ type: 'message', message: taskFailedMessage }); } if (msgType === 'task_started') { @@ -711,8 +744,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { messageBuffer.addMessage('Aborted by user', 'status'); session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } else { - messageBuffer.addMessage('Process exited unexpectedly', 'status'); - session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + const detail = formatCodexErrorForUser(error); + messageBuffer.addMessage(`Codex error: ${detail.split('\n')[0]}`, 'status'); + session.sendSessionEvent({ type: 'message', message: `Codex error:\n${detail}` }); this.currentTurnId = null; this.currentThreadId = null; hasThread = false; diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 08a957630..0792d1d28 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -176,10 +176,15 @@ export class AppServerEventConverter { event.turn_id = turnId; } if (msgType === 'task_failed') { - const error = asString(msg.error ?? msg.message ?? asRecord(msg.error)?.message); - if (error) { - event.error = error; - } + const errorRecord = asRecord(msg.error); + const error = asString(msg.error ?? msg.message ?? errorRecord?.message); + const stderr = asString(msg.stderr ?? errorRecord?.stderr); + const exitCode = asNumber(msg.exit_code ?? msg.exitCode ?? errorRecord?.exit_code ?? errorRecord?.exitCode); + const retryable = asBoolean(msg.retryable ?? errorRecord?.retryable); + if (error) event.error = error; + if (stderr) event.stderr = stderr; + if (exitCode !== null) event.exit_code = exitCode; + if (retryable !== null) event.retryable = retryable; } return [event]; } @@ -278,7 +283,11 @@ export class AppServerEventConverter { const statusRaw = asString(paramsRecord.status ?? turn.status); const status = statusRaw?.toLowerCase(); const turnId = asString(turn.turnId ?? turn.turn_id ?? turn.id); - const errorMessage = asString(paramsRecord.error ?? paramsRecord.message ?? paramsRecord.reason); + const errorRecord = asRecord(paramsRecord.error); + const errorMessage = asString(paramsRecord.error ?? paramsRecord.message ?? paramsRecord.reason ?? errorRecord?.message); + const stderr = asString(paramsRecord.stderr ?? errorRecord?.stderr); + const exitCode = asNumber(paramsRecord.exit_code ?? paramsRecord.exitCode ?? errorRecord?.exit_code ?? errorRecord?.exitCode); + const retryable = asBoolean(paramsRecord.retryable ?? errorRecord?.retryable); if (status === 'interrupted' || status === 'cancelled' || status === 'canceled') { events.push({ type: 'turn_aborted', ...(turnId ? { turn_id: turnId } : {}) }); @@ -286,7 +295,7 @@ export class AppServerEventConverter { } if (status === 'failed' || status === 'error') { - events.push({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}) }); + events.push({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}), ...(stderr ? { stderr } : {}), ...(exitCode !== null ? { exit_code: exitCode } : {}), ...(retryable !== null ? { retryable } : {}) }); return events; } @@ -311,9 +320,12 @@ export class AppServerEventConverter { if (method === 'error') { const willRetry = asBoolean(paramsRecord.will_retry ?? paramsRecord.willRetry) ?? false; if (willRetry) return events; - const message = asString(paramsRecord.message) ?? asString(asRecord(paramsRecord.error)?.message); - if (message) { - events.push({ type: 'task_failed', error: message }); + const errorRecord = asRecord(paramsRecord.error); + const message = asString(paramsRecord.message) ?? asString(errorRecord?.message); + const stderr = asString(paramsRecord.stderr ?? errorRecord?.stderr); + const exitCode = asNumber(paramsRecord.exit_code ?? paramsRecord.exitCode ?? errorRecord?.exit_code ?? errorRecord?.exitCode); + if (message || stderr) { + events.push({ type: 'task_failed', ...(message ? { error: message } : {}), ...(stderr ? { stderr } : {}), ...(exitCode !== null ? { exit_code: exitCode } : {}) }); } return events; } diff --git a/cli/src/codex/utils/terminalEventGuard.test.ts b/cli/src/codex/utils/terminalEventGuard.test.ts index 5896b11ed..4223a3bfb 100644 --- a/cli/src/codex/utils/terminalEventGuard.test.ts +++ b/cli/src/codex/utils/terminalEventGuard.test.ts @@ -44,6 +44,28 @@ describe('shouldIgnoreTerminalEvent', () => { expect(ignored).toBe(true); }); + it('accepts anonymous failed terminal event with details even when current turn id exists', () => { + const ignored = shouldIgnoreTerminalEvent({ + eventTurnId: null, + currentTurnId: 'turn-1', + turnInFlight: true, + acceptAnonymousFailureWithDetails: true + }); + + expect(ignored).toBe(false); + }); + + it('accepts anonymous failed terminal event with details while turn is still in flight', () => { + const ignored = shouldIgnoreTerminalEvent({ + eventTurnId: null, + currentTurnId: null, + turnInFlight: true, + acceptAnonymousFailureWithDetails: true + }); + + expect(ignored).toBe(false); + }); + it('ignores stale terminal events from another turn', () => { const ignored = shouldIgnoreTerminalEvent({ eventTurnId: 'turn-old', diff --git a/cli/src/codex/utils/terminalEventGuard.ts b/cli/src/codex/utils/terminalEventGuard.ts index a4b76b010..4f744a8b4 100644 --- a/cli/src/codex/utils/terminalEventGuard.ts +++ b/cli/src/codex/utils/terminalEventGuard.ts @@ -3,20 +3,28 @@ export type TerminalEventGuardInput = { currentTurnId: string | null; turnInFlight: boolean; allowAnonymousTerminalEvent?: boolean; + acceptAnonymousFailureWithDetails?: boolean; }; export function shouldIgnoreTerminalEvent(input: TerminalEventGuardInput): boolean { const allowAnonymousTerminalEvent = input.allowAnonymousTerminalEvent === true; + const acceptAnonymousFailureWithDetails = input.acceptAnonymousFailureWithDetails === true; if (input.eventTurnId) { return Boolean(input.currentTurnId && input.eventTurnId !== input.currentTurnId); } if (input.currentTurnId) { + if (acceptAnonymousFailureWithDetails) { + return false; + } return true; } if (input.turnInFlight && !allowAnonymousTerminalEvent) { + if (acceptAnonymousFailureWithDetails) { + return false; + } return true; } diff --git a/cli/src/utils/spawnWithAbort.ts b/cli/src/utils/spawnWithAbort.ts index 00b54cc07..b0f13d662 100644 --- a/cli/src/utils/spawnWithAbort.ts +++ b/cli/src/utils/spawnWithAbort.ts @@ -37,6 +37,13 @@ export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise { + const text = chunk.toString(); + if (!text) return; + const combined = stderrTail + text; + stderrTail = combined.length > 4000 ? combined.slice(-4000) : combined; + }; const logDebug = (message: string, ...args: unknown[]) => { logger.debug(`${logPrefix}${message}`, ...args); @@ -56,6 +63,12 @@ export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise { + appendStderrTail(chunk); + }); + } + const abortHandler = () => { if (abortKillTimeout) { return; @@ -141,7 +154,9 @@ export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise