Files
orca/src/main/codex/codex-structured-session-acquire.ts
T
Brennan BensonandMerge Sim 5868fdc9e3 feat(native-chat): report Codex background tasks in the chat strip (#19346)
* feat(native-chat): report Codex background tasks in the chat strip

The background-tasks strip works for Claude only; a structured Codex
session shows nothing in it. Feed it from the Codex app-server stream.

The strip stands for work that OUTLIVED a turn, which is what the
monitoring header, Claude's foreground suppression, and the conversation
command gate all already assume. Codex has no `is_backgrounded` flag, so
that fact is derived from the turn boundary: a `subAgentActivity` child or
a primary-thread `commandExecution` becomes visible once the turn it
belongs to completes and it is still unsettled.

`turn/completed` only reveals a task here, never settles one — measured on
`codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s
after its parent turn ended. Only a child's own activity kind settles it.

Codex exposes no honest stop: `turn/interrupt` on a child ends its turn
without emitting a terminal activity item and leaves its shell running. So
the state carries a new optional `supportsStopAll: false`, the strip hides
a control that could not act, and the blocked-command message asks the user
to wait rather than to press a button that does not exist.

* refactor(codex): move session teardown out of the structured adapter

Merging main crossed the 300-line cap on
`codex-structured-session-adapter.ts`: the rewind backend (#19235) and this
branch's close-time strip clear both landed in it. The four close paths move
verbatim into `codex-structured-session-teardown.ts`, where they funnel
through one `settled` helper instead of repeating the notification-retry and
background-task cleanup at each call site. No ratchet bump.

Also normalize a background task's description once at receipt rather than on
every projection; the roster is re-projected on each observed frame.

* fix(codex): drop the shell row the journal already settles

A `commandExecution` still `inProgress` when its turn ends was reported as a
`command` task. But `settleCodexJournalTurn` writes exactly those items to the
journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip
row would have claimed a shell was still running at the same instant Orca
recorded that it was not — two surfaces contradicting each other about the same
process.

A subagent is the opposite case and stays: the roster pointedly does not sweep
at a turn boundary, because children measurably outlive it. That leaves the
producer making exactly one claim — these spawn_agent children are still live
after their turn — which the durable roster row corroborates.

* fix(native-chat): track Codex background execution lifetimes

* fix(native-chat): keep running tool groups from claiming completion

* Fix runtime catalog and capability expectation

* fix(codex): keep a child's name on the command row that outlives it

A child agent's commands stay hidden behind its agent row while the child
works. Once the child's turn settles with a command still running, that
command surfaces as its own row labelled from the raw command string, so
'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'"
at the moment that row was the only remaining signal for the work.

Qualify a child's command row with the child's label. Resolved on read,
so a label registered after the command still lands, and bounded by the
existing description cap so admission accounting stays valid. Primary-
thread commands are left unqualified: they have no child to name.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-09 00:13:20 -07:00

242 lines
8.7 KiB
TypeScript

import {
AgentSessionAcquisitionRefusal,
AgentSessionPreSpawnError,
type AgentSessionAcquisition,
type StructuredAgentSessionAcquireInput
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
closeFailedCodexAcquisition,
stopSupersededCodexAcquisition
} from './codex-structured-acquisition-lifecycle'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity'
import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment'
import { openCodexThread } from './codex-structured-thread-open'
import {
closeCodexPublishedSession,
handleCodexSessionExit
} from './codex-structured-session-close'
import {
reportedCodexThreadOptions,
restoredCodexSessionOptions
} from './codex-structured-session-options'
import {
codexSessionLifecycle,
mintCodexAcquisitionGeneration,
type CodexAcquisitionRegistry,
type CodexAcquisitionAttempt,
type CodexSession,
type CodexStructuredSessionAdapterDeps
} from './codex-structured-session-state'
import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry'
import type { deliverCodexServerRequest } from './codex-structured-provider-events'
export async function acquireCodexStructuredSession(input: {
input: StructuredAgentSessionAcquireInput
deps: CodexStructuredSessionAdapterDeps
sessions: Map<string, CodexSession>
acquisitions: CodexAcquisitionRegistry
turnCancellation: CodexStructuredTurnCancellation
notificationRetries: CodexStructuredNotificationRetry
deliver: (
acquisition: CodexAcquisitionAttempt['window'],
sessionId: string,
event: () => unknown,
retainedBytes?: number
) => void
handleServerRequest: (
sessionId: string,
request: Parameters<typeof deliverCodexServerRequest>[2]
) => void
handleUnhandledFrame: (sessionId: string, kind: string, payload: unknown) => void
forceCloseUnexpected: (
sessionId: string,
fence: number,
acquisitionGeneration: string,
reason: Error
) => Promise<boolean>
}): Promise<AgentSessionAcquisition> {
const {
input: acquireInput,
deps,
sessions,
acquisitions,
turnCancellation,
notificationRetries
} = input
const sessionId = acquireInput.identity.sessionId
const { previousAttempt, attempt } = acquisitions.start(sessionId)
const acquisition = attempt.window
let unbindReadingControl: (() => void) | undefined
let primaryThreadId =
acquireInput.identity.providerHandle.kind === 'codex'
? acquireInput.identity.providerHandle.threadId
: null
const subagentExecutions = new CodexSubagentExecutions()
const translator = acquireInput.events
? createCodexJournalTranslator({
sink: acquireInput.events,
primaryThreadId: () => primaryThreadId,
subagentExecutions,
bindPromptItemId: (journalItemId, threadId, promptKey) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
})
: null
const open = deps.openConnection ?? openCodexAppServerConnection
try {
await stopSupersededCodexAcquisition({
sessionId,
registry: acquisitions,
replacement: attempt,
previous: previousAttempt
})
acquisitions.assertCurrent(sessionId, attempt)
if (!(await closeCodexPublishedSession(sessions, sessionId, deps.onEvent))) {
throw new Error(`codex app-server for session ${sessionId} could not be stopped`)
}
acquisitions.assertCurrent(sessionId, attempt)
const launch = await deps
.resolveLaunch({ identity: acquireInput.identity })
.catch((error: unknown) => {
throw new AgentSessionPreSpawnError(error)
})
acquisitions.assertCurrent(sessionId, attempt)
const connection = await open(
{
command: launch.command,
args: launch.args,
cwd: launch.cwd,
env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId)
},
{
onNotification: (method, params) =>
input.deliver(
acquisition,
sessionId,
() => notificationRetries.handle(sessionId, method, params),
Buffer.byteLength(JSON.stringify(params ?? null), 'utf8')
),
onServerRequest: (request) =>
input.deliver(
acquisition,
sessionId,
() => input.handleServerRequest(sessionId, request),
Buffer.byteLength(JSON.stringify(request), 'utf8')
),
onUnhandledFrame: (kind, payload) =>
input.deliver(
acquisition,
sessionId,
() => input.handleUnhandledFrame(sessionId, kind, payload),
Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8')
),
onExit: (error) => {
try {
handleCodexSessionExit({
sessions,
sessionId,
connection: acquisition.connection,
error,
prompts: acquisition.prompts,
onBackgroundTasksChanged: deps.onBackgroundTasksChanged,
...(deps.onEvent ? { onEvent: deps.onEvent } : {})
})
} finally {
notificationRetries.clear(sessionId, acquisition.connection)
}
}
}
)
acquisition.connection = connection
if (connection.pauseReading && connection.resumeReading) {
unbindReadingControl = acquireInput.events?.bindReadingControl?.({
pauseReading: connection.pauseReading,
resumeReading: () => {
connection.resumeReading?.()
notificationRetries.retry(sessionId, connection)
}
})
}
acquisitions.assertCurrent(sessionId, attempt)
const opened = await openCodexThread(connection, launch, deps.requestTimeoutMs)
acquisitions.assertCurrent(sessionId, attempt)
primaryThreadId = opened.threadId
const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {})
if (restoreAdmission && !restoreAdmission.accepted) {
throw new AgentSessionAcquisitionRefusal(
'Codex thread history exceeds the bounded restore queue; history was not partially imported.'
)
}
const process = await codexProcessIdentity(
{ ...acquireInput, pid: connection.pid },
deps.readProcessStartTime
)
acquisitions.assertCurrent(sessionId, attempt)
const acquired: AgentSessionAcquisition = {
process,
link: codexProviderHandleLink({
threadId: opened.threadId,
resumed: launch.resumeThreadId !== null,
fence: acquireInput.fence,
linkId: deps.mintLinkId?.(),
observedAt: deps.now?.() ?? Date.now()
}),
acquisitionGeneration: mintCodexAcquisitionGeneration(deps)
}
if (connection.closed) {
throw new Error(`codex app-server for session ${sessionId} exited while being acquired`)
}
acquisitions.assertCurrent(sessionId, attempt)
acquisitions.deleteIfCurrent(sessionId, attempt)
const session: CodexSession = {
connection,
...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string),
threadId: opened.threadId,
historyPath: opened.historyPath,
historyMode: opened.historyMode,
activeTurnIds: new Set(),
prompts: acquisition.prompts,
options: restoredCodexSessionOptions(acquireInput.options),
reportedOptions: reportedCodexThreadOptions(opened),
turnIdWaiters: [],
translator,
backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions),
forceCloseUnexpected: (reason) =>
input.forceCloseUnexpected(
sessionId,
acquireInput.fence,
acquired.acquisitionGeneration as string,
reason
),
...(unbindReadingControl ? { unbindReadingControl } : {})
}
turnCancellation.register(session)
sessions.set(sessionId, session)
for (const event of acquisition.drain()) {
event()
}
return acquired
} catch (error) {
if (sessions.get(sessionId)?.connection !== acquisition.connection) {
return closeFailedCodexAcquisition({
sessionId,
registry: acquisitions,
attempt,
cause: error,
dispose: () => {
unbindReadingControl?.()
translator?.dispose()
}
})
}
acquisitions.deleteIfCurrent(sessionId, attempt)
throw error
} finally {
attempt.finish()
}
}