Files
orca/src/main/codex/codex-structured-session-close.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

139 lines
5.0 KiB
TypeScript

import type { CodexAppServerConnection } from './codex-app-server-connection-types'
import { closeProcessRegistry } from '../../shared/child-process/close-process-registry'
import {
cancelCodexAcquisitionAttempt,
type CodexAcquisitionRegistry,
type CodexSession,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-state'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
export function handleCodexSessionExit(input: {
sessions: Map<string, CodexSession>
sessionId: string
connection: CodexAppServerConnection | null
error: Error
prompts?: CodexSession['prompts']
allowFailedSettlement?: boolean
onEvent?: (event: CodexStructuredSessionEvent) => void
onBackgroundTasksChanged?: CodexStructuredSessionAdapterDeps['onBackgroundTasksChanged']
}): boolean {
const session = input.sessions.get(input.sessionId)
if (!session || session.connection !== input.connection || session.ended) {
input.prompts?.clear()
return false
}
const event: StructuredAgentSessionLifecycleEvent = {
type: 'ended',
sessionId: input.sessionId,
reason: input.error.message,
cause: session.requestedClose ? 'requested-close' : 'unexpected-exit',
fence: session.fence,
acquisitionGeneration: session.acquisitionGeneration
} as const
// A synchronous sink rejection (usually backpressure) is handed to host
// recovery, which appends the bounded fallback before reacquisition.
const admission = session.translator?.handle(event) ?? { accepted: true }
if (!admission.accepted) {
// The connection invokes onExit exactly once. Forward a flagged event so
// host recovery can append its no-new-blob fallback even when admission is
// backpressured; waiting for a second callback would strand the lease.
if (event.cause !== 'unexpected-exit' && !input.allowFailedSettlement) {
return false
}
event.settlementRetryRequired = true
}
session.ended = true
session.backgroundTasks.clear()
input.onBackgroundTasksChanged?.(input.sessionId, null)
session.unbindReadingControl?.()
input.onEvent?.(event)
session.prompts.clear()
session.translator?.dispose()
return true
}
export async function closeCodexPublishedSession(
sessions: Map<string, CodexSession>,
sessionId: string,
onEvent?: (event: CodexStructuredSessionEvent) => void,
options?: {
allowFailedSettlement?: boolean
requestedClose?: boolean
expectedFence?: number
expectedAcquisitionGeneration?: string
unexpectedReason?: Error
}
): Promise<boolean> {
const session = sessions.get(sessionId)
if (!session) {
return true
}
if (
(options?.expectedFence !== undefined && session.fence !== options.expectedFence) ||
(options?.expectedAcquisitionGeneration !== undefined &&
session.acquisitionGeneration !== options.expectedAcquisitionGeneration)
) {
return false
}
// Sink-failure recovery force-closes the child but must preserve the
// observed-exit cause so host lease settlement runs as an unexpected death.
session.requestedClose = options?.requestedClose ?? true
// Keep the session indexed until the child exit is observed. A timeout or
// failed kill must leave the live connection available for a safe retry.
const exited = await session.connection.close()
if (exited !== true) {
return false
}
if (!session.ended) {
const handled = handleCodexSessionExit({
sessions,
sessionId,
connection: session.connection,
error: options?.unexpectedReason ?? new Error('codex session closed'),
prompts: session.prompts,
...(options?.allowFailedSettlement ? { allowFailedSettlement: true } : {}),
...(onEvent ? { onEvent } : {})
})
// Keep the closed session indexed when terminal settlement admission was
// rejected; a later close attempt retries the same stable lifecycle event.
if (!handled) {
return false
}
}
sessions.delete(sessionId)
return true
}
export async function closeCodexSession(
sessionId: string,
sessions: Map<string, CodexSession>,
acquisitions: CodexAcquisitionRegistry,
onEvent?: (event: CodexStructuredSessionEvent) => void
): Promise<boolean> {
const attempt = acquisitions.get(sessionId)
if (!(await cancelCodexAcquisitionAttempt(attempt))) {
return false
}
if (attempt) {
acquisitions.deleteIfCurrent(sessionId, attempt)
}
return closeCodexPublishedSession(sessions, sessionId, onEvent)
}
export async function closeAllCodexSessions(
sessions: Map<string, CodexSession>,
acquisitions: CodexAcquisitionRegistry,
close: (sessionId: string) => Promise<boolean>
): Promise<void> {
acquisitions.close()
await closeProcessRegistry({
attempts: 3,
hasEntries: () => sessions.size > 0 || acquisitions.size > 0,
entryIds: () => new Set([...sessions.keys(), ...acquisitions.sessionIds()]),
closeEntry: close,
failureMessage: 'codex structured session shutdown could not prove every child stopped'
})
}