Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1f424bd
refactor(native-chat): the Codex acquire names its turn-boundary meth…
brennanb2025 Sep 25, 2026
42bf8b4
feat(native-chat): Codex sessions write their subagents into the host…
brennanb2025 Sep 23, 2026
7513e36
fix(native-chat): close a Codex child's tool call by its item id alone
brennanb2025 Sep 23, 2026
4ccb972
test(native-chat): pin the Codex child-work evidence and every hop to…
brennanb2025 Sep 23, 2026
d88c397
test(native-chat): a Codex child's new run never inherits the last ru…
brennanb2025 Sep 23, 2026
b63cd14
test(native-chat): a Codex session with no child-work sink holds no e…
brennanb2025 Sep 23, 2026
bd28276
test(native-chat): deliver a Codex child's announcement twice, as Cod…
brennanb2025 Sep 23, 2026
4801fcb
refactor(native-chat): hand the Codex producer's pending edge over di…
brennanb2025 Sep 23, 2026
e342134
fix(native-chat): name every Codex turn state in the outcome map; typ…
brennanb2025 Sep 23, 2026
acf3516
fix(native-chat): a Codex child's turn ends on the error that ends it…
brennanb2025 Sep 23, 2026
91be06a
test(native-chat): a Codex child's turn ending by fatal error or thre…
brennanb2025 Sep 23, 2026
ba30b8b
test(native-chat): the Codex parity script reads a waiting child thro…
brennanb2025 Sep 24, 2026
9a6b75d
test(native-chat): a Codex child row's journal attempt is its record'…
brennanb2025 Sep 24, 2026
e7239f1
test(native-chat): a Codex session's end settles its live children an…
brennanb2025 Sep 25, 2026
b22661d
Merge remote-tracking branch 'origin/main' into brennanb2025/c4-codex…
brennanb2025 Sep 28, 2026
e930da1
fix(native-chat): a Codex subagent's shell is its open tool until the…
brennanb2025 Sep 28, 2026
754da3a
fix(native-chat): a Codex shell becomes a subagent's own work only on…
brennanb2025 Sep 28, 2026
6b2e70c
refactor(native-chat): child records keep every settled child and can…
brennanb2025 Sep 28, 2026
d85668e
fix(native-chat): a Codex command is live work from its start until i…
brennanb2025 Sep 28, 2026
77bf4ca
fix(native-chat): a Codex command whose approval its turn abandoned n…
brennanb2025 Sep 28, 2026
9ef9114
Merge remote-tracking branch 'origin/main' into brennanb2025/c4-codex…
brennanb2025 Sep 28, 2026
d514f6a
test(native-chat): start the Codex child-work runtime test without th…
brennanb2025 Sep 28, 2026
637397d
Merge remote-tracking branch 'origin/main' into brennanb2025/c4-codex…
brennanb2025 Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 62 additions & 19 deletions src/main/codex/codex-background-command-tracker.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire'
import type { CodexBackgroundTaskEvent } from './codex-background-task-frames'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import { readRecord, readString } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention'
import type { CodexAbandonedCommand } from './codex-prompt-registry'

const MAX_SETTLED_COMMANDS = 128
const MAX_DESCRIPTION_CHARS = 512

type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number }

/** A command process starting, or ending: it exited, its thread closed, or the session ended. */
export type CodexBackgroundCommandChange =
| { type: 'started'; threadId: string; task: AgentSessionBackgroundTask }
| { type: 'ended'; threadId: string; taskId: string }

