Files
orca/src/main/codex/codex-structured-thread-open.ts
T
Brennan Benson 52c4e0646b Opening or resuming a Codex chat waits for Codex instead of failing after 30 seconds (#30395)
* fix(codex): opening or resuming a Codex chat waits for Codex instead of failing after 30 s

The handshake (15 s) and thread/start / thread/resume (30 s) had their own deadlines because
the Codex start ignored the host's startup signal. It now honours it, as Claude's start does, so
those requests carry no deadline: the host's startup limits, a close, a Stop or quit end a start
that hangs, and the abort reaps Codex. Calls after the start keep the 30 s default for now: they
run in the session's queue, which a Stop or a close waits behind.

* test(codex): keep the handshake wait test in its own file under the line limit

* fix(codex): the one-shot app-server runner takes the same no-deadline option as the chat connection

* test: accept nullable Codex request timeout in probe fake

* fix(codex): bound direct opens at their caller

* Remove obsolete Codex startup timeout scenario
2026-10-20 11:49:29 -07:00

152 lines
5.8 KiB
TypeScript

// Starting or resuming the single Codex thread a structured session owns.
//
// The reply is verified before the caller registers the session, because a
// resume that lands on a different thread is a fork wearing a resume's name —
// recording it would make the durable handle chain lie about what this session
// actually proved.
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import {
isCodexAppServerRequestError,
type CodexAppServerConnection
} from './codex-app-server-connection'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { readCodexThreadId } from './codex-structured-thread-facts'
export type CodexOpenedThread = {
threadId: string
/** The unsaved thread this new one was started in place of. */
supersededThreadId?: string
thread?: Record<string, unknown>
historyMode?: 'legacy' | 'paginated'
model?: string
effort?: string
/** Present, including null, only when this app-server reports the effective tier. */
serviceTier?: string | null
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
/**
* Codex's own answer that it holds no rollout for this exact thread: the thread was started but
* never given input, so there is no conversation to lose. Codex matches the same exact text
* internally; any other resume failure, including a broader "not found", is not this proof.
* Orca's own wrapper prefix is deliberately not part of the match.
* Codex also uses this text for an archived thread read active-only; resume reads archived threads
* and answers "is archived" instead, so here the text means no rollout exists at all.
*/
function isCodexNoRolloutError(error: unknown, threadId: string): boolean {
return (
isCodexAppServerRequestError(error) &&
error.method === 'thread/resume' &&
error.code === -32600 &&
error.message.endsWith(`no rollout found for thread id ${threadId}`)
)
}
/**
* Codex rebuilds each command's env from `shell_environment_policy`, so `inherit = "core"` drops
* every Orca key the app-server holds. Codex adds `set` keys after that filter and merges these
* per key, so the user's own policy stays as written.
*/
function shellEnvironmentSetConfig(
env: Record<string, string> | undefined
): Record<string, string> {
return Object.fromEntries(
Object.entries(env ?? {}).map(([key, value]) => [`shell_environment_policy.set.${key}`, value])
)
}
export async function openCodexThread(
connection: Pick<CodexAppServerConnection, 'request'>,
launch: {
cwd: string
resumeThreadId: string | null
resumePath?: string | null
supersedeIfUnsaved?: boolean
permissionPolicy?: CodexStructuredPermissionPolicy
/** Why: Codex renders a thread's base instructions for its opening model; a first turn on
* another model reads as a mid-conversation switch and injects a second full prompt. */
model?: string
/** Config overrides for this thread, on start and resume alike. */
threadConfig?: Record<string, unknown>
/** Env every command the agent runs must see, whatever the user's shell environment policy. */
commandEnv?: Record<string, string>
},
/** null: no deadline; the start that opens the thread bounds it (`signal`). */
timeoutMs: number | null | undefined,
signal?: AbortSignal
): Promise<CodexOpenedThread> {
const resumeThreadId = launch.resumeThreadId
const config = { ...launch.threadConfig, ...shellEnvironmentSetConfig(launch.commandEnv) }
const threadSettings = {
cwd: launch.cwd,
...launch.permissionPolicy,
...(Object.keys(config).length > 0 ? { config } : {})
}
const startThread = (): Promise<unknown> => {
throwIfSignalAborted(signal)
return connection.request(
'thread/start',
{ ...threadSettings, ...(launch.model ? { model: launch.model } : {}) },
{ timeoutMs }
)
}
let supersededThreadId: string | undefined
let opened: unknown
if (!resumeThreadId) {
opened = await startThread()
} else {
// No model: naming one makes Codex skip the thread's saved model, provider and effort.
const resumeParams = {
threadId: resumeThreadId,
...threadSettings,
...(launch.resumePath ? { path: launch.resumePath } : {})
}
try {
throwIfSignalAborted(signal)
opened = await connection.request(
'thread/resume',
{ ...resumeParams, excludeTurns: true },
{ timeoutMs }
)
} catch (error) {
throwIfSignalAborted(signal)
if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) {
throw error
}
supersededThreadId = resumeThreadId
opened = await startThread()
}
}
const threadId = readCodexThreadId(opened)
if (!threadId) {
throw new Error('codex app-server did not name the thread it opened')
}
if (supersededThreadId === undefined && resumeThreadId && threadId !== resumeThreadId) {
throw new Error(`codex app-server resumed ${threadId} instead of ${resumeThreadId}`)
}
const result = opened as Record<string, unknown>
const thread =
typeof result.thread === 'object' && result.thread !== null
? (result.thread as Record<string, unknown>)
: {}
const model = nonEmptyString(result.model)
const effort = nonEmptyString(result.reasoningEffort)
const serviceTierKnown = Object.hasOwn(result, 'serviceTier')
const serviceTier = nonEmptyString(result.serviceTier)
return {
threadId,
...(supersededThreadId === undefined ? {} : { supersededThreadId }),
thread,
...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated'
? { historyMode: thread.historyMode }
: {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(serviceTierKnown ? { serviceTier } : {})
}
}