Files
orca/src/main/codex/codex-structured-thread-open.ts
T
Brennan Benson eb746a6d32 fix(codex): a Codex native chat that never sent a message reopens after restart (#22639)
* fix(codex): start a new thread when a chat's thread was never saved

A structured Codex chat records its thread at create time, but Codex writes
no rollout until the first input. After a restart, launch resumed that
thread, Codex answered "no rollout found for thread id", and the chat could
never run again.

When the head of the handle chain is the session's own creation and Codex
answers that exact error for that exact thread, start a new thread instead.
The new link supersedes the unsaved creation in place and names it, so the
chain keeps one live identity and does not grow across restarts. A thread a
resume, fork or adoption proved is never superseded, and no other resume
error starts fresh.

* test(codex): build launch-resolution chains without a type assertion

* fix(codex): match only Codex's own no-rollout text, pinned through the real connection

The fallback matched Orca's own error-wrapper prefix too, and every test built
that string itself, so rewording the wrapper would have disabled the fallback
with the suite green. Match the method, code -32600 and Codex's exact detail
as the message suffix, and drive Codex's raw error frame through the real
connection in a test.

The link builder now refuses, at the type level, a supersession on an adopted
or resumed link, which the chain would reject downstream anyway.

* docs(codex): note why the no-rollout text is safe on the resume path
2026-09-24 21:43:04 -07:00

153 lines
5.3 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 {
isCodexAppServerRequestError,
type CodexAppServerConnection
} from './codex-app-server-connection'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { readCodexThreadId, readCodexThreadPath } 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>
/** Rollout file Codex named, when it named one. */
historyPath: string | null
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
}
const resumeMetadataUnsupported = new WeakSet<object>()
function isExcludeTurnsUnsupported(error: unknown): boolean {
return (
isCodexAppServerRequestError(error) &&
error.code === -32602 &&
/(?:unknown|unexpected|unsupported|unrecognized).{0,80}excludeTurns|excludeTurns.{0,80}(?:unknown|unexpected|unsupported|unrecognized)/i.test(
error.message
)
)
}
async function resumeCodexThread(
connection: Pick<CodexAppServerConnection, 'request'>,
params: Record<string, unknown>,
timeoutMs: number | undefined
): Promise<unknown> {
if (resumeMetadataUnsupported.has(connection)) {
return connection.request('thread/resume', params, { timeoutMs })
}
try {
return await connection.request(
'thread/resume',
{ ...params, excludeTurns: true },
{ timeoutMs }
)
} catch (error) {
if (!isExcludeTurnsUnsupported(error)) {
throw error
}
resumeMetadataUnsupported.add(connection)
return connection.request('thread/resume', params, { timeoutMs })
}
}
/**
* 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}`)
)
}
export async function openCodexThread(
connection: Pick<CodexAppServerConnection, 'request'>,
launch: {
cwd: string
resumeThreadId: string | null
resumePath?: string | null
supersedeIfUnsaved?: boolean
permissionPolicy?: CodexStructuredPermissionPolicy
},
timeoutMs: number | undefined
): Promise<CodexOpenedThread> {
const resumeThreadId = launch.resumeThreadId
const startThread = (): Promise<unknown> =>
connection.request(
'thread/start',
{ cwd: launch.cwd, ...launch.permissionPolicy },
{ timeoutMs }
)
let supersededThreadId: string | undefined
let opened: unknown
if (!resumeThreadId) {
opened = await startThread()
} else {
const resumeParams = {
threadId: resumeThreadId,
cwd: launch.cwd,
...launch.permissionPolicy,
...(launch.resumePath ? { path: launch.resumePath } : {})
}
try {
opened = await resumeCodexThread(connection, resumeParams, timeoutMs)
} catch (error) {
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,
historyPath: readCodexThreadPath(opened),
...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated'
? { historyMode: thread.historyMode }
: {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(serviceTierKnown ? { serviceTier } : {})
}
}