/** The label's reserved share of the description. Reserved, not merely capped:
* a label free to spend the whole budget clips away the command it qualifies,
* leaving a command row naming an agent and no command — the failure this
Expand Down Expand Up @@ -65,35 +70,37 @@ export class CodexBackgroundCommandTracker {
)
}

observe(event: CodexBackgroundTaskEvent): void {
observe(event: CodexBackgroundTaskEvent): CodexBackgroundCommandChange | null {
const parsed = this.parse(event)
if (!parsed || this.settled.has(parsed.key)) {
return
return null
}
const { key, command, completed } = parsed
const existing = this.commands.get(key)
if (completed) {
if (existing) {
this.liveBytes -= existing.bytes
this.commands.delete(key)
}
const bytes = Buffer.byteLength(key, 'utf8') + 256
if (this.liveBytes + bytes <= this.maxMetadataBytes) {
this.settled.set(key, bytes)
this.settledBytes += bytes
}
this.trimSettled()
return
return this.end(key)
}
if (existing) {
return
if (this.commands.has(key)) {
return null
}
if (this.liveBytes + command.bytes > this.maxMetadataBytes) {
throw new Error('Codex command metadata was not admitted before observation')
}
this.commands.set(key, command)
this.liveBytes += command.bytes
this.trimSettled()
return { type: 'started', threadId: command.threadId, task: command.task }
}

/** The thread closed: Codex stops its processes first, so none of them can report an exit. */
endThread(threadId: string): CodexBackgroundCommandChange[] {
return [...this.commands]
.filter(([, command]) => command.threadId === threadId)
.flatMap(([key]) => this.end(key) ?? [])
}

/** Its approval went unanswered until its turn ended, so its process never started. */
endUnapproved(command: CodexAbandonedCommand): CodexBackgroundCommandChange | null {
return this.end(JSON.stringify([command.threadId, command.itemId]))
}

tasks(
Expand All @@ -113,11 +120,45 @@ export class CodexBackgroundCommandTracker {
})
}

