Files
orca/src/main/codex/codex-item-stream-retention.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

108 lines
2.8 KiB
TypeScript

import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import {
MAX_CODEX_ITEM_STREAM_ITEM_BYTES,
MAX_CODEX_ITEM_STREAM_STATES
} from './codex-structured-item-stream-bounds'
import type { CodexItemStreamState } from './codex-structured-item-stream-contracts'
// Preserve the previous metadata ceiling while letting small live commands share it.
export const MAX_CODEX_ITEM_STREAM_METADATA_BYTES =
MAX_CODEX_ITEM_STREAM_STATES * MAX_CODEX_ITEM_STREAM_ITEM_BYTES
type RetainedState = { state: CodexItemStreamState; bytes: number; persistent: boolean }
export class CodexItemStreamRetention {
private readonly states = new Map<string, RetainedState>()
private bytes = 0
private persistentBytes = 0
private persistentCount = 0
constructor(private readonly maxBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES) {}
get retainedBytes(): number {
return this.bytes
}
get size(): number {
return this.states.size
}
get persistentSize(): number {
return this.persistentCount
}
get overCapacity(): boolean {
return (
this.bytes > this.maxBytes ||
this.states.size - this.persistentCount > MAX_CODEX_ITEM_STREAM_STATES
)
}
get(key: string): CodexItemStreamState | undefined {
return this.states.get(key)?.state
}
isPersistent(key: string): boolean {
return this.states.get(key)?.persistent === true
}
canRetain(key: string, state: CodexItemStreamState): boolean {
const previous = this.states.get(key)
return (
this.persistentBytes -
(previous?.persistent ? previous.bytes : 0) +
this.stateBytes(key, state) <=
this.maxBytes
)
}
retain(key: string, state: CodexItemStreamState): boolean {
if (!this.canRetain(key, state)) {
return false
}
this.forget(key)
const bytes = this.stateBytes(key, state)
const persistent = codexCommandOutlivesTurn(state.item)
this.states.set(key, { state, bytes, persistent })
this.bytes += bytes
if (persistent) {
this.persistentBytes += bytes
this.persistentCount += 1
}
return true
}
oldestEvictable(): string | undefined {
for (const [key, entry] of this.states) {
if (!entry.persistent) {
return key
}
}
return undefined
}
forget(key: string): void {
const entry = this.states.get(key)
if (!entry) {
return
}
this.bytes -= entry.bytes
if (entry.persistent) {
this.persistentBytes -= entry.bytes
this.persistentCount -= 1
}
this.states.delete(key)
}
clear(): void {
this.states.clear()
this.bytes = 0
this.persistentBytes = 0
this.persistentCount = 0
}
private stateBytes(key: string, state: CodexItemStreamState): number {
return Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(state), 'utf8') + 256
}
}