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

261 lines
9.1 KiB
TypeScript

import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement'
import {
codexItemIdentity,
codexJournalItem,
CodexTurnOrdinals,
readCodexThreadItem,
type CodexThreadItem
} from './codex-structured-item-translation'
import { createCodexStructuredItemStreams } from './codex-structured-item-streams'
import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import type {
CodexItemTranslation,
CodexJournalTranslationAdmission,
CodexJournalTranslatorDeps
} from './codex-structured-journal-contracts'
import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts'
import {
MAX_CODEX_ACTIVE_ITEMS,
MAX_CODEX_DETAIL_BYTES,
MAX_CODEX_DETAIL_ENTRIES,
MAX_CODEX_IDENTITY_ENTRIES
} from './codex-structured-journal-limits'
import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink'
import type { CodexActiveJournalItem } from './codex-structured-journal-settlement'
import { readCodexJournalString } from './codex-structured-journal-translation-values'
import { readCodexTurnId } from './codex-structured-thread-facts'
export class CodexJournalItems {
readonly ordinals = new CodexTurnOrdinals()
readonly activeItems = new Map<string, CodexActiveJournalItem>()
readonly streams
private readonly identities = new Map<string, AgentJournalItemIdentity>()
private readonly details = new Map<string, string>()
constructor(
private readonly deps: Pick<
CodexJournalTranslatorDeps,
'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule'
> & { maxMetadataBytes?: number },
private readonly activeTurn: (threadId: string) => string | null,
private readonly suppress: (threadId: string, turnId: string) => void
) {
this.streams = createCodexStructuredItemStreams({
sink: deps.sink,
coalesceMs: deps.coalesceMs,
maxRetainedBytes: deps.maxRetainedBytes,
schedule: deps.schedule,
maxMetadataBytes: deps.maxMetadataBytes,
identityFor: (threadId, params, item) => {
const turnId = readCodexTurnId(params) ?? this.activeTurn(threadId)
return this.identityFor(threadId, turnId, item)
}
})
}
detailFor(threadId: string, itemId: string): string | null {
return this.details.get(codexStructuredItemKey(threadId, itemId)) ?? null
}
handle(
event: { threadId: string; method: string; params: unknown },
source: 'live' | 'history' = 'live'
): CodexItemTranslation {
const params =
typeof event.params === 'object' && event.params !== null
? (event.params as Record<string, unknown>)
: {}
const item = readCodexThreadItem(params.item)
if (!item) {
return { handled: false }
}
const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId)
const identity = this.identityFor(event.threadId, turnId, item)
// Count echoes for stable resume ordinals, but user bubbles come from submissions.
if (source === 'live' && item.type === 'userMessage') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (item.type === 'contextCompaction' && event.method === 'item/started') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (
event.method !== 'item/completed' &&
!this.streams.canTrack(event.threadId, item, identity)
) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
const translated = codexJournalItem(item)
const command = readCodexJournalString(item, 'command')
if (command) {
const boundedCommand = Buffer.from(command, 'utf8')
.subarray(0, MAX_CODEX_DETAIL_BYTES)
.toString('utf8')
this.details.set(codexStructuredItemKey(event.threadId, item.id), boundedCommand)
}
const itemKey = codexStructuredItemKey(event.threadId, item.id)
if (!translated.body) {
if (event.method === 'item/completed') {
this.streams.forget(event.threadId, item.id)
this.activeItems.delete(itemKey)
} else {
this.track(event.threadId, turnId, item, identity)
const admission = this.trimActiveState()
if (!admission.accepted) {
return { handled: true, admission }
}
}
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
const admission = this.appendTranslated(event.method, identity, translated)
if (!admission.accepted) {
return { handled: true, admission }
}
if (event.method === 'item/completed') {
this.streams.forget(event.threadId, item.id)
this.activeItems.delete(itemKey)
} else {
this.track(event.threadId, turnId, item, identity)
const trimAdmission = this.trimActiveState()
if (!trimAdmission.accepted) {
return { handled: true, admission: trimAdmission }
}
}
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
dispose(): void {
this.streams.dispose()
this.identities.clear()
this.details.clear()
this.activeItems.clear()
}
private appendTranslated(
method: string,
identity: AgentJournalItemIdentity,
translated: ReturnType<typeof codexJournalItem>
): CodexJournalTranslationAdmission {
if (!translated.body) {
return CODEX_JOURNAL_ADMITTED
}
if (method === 'item/completed') {
const admission = appendCodexLifecycleItem(this.deps.sink, identity, translated.body)
return admission.accepted ? publishCodexLifecycle(this.deps.sink) : admission
}
const options = requiresTerminalSettlement(translated.body) ? { lifecycle: true } : {}
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(identity, translated.body, options)
: (this.deps.sink.appendItem(identity, translated.body), CODEX_JOURNAL_ADMITTED)
if (!admission.accepted) {
return admission
}
return this.deps.sink.tryPublish
? this.deps.sink.tryPublish(options)
: (this.deps.sink.publish(options), CODEX_JOURNAL_ADMITTED)
}
private track(
threadId: string,
turnId: string | null,
item: CodexThreadItem,
identity: AgentJournalItemIdentity
): void {
const retainedItem = codexCommandOutlivesTurn(item)
? (boundStreamItem(item) as CodexThreadItem)
: item
this.streams.track(threadId, retainedItem, identity)
this.activeItems.set(codexStructuredItemKey(threadId, item.id), {
threadId,
turnId,
identity,
item: retainedItem
})
}
private identityFor(
threadId: string,
turnId: string | null,
item: Parameters<typeof codexItemIdentity>[0]['item']
): AgentJournalItemIdentity {
const key = codexStructuredItemKey(threadId, item.id)
const existing = this.identities.get(key)
if (existing) {
return existing
}
const identity = codexItemIdentity({ threadId, turnId, item, ordinals: this.ordinals })
this.identities.set(key, identity)
while (this.identities.size > MAX_CODEX_IDENTITY_ENTRIES) {
const oldest = this.identities.keys().next().value
if (typeof oldest === 'string') {
this.identities.delete(oldest)
}
}
while (this.details.size > MAX_CODEX_DETAIL_ENTRIES) {
const oldest = this.details.keys().next().value
if (typeof oldest === 'string') {
this.details.delete(oldest)
}
}
return identity
}
private trimActiveState(): CodexJournalTranslationAdmission {
while (this.activeItems.size - this.streams.persistentCount > MAX_CODEX_ACTIVE_ITEMS) {
const oldest = [...this.activeItems].find(
([, active]) => !codexCommandOutlivesTurn(active.item)
)?.[0]
if (typeof oldest !== 'string') {
break
}
const evicted = this.activeItems.get(oldest)
if (evicted) {
const translated = codexJournalItem(evicted.item).body
if (translated) {
const admission = appendCodexLifecycleItem(
this.deps.sink,
evicted.identity,
evictedActiveBody(translated)
)
if (!admission.accepted) {
return admission
}
const published = publishCodexLifecycle(this.deps.sink)
if (!published.accepted) {
return published
}
}
this.streams.forget(evicted.threadId, evicted.item.id)
this.suppress(evicted.threadId, evicted.turnId ?? 'outside-turn')
}
this.activeItems.delete(oldest)
}
return CODEX_JOURNAL_ADMITTED
}
}
function evictedActiveBody(body: AgentJournalItemBody): AgentJournalItemBody {
if (body.kind === 'tool-call' && body.state === 'running') {
return { ...body, state: 'failed' }
}
if (
(body.kind === 'approval' || body.kind === 'question') &&
body.resolution.state === 'pending'
) {
return {
...body,
resolution: {
state: 'cancelled',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
}
return body
}