clear(): void {
/** The live commands one thread launched, as the strip would publish them. */
threadTasks(threadId: string): AgentSessionBackgroundTask[] {
return [...this.commands.values()]
.filter((command) => command.threadId === threadId)
.map((command) => command.task)
}

/** The session ended, and every command with it. */
clear(): CodexBackgroundCommandChange[] {
const ended = [...this.commands.values()].map(
({ threadId, task }): CodexBackgroundCommandChange => ({
type: 'ended',
threadId,
taskId: task.id
})
)
this.commands.clear()
this.settled.clear()
this.liveBytes = 0
this.settledBytes = 0
return ended
}

/** Retires the key so a replayed frame cannot start the command again. */
private end(key: string): CodexBackgroundCommandChange | null {
const existing = this.commands.get(key)
if (existing) {
this.liveBytes -= existing.bytes
this.commands.delete(key)
}
const bytes = Buffer.byteLength(key, 'utf8') + 256
if (this.liveBytes + bytes <= this.maxMetadataBytes) {
this.settled.set(key, bytes)
this.settledBytes += bytes
}
this.trimSettled()
return existing
? { type: 'ended', threadId: existing.threadId, taskId: existing.task.id }
: null
}

private trimSettled(): void {
Expand All @@ -140,8 +181,10 @@ export class CodexBackgroundCommandTracker {
if (event.method !== 'item/started' && event.method !== 'item/completed') {
return null
}
// Any command may outlive its turn; `source` says only how Codex launched it. A stdin write
// starts no process: it reaches one already tracked.
const item = readCodexThreadItem(readRecord(event.params).item)
if (!item || !codexCommandOutlivesTurn(item)) {
if (item?.type !== 'commandExecution' || item.source === 'unifiedExecInteraction') {
Comment thread
pullfrog[bot] marked this conversation as resolved.
return null
}
const key = JSON.stringify([event.threadId, item.id])
Expand Down
40 changes: 39 additions & 1 deletion src/main/codex/codex-background-task-frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { codexChildTurnState } from './codex-subagent-executions'
import { readRecord } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { readCodexProviderVerdict } from './codex-structured-journal-provider-verdicts'
import { readCodexTurnId } from './codex-structured-thread-facts'

export type CodexBackgroundTaskFrame =
Expand All @@ -15,24 +16,59 @@ export type CodexBackgroundTaskFrame =
agentThreadId: string
label: string | null
parentTurnId: string | null | undefined
/** The reporting thread, for a `started` activity: the agent that spawned the child. */
spawnerThreadId: string | undefined
}
| {
kind: 'turn'
threadId: string
turnId: string
state: NativeChatSubagentState
}
| {
/** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. */
kind: 'turn-ended'
threadId: string
turnId: string | null
state: CodexChildTurnEnding
}

type CodexChildTurnEnding = Extract<NativeChatSubagentState, 'failed' | 'unverifiable'>

export type CodexBackgroundTaskEvent = {
method: string
threadId: string
params: unknown
}

/**
* The two ways Codex ends a child's turn without `turn/completed`. An `error` it will not retry is
* that turn's own end: the verdict the transcript settles the same turn on. A closed thread ran
* its last turn, and Codex never said how it went. A `systemError` status is neither: Codex raises
* it for errors that leave the turn running too (a refused steer), and a turn one ends also
* carries the `error`.
*/
function readCodexChildTurnEnding(
event: CodexBackgroundTaskEvent
): CodexBackgroundTaskFrame | null {
if (readCodexProviderVerdict(event.method, event.params) === 'turn-failed') {
const turnId = readCodexTurnId(event.params)
return { kind: 'turn-ended', threadId: event.threadId, turnId, state: 'failed' }
}
return event.method === 'thread/closed'
? { kind: 'turn-ended', threadId: event.threadId, turnId: null, state: 'unverifiable' }
: null
Comment on lines +58 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thread/closed may not be reachable while Orca holds the session. Codex emits it only from its "unload a thread with no subscribers" path, and Orca's connection is auto-subscribed to every thread it creates, including children — so a child's close may never notify us, leaving this arm defensive-only. Worth confirming with a live capture; if it cannot fire, a child whose only ending is a close still reads working until session end.

Technical details
# Is a child's `thread/closed` reachable through Orca's connection?

## Affected sites
- `src/main/codex/codex-background-task-frames.ts:58-60` — the `thread/closed` → `turn-ended` (`unverifiable`) arm
- `src/main/codex/codex-subagent-executions.ts:93-103` — `endTurn` with a null `turnId` is reached only from this arm

## Evidence
- Codex emits `ThreadClosedNotification` from `unload_thread_without_subscribers`, which fires only when the thread's subscriber set is empty (codex-rs `app-server/src/request_processors/thread_lifecycle.rs`).
- Orca's connection is attached as a listener to every created thread (codex-rs `app-server/src/lib.rs` → `try_attach_thread_listener`), so a child's subscriber set stays non-empty while the session is held.
- Attribution itself is sound: `ThreadClosedNotification` carries a required `threadId` and is broadcast, and `readCodexThreadId(params)` resolves it.

## Open questions for the human
Can a child's `thread/closed` reach Orca's connection in any supported scenario (a second client unloading the thread, a session-end unsubscribe race)? If not, the arm is dead and the `unverifiable` ending it produces never fires.

}

export function readCodexBackgroundTaskFrame(
event: CodexBackgroundTaskEvent,
primaryThreadId: string
): CodexBackgroundTaskFrame | null {
// The session's own turn ends through the journal's turn boundaries, never here.
const ending = event.threadId === primaryThreadId ? null : readCodexChildTurnEnding(event)
if (ending) {
return ending
}
if (event.method === 'turn/started' || event.method === 'turn/completed') {
const turnId = readCodexTurnId(event.params)
if (turnId === null) {
Expand Down Expand Up @@ -67,6 +103,8 @@ export function readCodexBackgroundTaskFrame(
parentTurnId:
activity.kind === 'started' || activity.kind === 'interacted'
? readCodexTurnId(event.params)
: undefined
: undefined,
// Only `started` names the spawner: other kinds ride whichever agent acted.
spawnerThreadId: activity.kind === 'started' ? event.threadId : undefined
}
}
82 changes: 70 additions & 12 deletions src/main/codex/codex-background-task-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,50 @@ import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskState
} from '../../shared/agent-session-wire'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import {
readCodexBackgroundTaskFrame,
type CodexBackgroundTaskEvent
} from './codex-background-task-frames'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import { CodexChildWorkEvidence } from './codex-child-work-evidence'
import type { CodexAbandonedCommand } from './codex-prompt-registry'
import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state'
import { boundSubagentField } from './codex-subagent-group-body'

/** Where a session's child-work evidence goes, and the host clock that stamps it. */
export type CodexChildWorkSink = {
deliver: (evidence: AgentChildWorkEvidence[]) => void
now: () => number
}

export function codexChildWorkSink(
sessionId: string,
deps: Pick<CodexStructuredSessionAdapterDeps, 'onChildWorkEvidence' | 'now'>
): CodexChildWorkSink {
return {
deliver: (evidence) => deps.onChildWorkEvidence?.(sessionId, evidence),
now: () => deps.now?.() ?? Date.now()
}
}

/** Projects the same child execution facts the durable roster consumes. */
export class CodexBackgroundTaskTracker {
private publishedFingerprint = '[]'
private publishedState: AgentSessionBackgroundTaskState | null = null
private readonly commands: CodexBackgroundCommandTracker
private readonly childWork: CodexChildWorkEvidence

constructor(
private readonly primaryThreadId: string,
private readonly executions = new CodexSubagentExecutions()
private readonly executions = new CodexSubagentExecutions(),
private readonly childWorkSink?: CodexChildWorkSink
) {
this.commands = new CodexBackgroundCommandTracker(primaryThreadId)
this.childWork = new CodexChildWorkEvidence(primaryThreadId, executions, (threadId) =>
this.commands.threadTasks(threadId)
)
}

get state(): AgentSessionBackgroundTaskState | null {
Expand All @@ -32,20 +57,38 @@ export class CodexBackgroundTaskTracker {
return this.commands.canObserve(event)
}

observe(event: CodexBackgroundTaskEvent): boolean {
/** `unapproved`: commands whose approval the journal dropped with this frame's turn ending. */
observe(
event: CodexBackgroundTaskEvent,
unapproved: readonly CodexAbandonedCommand[] = []
): boolean {
const itemEvent = event.method === 'item/started' || event.method === 'item/completed'
if (itemEvent) {
this.commands.observe(event)
}
const command = itemEvent ? this.commands.observe(event) : null
const commands = [
...unapproved.flatMap((abandoned) => this.commands.endUnapproved(abandoned) ?? []),
...(event.method === 'thread/closed'
? this.commands.endThread(event.threadId)
: command
? [command]
: [])
]
const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId)
if (!frame) {
return itemEvent ? this.refresh() : false
}
if (frame.kind === 'subagent') {
this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId)
} else if (frame.threadId !== this.primaryThreadId) {
if (frame?.kind === 'subagent') {
this.executions.register(
frame.agentThreadId,
frame.label,
frame.parentTurnId,
frame.spawnerThreadId
)
} else if (frame?.kind === 'turn-ended') {
this.executions.endTurn(frame.threadId, frame.turnId, frame.state)
} else if (frame && frame.threadId !== this.primaryThreadId) {
this.executions.observeTurn(frame.threadId, frame.turnId, frame.state)
}
this.childWork.observe(event, frame, commands)
if (!frame) {
return itemEvent || commands.length > 0 ? this.refresh() : false
}
// A primary-turn frame only prompts a republish: turn end reveals children,
// it never settles them. Codex `spawn_agent` children keep reporting well
// past their parent turn, so nothing here may sweep the roster.
Expand All @@ -54,10 +97,25 @@ export class CodexBackgroundTaskTracker {

clear(): boolean {
this.executions.clear()
this.commands.clear()
this.childWork.clear(this.commands.clear())
return this.refresh()
}

/** Everything the frames observed since the last drain said about the session's child work. */
drainChildWorkEvidence(observedAt: number): AgentChildWorkEvidence[] {
return this.childWork.drain(observedAt)
}

/** Hand the pending evidence to the host. Callers run this after the journal wrote the frame
* and the parent's own row republished, so a child record never lands ahead of either. */
publishChildWork(): void {
// Drained even with no sink, so undelivered evidence never accumulates.
const evidence = this.drainChildWorkEvidence(this.childWorkSink?.now() ?? Date.now())
if (evidence.length > 0) {
this.childWorkSink?.deliver(evidence)
}
}

private tasks(): AgentSessionBackgroundTask[] {
const children = this.executions.workingChildren()
const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({
Expand Down
Loading
Loading