From 1f424bd442ee13f614a157a8b69355c1b4cde79d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:53:50 -0700 Subject: [PATCH 01/20] refactor(native-chat): the Codex acquire names its turn-boundary methods as a set Behavior-neutral: the same two methods stamp receipt time. Keeps the file under the size limit once the child-work sink lands. --- src/main/codex/codex-structured-session-acquire.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 2ce13d212505..905c70869d17 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -41,6 +41,8 @@ import type { CodexStructuredTurnCancellation } from './codex-structured-turn-ca import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { deliverCodexServerRequest } from './codex-structured-provider-events' +const TURN_BOUNDARIES: ReadonlySet = new Set(['turn/started', 'turn/completed']) + export async function acquireCodexStructuredSession(input: { input: StructuredAgentSessionAcquireInput deps: CodexStructuredSessionAdapterDeps @@ -134,7 +136,7 @@ export async function acquireCodexStructuredSession(input: { { onNotification: (method, params) => { // Stamped at receipt, ahead of any pre-publication buffering or retry. - const observedAt = isCodexTurnBoundary(method) ? (deps.now?.() ?? Date.now()) : undefined + const observedAt = TURN_BOUNDARIES.has(method) ? (deps.now?.() ?? Date.now()) : undefined const dispatchSequenceAtReceipt = method === 'turn/started' ? dispatchEchoes.latestSequence() : undefined input.deliver( @@ -297,7 +299,3 @@ export async function acquireCodexStructuredSession(input: { attempt.finish() } } - -function isCodexTurnBoundary(method: string): boolean { - return method === 'turn/started' || method === 'turn/completed' -} From 42bf8b4d8d987e11dfafa353b5b70604d6b70370 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:00:45 -0700 Subject: [PATCH 02/20] feat(native-chat): Codex sessions write their subagents into the host status store A Codex child thread and each persistent command become host child records, fed through the same delivery, ingest and reducer the Claude lane uses. The child's own turn decides it: turn start is live, turn completion settles it with the outcome Codex reports, and a follow-up turn reopens the same record as a new run. Its open tool call, last message, usage and waiting-on-user flag come from its own thread's frames. A parent turn ending settles nothing. --- .../codex/codex-background-command-tracker.ts | 30 +- .../codex/codex-background-task-frames.ts | 6 +- .../codex/codex-background-task-tracker.ts | 62 +++- src/main/codex/codex-child-work-evidence.ts | 305 ++++++++++++++++++ .../codex/codex-child-work-translation.ts | 101 ++++++ .../codex/codex-structured-session-acquire.ts | 6 +- .../codex/codex-structured-session-adapter.ts | 2 + .../codex/codex-structured-session-close.ts | 2 + .../codex/codex-structured-session-state.ts | 3 + .../codex/codex-structured-thread-facts.ts | 10 + src/main/codex/codex-subagent-executions.ts | 5 + .../structured-agent-session-runtime.ts | 2 + src/shared/agent-status-child-work-alias.ts | 14 +- ...t-status-child-work-evidence-resolution.ts | 26 +- .../agent-status-child-work-evidence.ts | 6 +- src/shared/agent-status-child-work-view.ts | 3 +- 16 files changed, 550 insertions(+), 33 deletions(-) create mode 100644 src/main/codex/codex-child-work-evidence.ts create mode 100644 src/main/codex/codex-child-work-translation.ts diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 844134881ad6..e2e227e0bb15 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -2,7 +2,7 @@ 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 { readCodexThreadItem, type CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention' const MAX_SETTLED_COMMANDS = 128 @@ -10,6 +10,11 @@ const MAX_DESCRIPTION_CHARS = 512 type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } +/** What one frame did to a persistent command: it started, or its process exited with `item`. */ +export type CodexBackgroundCommandChange = + | { type: 'started'; threadId: string; task: AgentSessionBackgroundTask } + | { type: 'ended'; threadId: string; taskId: string; item: CodexThreadItem } + /** 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 @@ -65,12 +70,12 @@ 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 { key, command, completed, item } = parsed const existing = this.commands.get(key) if (completed) { if (existing) { @@ -83,10 +88,12 @@ export class CodexBackgroundCommandTracker { this.settledBytes += bytes } this.trimSettled() - return + return existing + ? { type: 'ended', threadId: existing.threadId, taskId: existing.task.id, item } + : null } if (existing) { - return + return null } if (this.liveBytes + command.bytes > this.maxMetadataBytes) { throw new Error('Codex command metadata was not admitted before observation') @@ -94,6 +101,7 @@ export class CodexBackgroundCommandTracker { this.commands.set(key, command) this.liveBytes += command.bytes this.trimSettled() + return { type: 'started', threadId: command.threadId, task: command.task } } tasks( @@ -113,6 +121,13 @@ export class CodexBackgroundCommandTracker { }) } + /** The live persistent 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) + } + clear(): void { this.commands.clear() this.settled.clear() @@ -136,7 +151,7 @@ export class CodexBackgroundCommandTracker { private parse( event: CodexBackgroundTaskEvent - ): { key: string; command: Command; completed: boolean } | null { + ): { key: string; command: Command; completed: boolean; item: CodexThreadItem } | null { if (event.method !== 'item/started' && event.method !== 'item/completed') { return null } @@ -163,6 +178,7 @@ export class CodexBackgroundCommandTracker { return { key, completed, + item, command: { ...value, bytes: diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index a2e7a7e2151a..4763fb896cdc 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -15,6 +15,8 @@ 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' @@ -67,6 +69,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 } } diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index a972b7bb4c11..651f1e4939aa 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -2,25 +2,49 @@ 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 { 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 +): 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 { @@ -34,18 +58,22 @@ export class CodexBackgroundTaskTracker { observe(event: CodexBackgroundTaskEvent): 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 frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) + if (frame?.kind === 'subagent') { + this.executions.register( + frame.agentThreadId, + frame.label, + frame.parentTurnId, + frame.spawnerThreadId + ) + } else if (frame && frame.threadId !== this.primaryThreadId) { + this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) + } + this.childWork.observe(event, frame, command) 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) { - this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) - } // 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. @@ -55,9 +83,25 @@ export class CodexBackgroundTaskTracker { clear(): boolean { this.executions.clear() this.commands.clear() + this.childWork.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) => ({ diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts new file mode 100644 index 000000000000..36d7d735bce2 --- /dev/null +++ b/src/main/codex/codex-child-work-evidence.ts @@ -0,0 +1,305 @@ +// Codex child threads and persistent commands, decoded into child-work evidence for the host's +// records. +// +// The background-task tracker already follows which child exists, which turn it runs and how that +// turn ended (the executions), and which persistent command started or exited (the command +// tracker). This module keeps what only the records read — the tool a child has open, what it said +// last, its usage, whether it waits on the user — and after each frame re-derives the whole +// observation of the child that frame was about. Edges are stamped with the host clock when +// drained, after the journal handled the frame, so the host never holds a record ahead of the +// frame's own rows. A parent turn ending is never evidence here: Codex children outlive the turn +// that spawned them, so only a child's own turn, or the session, ends it. + +import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' +import type { + AgentChildWorkEvidence, + AgentChildWorkLiveObservation +} from '../../shared/agent-status-child-work-evidence' +import type { CodexBackgroundCommandChange } from './codex-background-command-tracker' +import type { + CodexBackgroundTaskEvent, + CodexBackgroundTaskFrame +} from './codex-background-task-frames' +import { + codexChildMessageText, + codexChildToolCall, + codexChildTurnOutcome, + codexCommandOutcome, + codexToolCallEnded, + type CodexChildToolCall +} from './codex-child-work-translation' +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' +import { readRecord } from './codex-item-field-readers' +import { readCodexThreadItem } from './codex-structured-item-translation' +import { codexThreadWaitsOnUser, readCodexTurnId } from './codex-structured-thread-facts' +import { CODEX_TOKEN_USAGE_METHOD, readCodexThreadTokenTotal } from './codex-subagent-activity' +import type { CodexExecutionChild, CodexSubagentExecutions } from './codex-subagent-executions' + +/** The executions' own child bound. */ +const MAX_CHILD_FACTS = 128 +const MAX_OPEN_CALLS_PER_CHILD = 16 +const CHILD_FRAME_METHODS: ReadonlySet = new Set([ + 'item/started', + 'item/completed', + 'thread/status/changed', + CODEX_TOKEN_USAGE_METHOD +]) + +/** A tool call a child has started and not finished. `openedAt` is the host clock of the first + * drain that carried it, so a later edge keeps the time the call opened. */ +type OpenCall = CodexChildToolCall & { turnId: string | null; openedAt?: number } + +type ChildFacts = { + openCalls: Map + lastMessage?: { turnId: string | null; text: string } + totalTokens?: number + waiting: boolean + /** The last observation handed to the host, so an unchanged re-derivation sends nothing. */ + published?: string +} + +export type CodexPendingChildWork = (observedAt: number) => AgentChildWorkEvidence + +/** Evidence from a run only counts for that run: a fact recorded under another turn is stale. */ +function ofTurn(fact: T | undefined, turnId: string) { + return fact && (fact.turnId === null || fact.turnId === turnId) ? fact : undefined +} + +function commandLive(task: AgentSessionBackgroundTask, ownerId: string | null) { + return (observedAt: number): AgentChildWorkEvidence => ({ + type: 'live', + observedAt, + child: { + handle: { idKind: 'task_id', id: task.id }, + kind: 'command', + residency: 'background', + state: 'working', + ...(task.description ? { description: task.description } : {}), + ...(ownerId !== null ? { ownerId } : {}), + stoppable: false + } + }) +} + +export class CodexChildWorkEvidence { + private readonly facts = new Map() + private pending: CodexPendingChildWork[] = [] + + constructor( + private readonly primaryThreadId: string, + private readonly executions: CodexSubagentExecutions, + private readonly liveCommands: (threadId: string) => readonly AgentSessionBackgroundTask[] + ) {} + + /** After the tracker applied the frame: what it did to a persistent command, and to the child + * the frame is about. */ + observe( + event: CodexBackgroundTaskEvent, + frame: CodexBackgroundTaskFrame | null, + command: CodexBackgroundCommandChange | null + ): void { + if (command?.type === 'started') { + this.pending.push(commandLive(command.task, this.commandOwner(command.threadId))) + } else if (command?.type === 'ended') { + const outcome = codexCommandOutcome(command.item) + this.pending.push((observedAt) => ({ + type: 'ended', + observedAt, + handle: { idKind: 'task_id', id: command.taskId }, + outcome + })) + } + const threadId = this.childThread(event, frame) + if (threadId === null) { + return + } + const facts = this.factsFor(threadId) + if (facts && event.threadId === threadId) { + this.record(facts, event) + } + this.queueChild(threadId) + } + + /** The provider session is gone, and every child with it. */ + clear(): void { + this.facts.clear() + this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) + } + + drain(observedAt: number): AgentChildWorkEvidence[] { + const pending = this.pending + this.pending = [] + return pending.map((edge) => edge(observedAt)) + } + + private childThread( + event: CodexBackgroundTaskEvent, + frame: CodexBackgroundTaskFrame | null + ): string | null { + const threadId = + frame?.kind === 'subagent' + ? frame.agentThreadId + : frame?.kind === 'turn' || CHILD_FRAME_METHODS.has(event.method) + ? event.threadId + : null + return threadId === this.primaryThreadId ? null : threadId + } + + /** A command belongs to the child thread that launched it; the session's own agent is no owner. */ + private commandOwner(threadId: string): string | null { + return threadId === this.primaryThreadId ? null : threadId + } + + private record(facts: ChildFacts, event: CodexBackgroundTaskEvent): void { + if (event.method === CODEX_TOKEN_USAGE_METHOD) { + facts.totalTokens = readCodexThreadTokenTotal(event.params)?.totalTokens ?? facts.totalTokens + return + } + if (event.method === 'thread/status/changed') { + facts.waiting = codexThreadWaitsOnUser(event.params) + return + } + const item = readCodexThreadItem(readRecord(event.params).item) + if (!item || codexCommandOutlivesTurn(item)) { + return + } + const turnId = readCodexTurnId(event.params) + const text = event.method === 'item/completed' ? codexChildMessageText(item) : undefined + if (text) { + facts.lastMessage = { turnId, text } + } + const call = codexChildToolCall(item) + if (!call) { + return + } + if (codexToolCallEnded(event.method, item)) { + facts.openCalls.delete(item.id) + } else if (!facts.openCalls.has(item.id)) { + facts.openCalls.set(item.id, { ...call, turnId }) + for (const stale of [...facts.openCalls.keys()].slice(0, -MAX_OPEN_CALLS_PER_CHILD)) { + facts.openCalls.delete(stale) + } + } + } + + /** Re-derive the child's observation and hand it on when it changed. A child the provider has + * not announced, or that never ran a turn, is no record. */ + private queueChild(threadId: string): void { + const child = this.executions.find(threadId) + const facts = this.facts.get(threadId) + if (!child?.registered || !child.execution || !facts) { + return + } + const { turnId, state } = child.execution + if (state !== 'working') { + // The turn is over, and so is every call it had open. + facts.openCalls.clear() + facts.waiting = false + this.publish(facts, JSON.stringify(['ended', turnId, state]), () => { + const lastMessage = ofTurn(facts.lastMessage, turnId)?.text + const { totalTokens } = facts + const outcome = codexChildTurnOutcome(state) + return (observedAt) => ({ + type: 'ended', + observedAt, + handle: { idKind: 'thread_id', id: threadId, runId: turnId }, + outcome, + ...(lastMessage ? { lastMessage } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}) + }) + }) + return + } + for (const [itemId, call] of facts.openCalls) { + if (!ofTurn(call, turnId)) { + facts.openCalls.delete(itemId) + } + } + const observation = this.liveAgent(threadId, child, facts, turnId) + const openCall = [...facts.openCalls].at(-1) + const announced = facts.published !== undefined + this.publish(facts, JSON.stringify(['live', observation, openCall?.[0]]), () => { + return (observedAt) => { + if (!openCall) { + return { type: 'live', observedAt, child: { ...observation, operation: null } } + } + const [, call] = openCall + call.openedAt ??= observedAt + const operation = { + toolName: call.toolName, + ...(call.input ? { input: call.input } : {}), + basis: 'open' as const, + observedAt: call.openedAt + } + return { type: 'live', observedAt, child: { ...observation, operation } } + } + }) + if (!announced) { + this.requeueOwnedBy(threadId) + } + } + + private liveAgent( + threadId: string, + child: Readonly, + facts: ChildFacts, + turnId: string + ): AgentChildWorkLiveObservation { + const lastMessage = ofTurn(facts.lastMessage, turnId)?.text + const spawner = child.spawnerThreadId + return { + handle: { idKind: 'thread_id', id: threadId, runId: turnId }, + kind: 'agent', + // A spawned child may outlive the turn that spawned it. + residency: 'background', + state: facts.waiting ? 'waiting' : 'working', + // The agent path's last segment is the child's only label; today's row shows it there. + ...(child.label ? { description: child.label } : {}), + ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), + ...(lastMessage ? { lastMessage } : {}), + ...(spawner && spawner !== this.primaryThreadId ? { ownerId: spawner } : {}), + stoppable: false + } + } + + private publish(facts: ChildFacts, fingerprint: string, edge: () => CodexPendingChildWork) { + if (facts.published !== fingerprint) { + facts.published = fingerprint + this.pending.push(edge()) + } + } + + /** Work a child launched before the host held its record was admitted with no owner; now that + * the owner is recorded, say again whose it is. */ + private requeueOwnedBy(threadId: string): void { + for (const task of this.liveCommands(threadId)) { + this.pending.push(commandLive(task, threadId)) + } + for (const spawned of this.executions.workingChildren()) { + const facts = this.facts.get(spawned.agentThreadId) + if (spawned.spawnerThreadId === threadId && facts?.published !== undefined) { + facts.published = undefined + this.queueChild(spawned.agentThreadId) + } + } + } + + private factsFor(threadId: string): ChildFacts | undefined { + const existing = this.facts.get(threadId) + if (existing) { + return existing + } + if (this.facts.size >= MAX_CHILD_FACTS) { + const idle = [...this.facts.keys()].find( + (id) => this.executions.find(id)?.execution?.state !== 'working' + ) + if (idle === undefined) { + return undefined + } + this.facts.delete(idle) + } + const facts: ChildFacts = { openCalls: new Map(), waiting: false } + this.facts.set(threadId, facts) + return facts + } +} diff --git a/src/main/codex/codex-child-work-translation.ts b/src/main/codex/codex-child-work-translation.ts new file mode 100644 index 000000000000..72a383144d7a --- /dev/null +++ b/src/main/codex/codex-child-work-translation.ts @@ -0,0 +1,101 @@ +// Codex items and statuses, read in the child-work vocabulary. +// +// A tool is named the way Codex names it to its own hooks (`Bash`, `apply_patch`, +// `mcp__server__tool`), so a structured Codex child running a shell reads exactly as a Codex +// CLI agent running one does. + +import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview +} from '../../shared/agent-hook-listener/tool-input-preview' +import { readRecord, readString, readTextContent } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-structured-item-translation' + +/** Raw provider text kept for a record; admission folds it to its own one-line bound. */ +export const CODEX_CHILD_WORK_TEXT_MAX_CHARS = 2_048 + +export type CodexChildToolCall = { toolName: string; input?: string } + +function bounded(text: string | null | undefined): string | undefined { + return text ? text.slice(0, CODEX_CHILD_WORK_TEXT_MAX_CHARS) : undefined +} + +function withInput(toolName: string, input: string | undefined): CodexChildToolCall { + const preview = bounded(input) + return preview ? { toolName, input: preview } : { toolName } +} + +function firstChangePath(changes: unknown): string | undefined { + const [first] = Array.isArray(changes) ? changes : [] + return readString(readRecord(first), 'path') ?? undefined +} + +/** The tool a thread item runs, or null for an item that is not a tool call (a message, a + * thought, a plan). A persistent command is not one either: it is work of its own. */ +export function codexChildToolCall(item: CodexThreadItem): CodexChildToolCall | null { + switch (item.type) { + case 'commandExecution': + return withInput('Bash', deriveToolInputPreview('Bash', { command: item.command })) + case 'fileChange': + return withInput('apply_patch', firstChangePath(item.changes)) + case 'mcpToolCall': { + const server = readString(item, 'server') + const tool = readString(item, 'tool') + if (!tool) { + return null + } + return withInput( + server ? `mcp__${server}__${tool}` : tool, + deriveFallbackToolInputPreview(item.arguments) + ) + } + case 'webSearch': + return withInput('web_search', readString(item, 'query') ?? undefined) + default: + return null + } +} + +/** Whether an item frame says the call is over, whatever frame carried it. */ +export function codexToolCallEnded(method: string, item: CodexThreadItem): boolean { + const status = readString(item, 'status') + return method === 'item/completed' || (status !== null && status !== 'inProgress') +} + +/** What a child said: an assistant message's text. */ +export function codexChildMessageText(item: CodexThreadItem): string | undefined { + return item.type === 'agentMessage' + ? bounded(readString(item, 'text') ?? readTextContent(item, 'content')) + : undefined +} + +/** A child turn's ending. Codex states three; anything else is an ending nobody classified. */ +export function codexChildTurnOutcome(state: NativeChatSubagentState): AgentChildWorkOutcome { + switch (state) { + case 'completed': + return 'succeeded' + case 'failed': + return 'failed' + case 'stopped': + return 'cancelled' + default: + return 'unknown' + } +} + +/** A persistent command's exit: a zero (or unreported) exit code is the only success, as the + * command's own transcript row reads it; a declined command never ran. */ +export function codexCommandOutcome(item: CodexThreadItem): AgentChildWorkOutcome { + switch (readString(item, 'status')) { + case 'completed': + return typeof item.exitCode === 'number' && item.exitCode !== 0 ? 'failed' : 'succeeded' + case 'failed': + return 'failed' + case 'declined': + return 'cancelled' + default: + return 'unknown' + } +} diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 905c70869d17..56890c2f204f 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -8,7 +8,7 @@ import { closeFailedCodexAcquisition, stopSupersededCodexAcquisition } from './codex-structured-acquisition-lifecycle' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexJournalTranslator } from './codex-structured-journal-translation' @@ -240,6 +240,8 @@ export async function acquireCodexStructuredSession(input: { throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) } acquisitions.deleteIfCurrent(sessionId, attempt) + // Where this session's child work goes: the host's records, after each frame is journaled. + const sink = codexChildWorkSink(sessionId, deps) const session: CodexSession = { connection, ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), @@ -254,7 +256,7 @@ export async function acquireCodexStructuredSession(input: { ...(catalogAccess ? { catalogAccess } : {}), dispatchEchoes, translator, - backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), + backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions, sink), forceCloseUnexpected: (reason) => input.forceCloseUnexpected( sessionId, diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index b9affd8bd003..0b40c7e3ff53 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -159,6 +159,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap if (session.backgroundTasks.observe(event)) { this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state) } + // After the journal and the parent's republished row, never ahead of either. + session.backgroundTasks.publishChildWork() } if (event.type === 'ended') { this.compactions.ended(event.sessionId) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 1ed8dcd385ce..e66e7eb49f4d 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -52,6 +52,8 @@ export function handleCodexSessionExit(input: { session.dispatchEchoes.clear() session.backgroundTasks.clear() input.onBackgroundTasksChanged?.(input.sessionId, null) + // Every close path funnels here, so the session's children end with it on each one. + session.backgroundTasks.publishChildWork() session.unbindReadingControl?.() input.onEvent?.(event) session.prompts.clear() diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index b5e4796d21d9..3705b8d93580 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -11,6 +11,7 @@ import type { import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes' @@ -75,6 +76,8 @@ export type CodexStructuredSessionAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void + /** What the session's child work did, delivered after the journal handled the frame. */ + onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void /** Identity for a send admitted earlier, once Codex echoes the user message. */ onDispatchSettledLate?: (input: { sessionId: string diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index 7422d7e44ff3..0edfad442db3 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -70,3 +70,13 @@ export function codexThreadStoppedRunning(payload: unknown): boolean { const type = record(record(payload)?.status)?.type return type === 'idle' || type === 'systemError' } + +/** An `active` thread flags each request it has open on the user (an approval, a question). */ +export function codexThreadWaitsOnUser(payload: unknown): boolean { + const status = record(record(payload)?.status) + const flags = status?.type === 'active' ? status.activeFlags : null + return ( + Array.isArray(flags) && + flags.some((flag) => flag === 'waitingOnApproval' || flag === 'waitingOnUserInput') + ) +} diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 0f74babe7341..6ae9281dce9f 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -109,6 +109,11 @@ export class CodexSubagentExecutions { return this.children.get(agentThreadId)?.turnOrdinals.get(turnId) ?? null } + /** The child as last observed, without creating one. */ + find(agentThreadId: string): Readonly | undefined { + return this.children.get(agentThreadId) + } + workingChildren(): CodexExecutionChild[] { return [...this.children.values()].filter( (child) => child.registered && child.execution?.state === 'working' diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index a51da3f1c3c6..7150dae49136 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -245,6 +245,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), + onChildWorkEvidence: (sessionId, evidence) => + host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, onPrimaryThreadStoppedRunning: ({ sessionId }) => { void host diff --git a/src/shared/agent-status-child-work-alias.ts b/src/shared/agent-status-child-work-alias.ts index 539c9c38214e..c7ff29a5c6a4 100644 --- a/src/shared/agent-status-child-work-alias.ts +++ b/src/shared/agent-status-child-work-alias.ts @@ -16,11 +16,17 @@ const CHILD_ALIAS_KEY_PREFIX = 'agent-child-work-alias-v1:' const MAX_ALIAS_PART_LENGTH = 512 /** - * `thread_id` names a child by its own provider thread (a Codex subagent). The hook lane registers - * a Claude `agent_id` under `task_id` (it is the same registry id) and a Codex `agent_id` under - * `thread_id`; no `agent_id` kind exists on purpose. + * `thread_id` names a child by its own provider thread (a Codex subagent); `turn_id` names one run + * of such a child, as `tool_use_id` names one run of a task. The hook lane registers a Claude + * `agent_id` under `task_id` (it is the same registry id) and a Codex `agent_id` under `thread_id`; + * no `agent_id` kind exists on purpose. */ -export const AGENT_CHILD_WORK_ALIAS_KINDS = ['task_id', 'tool_use_id', 'thread_id'] as const +export const AGENT_CHILD_WORK_ALIAS_KINDS = [ + 'task_id', + 'tool_use_id', + 'thread_id', + 'turn_id' +] as const export type AgentChildWorkAliasKind = (typeof AGENT_CHILD_WORK_ALIAS_KINDS)[number] const ALIAS_KIND_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_ALIAS_KINDS) diff --git a/src/shared/agent-status-child-work-evidence-resolution.ts b/src/shared/agent-status-child-work-evidence-resolution.ts index 50c80e5bdbd7..14c31bee4b6b 100644 --- a/src/shared/agent-status-child-work-evidence-resolution.ts +++ b/src/shared/agent-status-child-work-evidence-resolution.ts @@ -20,7 +20,15 @@ import { agentStatusSubjectsEqual, type AgentStatusSubject } from './agent-statu * a handle is unique per parent and provider without being unique across producers. */ export const STRUCTURED_CHILD_WORK_PRODUCER_ID = 'structured-session-child-work' const SEGMENT_ID = STRUCTURED_CHILD_WORK_PRODUCER_ID -const RUN_ALIAS_KIND: AgentChildWorkAliasKind = 'tool_use_id' +/** A run is named in the provider's own terms: a task runs under its spawn call, a thread under + * its turn. The kinds stay apart so a turn id can never pass for a spawn call. */ +const RUN_ALIAS_KIND_BY_ID_KIND = { + task_id: 'tool_use_id', + thread_id: 'turn_id' +} as const satisfies Record +const RUN_ALIAS_KINDS: ReadonlySet = new Set( + Object.values(RUN_ALIAS_KIND_BY_ID_KIND) +) export const STRUCTURED_CHILD_WORK_PROVENANCE: AgentChildWorkProvenance = { source: 'structured-session', @@ -70,7 +78,13 @@ export function agentChildWorkHandleAliases( return [ { segmentId: SEGMENT_ID, aliasKind: handle.idKind, alias: handle.id }, ...(handle.runId !== undefined && handle.runId !== handle.id - ? [{ segmentId: SEGMENT_ID, aliasKind: RUN_ALIAS_KIND, alias: handle.runId }] + ? [ + { + segmentId: SEGMENT_ID, + aliasKind: RUN_ALIAS_KIND_BY_ID_KIND[handle.idKind], + alias: handle.runId + } + ] : []) ] } @@ -123,14 +137,14 @@ export function resolveAgentChildWorkHandle( : { child: child ?? null, ambiguous: false, highestGeneration } } -/** The owner a handle id names, by its stable id or by the run handle it spawned under. */ +/** The owner a handle id names, by its stable id or by the spawn call it runs under. */ export function resolveAgentChildWorkOwner( scope: AgentChildWorkEvidenceScope, ownerId: string ): string | undefined { const resolution = resolveAgentChildWorkHandle( scope, - ['task_id', 'thread_id', RUN_ALIAS_KIND], + ['task_id', 'thread_id', 'tool_use_id'], ownerId ) return resolution?.child?.childWorkId @@ -164,7 +178,7 @@ export function currentAgentChildWorkAliases( return { ...(stable ? { stable } : {}), stableId: stable?.id, - runId: current.find((alias) => alias.aliasKind === RUN_ALIAS_KIND)?.alias, + runId: current.find((alias) => RUN_ALIAS_KINDS.has(alias.aliasKind))?.alias, aliases: current.map((alias) => ({ segmentId: alias.segmentId, aliasKind: alias.aliasKind, @@ -183,7 +197,7 @@ export function isPreviousAgentChildWorkRun( .getAliasesForChild(child.childWorkId) .some( (alias) => - alias.aliasKind === RUN_ALIAS_KIND && + RUN_ALIAS_KINDS.has(alias.aliasKind) && alias.alias === runId && !agentChildWorkFencesEqual(alias.fence, child.invocation) ) diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 06fe05d38a8c..68206941e5b9 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -13,9 +13,9 @@ import type { AgentChildWorkState } from './agent-status-child-work' -/** How the provider names one child. `id` is the stable handle today's wire already publishes - * (a Claude task id); `runId` names the current run when the provider mints one per run (the - * spawn call), and a different one is the provider starting the child again. */ +/** How the provider names one child. `id` is the stable handle: a task id, or the child's own + * thread. `runId` names the current run when the provider mints one per run (a task's spawn + * call, a thread's turn), and a different one is the provider starting the child again. */ export type AgentChildWorkEvidenceHandle = { idKind: Extract id: string diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index 80324d854e43..8c70f6a3fea9 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -60,7 +60,8 @@ export type AgentChildWorkViewAlias = Pick< const PROVIDER_ID_ALIAS_RANK: Record = { task_id: 0, thread_id: 1, - tool_use_id: 2 + tool_use_id: 2, + turn_id: 3 } const PROVIDER_ID_ALIAS_ORDER = [...AGENT_CHILD_WORK_ALIAS_KINDS].sort( (left, right) => PROVIDER_ID_ALIAS_RANK[left] - PROVIDER_ID_ALIAS_RANK[right] From 7513e36da79f9328347720f64253ada91074e9ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:10:41 -0700 Subject: [PATCH 03/20] fix(native-chat): close a Codex child's tool call by its item id alone A completion frame need not restate the tool it ran, so reading the tool name before closing left the call open and the record naming a finished tool. --- src/main/codex/codex-child-work-evidence.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 36d7d735bce2..1d31a19b1c36 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -168,13 +168,13 @@ export class CodexChildWorkEvidence { if (text) { facts.lastMessage = { turnId, text } } - const call = codexChildToolCall(item) - if (!call) { - return - } + // An end closes the call by id alone: its closing frame need not restate what it ran. if (codexToolCallEnded(event.method, item)) { facts.openCalls.delete(item.id) - } else if (!facts.openCalls.has(item.id)) { + return + } + const call = codexChildToolCall(item) + if (call && !facts.openCalls.has(item.id)) { facts.openCalls.set(item.id, { ...call, turnId }) for (const stale of [...facts.openCalls.keys()].slice(0, -MAX_OPEN_CALLS_PER_CHILD)) { facts.openCalls.delete(stale) From 4ccb972fc947a24e2f1b541c015d80e33c4074cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:10:44 -0700 Subject: [PATCH 04/20] test(native-chat): pin the Codex child-work evidence and every hop to the host's records Child turn start/end/follow-up, open tool call, last message, usage, waiting, the persistent command a child owns and its monitoring display, a primary turn end settling nothing, and session end. End to end through the real adapter: evidence after the journal and the legacy republish, and the parent state the records imply equals today's at every frame of a scripted session. Through the production runtime: a Codex session's child work reaches the status sink under its own address, and a provider exit ends it there. --- .../codex/codex-child-work-evidence.test.ts | 388 ++++++++++++++++++ ...dex-structured-child-work-producer.test.ts | 312 ++++++++++++++ ...tructured-codex-child-work-runtime.test.ts | 132 ++++++ 3 files changed, 832 insertions(+) create mode 100644 src/main/codex/codex-child-work-evidence.test.ts create mode 100644 src/main/codex/codex-structured-child-work-producer.test.ts create mode 100644 src/main/runtime/structured-codex-child-work-runtime.test.ts diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts new file mode 100644 index 000000000000..8399b7c39a62 --- /dev/null +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from 'vitest' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { + agentChildWorkOwnedLiveness, + deriveAgentChildDisplayState, + projectAgentChildWorkViews +} from '../../shared/agent-status-child-work-view' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' + +const PRIMARY = 'thread-parent' +const PARENT_TURN = 'turn-parent' +const CHILD = 'thread-child' +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) + +function turn( + method: 'turn/started' | 'turn/completed', + threadId: string, + turnId: string, + status = 'completed' +): CodexBackgroundTaskEvent { + return { method, threadId, params: { threadId, turn: { id: turnId, status } } } +} + +function spawned( + child = CHILD, + reporter = PRIMARY, + name = 'audit_build' +): CodexBackgroundTaskEvent { + return { + method: 'item/started', + threadId: reporter, + params: { + threadId: reporter, + turnId: PARENT_TURN, + item: { + type: 'subAgentActivity', + id: `activity-${child}`, + kind: 'started', + agentThreadId: child, + agentPath: `/root/${name}` + } + } + } +} + +function item( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): CodexBackgroundTaskEvent { + return { method, threadId, params: { threadId, turnId, item: fields } } +} + +function shell(id: string, command: string, status = 'inProgress', source = 'agent') { + return { type: 'commandExecution', id, command, source, status } +} + +function harness() { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + let clock = 1_000 + const log: AgentChildWorkEvidence[][] = [] + const send = (...events: CodexBackgroundTaskEvent[]): void => { + for (const event of events) { + tracker.observe(event) + clock += 10 + const evidence = tracker.drainChildWorkEvidence(clock) + log.push(evidence) + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) + } + } + const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + const byKind = (kind: AgentChildWorkRecord['kind']) => + records().filter((record) => record.kind === kind) + const display = (childWorkId: string) => { + const children = records() + const views = projectAgentChildWorkViews( + children, + children.flatMap((child) => store.getAliasesForChild(child.childWorkId)) + ) + const view = views.find((candidate) => candidate.id === childWorkId) + return view && deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)) + } + return { tracker, store, send, records, byKind, display, log } +} + +/** A child spawned by the parent turn and running its first turn. */ +function runningChild() { + const run = harness() + run.send(turn('turn/started', PRIMARY, PARENT_TURN), spawned(), turn('turn/started', CHILD, 'c1')) + return run +} + +describe('Codex child-work evidence', () => { + it('records a spawned child by its thread, with its turn as the run', () => { + const { records, store, log } = runningChild() + expect(records()).toEqual([ + expect.objectContaining({ + kind: 'agent', + membership: 'live', + state: 'working', + residency: 'background', + description: 'audit_build', + invocation: { invocationId: 'c1', generation: 1 }, + stoppable: false + }) + ]) + const aliases = store.getAliasesForChild(records()[0]!.childWorkId) + expect(aliases.map(({ aliasKind, alias }) => [aliasKind, alias])).toEqual([ + ['thread_id', CHILD], + ['turn_id', 'c1'] + ]) + // The announcement arrives twice; the host hears it once. + expect(log.flat().filter((edge) => edge.type === 'live')).toHaveLength(1) + }) + + it('makes no record for a child whose turn began before its announcement, until it lands', () => { + const { send, records } = harness() + send(turn('turn/started', PRIMARY, PARENT_TURN), turn('turn/started', CHILD, 'c1')) + expect(records()).toEqual([]) + send(spawned()) + expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) + }) + + it.each([ + ['completed', 'succeeded'], + ['interrupted', 'cancelled'], + ['failed', 'failed'], + ['somethingNew', 'unknown'] + ])('settles a child whose own turn ended %s as %s', (status, outcome) => { + const { send, tracker, records } = runningChild() + send(turn('turn/completed', CHILD, 'c1', status)) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome }) + ]) + // Today's strip drops the child the moment its turn ends; only the record keeps its ending. + expect(tracker.state).toBeNull() + }) + + it('never settles a child on its PARENT turn ending: children outlive the turn', () => { + const { send, records } = runningChild() + send(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) + }) + + it('reopens the same record for a follow-up turn on a finished child, as a new run', () => { + const { send, records } = runningChild() + send(turn('turn/completed', CHILD, 'c1')) + const [finished] = records() + send(turn('turn/started', CHILD, 'c2')) + expect(records()).toEqual([ + expect.objectContaining({ + childWorkId: finished!.childWorkId, + membership: 'live', + state: 'working', + invocation: { invocationId: 'c2', generation: 2 }, + previousInvocations: [ + expect.objectContaining({ + fence: { invocationId: 'c1', generation: 1 }, + outcome: 'succeeded' + }) + ] + }) + ]) + // A late ending of the first run neither ends nor restarts the second. + send(turn('turn/completed', CHILD, 'c1', 'failed')) + expect(records()).toEqual([ + expect.objectContaining({ + membership: 'live', + invocation: { invocationId: 'c2', generation: 2 } + }) + ]) + send(turn('turn/completed', CHILD, 'c2', 'interrupted')) + expect(records()).toEqual([ + expect.objectContaining({ + childWorkId: finished!.childWorkId, + membership: 'settled', + outcome: 'cancelled' + }) + ]) + }) + + it('says which tool the child has open, the way a CLI row names a Codex shell', () => { + const { send, byKind } = runningChild() + send(item('item/started', CHILD, 'c1', shell('cmd-1', 'npm test'))) + expect(byKind('agent')[0]?.operation).toEqual({ + toolName: 'Bash', + input: 'npm test', + basis: 'open', + observedAt: 1_040 + }) + send( + item('item/started', CHILD, 'c1', { + type: 'mcpToolCall', + id: 'mcp-1', + server: 'github', + tool: 'search_issues', + arguments: { query: 'flaky' }, + status: 'inProgress' + }) + ) + expect(byKind('agent')[0]?.operation).toMatchObject({ + toolName: 'mcp__github__search_issues', + input: 'flaky' + }) + // The newer call ends first: the child is still running the older one, since it opened. + send( + item('item/completed', CHILD, 'c1', { type: 'mcpToolCall', id: 'mcp-1', status: 'completed' }) + ) + expect(byKind('agent')[0]?.operation).toEqual({ + toolName: 'Bash', + input: 'npm test', + basis: 'open', + observedAt: 1_040 + }) + send(item('item/completed', CHILD, 'c1', shell('cmd-1', 'npm test', 'completed'))) + expect(byKind('agent')[0]?.operation).toBeUndefined() + }) + + it('keeps what the child said last, and its usage, through to how it ended', () => { + const { send, byKind } = runningChild() + send( + item('item/completed', CHILD, 'c1', { + type: 'agentMessage', + id: 'msg-1', + text: 'Two tests\nflake on CI' + }), + { + method: 'thread/tokenUsage/updated', + threadId: CHILD, + params: { threadId: CHILD, tokenUsage: { total: { totalTokens: 4_200 } } } + } + ) + expect(byKind('agent')[0]).toMatchObject({ + lastMessage: 'Two tests flake on CI', + totalTokens: 4_200 + }) + send(turn('turn/completed', CHILD, 'c1')) + expect(byKind('agent')[0]).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Two tests flake on CI', + totalTokens: 4_200 + }) + // A new run has said nothing yet. + send(turn('turn/started', CHILD, 'c2')) + expect(byKind('agent')[0]).not.toHaveProperty('lastMessage') + }) + + it('reads a child waiting on the user from its own thread status', () => { + const { send, byKind } = runningChild() + const status = (status: unknown): CodexBackgroundTaskEvent => ({ + method: 'thread/status/changed', + threadId: CHILD, + params: { threadId: CHILD, status } + }) + send(status({ type: 'active', activeFlags: ['waitingOnApproval'] })) + expect(byKind('agent')[0]?.state).toBe('waiting') + send(status({ type: 'active', activeFlags: [] })) + expect(byKind('agent')[0]?.state).toBe('working') + send(status({ type: 'active', activeFlags: ['waitingOnUserInput'] })) + send(turn('turn/completed', CHILD, 'c1')) + send(turn('turn/started', CHILD, 'c2')) + // The wait ended with the turn that asked. + expect(byKind('agent')[0]?.state).toBe('working') + }) + + it("owns the child's persistent command, so a finished child reads monitoring while it runs", () => { + const { send, byKind, display } = runningChild() + send( + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') + ) + ) + const [agent] = byKind('agent') + expect(byKind('command')).toEqual([ + expect.objectContaining({ + membership: 'live', + description: 'npm run dev', + parentChildWorkId: agent!.childWorkId + }) + ]) + // A persistent command is work of its own, never the tool the child is running. + expect(agent?.operation).toBeUndefined() + send(turn('turn/completed', CHILD, 'c1')) + expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) + expect(display(agent!.childWorkId)).toBe('monitoring') + send( + item('item/completed', CHILD, 'c1', { + ...shell('exec-1', 'npm run dev', 'completed', 'unifiedExecStartup'), + exitCode: 1 + }) + ) + expect(byKind('command')[0]).toMatchObject({ membership: 'settled', outcome: 'failed' }) + expect(display(agent!.childWorkId)).toBe('done') + }) + + it("names the owner of a command launched before the host held its child's record", () => { + const { send, byKind } = harness() + send( + turn('turn/started', PRIMARY, PARENT_TURN), + turn('turn/started', CHILD, 'c1'), + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'tail -f log', 'inProgress', 'unifiedExecStartup') + ) + ) + expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') + send(spawned()) + expect(byKind('command')[0]?.parentChildWorkId).toBe(byKind('agent')[0]?.childWorkId) + }) + + it("records the session's own persistent command with no owner", () => { + const { send, byKind } = harness() + send( + item( + 'item/started', + PRIMARY, + PARENT_TURN, + shell('exec-9', 'sleep 90', 'inProgress', 'unifiedExecStartup') + ) + ) + expect(byKind('command')).toEqual([ + expect.objectContaining({ membership: 'live', description: 'sleep 90' }) + ]) + expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') + }) + + it('names the child that spawned a nested child as its owner', () => { + const { send, byKind } = runningChild() + send( + spawned('thread-grandchild', CHILD, 'lint'), + turn('turn/started', 'thread-grandchild', 'g1') + ) + const nested = byKind('agent').find((record) => record.description === 'lint') + const owner = byKind('agent').find((record) => record.description === 'audit_build') + expect(nested?.parentChildWorkId).toBe(owner?.childWorkId) + }) + + it('drops every record when the provider session ends', () => { + const { send, tracker, records, store } = runningChild() + send( + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') + ) + ) + expect(records()).toHaveLength(2) + tracker.clear() + const evidence = tracker.drainChildWorkEvidence(9_000) + expect(evidence).toEqual([{ type: 'session-ended', observedAt: 9_000 }]) + reconcileAgentChildWorkEvidence({ + store, + admission: createAgentChildWorkAdmission(store, { mintChildWorkId: () => 'unused' }), + parent, + provider: 'codex', + evidence + }) + expect(records()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts new file mode 100644 index 000000000000..2a741baad327 --- /dev/null +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -0,0 +1,312 @@ +// A Codex session's frames, through the real adapter, into the host's child records: the order the +// host receives them in, and whether the parent row the records imply is today's row. + +import { describe, expect, it } from 'vitest' +import { foldAgentLeadStatus } from '../../shared/agent-lead-status-fold' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' +import { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liveness' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { + agentChildWorkOwnedLiveness, + deriveAgentChildDisplayState, + projectAgentChildWorkViews +} from '../../shared/agent-status-child-work-view' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) +const REVIEWER = 'thread-reviewer' +const TESTER = 'thread-tester' + +type Frame = { method: string; params: Record } +type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } + +const turn = ( + method: 'turn/started' | 'turn/completed', + threadId: string, + id: string, + status = 'completed' +): Frame => ({ + method, + params: { threadId, turn: { id, status } } +}) +const spawned = (child: string, name: string, parentTurn: string): Frame => ({ + method: 'item/started', + params: { + threadId: THREAD_ID, + turnId: parentTurn, + item: { + type: 'subAgentActivity', + id: `spawn-${child}`, + kind: 'started', + agentThreadId: child, + agentPath: `/root/${name}` + } + } +}) +const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): Frame => ({ + method, + params: { threadId, turnId, item: fields } +}) +const status = (threadId: string, activeFlags: string[]): Frame => ({ + method: 'thread/status/changed', + params: { threadId, status: { type: 'active', activeFlags } } +}) + +async function producer() { + const codex = fakeCodex() + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + const deliveries: Delivery[] = [] + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + onBackgroundTasksChanged: (_sessionId, state) => + deliveries.push({ kind: 'legacy', detail: String(state?.tasks?.length ?? 0) }), + onChildWorkEvidence: (sessionId, evidence) => { + expect(sessionId).toBe('session-1') + deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) + } + }) + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity) => + deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }), + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + const send = (frame: Frame): Delivery[] => { + const from = deliveries.length + codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) + return deliveries.slice(from) + } + const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + const byDescription = (description: string) => + records().find((record) => record.description === description) + const display = (description: string) => { + const children = records() + const views = projectAgentChildWorkViews( + children, + children.flatMap((child) => store.getAliasesForChild(child.childWorkId)) + ) + const view = views.find((candidate) => candidate.description === description) + return view && deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)) + } + return { adapter, codex, send, records, byDescription, display } +} + +describe('Codex structured child-work producer', () => { + it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { + const { send, records } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(turn('turn/started', REVIEWER, 'r1')) + const deliveries = send(spawned(REVIEWER, 'review', 'p1')) + const kinds = deliveries.map((delivery) => delivery.kind) + // The frame's own rows, then the parent's republished row, and only then its children. + expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) + expect(kinds.slice(kinds.indexOf('legacy'))).toEqual(['legacy', 'evidence']) + expect(records()).toEqual([ + expect.objectContaining({ description: 'review', membership: 'live' }) + ]) + }) + + it('records the parent state today reads, frame by frame, while adding outcome and activity', async () => { + const { adapter, send, records, byDescription, display } = await producer() + const steps: { frame: Frame; lead: 'working' | 'done'; check?: () => void }[] = [ + { frame: turn('turn/started', THREAD_ID, 'p1'), lead: 'working' }, + // Codex reports the child's turn before its announcement. + { + frame: turn('turn/started', REVIEWER, 'r1'), + lead: 'working', + check: () => expect(records()).toEqual([]) + }, + { frame: spawned(REVIEWER, 'review', 'p1'), lead: 'working' }, + { + frame: item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'cmd-1', + command: 'npm test', + source: 'agent', + status: 'inProgress' + }), + lead: 'working', + check: () => + expect(byDescription('review')?.operation).toMatchObject({ + toolName: 'Bash', + input: 'npm test', + basis: 'open' + }) + }, + // The child leaves a dev server running past its own turn. + { + frame: item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'inProgress' + }), + lead: 'working', + check: () => + expect(byDescription('npm run dev')?.parentChildWorkId).toBe( + byDescription('review')?.childWorkId + ) + }, + { + frame: item('item/completed', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'cmd-1', + command: 'npm test', + source: 'agent', + status: 'completed', + exitCode: 0 + }), + lead: 'working', + check: () => expect(byDescription('review')?.operation).toBeUndefined() + }, + { + frame: item('item/completed', REVIEWER, 'r1', { + type: 'agentMessage', + id: 'msg-1', + text: 'Dev server is up' + }), + lead: 'working' + }, + // The parent's turn ends first; its child keeps running. + { + frame: turn('turn/completed', THREAD_ID, 'p1'), + lead: 'done', + check: () => + expect(byDescription('review')).toMatchObject({ membership: 'live', state: 'working' }) + }, + { + frame: status(REVIEWER, ['waitingOnApproval']), + lead: 'done', + check: () => expect(byDescription('review')?.state).toBe('waiting') + }, + { frame: status(REVIEWER, []), lead: 'done' }, + { + frame: turn('turn/completed', REVIEWER, 'r1'), + lead: 'done', + check: () => { + expect(byDescription('review')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Dev server is up' + }) + // Finished, but a shell it launched still runs: the CLI parent rule reads monitoring. + expect(display('review')).toBe('monitoring') + } + }, + { frame: turn('turn/started', THREAD_ID, 'p2'), lead: 'working' }, + // The parent asks the finished child a follow-up: the same record, a new run. + { + frame: turn('turn/started', REVIEWER, 'r2'), + lead: 'working', + check: () => + expect(byDescription('review')).toMatchObject({ + childWorkId: 'child-1', + membership: 'live', + invocation: { invocationId: 'r2', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] + }) + }, + { frame: spawned(TESTER, 'test', 'p2'), lead: 'working' }, + { frame: turn('turn/started', TESTER, 't1'), lead: 'working' }, + { + frame: turn('turn/completed', REVIEWER, 'r2', 'interrupted'), + lead: 'working', + check: () => + expect(byDescription('review')).toMatchObject({ + membership: 'settled', + outcome: 'cancelled' + }) + }, + { frame: turn('turn/completed', THREAD_ID, 'p2'), lead: 'done' }, + { + frame: turn('turn/completed', TESTER, 't1', 'failed'), + lead: 'done', + check: () => + expect(byDescription('test')).toMatchObject({ membership: 'settled', outcome: 'failed' }) + }, + { + frame: item('item/completed', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'completed', + exitCode: 0 + }), + lead: 'done', + check: () => { + expect(byDescription('npm run dev')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded' + }) + expect(display('review')).toBe('interrupted') + } + } + ] + for (const [index, step] of steps.entries()) { + send(step.frame) + const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + const recorded = agentChildWorkLiveness( + records().filter((record) => record.membership === 'live') + ) + const fold = (childWorkLiveness: typeof legacy) => + foldAgentLeadStatus({ leadState: step.lead, childWorkLiveness }) + expect({ index, parent: fold(recorded) }).toEqual({ index, parent: fold(legacy) }) + expect({ index, liveness: recorded }).toEqual({ index, liveness: legacy }) + step.check?.() + } + await adapter.closeSession('session-1') + expect(records()).toEqual([]) + expect(adapter.backgroundTaskState('session-1')).toBeUndefined() + }) + + it('drops the records when the provider exits unexpectedly', async () => { + const { codex, send, records } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(spawned(REVIEWER, 'review', 'p1')) + send(turn('turn/started', REVIEWER, 'r1')) + expect(records()).toHaveLength(1) + codex.connections[0]!.handlers.onExit?.(new Error('provider exited')) + expect(records()).toEqual([]) + }) +}) diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts new file mode 100644 index 000000000000..0a020bd9a768 --- /dev/null +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -0,0 +1,132 @@ +// The production runtime hands a Codex session's child work to the status sink, under the address +// the session's own row landed under, and ends it with the provider. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const THREAD = 'thread-runtime-child-work' +const CHILD = 'thread-runtime-reviewer' +const ROUTES: Record = { + 'thread/start': { thread: { id: THREAD } }, + 'model/list': { + data: [ + { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true + } + ], + nextCursor: null + } +} + +describe('structured Codex child work through the production runtime', () => { + let root: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } + }) + + it("hands its subagents to the status sink under the session's own address", async () => { + root = await mkdtemp(join(tmpdir(), 'orca-runtime-codex-child-work-')) + const connections: CodexAppServerConnectionHandlers[] = [] + const openConnection = (async (_launch, handlers = {}) => { + connections.push(handlers) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => (method in ROUTES ? ROUTES[method] : {}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + }) as typeof openCodexAppServerConnection + const childWork: Parameters< + NonNullable + >[] = [] + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + statusSink: { + publish: () => {}, + forget: () => {}, + publishChildWork: (...args) => childWork.push(args) + } + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` + const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + expect(attached).toMatchObject({ ok: true }) + await host.hold(SESSION, 'desktop-chat:1') + const notify = (method: string, params: Record) => + connections[0]?.onNotification?.(method, params) + notify('turn/started', { threadId: THREAD, turn: { id: 'turn-1' } }) + notify('turn/started', { threadId: CHILD, turn: { id: 'child-turn-1' } }) + notify('item/started', { + threadId: THREAD, + turnId: 'turn-1', + item: { + type: 'subAgentActivity', + id: 'spawn-1', + kind: 'started', + agentThreadId: CHILD, + agentPath: '/root/review' + } + }) + const subject = expect.objectContaining({ kind: 'structured-session', sessionId: SESSION }) + expect(childWork).toEqual([ + [ + subject, + [ + expect.objectContaining({ + type: 'live', + child: expect.objectContaining({ + handle: { idKind: 'thread_id', id: CHILD, runId: 'child-turn-1' }, + description: 'review' + }) + }) + ], + 'codex' + ] + ]) + // The provider dies: its children go with it, reported under the same address. + connections[0]?.onExit?.(new Error('scripted provider exit')) + expect(childWork.at(-1)).toEqual([ + subject, + [expect.objectContaining({ type: 'session-ended' })], + 'codex' + ]) + }) +}) From d88c397840bb774b64089a590770d9f49e0ddf57 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:12:23 -0700 Subject: [PATCH 05/20] test(native-chat): a Codex child's new run never inherits the last run's open call --- src/main/codex/codex-child-work-evidence.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 8399b7c39a62..031f93c916e0 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -237,6 +237,14 @@ describe('Codex child-work evidence', () => { expect(byKind('agent')[0]?.operation).toBeUndefined() }) + it("never carries a run's open call into the next run when its ending was lost", () => { + const { send, byKind } = runningChild() + send(item('item/started', CHILD, 'c1', shell('cmd-1', 'npm test'))) + send(turn('turn/started', CHILD, 'c2')) + expect(byKind('agent')[0]).toMatchObject({ invocation: { invocationId: 'c2', generation: 2 } }) + expect(byKind('agent')[0]?.operation).toBeUndefined() + }) + it('keeps what the child said last, and its usage, through to how it ended', () => { const { send, byKind } = runningChild() send( From b63cd148aa7e3e9d32ef7724eb0e55444f82a2aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:13:25 -0700 Subject: [PATCH 06/20] test(native-chat): a Codex session with no child-work sink holds no evidence --- src/main/codex/codex-child-work-evidence.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 031f93c916e0..ea1a595cc58a 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -370,6 +370,14 @@ describe('Codex child-work evidence', () => { expect(nested?.parentChildWorkId).toBe(owner?.childWorkId) }) + it('holds no evidence for a session with nowhere to deliver it', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(spawned()) + tracker.observe(turn('turn/started', CHILD, 'c1')) + tracker.publishChildWork() + expect(tracker.drainChildWorkEvidence(1)).toEqual([]) + }) + it('drops every record when the provider session ends', () => { const { send, tracker, records, store } = runningChild() send( From bd28276f93b9192b2a2c7ca386824364932634be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:17:49 -0700 Subject: [PATCH 07/20] test(native-chat): deliver a Codex child's announcement twice, as Codex does, before counting edges --- src/main/codex/codex-child-work-evidence.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index ea1a595cc58a..6b06e7e622d9 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -113,7 +113,9 @@ function runningChild() { describe('Codex child-work evidence', () => { it('records a spawned child by its thread, with its turn as the run', () => { - const { records, store, log } = runningChild() + const { records, store, log, send } = runningChild() + // Codex delivers the announcement a second time, on `item/completed`. + send({ ...spawned(), method: 'item/completed' }) expect(records()).toEqual([ expect.objectContaining({ kind: 'agent', @@ -130,7 +132,7 @@ describe('Codex child-work evidence', () => { ['thread_id', CHILD], ['turn_id', 'c1'] ]) - // The announcement arrives twice; the host hears it once. + // The host hears the child once. expect(log.flat().filter((edge) => edge.type === 'live')).toHaveLength(1) }) From 4801fcb3b29529c947d50ba3e60c6e7c5f29a458 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:18:56 -0700 Subject: [PATCH 08/20] refactor(native-chat): hand the Codex producer's pending edge over directly --- src/main/codex/codex-child-work-evidence.ts | 54 ++++++++++----------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 1d31a19b1c36..4b38169570c6 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -195,19 +195,17 @@ export class CodexChildWorkEvidence { // The turn is over, and so is every call it had open. facts.openCalls.clear() facts.waiting = false - this.publish(facts, JSON.stringify(['ended', turnId, state]), () => { - const lastMessage = ofTurn(facts.lastMessage, turnId)?.text - const { totalTokens } = facts - const outcome = codexChildTurnOutcome(state) - return (observedAt) => ({ - type: 'ended', - observedAt, - handle: { idKind: 'thread_id', id: threadId, runId: turnId }, - outcome, - ...(lastMessage ? { lastMessage } : {}), - ...(totalTokens !== undefined ? { totalTokens } : {}) - }) - }) + const lastMessage = ofTurn(facts.lastMessage, turnId)?.text + const { totalTokens } = facts + const outcome = codexChildTurnOutcome(state) + this.publish(facts, JSON.stringify(['ended', turnId, state]), (observedAt) => ({ + type: 'ended', + observedAt, + handle: { idKind: 'thread_id', id: threadId, runId: turnId }, + outcome, + ...(lastMessage ? { lastMessage } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}) + })) return } for (const [itemId, call] of facts.openCalls) { @@ -218,21 +216,19 @@ export class CodexChildWorkEvidence { const observation = this.liveAgent(threadId, child, facts, turnId) const openCall = [...facts.openCalls].at(-1) const announced = facts.published !== undefined - this.publish(facts, JSON.stringify(['live', observation, openCall?.[0]]), () => { - return (observedAt) => { - if (!openCall) { - return { type: 'live', observedAt, child: { ...observation, operation: null } } - } - const [, call] = openCall - call.openedAt ??= observedAt - const operation = { - toolName: call.toolName, - ...(call.input ? { input: call.input } : {}), - basis: 'open' as const, - observedAt: call.openedAt - } - return { type: 'live', observedAt, child: { ...observation, operation } } + this.publish(facts, JSON.stringify(['live', observation, openCall?.[0]]), (observedAt) => { + if (!openCall) { + return { type: 'live', observedAt, child: { ...observation, operation: null } } + } + const [, call] = openCall + call.openedAt ??= observedAt + const operation = { + toolName: call.toolName, + ...(call.input ? { input: call.input } : {}), + basis: 'open' as const, + observedAt: call.openedAt } + return { type: 'live', observedAt, child: { ...observation, operation } } }) if (!announced) { this.requeueOwnedBy(threadId) @@ -262,10 +258,10 @@ export class CodexChildWorkEvidence { } } - private publish(facts: ChildFacts, fingerprint: string, edge: () => CodexPendingChildWork) { + private publish(facts: ChildFacts, fingerprint: string, edge: CodexPendingChildWork): void { if (facts.published !== fingerprint) { facts.published = fingerprint - this.pending.push(edge()) + this.pending.push(edge) } } From e342134d9322c2ac9e60131946d9c4487c6cd7a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:24:43 -0700 Subject: [PATCH 09/20] fix(native-chat): name every Codex turn state in the outcome map; type the runtime test's fake opener --- .../codex/codex-child-work-translation.ts | 20 +++++++++---------- ...tructured-codex-child-work-runtime.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/codex/codex-child-work-translation.ts b/src/main/codex/codex-child-work-translation.ts index 72a383144d7a..15620443e7da 100644 --- a/src/main/codex/codex-child-work-translation.ts +++ b/src/main/codex/codex-child-work-translation.ts @@ -80,7 +80,9 @@ export function codexChildTurnOutcome(state: NativeChatSubagentState): AgentChil return 'failed' case 'stopped': return 'cancelled' - default: + case 'unverifiable': + case 'working': + case 'idle': return 'unknown' } } @@ -88,14 +90,12 @@ export function codexChildTurnOutcome(state: NativeChatSubagentState): AgentChil /** A persistent command's exit: a zero (or unreported) exit code is the only success, as the * command's own transcript row reads it; a declined command never ran. */ export function codexCommandOutcome(item: CodexThreadItem): AgentChildWorkOutcome { - switch (readString(item, 'status')) { - case 'completed': - return typeof item.exitCode === 'number' && item.exitCode !== 0 ? 'failed' : 'succeeded' - case 'failed': - return 'failed' - case 'declined': - return 'cancelled' - default: - return 'unknown' + const status = readString(item, 'status') + if (status === 'completed') { + return typeof item.exitCode === 'number' && item.exitCode !== 0 ? 'failed' : 'succeeded' + } + if (status === 'failed') { + return 'failed' } + return status === 'declined' ? 'cancelled' : 'unknown' } diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 0a020bd9a768..9ccdc7a07e7d 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -53,7 +53,7 @@ describe('structured Codex child work through the production runtime', () => { it("hands its subagents to the status sink under the session's own address", async () => { root = await mkdtemp(join(tmpdir(), 'orca-runtime-codex-child-work-')) const connections: CodexAppServerConnectionHandlers[] = [] - const openConnection = (async (_launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { connections.push(handlers) const connection: CodexAppServerConnection = { pid: 4321, @@ -65,7 +65,7 @@ describe('structured Codex child work through the production runtime', () => { close: async () => true } return connection - }) as typeof openCodexAppServerConnection + } const childWork: Parameters< NonNullable >[] = [] From acf35163eba16c8a37b3b2c2cb20a49f390302a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:58:39 -0700 Subject: [PATCH 10/20] fix(native-chat): a Codex child's turn ends on the error that ends it, or on its thread closing Codex can end a child's turn with no turn/completed: an error it will not retry is that turn's own end (the verdict the transcript already settles the same turn on), and a closed thread ran its last turn. The executions, the one owner of child turn state, now end the turn on both, so the strip drops the child and its record settles (failed, or unknown for a close) together, instead of reading working for the life of the session. A systemError status is not an ending: Codex raises it for errors that leave the turn running. A child fact whose frame names no turn now belongs to the turn the child is running, instead of counting for every run. --- .../codex/codex-background-task-frames.ts | 34 +++++++++++++++++++ .../codex/codex-background-task-tracker.ts | 2 ++ src/main/codex/codex-child-work-evidence.ts | 10 ++++-- src/main/codex/codex-subagent-executions.ts | 14 ++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 4763fb896cdc..65f51846c247 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -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 = @@ -24,6 +25,15 @@ export type CodexBackgroundTaskFrame = 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 export type CodexBackgroundTaskEvent = { method: string @@ -31,10 +41,34 @@ export type CodexBackgroundTaskEvent = { 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 +} + 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) { diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 651f1e4939aa..065d00f93baf 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -67,6 +67,8 @@ export class CodexBackgroundTaskTracker { 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) } diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 4b38169570c6..65f67bea5f69 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -62,7 +62,7 @@ export type CodexPendingChildWork = (observedAt: number) => AgentChildWorkEviden /** Evidence from a run only counts for that run: a fact recorded under another turn is stale. */ function ofTurn(fact: T | undefined, turnId: string) { - return fact && (fact.turnId === null || fact.turnId === turnId) ? fact : undefined + return fact?.turnId === turnId ? fact : undefined } function commandLive(task: AgentSessionBackgroundTask, ownerId: string | null) { @@ -139,7 +139,7 @@ export class CodexChildWorkEvidence { const threadId = frame?.kind === 'subagent' ? frame.agentThreadId - : frame?.kind === 'turn' || CHILD_FRAME_METHODS.has(event.method) + : frame || CHILD_FRAME_METHODS.has(event.method) ? event.threadId : null return threadId === this.primaryThreadId ? null : threadId @@ -163,7 +163,11 @@ export class CodexChildWorkEvidence { if (!item || codexCommandOutlivesTurn(item)) { return } - const turnId = readCodexTurnId(event.params) + // A frame that names no turn belongs to the one the child is running. + const turnId = + readCodexTurnId(event.params) ?? + this.executions.find(event.threadId)?.execution?.turnId ?? + null const text = event.method === 'item/completed' ? codexChildMessageText(item) : undefined if (text) { facts.lastMessage = { turnId, text } diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 6ae9281dce9f..939d1b903317 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -94,6 +94,20 @@ export class CodexSubagentExecutions { return { child, execution } } + /** A child turn that ended with no `turn/completed`. With no turn named, the one the child is + * running ended; a child running none has nothing to end. The first ending a turn gets stands. */ + endTurn( + agentThreadId: string, + turnId: string | null, + state: Exclude + ): void { + const current = this.children.get(agentThreadId)?.execution + const ended = turnId ?? (current?.state === 'working' ? current.turnId : null) + if (current && ended !== null) { + this.observeTurn(agentThreadId, ended, state) + } + } + /** Survives the child's turn, so a row outliving that turn can still name it. */ label(agentThreadId: string): string | null { return this.children.get(agentThreadId)?.label ?? null From 91be06a26540f7b84850723a20f28da27eab9fc5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:58:43 -0700 Subject: [PATCH 11/20] test(native-chat): a Codex child's turn ending by fatal error or thread close settles strip and record together --- .../codex/codex-child-work-evidence.test.ts | 67 +++++++++++++++++++ ...dex-structured-child-work-producer.test.ts | 13 ++++ 2 files changed, 80 insertions(+) diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 6b06e7e622d9..e5d0b558a074 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -159,6 +159,59 @@ describe('Codex child-work evidence', () => { expect(tracker.state).toBeNull() }) + const childError = ( + turnId: string | undefined, + willRetry: boolean + ): CodexBackgroundTaskEvent => ({ + method: 'error', + threadId: CHILD, + params: { + threadId: CHILD, + ...(turnId ? { turnId } : {}), + willRetry, + error: { message: 'boom' } + } + }) + const childClosed: CodexBackgroundTaskEvent = { + method: 'thread/closed', + threadId: CHILD, + params: { threadId: CHILD } + } + + it.each([ + ['an error naming its turn that Codex will not retry', childError('c1', false), 'failed'], + ['an error naming no turn that Codex will not retry', childError(undefined, false), 'failed'], + ['its thread closing', childClosed, 'unknown'] + ])( + 'settles a working child whose turn ended with no turn/completed, by %s, in the strip and the record together', + (_label, ending, outcome) => { + const { send, tracker, records } = runningChild() + expect(tracker.state?.tasks).toHaveLength(1) + send(ending) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome }) + ]) + expect(tracker.state).toBeNull() + // The first ending a turn gets stands. + send(turn('turn/completed', CHILD, 'c1', 'completed')) + expect(records()).toEqual([expect.objectContaining({ outcome })]) + } + ) + + it('keeps a child working through a retried error and a systemError status: its turn runs on', () => { + const { send, tracker, records } = runningChild() + send(childError('c1', true), { + method: 'thread/status/changed', + threadId: CHILD, + params: { threadId: CHILD, status: { type: 'systemError' } } + }) + expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) + expect(tracker.state?.tasks).toHaveLength(1) + // A fatal error naming a turn the child already finished ends nothing. + send(turn('turn/completed', CHILD, 'c1'), childError('c1', false)) + expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })]) + }) + it('never settles a child on its PARENT turn ending: children outlive the turn', () => { const { send, records } = runningChild() send(turn('turn/completed', PRIMARY, PARENT_TURN)) @@ -277,6 +330,20 @@ describe('Codex child-work evidence', () => { expect(byKind('agent')[0]).not.toHaveProperty('lastMessage') }) + it('files a message whose frame names no turn under the run that said it, never the next', () => { + const { send, byKind } = runningChild() + send({ + method: 'item/completed', + threadId: CHILD, + params: { threadId: CHILD, item: { type: 'agentMessage', id: 'msg-1', text: 'Done' } } + }) + expect(byKind('agent')[0]?.lastMessage).toBe('Done') + send(turn('turn/completed', CHILD, 'c1'), turn('turn/started', CHILD, 'c2')) + send(turn('turn/completed', CHILD, 'c2')) + expect(byKind('agent')[0]).toMatchObject({ outcome: 'succeeded' }) + expect(byKind('agent')[0]).not.toHaveProperty('lastMessage') + }) + it('reads a child waiting on the user from its own thread status', () => { const { send, byKind } = runningChild() const status = (status: unknown): CodexBackgroundTaskEvent => ({ diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 2a741baad327..8cdbc987e52a 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -29,6 +29,7 @@ const parent = makeStructuredAgentStatusSubject( ) const REVIEWER = 'thread-reviewer' const TESTER = 'thread-tester' +const LINTER = 'thread-linter' type Frame = { method: string; params: Record } type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } @@ -248,6 +249,18 @@ describe('Codex structured child-work producer', () => { }, { frame: spawned(TESTER, 'test', 'p2'), lead: 'working' }, { frame: turn('turn/started', TESTER, 't1'), lead: 'working' }, + { frame: spawned(LINTER, 'lint', 'p2'), lead: 'working' }, + { frame: turn('turn/started', LINTER, 'l1'), lead: 'working' }, + // Codex ends this child's turn with an error it will not retry, and no turn/completed. + { + frame: { + method: 'error', + params: { threadId: LINTER, turnId: 'l1', willRetry: false, error: { message: 'boom' } } + }, + lead: 'working', + check: () => + expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' }) + }, { frame: turn('turn/completed', REVIEWER, 'r2', 'interrupted'), lead: 'working', From ba30b8bbcb66d6b7271c08278f814ace74df3cba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:18:42 -0700 Subject: [PATCH 12/20] test(native-chat): the Codex parity script reads a waiting child through the shared fold's waiting arm --- ...dex-structured-child-work-producer.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 8cdbc987e52a..ab108fd7a58f 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -148,7 +148,12 @@ describe('Codex structured child-work producer', () => { it('records the parent state today reads, frame by frame, while adding outcome and activity', async () => { const { adapter, send, records, byDescription, display } = await producer() - const steps: { frame: Frame; lead: 'working' | 'done'; check?: () => void }[] = [ + const steps: { + frame: Frame + lead: 'working' | 'done' + childWaits?: true + check?: () => void + }[] = [ { frame: turn('turn/started', THREAD_ID, 'p1'), lead: 'working' }, // Codex reports the child's turn before its announcement. { @@ -215,10 +220,18 @@ describe('Codex structured child-work producer', () => { check: () => expect(byDescription('review')).toMatchObject({ membership: 'live', state: 'working' }) }, + // The legacy task list carries no child state, so only the records can say a child waits, + // and the shared fold ranks that wait above the parent's own state. { frame: status(REVIEWER, ['waitingOnApproval']), lead: 'done', - check: () => expect(byDescription('review')?.state).toBe('waiting') + childWaits: true, + check: () => { + expect(byDescription('review')?.state).toBe('waiting') + expect(agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks)).toBe( + 'working' + ) + } }, { frame: status(REVIEWER, []), lead: 'done' }, { @@ -302,10 +315,11 @@ describe('Codex structured child-work producer', () => { const recorded = agentChildWorkLiveness( records().filter((record) => record.membership === 'live') ) + const expected = step.childWaits ? 'waiting' : legacy const fold = (childWorkLiveness: typeof legacy) => foldAgentLeadStatus({ leadState: step.lead, childWorkLiveness }) - expect({ index, parent: fold(recorded) }).toEqual({ index, parent: fold(legacy) }) - expect({ index, liveness: recorded }).toEqual({ index, liveness: legacy }) + expect({ index, parent: fold(recorded) }).toEqual({ index, parent: fold(expected) }) + expect({ index, liveness: recorded }).toEqual({ index, liveness: expected }) step.check?.() } await adapter.closeSession('session-1') From 9a6b75d734a0f11e944f84822b50535d22b8bf93 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:03:18 -0700 Subject: [PATCH 13/20] test(native-chat): a Codex child row's journal attempt is its record's generation The journal numbers a Codex child's runs by the turns it observed on the child's thread; the host record numbers them by the runs its evidence opened. Both are keyed by the child's own turn id, so they must agree run for run, including when Codex reports the child's first turn before the spawn that announces it. --- ...dex-structured-child-work-producer.test.ts | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index ab108fd7a58f..a8c3fdce820d 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -13,6 +13,11 @@ import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' import { createAgentStatusStore } from '../../shared/agent-status-store' +import { agentJournalLinkageFields } from '../../shared/agent-session-journal-producer' +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage +} from '../../shared/agent-session-journal-types' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' @@ -99,9 +104,12 @@ async function producer() { reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) } }) + const rows: { body: AgentJournalItemBody; linkage: AgentJournalProducerLinkage }[] = [] const journal: StructuredAgentSessionEventSink = { - appendItem: (identity) => - deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }), + appendItem: (identity, body, options) => { + deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }) + rows.push({ body, linkage: agentJournalLinkageFields(options) }) + }, appendTombstone: () => {}, publish: () => {} } @@ -128,7 +136,10 @@ async function producer() { const view = views.find((candidate) => candidate.description === description) return view && deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)) } - return { adapter, codex, send, records, byDescription, display } + /** The producer stamp on the newest journal row that carries this text. */ + const stampOf = (text: string) => + rows.findLast((row) => JSON.stringify(row.body).includes(text))?.linkage + return { adapter, codex, send, records, byDescription, display, stampOf } } describe('Codex structured child-work producer', () => { @@ -327,6 +338,35 @@ describe('Codex structured child-work producer', () => { expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) + it("numbers a child's runs as the journal does: a row's attempt is its record's generation", async () => { + const { send, byDescription, stampOf } = await producer() + const says = (turnId: string, text: string) => + item('item/completed', REVIEWER, turnId, { type: 'agentMessage', id: `msg-${text}`, text }) + // The journal stamps a child row with its run only once it is past the first. + const runs = (text: string) => { + const stamp = stampOf(text) + return { + agentId: stamp?.agentId, + attempt: stamp ? (stamp.attempt ?? 1) : undefined, + generation: byDescription('review')?.invocation.generation + } + } + send(turn('turn/started', THREAD_ID, 'p1')) + // Codex reports the child's first turn before the spawn that announces it. + send(turn('turn/started', REVIEWER, 'r1')) + send(spawned(REVIEWER, 'review', 'p1')) + send(says('r1', 'run 1')) + expect(runs('run 1')).toEqual({ agentId: REVIEWER, attempt: 1, generation: 1 }) + send(turn('turn/completed', REVIEWER, 'r1')) + // Each follow-up the parent sends is the child's next run, on both sides. + for (const run of [2, 3]) { + send(turn('turn/started', REVIEWER, `r${run}`)) + send(says(`r${run}`, `run ${run}`)) + expect(runs(`run ${run}`)).toEqual({ agentId: REVIEWER, attempt: run, generation: run }) + send(turn('turn/completed', REVIEWER, `r${run}`)) + } + }) + it('drops the records when the provider exits unexpectedly', async () => { const { codex, send, records } = await producer() send(turn('turn/started', THREAD_ID, 'p1')) From e7239f19fe6b3ccd3b05a02b2c3e2eded151648c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:44:53 -0700 Subject: [PATCH 14/20] test(native-chat): a Codex session's end settles its live children and keeps the ended ones The host no longer erases a session's children when its provider goes away: a child still running settles with an outcome nobody reported, and a child that had already ended keeps what it said. The producer tests now expect exactly that, from the close path and from an unexpected exit. --- .../codex/codex-child-work-evidence.test.ts | 7 +++-- src/main/codex/codex-child-work-evidence.ts | 2 +- ...dex-structured-child-work-producer.test.ts | 31 ++++++++++++++++--- ...tructured-codex-child-work-runtime.test.ts | 2 +- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index e5d0b558a074..6d651273308f 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -447,7 +447,7 @@ describe('Codex child-work evidence', () => { expect(tracker.drainChildWorkEvidence(1)).toEqual([]) }) - it('drops every record when the provider session ends', () => { + it('settles every live record with no reported outcome when the provider session ends', () => { const { send, tracker, records, store } = runningChild() send( item( @@ -468,6 +468,9 @@ describe('Codex child-work evidence', () => { provider: 'codex', evidence }) - expect(records()).toEqual([]) + expect(records()).toEqual([ + expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'unknown' }), + expect.objectContaining({ kind: 'command', membership: 'settled', outcome: 'unknown' }) + ]) }) }) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 65f67bea5f69..ca3607a2ce6d 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -120,7 +120,7 @@ export class CodexChildWorkEvidence { this.queueChild(threadId) } - /** The provider session is gone, and every child with it. */ + /** The provider session is gone: no child it still ran can report its own ending. */ clear(): void { this.facts.clear() this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index a8c3fdce820d..d198025cbceb 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -333,8 +333,22 @@ describe('Codex structured child-work producer', () => { expect({ index, liveness: recorded }).toEqual({ index, liveness: expected }) step.check?.() } + const settled = records() await adapter.closeSession('session-1') - expect(records()).toEqual([]) + // Every child had already ended; closing the session changes none of what they said. + expect(records()).toEqual(settled) + expect( + records().map(({ description, membership, outcome }) => ({ + description, + membership, + outcome + })) + ).toEqual([ + { description: 'review', membership: 'settled', outcome: 'cancelled' }, + { description: 'npm run dev', membership: 'settled', outcome: 'succeeded' }, + { description: 'test', membership: 'settled', outcome: 'failed' }, + { description: 'lint', membership: 'settled', outcome: 'failed' } + ]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) @@ -367,13 +381,22 @@ describe('Codex structured child-work producer', () => { } }) - it('drops the records when the provider exits unexpectedly', async () => { + it('settles a live child with no reported outcome when the provider exits unexpectedly', async () => { const { codex, send, records } = await producer() send(turn('turn/started', THREAD_ID, 'p1')) send(spawned(REVIEWER, 'review', 'p1')) send(turn('turn/started', REVIEWER, 'r1')) - expect(records()).toHaveLength(1) + expect(records()).toEqual([ + expect.objectContaining({ description: 'review', membership: 'live', state: 'working' }) + ]) codex.connections[0]!.handlers.onExit?.(new Error('provider exited')) - expect(records()).toEqual([]) + expect(records()).toEqual([ + expect.objectContaining({ + description: 'review', + membership: 'settled', + state: 'done', + outcome: 'unknown' + }) + ]) }) }) diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 9ccdc7a07e7d..7313dd8b5311 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -121,7 +121,7 @@ describe('structured Codex child work through the production runtime', () => { 'codex' ] ]) - // The provider dies: its children go with it, reported under the same address. + // The provider dies: its session's end is reported under the same address. connections[0]?.onExit?.(new Error('scripted provider exit')) expect(childWork.at(-1)).toEqual([ subject, From e930da150cf92883977c0d8c1d8865c3aca1fb3c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:31:52 -0700 Subject: [PATCH 15/20] fix(native-chat): a Codex subagent's shell is its open tool until the process exits Codex runs every agent shell through unified exec, so every subagent shell arrives with the source the persistent-command tracker keys on. The producer skipped those items, so a working subagent never named its shell, and an approved command (started on the approval path, completed from unified exec) stayed its open tool until the turn ended. The tracker still records the process separately, so a command that outlives the turn reads as monitoring. --- .../codex/codex-child-work-evidence.test.ts | 42 ++++++++++++++++++- src/main/codex/codex-child-work-evidence.ts | 4 +- .../codex/codex-child-work-translation.ts | 2 +- ...dex-structured-child-work-producer.test.ts | 7 +++- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 6d651273308f..875114503648 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -292,6 +292,44 @@ describe('Codex child-work evidence', () => { expect(byKind('agent')[0]?.operation).toBeUndefined() }) + it('names a unified-exec shell as the open call until its process exits', () => { + const { send, byKind } = runningChild() + // Codex runs every agent shell through unified exec, not only the ones that outlive a turn. + send( + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'npm test', 'inProgress', 'unifiedExecStartup') + ) + ) + expect(byKind('agent')[0]?.operation).toMatchObject({ toolName: 'Bash', input: 'npm test' }) + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-1', 'npm test', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('agent')[0]?.operation).toBeUndefined() + // An approved command starts on the approval path and completes from unified exec. + send(item('item/started', CHILD, 'c1', shell('exec-2', 'touch ~/marker'))) + expect(byKind('agent')[0]?.operation).toMatchObject({ + toolName: 'Bash', + input: 'touch ~/marker' + }) + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-2', 'touch ~/marker', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('agent')[0]?.operation).toBeUndefined() + }) + it("never carries a run's open call into the next run when its ending was lost", () => { const { send, byKind } = runningChild() send(item('item/started', CHILD, 'c1', shell('cmd-1', 'npm test'))) @@ -380,8 +418,8 @@ describe('Codex child-work evidence', () => { parentChildWorkId: agent!.childWorkId }) ]) - // A persistent command is work of its own, never the tool the child is running. - expect(agent?.operation).toBeUndefined() + // While the child's turn runs, the command is also the tool it has open. + expect(agent?.operation).toMatchObject({ toolName: 'Bash', input: 'npm run dev' }) send(turn('turn/completed', CHILD, 'c1')) expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) expect(display(agent!.childWorkId)).toBe('monitoring') diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index ca3607a2ce6d..3562690ed019 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -28,7 +28,6 @@ import { codexToolCallEnded, type CodexChildToolCall } from './codex-child-work-translation' -import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' import { codexThreadWaitsOnUser, readCodexTurnId } from './codex-structured-thread-facts' @@ -159,8 +158,9 @@ export class CodexChildWorkEvidence { facts.waiting = codexThreadWaitsOnUser(event.params) return } + // Every Codex agent shell is unified exec: it is the open call until its process exits. const item = readCodexThreadItem(readRecord(event.params).item) - if (!item || codexCommandOutlivesTurn(item)) { + if (!item) { return } // A frame that names no turn belongs to the one the child is running. diff --git a/src/main/codex/codex-child-work-translation.ts b/src/main/codex/codex-child-work-translation.ts index 15620443e7da..b48c11090482 100644 --- a/src/main/codex/codex-child-work-translation.ts +++ b/src/main/codex/codex-child-work-translation.ts @@ -33,7 +33,7 @@ function firstChangePath(changes: unknown): string | undefined { } /** The tool a thread item runs, or null for an item that is not a tool call (a message, a - * thought, a plan). A persistent command is not one either: it is work of its own. */ + * thought, a plan). */ export function codexChildToolCall(item: CodexThreadItem): CodexChildToolCall | null { switch (item.type) { case 'commandExecution': diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index d198025cbceb..0fcf30ab32ad 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -214,7 +214,12 @@ describe('Codex structured child-work producer', () => { exitCode: 0 }), lead: 'working', - check: () => expect(byDescription('review')?.operation).toBeUndefined() + // The dev server is still the child's open call while its turn runs. + check: () => + expect(byDescription('review')?.operation).toMatchObject({ + toolName: 'Bash', + input: 'npm run dev' + }) }, { frame: item('item/completed', REVIEWER, 'r1', { From 754da3a914b12ed9d1642add4d35b2d986a94512 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:51:22 -0700 Subject: [PATCH 16/20] fix(native-chat): a Codex shell becomes a subagent's own work only once it outlives its turn Codex runs every agent shell through unified exec and never says when one is left running, so the producer turned every shell, even a millisecond `rg`, into a command record the moment it started. Each settled into the session's pool of 32 settled records, so a busy turn evicted a finished subagent's record (its outcome row would vanish) and listed dozens of finished shells beside it. A command now becomes a record at the first turn boundary of the thread that launched it while its process still runs: until then it is the agent's open call. A shell that exits within its turn never becomes a record. --- .../codex/codex-background-command-tracker.ts | 14 ++-- .../codex/codex-background-task-tracker.ts | 4 +- .../codex/codex-child-work-evidence.test.ts | 69 ++++++++++++++++--- src/main/codex/codex-child-work-evidence.ts | 59 ++++++++++------ ...dex-structured-child-work-producer.test.ts | 12 ++-- 5 files changed, 110 insertions(+), 48 deletions(-) diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index e2e227e0bb15..5fd4a45457b0 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -10,10 +10,8 @@ const MAX_DESCRIPTION_CHARS = 512 type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } -/** What one frame did to a persistent command: it started, or its process exited with `item`. */ -export type CodexBackgroundCommandChange = - | { type: 'started'; threadId: string; task: AgentSessionBackgroundTask } - | { type: 'ended'; threadId: string; taskId: string; item: CodexThreadItem } +/** A tracked command whose process exited, with the item that said so. */ +export type CodexBackgroundCommandExit = { threadId: string; taskId: string; item: CodexThreadItem } /** 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, @@ -70,7 +68,7 @@ export class CodexBackgroundCommandTracker { ) } - observe(event: CodexBackgroundTaskEvent): CodexBackgroundCommandChange | null { + observe(event: CodexBackgroundTaskEvent): CodexBackgroundCommandExit | null { const parsed = this.parse(event) if (!parsed || this.settled.has(parsed.key)) { return null @@ -88,9 +86,7 @@ export class CodexBackgroundCommandTracker { this.settledBytes += bytes } this.trimSettled() - return existing - ? { type: 'ended', threadId: existing.threadId, taskId: existing.task.id, item } - : null + return existing ? { threadId: existing.threadId, taskId: existing.task.id, item } : null } if (existing) { return null @@ -101,7 +97,7 @@ export class CodexBackgroundCommandTracker { this.commands.set(key, command) this.liveBytes += command.bytes this.trimSettled() - return { type: 'started', threadId: command.threadId, task: command.task } + return null } tasks( diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 065d00f93baf..0be31a013665 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -58,7 +58,7 @@ export class CodexBackgroundTaskTracker { observe(event: CodexBackgroundTaskEvent): boolean { const itemEvent = event.method === 'item/started' || event.method === 'item/completed' - const command = itemEvent ? this.commands.observe(event) : null + const commandExit = itemEvent ? this.commands.observe(event) : null const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) if (frame?.kind === 'subagent') { this.executions.register( @@ -72,7 +72,7 @@ export class CodexBackgroundTaskTracker { } else if (frame && frame.threadId !== this.primaryThreadId) { this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) } - this.childWork.observe(event, frame, command) + this.childWork.observe(event, frame, commandExit) if (!frame) { return itemEvent ? this.refresh() : false } diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 875114503648..da35d1e46242 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -400,7 +400,7 @@ describe('Codex child-work evidence', () => { expect(byKind('agent')[0]?.state).toBe('working') }) - it("owns the child's persistent command, so a finished child reads monitoring while it runs", () => { + it('records a command the child left running once its turn ends, so it reads monitoring', () => { const { send, byKind, display } = runningChild() send( item( @@ -411,6 +411,11 @@ describe('Codex child-work evidence', () => { ) ) const [agent] = byKind('agent') + // While the child's turn runs, the command is the tool it has open, not work of its own. + expect(agent?.operation).toMatchObject({ toolName: 'Bash', input: 'npm run dev' }) + expect(byKind('command')).toEqual([]) + send(turn('turn/completed', CHILD, 'c1')) + expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) expect(byKind('command')).toEqual([ expect.objectContaining({ membership: 'live', @@ -418,10 +423,6 @@ describe('Codex child-work evidence', () => { parentChildWorkId: agent!.childWorkId }) ]) - // While the child's turn runs, the command is also the tool it has open. - expect(agent?.operation).toMatchObject({ toolName: 'Bash', input: 'npm run dev' }) - send(turn('turn/completed', CHILD, 'c1')) - expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) expect(display(agent!.childWorkId)).toBe('monitoring') send( item('item/completed', CHILD, 'c1', { @@ -443,16 +444,31 @@ describe('Codex child-work evidence', () => { CHILD, 'c1', shell('exec-1', 'tail -f log', 'inProgress', 'unifiedExecStartup') + ), + turn('turn/completed', CHILD, 'c1'), + turn('turn/started', CHILD, 'c2'), + // Still inside its turn, so this one is only the child's open call. + item( + 'item/started', + CHILD, + 'c2', + shell('exec-2', 'npm test', 'inProgress', 'unifiedExecStartup') ) ) expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') send(spawned()) - expect(byKind('command')[0]?.parentChildWorkId).toBe(byKind('agent')[0]?.childWorkId) + expect(byKind('command')).toEqual([ + expect.objectContaining({ + description: 'tail -f log', + parentChildWorkId: byKind('agent')[0]?.childWorkId + }) + ]) }) - it("records the session's own persistent command with no owner", () => { + it("records the session's own command with no owner once it outlives the session's turn", () => { const { send, byKind } = harness() send( + turn('turn/started', PRIMARY, PARENT_TURN), item( 'item/started', PRIMARY, @@ -460,12 +476,44 @@ describe('Codex child-work evidence', () => { shell('exec-9', 'sleep 90', 'inProgress', 'unifiedExecStartup') ) ) + expect(byKind('command')).toEqual([]) + send(turn('turn/completed', PRIMARY, PARENT_TURN)) expect(byKind('command')).toEqual([ expect.objectContaining({ membership: 'live', description: 'sleep 90' }) ]) expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') }) + it('keeps shells that exit within their turn out of the records, so none displaces a finished child', () => { + const { send, byKind, records } = runningChild() + // Codex runs every shell, however short, the way it runs one left running. + const shells = (threadId: string, turnId: string, count: number) => { + for (let index = 0; index < count; index += 1) { + const id = `${threadId}-exec-${index}` + send( + item( + 'item/started', + threadId, + turnId, + shell(id, 'rg foo', 'inProgress', 'unifiedExecStartup') + ), + item('item/completed', threadId, turnId, { + ...shell(id, 'rg foo', 'completed', 'unifiedExecStartup'), + exitCode: 0 + }) + ) + } + } + shells(CHILD, 'c1', 20) + send(turn('turn/completed', CHILD, 'c1')) + shells(PRIMARY, PARENT_TURN, 40) + send(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(records()).toEqual([ + expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'succeeded' }) + ]) + expect(byKind('command')).toEqual([]) + }) + it('names the child that spawned a nested child as its owner', () => { const { send, byKind } = runningChild() send( @@ -490,10 +538,11 @@ describe('Codex child-work evidence', () => { send( item( 'item/started', - CHILD, - 'c1', + PRIMARY, + PARENT_TURN, shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') - ) + ), + turn('turn/completed', PRIMARY, PARENT_TURN) ) expect(records()).toHaveLength(2) tracker.clear() diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 3562690ed019..8c29086a7d37 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -1,21 +1,21 @@ -// Codex child threads and persistent commands, decoded into child-work evidence for the host's -// records. +// Codex child threads, and the commands they leave running, decoded into child-work evidence for +// the host's records. // // The background-task tracker already follows which child exists, which turn it runs and how that -// turn ended (the executions), and which persistent command started or exited (the command -// tracker). This module keeps what only the records read — the tool a child has open, what it said -// last, its usage, whether it waits on the user — and after each frame re-derives the whole -// observation of the child that frame was about. Edges are stamped with the host clock when -// drained, after the journal handled the frame, so the host never holds a record ahead of the -// frame's own rows. A parent turn ending is never evidence here: Codex children outlive the turn -// that spawned them, so only a child's own turn, or the session, ends it. +// turn ended (the executions), and which command process is still running (the command tracker). +// This module keeps what only the records read — the tool a child has open, what it said last, its +// usage, whether it waits on the user — and after each frame re-derives the whole observation of +// the child that frame was about. Edges are stamped with the host clock when drained, after the +// journal handled the frame, so the host never holds a record ahead of the frame's own rows. A +// parent turn ending is never evidence here: Codex children outlive the turn that spawned them, so +// only a child's own turn, or the session, ends it. import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence, AgentChildWorkLiveObservation } from '../../shared/agent-status-child-work-evidence' -import type { CodexBackgroundCommandChange } from './codex-background-command-tracker' +import type { CodexBackgroundCommandExit } from './codex-background-command-tracker' import type { CodexBackgroundTaskEvent, CodexBackgroundTaskFrame @@ -82,6 +82,8 @@ function commandLive(task: AgentSessionBackgroundTask, ownerId: string | null) { export class CodexChildWorkEvidence { private readonly facts = new Map() + /** Commands handed to the host: each outlived the turn that launched it and still runs. */ + private readonly recordedCommands = new Set() private pending: CodexPendingChildWork[] = [] constructor( @@ -90,24 +92,27 @@ export class CodexChildWorkEvidence { private readonly liveCommands: (threadId: string) => readonly AgentSessionBackgroundTask[] ) {} - /** After the tracker applied the frame: what it did to a persistent command, and to the child - * the frame is about. */ + /** After the tracker applied the frame: which command process it saw exit, and the child the + * frame is about. */ observe( event: CodexBackgroundTaskEvent, frame: CodexBackgroundTaskFrame | null, - command: CodexBackgroundCommandChange | null + exit: CodexBackgroundCommandExit | null ): void { - if (command?.type === 'started') { - this.pending.push(commandLive(command.task, this.commandOwner(command.threadId))) - } else if (command?.type === 'ended') { - const outcome = codexCommandOutcome(command.item) + if (exit && this.recordedCommands.delete(exit.taskId)) { + const outcome = codexCommandOutcome(exit.item) this.pending.push((observedAt) => ({ type: 'ended', observedAt, - handle: { idKind: 'task_id', id: command.taskId }, + handle: { idKind: 'task_id', id: exit.taskId }, outcome })) } + // Codex runs every agent shell alike and never says one was left running, so a shell is work + // of its own only once it outlives its turn; until then it is the agent's open call. + if (frame?.kind === 'turn' || frame?.kind === 'turn-ended') { + this.recordCommandsOf(frame.threadId) + } const threadId = this.childThread(event, frame) if (threadId === null) { return @@ -122,6 +127,7 @@ export class CodexChildWorkEvidence { /** The provider session is gone: no child it still ran can report its own ending. */ clear(): void { this.facts.clear() + this.recordedCommands.clear() this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) } @@ -144,9 +150,16 @@ export class CodexChildWorkEvidence { return threadId === this.primaryThreadId ? null : threadId } - /** A command belongs to the child thread that launched it; the session's own agent is no owner. */ - private commandOwner(threadId: string): string | null { - return threadId === this.primaryThreadId ? null : threadId + /** A turn of this thread began or ended, so every command it still runs outlived its turn. A + * command belongs to the child thread that launched it; the session's own agent is no owner. */ + private recordCommandsOf(threadId: string): void { + const ownerId = threadId === this.primaryThreadId ? null : threadId + for (const task of this.liveCommands(threadId)) { + if (!this.recordedCommands.has(task.id)) { + this.recordedCommands.add(task.id) + this.pending.push(commandLive(task, ownerId)) + } + } } private record(facts: ChildFacts, event: CodexBackgroundTaskEvent): void { @@ -273,7 +286,9 @@ export class CodexChildWorkEvidence { * the owner is recorded, say again whose it is. */ private requeueOwnedBy(threadId: string): void { for (const task of this.liveCommands(threadId)) { - this.pending.push(commandLive(task, threadId)) + if (this.recordedCommands.has(task.id)) { + this.pending.push(commandLive(task, threadId)) + } } for (const spawned of this.executions.workingChildren()) { const facts = this.facts.get(spawned.agentThreadId) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 0fcf30ab32ad..43b56923f2fb 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -189,7 +189,7 @@ describe('Codex structured child-work producer', () => { basis: 'open' }) }, - // The child leaves a dev server running past its own turn. + // The child starts a dev server it will leave running past its own turn. { frame: item('item/started', REVIEWER, 'r1', { type: 'commandExecution', @@ -199,10 +199,8 @@ describe('Codex structured child-work producer', () => { status: 'inProgress' }), lead: 'working', - check: () => - expect(byDescription('npm run dev')?.parentChildWorkId).toBe( - byDescription('review')?.childWorkId - ) + // Not work of its own until it outlives the child's turn. + check: () => expect(byDescription('npm run dev')).toBeUndefined() }, { frame: item('item/completed', REVIEWER, 'r1', { @@ -259,6 +257,10 @@ describe('Codex structured child-work producer', () => { outcome: 'succeeded', lastMessage: 'Dev server is up' }) + expect(byDescription('npm run dev')).toMatchObject({ + membership: 'live', + parentChildWorkId: byDescription('review')?.childWorkId + }) // Finished, but a shell it launched still runs: the CLI parent rule reads monitoring. expect(display('review')).toBe('monitoring') } From 6b2e70c5ff40366bdf0cb8723431316a3e931323 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:20 -0700 Subject: [PATCH 17/20] refactor(native-chat): child records keep every settled child and can be removed outright Settled child records now stay until the host drops the session's row; the 32-record trim is gone. A producer can say work stopped with nothing to report, and its record (and the handles it answered to) goes instead of settling. Evidence stays host-internal: the producer and the store share one process. --- .../agent-status-child-work-evidence.ts | 12 ++++- ...t-status-child-work-reconciliation.test.ts | 38 +++++++------- .../agent-status-child-work-reconciliation.ts | 52 +++++++------------ 3 files changed, 50 insertions(+), 52 deletions(-) diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 68206941e5b9..8df08126827c 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -2,7 +2,8 @@ // // A producer decodes provider frames into these edges and the host folds them into the one // record per child it owns. Edges carry facts, not records: which child is live, what it is -// doing, how it ended. Only a child's own ending settles it, or the end of its session. +// doing, how it ended, or that it is gone. Only a child's own ending settles it, or the end of its +// session. Edges are host-internal: the producer and the store share one process. import type { AgentChildWorkAliasKind } from './agent-status-child-work-alias' import type { @@ -74,8 +75,17 @@ export type AgentChildWorkEndedEvidence = { * with an outcome nobody reported. Settled children stay; the parent's removal drops them. */ export type AgentChildWorkSessionEndedEvidence = { type: 'session-ended'; observedAt: number } +/** Work that leaves nothing to report once it stops, such as a command whose process exited: its + * record goes rather than settles. For work that owns no other record. */ +export type AgentChildWorkRemovedEvidence = { + type: 'removed' + observedAt: number + handle: AgentChildWorkEvidenceHandle +} + export type AgentChildWorkEvidence = | AgentChildWorkLiveEvidence | AgentChildWorkOperationEvidence | AgentChildWorkEndedEvidence + | AgentChildWorkRemovedEvidence | AgentChildWorkSessionEndedEvidence diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 377c580fc41c..79ffc1c57616 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -5,10 +5,7 @@ import type { AgentChildWorkEvidence, AgentChildWorkLiveObservation } from './agent-status-child-work-evidence' -import { - reconcileAgentChildWorkEvidence, - STRUCTURED_CHILD_WORK_MAX_SETTLED -} from './agent-status-child-work-reconciliation' +import { reconcileAgentChildWorkEvidence } from './agent-status-child-work-reconciliation' import { STRUCTURED_CHILD_WORK_MAX_LIVE } from './agent-status-child-work-evidence-admission' import { createAgentStatusStore, type AgentStatusStore } from './agent-status-store' import { makeStructuredAgentStatusSubject } from './agent-status-subject' @@ -399,17 +396,9 @@ describe('structured child-work reconciliation', () => { expect(records(store)).toHaveLength(STRUCTURED_CHILD_WORK_MAX_LIVE) }) - it('keeps a bounded settled history, never dropping a child that owns live work', () => { + it('keeps every settled child until the parent row goes', () => { const { store, apply } = harness() - apply(live(child('owner'))) - apply(live(child('shell', { kind: 'command', ownerId: 'owner' }))) - apply({ - type: 'ended', - observedAt: 101, - handle: { idKind: 'task_id', id: 'owner' }, - outcome: 'succeeded' - }) - for (let index = 0; index < STRUCTURED_CHILD_WORK_MAX_SETTLED + 1; index += 1) { + for (let index = 0; index < 100; index += 1) { apply(live(child(`done-${index}`), 200 + index), { type: 'ended', observedAt: 200 + index, @@ -418,9 +407,22 @@ describe('structured child-work reconciliation', () => { }) } const settled = records(store).filter((record) => record.membership === 'settled') - expect(settled).toHaveLength(STRUCTURED_CHILD_WORK_MAX_SETTLED) - expect(settled.map((record) => record.description)).toContain('Task owner') - expect(settled.map((record) => record.description)).not.toContain('Task done-0') - expect(settled.map((record) => record.description)).not.toContain('Task done-1') + expect(settled).toHaveLength(100) + expect(settled.map((record) => record.description)).toContain('Task done-0') + }) + + it('removes work that stopped with nothing to report, and only the record it names', () => { + const { store, apply } = harness() + apply(live(child('owner')), live(child('shell', { kind: 'command', ownerId: 'owner' }))) + expect( + apply({ type: 'removed', observedAt: 200, handle: { idKind: 'task_id', id: 'shell' } }) + ).toMatchObject({ removed: 1, settled: 0 }) + expect(only(store)).toMatchObject({ description: 'Task owner', membership: 'live' }) + expect(store.getAliasesForChild('child-2')).toEqual([]) + // A handle it no longer answers to removes nothing. + expect( + apply({ type: 'removed', observedAt: 201, handle: { idKind: 'task_id', id: 'shell' } }) + ).toMatchObject({ removed: 0 }) + expect(records(store)).toHaveLength(1) }) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 08d057afc96b..43f23b85483a 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -1,14 +1,17 @@ // Fold one structured session's child-work evidence into the host's records. // // The store holds the only current record per child; evidence patches it. A child settles on its -// own ending, or `unknown` when its session ends while it is still live. It owns only the records -// its own producer admitted, and never claims an outcome the evidence did not report. +// own ending, or `unknown` when its session ends while it is still live; work with nothing to +// report once it stops is removed instead. Settled children stay until the host drops the parent's +// row. It owns only the records its own producer admitted, and never claims an outcome the +// evidence did not report. import type { AgentChildWorkAdmission } from './agent-status-child-work-admission' import type { AgentChildWorkEndedEvidence, AgentChildWorkEvidence, - AgentChildWorkOperationEvidence + AgentChildWorkOperationEvidence, + AgentChildWorkRemovedEvidence } from './agent-status-child-work-evidence' import { applyAgentChildWorkLive, @@ -26,9 +29,6 @@ import { export type { AgentChildWorkReconcileOutcome } from './agent-status-child-work-evidence-admission' -/** Settled children kept per session. The oldest go first, never one that owns live work. */ -export const STRUCTURED_CHILD_WORK_MAX_SETTLED = 32 - export type AgentChildWorkReconcileInput = AgentChildWorkEvidenceScope & { admission: AgentChildWorkAdmission evidence: readonly AgentChildWorkEvidence[] @@ -98,32 +98,20 @@ function settleLive(ctx: ReconcileContext, observedAt: number): void { } } -function removeChildren(ctx: ReconcileContext, childWorkIds: string[]): void { - if (childWorkIds.length > 0 && ctx.store.applyMutation({ removeChildren: childWorkIds })) { - ctx.outcome.removed += childWorkIds.length +/** The work is gone and has no ending to keep: its record, and the handles it answered to, go. */ +function applyRemoved(ctx: ReconcileContext, edge: AgentChildWorkRemovedEvidence): void { + const resolution = resolveAgentChildWorkHandle(ctx, [edge.handle.idKind], edge.handle.id) + if (resolution?.ambiguous) { + ctx.outcome.rejected.push({ handleId: edge.handle.id, reason: 'ambiguous' }) + return } -} - -/** Oldest-settled first; a settled child that still owns live work stays so its work keeps an owner. */ -function trimSettled(ctx: ReconcileContext): void { - const owned = ownedStructuredChildWork(ctx) - const settled = owned.filter((record) => record.membership === 'settled') - const excess = settled.length - STRUCTURED_CHILD_WORK_MAX_SETTLED - if (excess <= 0) { + const existing = resolution?.child + if (!existing || agentChildWorkRunVerdict(ctx, existing, edge.handle.runId) === 'previous') { return } - const owners = new Set( - owned.flatMap((record) => - record.membership === 'live' && record.parentChildWorkId ? [record.parentChildWorkId] : [] - ) - ) - const removable = settled - .filter((record) => !owners.has(record.childWorkId)) - .sort((a, b) => (a.settledAt ?? a.observedAt) - (b.settledAt ?? b.observedAt)) - removeChildren( - ctx, - removable.slice(0, excess).map((record) => record.childWorkId) - ) + if (ctx.store.applyMutation({ removeChildren: [existing.childWorkId] })) { + ctx.outcome.removed += 1 + } } /** Apply one batch of evidence. The parent must already be held: the store refuses a child whose @@ -145,13 +133,11 @@ export function reconcileAgentChildWorkEvidence( applyOperation(ctx, edge) } else if (edge.type === 'ended') { applyEnded(ctx, edge) + } else if (edge.type === 'removed') { + applyRemoved(ctx, edge) } else { settleLive(ctx, edge.observedAt) } } - // Only a settle adds settled history; skipping the scan otherwise keeps progress edges cheap. - if (ctx.outcome.settled > 0) { - trimSettled(ctx) - } return ctx.outcome } From d85668e9952757e10a9938038251d4fa48400525 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:36 -0700 Subject: [PATCH 18/20] fix(native-chat): a Codex command is live work from its start until its process stops The command tracker is now the one owner of a Codex command's lifetime. It admits every command whatever `source` Codex tags it with (the approval path starts one as `agent`), and ends it when its process exits, when its thread closes (Codex stops the processes first, so no exit ever arrives), or when the session ends. The producer mirrors that one-to-one: a live record from the start, removed when the command stops, never settled. This removes the turn-boundary rule: a command that was only recorded at its turn's end left the parent reading done for one publish when the main agent's turn ended with a shell still running. The parity script now checks the parent at every journal write, not only at frame end. --- .../codex/codex-background-command-tracker.ts | 75 +++++--- .../codex/codex-background-task-tracker.ts | 15 +- .../codex/codex-child-work-evidence.test.ts | 152 +++++++++++----- src/main/codex/codex-child-work-evidence.ts | 74 ++++---- .../codex/codex-child-work-translation.ts | 13 -- ...dex-structured-child-work-producer.test.ts | 170 ++++++++++++------ 6 files changed, 316 insertions(+), 183 deletions(-) diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 5fd4a45457b0..654a06a76c06 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -1,8 +1,7 @@ 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, type CodexThreadItem } from './codex-structured-item-translation' +import { readCodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention' const MAX_SETTLED_COMMANDS = 128 @@ -10,8 +9,10 @@ const MAX_DESCRIPTION_CHARS = 512 type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } -/** A tracked command whose process exited, with the item that said so. */ -export type CodexBackgroundCommandExit = { threadId: string; taskId: string; item: CodexThreadItem } +/** 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, @@ -68,27 +69,16 @@ export class CodexBackgroundCommandTracker { ) } - observe(event: CodexBackgroundTaskEvent): CodexBackgroundCommandExit | null { + observe(event: CodexBackgroundTaskEvent): CodexBackgroundCommandChange | null { const parsed = this.parse(event) if (!parsed || this.settled.has(parsed.key)) { return null } - const { key, command, completed, item } = parsed - const existing = this.commands.get(key) + const { key, command, completed } = parsed 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 existing ? { threadId: existing.threadId, taskId: existing.task.id, item } : null + return this.end(key) } - if (existing) { + if (this.commands.has(key)) { return null } if (this.liveBytes + command.bytes > this.maxMetadataBytes) { @@ -97,7 +87,14 @@ export class CodexBackgroundCommandTracker { this.commands.set(key, command) this.liveBytes += command.bytes this.trimSettled() - return null + 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) ?? []) } tasks( @@ -117,18 +114,45 @@ export class CodexBackgroundCommandTracker { }) } - /** The live persistent commands one thread launched, as the strip would publish them. */ + /** 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) } - clear(): void { + /** 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 { @@ -147,12 +171,14 @@ export class CodexBackgroundCommandTracker { private parse( event: CodexBackgroundTaskEvent - ): { key: string; command: Command; completed: boolean; item: CodexThreadItem } | null { + ): { key: string; command: Command; completed: boolean } | null { 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') { return null } const key = JSON.stringify([event.threadId, item.id]) @@ -174,7 +200,6 @@ export class CodexBackgroundCommandTracker { return { key, completed, - item, command: { ...value, bytes: diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 0be31a013665..f3565d657537 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -58,7 +58,13 @@ export class CodexBackgroundTaskTracker { observe(event: CodexBackgroundTaskEvent): boolean { const itemEvent = event.method === 'item/started' || event.method === 'item/completed' - const commandExit = itemEvent ? this.commands.observe(event) : null + const command = itemEvent ? this.commands.observe(event) : null + const commands = + event.method === 'thread/closed' + ? this.commands.endThread(event.threadId) + : command + ? [command] + : [] const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) if (frame?.kind === 'subagent') { this.executions.register( @@ -72,9 +78,9 @@ export class CodexBackgroundTaskTracker { } else if (frame && frame.threadId !== this.primaryThreadId) { this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) } - this.childWork.observe(event, frame, commandExit) + this.childWork.observe(event, frame, commands) if (!frame) { - return itemEvent ? this.refresh() : false + 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 @@ -84,8 +90,7 @@ export class CodexBackgroundTaskTracker { clear(): boolean { this.executions.clear() - this.commands.clear() - this.childWork.clear() + this.childWork.clear(this.commands.clear()) return this.refresh() } diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index da35d1e46242..0f3605bee228 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -400,7 +400,7 @@ describe('Codex child-work evidence', () => { expect(byKind('agent')[0]?.state).toBe('working') }) - it('records a command the child left running once its turn ends, so it reads monitoring', () => { + it('records a command from its start until its process exits, then removes it', () => { const { send, byKind, display } = runningChild() send( item( @@ -411,18 +411,20 @@ describe('Codex child-work evidence', () => { ) ) const [agent] = byKind('agent') - // While the child's turn runs, the command is the tool it has open, not work of its own. + // While the child's turn runs, the command is also the tool it has open. expect(agent?.operation).toMatchObject({ toolName: 'Bash', input: 'npm run dev' }) - expect(byKind('command')).toEqual([]) - send(turn('turn/completed', CHILD, 'c1')) - expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) expect(byKind('command')).toEqual([ expect.objectContaining({ membership: 'live', description: 'npm run dev', - parentChildWorkId: agent!.childWorkId + residency: 'background', + parentChildWorkId: agent!.childWorkId, + firstObservedAt: 1_040 }) ]) + send(turn('turn/completed', CHILD, 'c1')) + expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) + expect(byKind('command')).toEqual([expect.objectContaining({ membership: 'live' })]) expect(display(agent!.childWorkId)).toBe('monitoring') send( item('item/completed', CHILD, 'c1', { @@ -430,32 +432,79 @@ describe('Codex child-work evidence', () => { exitCode: 1 }) ) - expect(byKind('command')[0]).toMatchObject({ membership: 'settled', outcome: 'failed' }) + expect(byKind('command')).toEqual([]) expect(display(agent!.childWorkId)).toBe('done') }) - it("names the owner of a command launched before the host held its child's record", () => { - const { send, byKind } = harness() + it('records an approved command while it runs, whatever source Codex starts it with', () => { + const { send, tracker, byKind, display } = runningChild() + // The approval path starts the item as `agent`; unified exec reports its exit. + send(item('item/started', CHILD, 'c1', shell('exec-2', 'npm run dev'))) + const [agent] = byKind('agent') + expect(byKind('command')).toEqual([ + expect.objectContaining({ membership: 'live', parentChildWorkId: agent!.childWorkId }) + ]) + send(turn('turn/completed', CHILD, 'c1')) + expect(display(agent!.childWorkId)).toBe('monitoring') + expect(tracker.state?.tasks).toEqual([expect.objectContaining({ kind: 'command' })]) + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-2', 'npm run dev', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('command')).toEqual([]) + expect(display(agent!.childWorkId)).toBe('done') + expect(tracker.state).toBeNull() + }) + + it("removes a closed thread's running commands: Codex stops them and never reports their exit", () => { + const { send, tracker, byKind, display } = runningChild() send( - turn('turn/started', PRIMARY, PARENT_TURN), - turn('turn/started', CHILD, 'c1'), item( 'item/started', CHILD, 'c1', - shell('exec-1', 'tail -f log', 'inProgress', 'unifiedExecStartup') + shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') ), turn('turn/completed', CHILD, 'c1'), - turn('turn/started', CHILD, 'c2'), - // Still inside its turn, so this one is only the child's open call. + turn('turn/completed', PRIMARY, PARENT_TURN) + ) + const [agent] = byKind('agent') + expect(display(agent!.childWorkId)).toBe('monitoring') + send({ method: 'thread/closed', threadId: CHILD, params: { threadId: CHILD } }) + expect(byKind('command')).toEqual([]) + expect(display(agent!.childWorkId)).toBe('done') + expect(tracker.state).toBeNull() + // The exit Codex could not deliver starts nothing if it ever arrives. + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-1', 'npm run dev', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('command')).toEqual([]) + }) + + it("names the owner of a command launched before the host held its child's record", () => { + const { send, byKind } = harness() + send( + turn('turn/started', PRIMARY, PARENT_TURN), + turn('turn/started', CHILD, 'c1'), item( 'item/started', CHILD, - 'c2', - shell('exec-2', 'npm test', 'inProgress', 'unifiedExecStartup') + 'c1', + shell('exec-1', 'tail -f log', 'inProgress', 'unifiedExecStartup') ) ) - expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') + expect(byKind('command')).toEqual([ + expect.not.objectContaining({ parentChildWorkId: expect.anything() }) + ]) send(spawned()) expect(byKind('command')).toEqual([ expect.objectContaining({ @@ -465,7 +514,7 @@ describe('Codex child-work evidence', () => { ]) }) - it("records the session's own command with no owner once it outlives the session's turn", () => { + it("records the session's own command with no owner from its start", () => { const { send, byKind } = harness() send( turn('turn/started', PRIMARY, PARENT_TURN), @@ -476,42 +525,47 @@ describe('Codex child-work evidence', () => { shell('exec-9', 'sleep 90', 'inProgress', 'unifiedExecStartup') ) ) - expect(byKind('command')).toEqual([]) - send(turn('turn/completed', PRIMARY, PARENT_TURN)) expect(byKind('command')).toEqual([ expect.objectContaining({ membership: 'live', description: 'sleep 90' }) ]) expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') + send(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(byKind('command')).toEqual([expect.objectContaining({ membership: 'live' })]) }) - it('keeps shells that exit within their turn out of the records, so none displaces a finished child', () => { - const { send, byKind, records } = runningChild() + it('leaves no record behind a finished shell, so none displaces a finished child', () => { + const { send, byKind, records, log } = runningChild() + send(turn('turn/completed', CHILD, 'c1')) + const before = log.length // Codex runs every shell, however short, the way it runs one left running. - const shells = (threadId: string, turnId: string, count: number) => { - for (let index = 0; index < count; index += 1) { - const id = `${threadId}-exec-${index}` - send( - item( - 'item/started', - threadId, - turnId, - shell(id, 'rg foo', 'inProgress', 'unifiedExecStartup') - ), - item('item/completed', threadId, turnId, { - ...shell(id, 'rg foo', 'completed', 'unifiedExecStartup'), - exitCode: 0 - }) - ) - } + for (let index = 0; index < 40; index += 1) { + const id = `exec-${index}` + send( + item('item/started', PRIMARY, PARENT_TURN, { + ...shell(id, 'rg foo', 'inProgress', 'unifiedExecStartup'), + durationMs: 0 + }) + ) + expect(byKind('command')).toEqual([expect.objectContaining({ description: 'rg foo' })]) + send( + item('item/completed', PRIMARY, PARENT_TURN, { + ...shell(id, 'rg foo', 'completed', 'unifiedExecStartup'), + exitCode: 0 + }) + ) + expect(byKind('command')).toEqual([]) } - shells(CHILD, 'c1', 20) - send(turn('turn/completed', CHILD, 'c1')) - shells(PRIMARY, PARENT_TURN, 40) send(turn('turn/completed', PRIMARY, PARENT_TURN)) expect(records()).toEqual([ expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'succeeded' }) ]) - expect(byKind('command')).toEqual([]) + // One edge when each shell starts and one when it exits, as the strip republishes today. + expect( + log + .slice(before) + .flat() + .map((edge) => edge.type) + ).toEqual(Array.from({ length: 40 }, () => ['live', 'removed']).flat()) }) it('names the child that spawned a nested child as its owner', () => { @@ -533,7 +587,7 @@ describe('Codex child-work evidence', () => { expect(tracker.drainChildWorkEvidence(1)).toEqual([]) }) - it('settles every live record with no reported outcome when the provider session ends', () => { + it('settles every live child with no reported outcome, and removes every command, when the provider session ends', () => { const { send, tracker, records, store } = runningChild() send( item( @@ -547,7 +601,14 @@ describe('Codex child-work evidence', () => { expect(records()).toHaveLength(2) tracker.clear() const evidence = tracker.drainChildWorkEvidence(9_000) - expect(evidence).toEqual([{ type: 'session-ended', observedAt: 9_000 }]) + expect(evidence).toEqual([ + { + type: 'removed', + observedAt: 9_000, + handle: { idKind: 'task_id', id: 'codex-command:primary:exec-1' } + }, + { type: 'session-ended', observedAt: 9_000 } + ]) reconcileAgentChildWorkEvidence({ store, admission: createAgentChildWorkAdmission(store, { mintChildWorkId: () => 'unused' }), @@ -556,8 +617,7 @@ describe('Codex child-work evidence', () => { evidence }) expect(records()).toEqual([ - expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'unknown' }), - expect.objectContaining({ kind: 'command', membership: 'settled', outcome: 'unknown' }) + expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'unknown' }) ]) }) }) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 8c29086a7d37..93bda7a54a1f 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -3,19 +3,21 @@ // // The background-task tracker already follows which child exists, which turn it runs and how that // turn ended (the executions), and which command process is still running (the command tracker). -// This module keeps what only the records read — the tool a child has open, what it said last, its -// usage, whether it waits on the user — and after each frame re-derives the whole observation of -// the child that frame was about. Edges are stamped with the host clock when drained, after the -// journal handled the frame, so the host never holds a record ahead of the frame's own rows. A -// parent turn ending is never evidence here: Codex children outlive the turn that spawned them, so -// only a child's own turn, or the session, ends it. +// A command is a record from its process start until it stops, and then its record goes: the +// tracker says when, and this module only mirrors it. This module keeps what only the records +// read — the tool a child has open, what it said last, its usage, whether it waits on the user — +// and after each frame re-derives the whole observation of the child that frame was about. Edges +// are stamped with the host clock when drained, after the journal handled the frame, so the host +// never holds a record ahead of the frame's own rows. A parent turn ending is never evidence here: +// Codex children outlive the turn that spawned them, so only a child's own turn, or the session, +// ends it. import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence, AgentChildWorkLiveObservation } from '../../shared/agent-status-child-work-evidence' -import type { CodexBackgroundCommandExit } from './codex-background-command-tracker' +import type { CodexBackgroundCommandChange } from './codex-background-command-tracker' import type { CodexBackgroundTaskEvent, CodexBackgroundTaskFrame @@ -24,7 +26,6 @@ import { codexChildMessageText, codexChildToolCall, codexChildTurnOutcome, - codexCommandOutcome, codexToolCallEnded, type CodexChildToolCall } from './codex-child-work-translation' @@ -71,6 +72,7 @@ function commandLive(task: AgentSessionBackgroundTask, ownerId: string | null) { child: { handle: { idKind: 'task_id', id: task.id }, kind: 'command', + // Its own process, not a turn's: no turn ending may settle it. residency: 'background', state: 'working', ...(task.description ? { description: task.description } : {}), @@ -82,8 +84,6 @@ function commandLive(task: AgentSessionBackgroundTask, ownerId: string | null) { export class CodexChildWorkEvidence { private readonly facts = new Map() - /** Commands handed to the host: each outlived the turn that launched it and still runs. */ - private readonly recordedCommands = new Set() private pending: CodexPendingChildWork[] = [] constructor( @@ -92,27 +92,14 @@ export class CodexChildWorkEvidence { private readonly liveCommands: (threadId: string) => readonly AgentSessionBackgroundTask[] ) {} - /** After the tracker applied the frame: which command process it saw exit, and the child the - * frame is about. */ + /** After the tracker applied the frame: which command processes it saw start or stop, and the + * child the frame is about. */ observe( event: CodexBackgroundTaskEvent, frame: CodexBackgroundTaskFrame | null, - exit: CodexBackgroundCommandExit | null + commands: readonly CodexBackgroundCommandChange[] ): void { - if (exit && this.recordedCommands.delete(exit.taskId)) { - const outcome = codexCommandOutcome(exit.item) - this.pending.push((observedAt) => ({ - type: 'ended', - observedAt, - handle: { idKind: 'task_id', id: exit.taskId }, - outcome - })) - } - // Codex runs every agent shell alike and never says one was left running, so a shell is work - // of its own only once it outlives its turn; until then it is the agent's open call. - if (frame?.kind === 'turn' || frame?.kind === 'turn-ended') { - this.recordCommandsOf(frame.threadId) - } + this.queueCommands(commands) const threadId = this.childThread(event, frame) if (threadId === null) { return @@ -124,10 +111,11 @@ export class CodexChildWorkEvidence { this.queueChild(threadId) } - /** The provider session is gone: no child it still ran can report its own ending. */ - clear(): void { + /** The provider session is gone, with the commands it ended: no child it still ran can report + * its own ending. */ + clear(commands: readonly CodexBackgroundCommandChange[]): void { this.facts.clear() - this.recordedCommands.clear() + this.queueCommands(commands) this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) } @@ -150,15 +138,21 @@ export class CodexChildWorkEvidence { return threadId === this.primaryThreadId ? null : threadId } - /** A turn of this thread began or ended, so every command it still runs outlived its turn. A - * command belongs to the child thread that launched it; the session's own agent is no owner. */ - private recordCommandsOf(threadId: string): void { - const ownerId = threadId === this.primaryThreadId ? null : threadId - for (const task of this.liveCommands(threadId)) { - if (!this.recordedCommands.has(task.id)) { - this.recordedCommands.add(task.id) - this.pending.push(commandLive(task, ownerId)) + /** A command belongs to the child thread that launched it; the session's own agent is no owner. + * A stopped command leaves no record: it has nothing left to report. */ + private queueCommands(commands: readonly CodexBackgroundCommandChange[]): void { + for (const command of commands) { + if (command.type === 'started') { + const ownerId = command.threadId === this.primaryThreadId ? null : command.threadId + this.pending.push(commandLive(command.task, ownerId)) + continue } + const { taskId } = command + this.pending.push((observedAt) => ({ + type: 'removed', + observedAt, + handle: { idKind: 'task_id', id: taskId } + })) } } @@ -286,9 +280,7 @@ export class CodexChildWorkEvidence { * the owner is recorded, say again whose it is. */ private requeueOwnedBy(threadId: string): void { for (const task of this.liveCommands(threadId)) { - if (this.recordedCommands.has(task.id)) { - this.pending.push(commandLive(task, threadId)) - } + this.pending.push(commandLive(task, threadId)) } for (const spawned of this.executions.workingChildren()) { const facts = this.facts.get(spawned.agentThreadId) diff --git a/src/main/codex/codex-child-work-translation.ts b/src/main/codex/codex-child-work-translation.ts index b48c11090482..2763ac53b1c7 100644 --- a/src/main/codex/codex-child-work-translation.ts +++ b/src/main/codex/codex-child-work-translation.ts @@ -86,16 +86,3 @@ export function codexChildTurnOutcome(state: NativeChatSubagentState): AgentChil return 'unknown' } } - -/** A persistent command's exit: a zero (or unreported) exit code is the only success, as the - * command's own transcript row reads it; a declined command never ran. */ -export function codexCommandOutcome(item: CodexThreadItem): AgentChildWorkOutcome { - const status = readString(item, 'status') - if (status === 'completed') { - return typeof item.exitCode === 'number' && item.exitCode !== 0 ? 'failed' : 'succeeded' - } - if (status === 'failed') { - return 'failed' - } - return status === 'declined' ? 'cancelled' : 'unknown' -} diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 43b56923f2fb..343ea333a07c 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -2,7 +2,10 @@ // host receives them in, and whether the parent row the records imply is today's row. import { describe, expect, it } from 'vitest' -import { foldAgentLeadStatus } from '../../shared/agent-lead-status-fold' +import { + foldAgentLeadStatus, + type AgentLeadStatusResolution +} from '../../shared/agent-lead-status-fold' import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' import { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liveness' @@ -38,6 +41,9 @@ const LINTER = 'thread-linter' type Frame = { method: string; params: Record } type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +type Liveness = ReturnType +/** What the records and today's strip each said when the journal wrote or published a row. */ +type JournalMoment = { recorded: Liveness; legacy: Liveness } const turn = ( method: 'turn/started' | 'turn/completed', @@ -85,6 +91,16 @@ async function producer() { mintChildWorkId: () => `child-${++minted}` }) const deliveries: Delivery[] = [] + const moments: JournalMoment[] = [] + const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + const recordedLiveness = () => + agentChildWorkLiveness(records().filter((record) => record.membership === 'live')) + // Each journal write publishes the parent's row, so the records must imply its state right then. + const moment = () => + moments.push({ + recorded: recordedLiveness(), + legacy: agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + }) const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', @@ -109,9 +125,10 @@ async function producer() { appendItem: (identity, body, options) => { deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }) rows.push({ body, linkage: agentJournalLinkageFields(options) }) + moment() }, appendTombstone: () => {}, - publish: () => {} + publish: moment } await adapter.acquire({ identity: identityFor('session-1'), @@ -124,7 +141,12 @@ async function producer() { codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) return deliveries.slice(from) } - const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + /** The journal moments one frame produced. */ + const momentsOf = (frame: Frame): JournalMoment[] => { + const from = moments.length + send(frame) + return moments.slice(from) + } const byDescription = (description: string) => records().find((record) => record.description === description) const display = (description: string) => { @@ -139,9 +161,42 @@ async function producer() { /** The producer stamp on the newest journal row that carries this text. */ const stampOf = (text: string) => rows.findLast((row) => JSON.stringify(row.body).includes(text))?.linkage - return { adapter, codex, send, records, byDescription, display, stampOf } + return { + adapter, + codex, + send, + momentsOf, + records, + recordedLiveness, + byDescription, + display, + stampOf + } } +const fold = ( + leadState: 'working' | 'done', + childWorkLiveness: Liveness +): AgentLeadStatusResolution => foldAgentLeadStatus({ leadState, childWorkLiveness }) +/** The strip has no word for a child waiting on a human: to it, that child is working. */ +const asStrip = (liveness: Liveness): Liveness => (liveness === 'waiting' ? 'working' : liveness) +const shellFrame = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + id: string, + command: string, + source = 'unifiedExecStartup' +): Frame => + item(method, threadId, turnId, { + type: 'commandExecution', + id, + command, + source, + status: method === 'item/started' ? 'inProgress' : 'completed', + ...(method === 'item/completed' ? { exitCode: 0 } : {}) + }) + describe('Codex structured child-work producer', () => { it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { const { send, records } = await producer() @@ -157,8 +212,9 @@ describe('Codex structured child-work producer', () => { ]) }) - it('records the parent state today reads, frame by frame, while adding outcome and activity', async () => { - const { adapter, send, records, byDescription, display } = await producer() + it('records the parent state today reads, at every journal write, while adding outcome and activity', async () => { + const { adapter, momentsOf, records, recordedLiveness, byDescription, display } = + await producer() const steps: { frame: Frame lead: 'working' | 'done' @@ -173,51 +229,45 @@ describe('Codex structured child-work producer', () => { check: () => expect(records()).toEqual([]) }, { frame: spawned(REVIEWER, 'review', 'p1'), lead: 'working' }, + // An approved command: Codex starts it on the approval path and reports its exit from + // unified exec. { - frame: item('item/started', REVIEWER, 'r1', { - type: 'commandExecution', - id: 'cmd-1', - command: 'npm test', - source: 'agent', - status: 'inProgress' - }), + frame: shellFrame('item/started', REVIEWER, 'r1', 'cmd-1', 'npm test', 'agent'), lead: 'working', - check: () => + check: () => { expect(byDescription('review')?.operation).toMatchObject({ toolName: 'Bash', input: 'npm test', basis: 'open' }) + expect(byDescription('npm test')).toMatchObject({ + membership: 'live', + parentChildWorkId: byDescription('review')?.childWorkId + }) + } }, // The child starts a dev server it will leave running past its own turn. { - frame: item('item/started', REVIEWER, 'r1', { - type: 'commandExecution', - id: 'exec-1', - command: 'npm run dev', - source: 'unifiedExecStartup', - status: 'inProgress' - }), + frame: shellFrame('item/started', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'working', - // Not work of its own until it outlives the child's turn. - check: () => expect(byDescription('npm run dev')).toBeUndefined() + check: () => + expect(byDescription('npm run dev')).toMatchObject({ + membership: 'live', + parentChildWorkId: byDescription('review')?.childWorkId + }) }, { - frame: item('item/completed', REVIEWER, 'r1', { - type: 'commandExecution', - id: 'cmd-1', - command: 'npm test', - source: 'agent', - status: 'completed', - exitCode: 0 - }), + frame: shellFrame('item/completed', REVIEWER, 'r1', 'cmd-1', 'npm test'), lead: 'working', - // The dev server is still the child's open call while its turn runs. - check: () => + check: () => { + // A finished command leaves nothing behind. + expect(byDescription('npm test')).toBeUndefined() + // The dev server is still the child's open call while its turn runs. expect(byDescription('review')?.operation).toMatchObject({ toolName: 'Bash', input: 'npm run dev' }) + } }, { frame: item('item/completed', REVIEWER, 'r1', { @@ -309,37 +359,36 @@ describe('Codex structured child-work producer', () => { expect(byDescription('test')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, { - frame: item('item/completed', REVIEWER, 'r1', { - type: 'commandExecution', - id: 'exec-1', - command: 'npm run dev', - source: 'unifiedExecStartup', - status: 'completed', - exitCode: 0 - }), + frame: shellFrame('item/completed', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'done', check: () => { - expect(byDescription('npm run dev')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded' - }) + expect(byDescription('npm run dev')).toBeUndefined() expect(display('review')).toBe('interrupted') } } ] + let journalMoments = 0 for (const [index, step] of steps.entries()) { - send(step.frame) + const frameMoments = momentsOf(step.frame) + journalMoments += frameMoments.length + for (const [at, { recorded, legacy }] of frameMoments.entries()) { + expect({ index, at, parent: fold(step.lead, asStrip(recorded)) }).toEqual({ + index, + at, + parent: fold(step.lead, legacy) + }) + } const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) - const recorded = agentChildWorkLiveness( - records().filter((record) => record.membership === 'live') - ) + const recorded = recordedLiveness() const expected = step.childWaits ? 'waiting' : legacy - const fold = (childWorkLiveness: typeof legacy) => - foldAgentLeadStatus({ leadState: step.lead, childWorkLiveness }) - expect({ index, parent: fold(recorded) }).toEqual({ index, parent: fold(expected) }) + expect({ index, parent: fold(step.lead, recorded) }).toEqual({ + index, + parent: fold(step.lead, expected) + }) expect({ index, liveness: recorded }).toEqual({ index, liveness: expected }) step.check?.() } + expect(journalMoments).toBeGreaterThan(steps.length) const settled = records() await adapter.closeSession('session-1') // Every child had already ended; closing the session changes none of what they said. @@ -352,13 +401,28 @@ describe('Codex structured child-work producer', () => { })) ).toEqual([ { description: 'review', membership: 'settled', outcome: 'cancelled' }, - { description: 'npm run dev', membership: 'settled', outcome: 'succeeded' }, { description: 'test', membership: 'settled', outcome: 'failed' }, { description: 'lint', membership: 'settled', outcome: 'failed' } ]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) + it("never reads done while the main agent's own shell runs past its turn", async () => { + const { momentsOf, send, recordedLiveness } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(shellFrame('item/started', THREAD_ID, 'p1', 'exec-dev', 'npm run dev')) + const monitoring = { stateName: 'working', workingMode: 'monitoring' } + // The turn ends with the dev server running: straight to monitoring, never done in between. + const turnEnd = momentsOf(turn('turn/completed', THREAD_ID, 'p1')) + expect(turnEnd.length).toBeGreaterThan(0) + for (const { recorded } of turnEnd) { + expect(fold('done', recorded)).toEqual(monitoring) + } + expect(fold('done', recordedLiveness())).toEqual(monitoring) + momentsOf(shellFrame('item/completed', THREAD_ID, 'p1', 'exec-dev', 'npm run dev')) + expect(fold('done', recordedLiveness())).toEqual({ stateName: 'done' }) + }) + it("numbers a child's runs as the journal does: a row's attempt is its record's generation", async () => { const { send, byDescription, stampOf } = await producer() const says = (turnId: string, text: string) => From 77bf4ca0657678cbc240ae8fe80964fd7f9034da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:54:24 -0700 Subject: [PATCH 19/20] fix(native-chat): a Codex command whose approval its turn abandoned never ran Codex starts an approval's command item before it asks, and when the turn ends with the question unanswered (the user stops at the approval), it drops the question and never completes the item. The command tracker admitted that start as a running process, so the strip kept a phantom command row and the session row read working until the session ended. The prompt registry, which owns which approvals are still unanswered, reports the command approvals a turn ended without; the tracker ends those commands with the frame that ended the turn. An answered approval keeps its command. --- .../codex/codex-background-command-tracker.ts | 6 +++ .../codex/codex-background-task-tracker.ts | 15 ++++-- .../codex-prompt-registry-retention.test.ts | 19 +++++++ src/main/codex/codex-prompt-registry.ts | 23 ++++++++ ...dex-structured-child-work-producer.test.ts | 52 +++++++++++++++++++ .../codex/codex-structured-session-adapter.ts | 2 +- 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 654a06a76c06..922743f1a831 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -3,6 +3,7 @@ import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' 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 @@ -97,6 +98,11 @@ export class CodexBackgroundCommandTracker { .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( coveredThreads?: ReadonlySet, childLabel?: (threadId: string) => string | null diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index f3565d657537..351b35e6b84a 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -10,6 +10,7 @@ import { 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' @@ -56,15 +57,21 @@ 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' const command = itemEvent ? this.commands.observe(event) : null - const commands = - event.method === 'thread/closed' + 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?.kind === 'subagent') { this.executions.register( diff --git a/src/main/codex/codex-prompt-registry-retention.test.ts b/src/main/codex/codex-prompt-registry-retention.test.ts index 78e10cbcc77d..4757d731dcf7 100644 --- a/src/main/codex/codex-prompt-registry-retention.test.ts +++ b/src/main/codex/codex-prompt-registry-retention.test.ts @@ -108,3 +108,22 @@ describe('Codex prompt claim lifetime', () => { expect(prompt.deref()).toBeUndefined() }) }) + +describe('Codex abandoned command approvals', () => { + it("reports only a command's own approval that its turn ended unanswered, once", () => { + const registry = new CodexPromptRegistry() + const ask = (id: number, method: string, params: Record) => + registry.register({ id, method, params: { threadId: 'thread', turnId: 'turn', ...params } }) + ask(1, 'item/commandExecution/requestApproval', { itemId: 'unanswered' }) + const answered = ask(2, 'item/commandExecution/requestApproval', { itemId: 'answered' }) + ask(3, 'item/commandExecution/requestApproval', { itemId: 'parent', approvalId: 'sub' }) + ask(4, 'item/fileChange/requestApproval', { itemId: 'patch' }) + if (!answered) { + throw new Error('Fixture prompt was refused') + } + registry.forget(answered) + registry.clearTurn('thread', 'turn') + expect(registry.takeAbandonedCommands()).toEqual([{ threadId: 'thread', itemId: 'unanswered' }]) + expect(registry.takeAbandonedCommands()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts index 059f8a7a0d31..eda1fa0bf306 100644 --- a/src/main/codex/codex-prompt-registry.ts +++ b/src/main/codex/codex-prompt-registry.ts @@ -31,6 +31,9 @@ export type CodexPendingPrompt = { answers: Map } +export type CodexAbandonedCommand = { threadId: string; itemId: string } +const NO_ABANDONED_COMMANDS: readonly CodexAbandonedCommand[] = [] + export type CodexPromptClaim = { readonly itemId: string readonly prompt: CodexPendingPrompt @@ -54,6 +57,7 @@ export class CodexPromptRegistry { private readonly journalItemIds = new Map() private readonly boundPrompts = new Map() private readonly claims = new Map() + private abandonedCommands: CodexAbandonedCommand[] = [] get sizes(): { prompts: number; journalBindings: number } { return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } @@ -232,7 +236,25 @@ export class CodexPromptRegistry { ) for (const prompt of prompts) { this.forget(prompt) + // Codex abandons a turn's unanswered prompts: a command still awaiting approval never ran. + // An `approvalId` asks for a subcommand, not the item's own command. + if ( + prompt.method === CODEX_COMMAND_APPROVAL_METHOD && + prompt.promptKey === prompt.codexItemId + ) { + this.abandonedCommands.push({ threadId: prompt.threadId, itemId: prompt.codexItemId }) + } + } + } + + /** The commands whose approval a turn ended without, since the last call. */ + takeAbandonedCommands(): readonly CodexAbandonedCommand[] { + if (this.abandonedCommands.length === 0) { + return NO_ABANDONED_COMMANDS } + const taken = this.abandonedCommands + this.abandonedCommands = [] + return taken } clear(): void { @@ -240,6 +262,7 @@ export class CodexPromptRegistry { this.journalItemIds.clear() this.boundPrompts.clear() this.claims.clear() + this.abandonedCommands = [] } private address(threadId: string, promptKey: string): string { diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 343ea333a07c..44d4463a30d7 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -423,6 +423,58 @@ describe('Codex structured child-work producer', () => { expect(fold('done', recordedLiveness())).toEqual({ stateName: 'done' }) }) + it('ends an approval left unanswered when its turn ends: Codex never ran the command', async () => { + const { adapter, codex, send, records, recordedLiveness, display, byDescription } = + await producer() + const approve = (threadId: string, turnId: string, itemId: string, command: string) => { + // The approval path starts the item before it asks, and drops the question at turn end. + send(shellFrame('item/started', threadId, turnId, itemId, command, 'agent')) + codex.connections[0]!.handlers.onServerRequest?.({ + id: `approval-${itemId}`, + method: 'item/commandExecution/requestApproval', + params: { itemId, threadId, turnId } + }) + } + send(turn('turn/started', THREAD_ID, 'p1')) + send(turn('turn/started', REVIEWER, 'r1')) + send(spawned(REVIEWER, 'review', 'p1')) + approve(REVIEWER, 'r1', 'call-child', 'npm run e2e') + approve(THREAD_ID, 'p1', 'call-main', 'npm run dev') + expect(records().filter((record) => record.kind === 'command')).toHaveLength(2) + // The user stops the child, then the main agent, each at its approval. + send(turn('turn/completed', REVIEWER, 'r1', 'interrupted')) + expect(byDescription('npm run e2e')).toBeUndefined() + expect(display('review')).toBe('interrupted') + send(turn('turn/completed', THREAD_ID, 'p1', 'interrupted')) + expect(byDescription('npm run dev')).toBeUndefined() + expect(adapter.backgroundTaskState('session-1')).toBeNull() + expect(fold('done', recordedLiveness())).toEqual({ stateName: 'done' }) + }) + + it('keeps an answered approval running past its turn', async () => { + const { adapter, codex, send, byDescription } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(shellFrame('item/started', THREAD_ID, 'p1', 'call-1', 'npm run dev', 'agent')) + codex.connections[0]!.handlers.onServerRequest?.({ + id: 'approval-1', + method: 'item/commandExecution/requestApproval', + params: { itemId: 'call-1', threadId: THREAD_ID, turnId: 'p1' } + }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'call-1', + kind: 'approval', + response: { kind: 'option', optionId: 'accept' }, + fence: 7, + commit: async () => {} + }) + send(turn('turn/completed', THREAD_ID, 'p1')) + expect(byDescription('npm run dev')).toMatchObject({ membership: 'live' }) + expect(adapter.backgroundTaskState('session-1')?.tasks).toEqual([ + expect.objectContaining({ kind: 'command', description: 'npm run dev' }) + ]) + }) + it("numbers a child's runs as the journal does: a row's attempt is its record's generation", async () => { const { send, byDescription, stampOf } = await producer() const says = (turnId: string, text: string) => diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 0b40c7e3ff53..6ce928a077d4 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -156,7 +156,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.compactions.codex(event.sessionId, event.method, event.params) // After the admission check, so a refused frame is observed by the strip // only on the retry that also reaches the journal. - if (session.backgroundTasks.observe(event)) { + if (session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands())) { this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state) } // After the journal and the parent's republished row, never ahead of either. From d514f6a1710542c344e4f95854a6a32cf54a0ed8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:16:52 -0700 Subject: [PATCH 20/20] test(native-chat): start the Codex child-work runtime test without the removed hold Main no longer has host.hold: creating the session starts its child, and nothing a viewer does keeps it running. The test attaches and asserts the one child that attach started, then drives it as before. --- src/main/runtime/structured-codex-child-work-runtime.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 7313dd8b5311..d03ab6f1be5d 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -89,7 +89,8 @@ describe('structured Codex child work through the production runtime', () => { attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) - await host.hold(SESSION, 'desktop-chat:1') + // Creating the session starts its child; nothing else has to keep it running. + expect(connections).toHaveLength(1) const notify = (method: string, params: Record) => connections[0]?.onNotification?.(method, params) notify('turn/started', { threadId: THREAD, turn: { id: 'turn-1' } })