Skip to content

feat(native-chat): Codex sessions write their subagents into the host status store - #22553

Open
brennanb2025 wants to merge 14 commits into
brennanb2025/c3-claude-producerfrom
brennanb2025/c4-codex-producer
Open

brennanb2025 wants to merge 14 commits into
brennanb2025/c3-claude-producerfrom
brennanb2025/c4-codex-producer

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1010 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1010
Prod 16 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​603 $\color{#cf222e}{\Huge{\mathbf{−}}}$​38 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​565

ELI5

When a Codex chat starts a subagent (in Codex's multi-agent v2 mode; see "Mode coverage" below), or a subagent leaves a dev server running, Orca now keeps one record for it on the machine that runs the chat. The record says whether the subagent is still working, which tool it is running right now (for example Bash: npm test), what it said last, how many tokens it has used, whether it is waiting for your approval, how it ended (finished, failed, stopped, or ended with no reason given), and when. If you send a finished subagent a follow-up, the same record opens again as a second run. Nothing on screen reads these records yet. #22565 adds one row component for CLI and structured children, and #22614 switches the sidebar and the chat strip over to these records, so this PR changes nothing you can see.

Stacked on #22536 (the Claude producer, head fc7fd5d8ab), which is stacked on #22521 (the child-record contract). The base branch is brennanb2025/c3-claude-producer.

Merge order

What Changed

This PR adds a second producer for the records #22536 introduced. It reuses #22536's delivery path, ingest and reducer, including the reducer's run verdict for follow-up runs. It adds no new hop, no new parent-held check and no run counter of its own. The one shared change is which alias kind names a run (see "Contract change").

The path from a Codex frame to a record, hop by hop

  1. Codex adapter emit (codex-structured-session-adapter.ts). The journal translator writes the frame's rows. Then the background-task tracker observes the frame (CodexBackgroundTaskTracker.observe), and the legacy strip state is republished. Only after both, backgroundTasks.publishChildWork() drains the evidence that frame produced.
  2. Exit funnel (handleCodexSessionExit, codex-structured-session-close.ts). Every way a Codex app-server stops (unexpected exit, requested close, forced close, a superseded acquisition) ends here. The tracker is cleared, which queues session-ended. The legacy null is published, and then the evidence is drained. The session object carries its own sink, so none of those close paths needs extra plumbing.
  3. Session sink (codexChildWorkSink, bound at acquire): deps.onChildWorkEvidence(sessionId, evidence).
  4. Runtime (structured-agent-session-runtime.ts). The Codex adapter's onChildWorkEvidence calls host.publishChildWorkEvidence. This is the same host method the Claude adapter calls.
  5. Unchanged from feat(native-chat): Claude sessions write their subagents into the host status store #22536: client delivery → status feed (the session's provider, codex) → status ownership (only under the address the parent row landed under) → status sink (orcad-entry.ts / main-process-runtime-service.ts) → ingestStructuredChildWork (refuses a parent the store does not hold) → reconcileAgentChildWorkEvidence.

What the producer writes, and from which Codex frame

Mode coverage. Codex has two multi-agent modes, and the model picks one: GPT-5.5 runs the default mode, where a helper is announced only by the collabAgentToolCall that spawned it; GPT-5.6-Terra runs multi-agent v2, which emits subAgentActivity items. This PR registers children from subAgentActivity only, so in the default mode it records nothing — a live default-mode session is unchanged by this PR alone. #22619, stacked on this PR, registers default-mode helpers from the spawn call into the same executions, after which both modes produce the same records. Everything below the frame table's first row is mode-independent: once a child is registered, its turns, items, status and endings flow the same way.

Codex frame Who decides Evidence edge Record effect
subAgentActivity started (on the spawning agent's thread) executions register the child: label = last segment of the agent path, spawner = the reporting thread live, once the child also has a turn running created live. thread_id alias = the child thread; fence {child turn id, 1} with a turn_id alias; description = label; residency background; not stoppable
child turn/started executions: the child is working under that turn live with runId = the turn id first run: created. A new turn on a finished child: resumed, with the same childWorkId, generation + 1, and the previous run's outcome kept in previousInvocations
child turn/completed executions: that turn is over, with turn.status ended with runId = that turn: completed→succeeded, interrupted→cancelled, failed→failed, anything else →unknown; plus the last message and usage settled. A late ending of an earlier run is stopped twice: the executions keep the current run, so no edge is sent at all, and the reducer's previous-run fence would refuse one that was sent
child error that Codex will not retry (with no turn/completed) executions end that turn (the one it names, else the running one) as failed ended, outcome failed settled failed. The strip drops the child on the same frame. The transcript already settles the same turn on this verdict
child thread/closed executions end the running turn with no stated verdict ended, outcome unknown settled unknown (Codex closes a thread only after shutting it down idle, and states no verdict)
child item/started / item/completed for a tool call the producer's per-child open calls live with operation {toolName, input, basis: 'open'}, or operation: null Bash: npm test (commandExecution), apply_patch: <path>, mcp__<server>__<tool>, web_search: <query>. If a newer call closes first, the older one shows again
child item/completed agentMessage producer live with lastMessage last message
child thread/tokenUsage/updated producer live with totalTokens (a running total, replaced) tokens
child thread/status/changed active with waitingOnApproval / waitingOnUserInput producer live with state waiting; back to working when the flags clear; reset when the turn ends waiting
commandExecution with source unifiedExecStartup (a persistent command) item/started, on any thread the command tracker admits it (under its existing byte budget) live kind command. task_id = today's strip id (codex-command:…). The owner is the child thread that launched it, or nobody for the session's own agent command record with parentChildWorkId = that child
the same command's item/completed (its process exited) the command tracker settles it ended: completed with exit 0 or no exit code →succeeded, non-zero exit or failed →failed, declined →cancelled, otherwise unknown settled
the parent's turn/completed nobody nothing nothing. A Codex child outlives the turn that spawned it, so a parent turn never settles one
provider exit / close tracker cleared session-ended the session's records are removed

A child's evidence is re-derived from state after each frame about it. The executions (which already own a child's identity and turn state) supply identity and turn state. The producer supplies the open call, last message, usage and waiting. An unchanged re-derivation sends nothing, so the announcement Codex delivers twice produces one edge. A child whose turn began before its announcement gets no record until the announcement lands, matching today's strip. When a child is first recorded, work it launched earlier (a persistent command, a nested child) is sent again with its owner named, so a launch that raced the announcement is not left ownerless.

Contract change (host-internal)

A run alias is now named in the provider's own terms. A task runs under its spawn call (tool_use_id, as before) and a thread runs under its turn (new alias kind turn_id). The reducer's run verdict, previous-run fence and resume are unchanged. They now find the current run's alias under either kind. Owner lookup still accepts only a spawn call, so a turn id can never pass for one. Alias records are host memory only (never persisted or transmitted), so this needs no wire negotiation.

Supporting changes

  • CodexBackgroundCommandTracker.observe now returns the decision it made (started, or ended with the item), and threadTasks(threadId) lists a thread's live commands. The producer follows the tracker's own admission, so the record and today's strip row cannot disagree about which commands exist.
  • CodexSubagentExecutions gains:
    • endTurn(), which ends a child's turn on a frame other than turn/completed. The executions are the one owner of child turn state, so today's strip and the records settle on the same frame. The first ending a turn gets stands.
    • a read-only find();
    • the child's spawnerThreadId. The field and its rule are identical to fix(native-chat): record which Codex agent produced each journal row #22532's.
  • readCodexBackgroundTaskFrame classifies a child's non-retry error using the same verdict reader the transcript uses (readCodexProviderVerdict), and a child's thread/closed.
  • codexThreadWaitsOnUser sits beside the existing thread-status reader.

Why

A Codex child today is one line in the chat strip that says only "a working child exists". It vanishes the moment its turn ends, even though the host already knows how it ended, what it said, its usage, and which tool it is running. This PR writes those facts into the one host record per child that #22536 created for Claude, so #22614 can make the sidebar and the strip read the same record for both providers.

The producer decodes, and the shared reducer decides. Codex has a terminal frame per child (turn/completed), so this lane settles on that and on session end. It needs no roster inventory and no turn-end sweep.

Alternatives considered:

  • Settling children when the parent's turn ends. Rejected: Codex children routinely keep working 57–87 s past the parent's turn. Two independent mechanisms prevent it: no turn-end edge is ever sent, and every Codex child is recorded as background residency, which the reducer's turn sweep never touches. A test pins it.
  • A second run counter for Codex follow-ups. Rejected: the child's turn id is the run handle. The reducer's existing run verdict resumes on a new one and fences a late frame from an old one. Generation is the reducer's own.
  • Deriving waiting from the prompt registry. Rejected: Codex already reports each thread's open requests to the user as thread/status/changed active flags, and that frame is the provider's own statement about the child.

Deviation, recorded: a Codex child whose turn completed can be sent a follow-up. One could model the completed turn as an idle, resumable child. This PR records it as finished (outcome succeeded) and reopens the same record as a new run when a follow-up turn starts. Otherwise a Codex child could never show "finished".

Linked Issue

None — part of the structured chat status/orchestration program.

Visual Proof

N/A. Nothing reads the records yet. The sidebar and the chat strip keep their current sources until #22614. A parity test (below) asserts that the parent state the records imply equals today's at every frame.

Testing

All tests were run with env -u ORCA_STRUCTURED_SESSION ORCA_BACKGROUND_LAUNCH=1, at the head rebased onto #22536's fc7fd5d8ab.

  • pnpm tc:node, pnpm tc:web and pnpm tc:cli all exit 0.
  • 27 new tests in 3 new files:
    • codex-child-work-evidence.test.ts (23). The real tracker feeds evidence through the shared reducer into a real store. Covered:
      • identity and aliases;
      • no record before the announcement;
      • each turn status → outcome (4);
      • a child's turn ended by a non-retry error (naming its turn, or none) or by thread/closed settles the strip and the record on the same frame (3);
      • a retried error and a systemError status end nothing, and a fatal error naming an already-finished turn ends nothing;
      • a message whose frame names no turn is filed under the running turn and never leaks into the next;
      • a parent turn end settles nothing;
      • a follow-up reopens the same record at generation 2, and a late ending of run 1 is fenced;
      • the open call (Bash, MCP, newer-closes-first), and no open call inherited by a new run;
      • last message and usage;
      • waiting;
      • a child's persistent command owned by it, whose display reads monitoring through the shared fold, then done;
      • a command launched before its owner was recorded;
      • the session's own command;
      • a nested child's owner;
      • no evidence held without a sink;
      • session end.
    • codex-structured-child-work-producer.test.ts (3), through the real CodexStructuredSessionAdapter:
      • Ordering. For the announcing frame, the journal rows and the legacy republish precede the evidence.

      • Parity. A 22-frame session covers:

        • a child turn before its announcement;
        • the child's own shell, a persistent dev server and a message;
        • the parent turn ending first;
        • waiting and back;
        • the child finishing while its dev server runs;
        • a follow-up turn;
        • two more children, one of which Codex ends with a non-retry error and no turn/completed;
        • an interrupt, a failure, and the dev server exiting.

        At every frame, the parent state folded from the records equals the one folded from today's backgroundTasks, and so does the raw liveness (asserted at every frame, not only once the main agent is done). Liveness takes all three values across the script. closeSession removes every record.

        Limit of this proof: both sides read the same CodexSubagentExecutions. The strip's tasks() is executions.workingChildren(), and the producer reads the same object. So parity proves the record pipeline (evidence → reducer → store) matches today's strip. It cannot see a defect in the executions' own set of ending observers, because such a defect strands both sides identically. The missing ending signals fixed here were exactly that kind of defect. feat(native-chat): the chat strip and the sidebar read the host's child records #22614 must not use this test alone as proof that the Codex cutover is invisible.

      • Unexpected exit. An unexpected provider exit removes the records.

    • structured-codex-child-work-runtime.test.ts (1). Through the production ensureStructuredAgentSessionHost, a Codex session's child reaches the status sink under the session's own address with provider codex, and a provider exit delivers session-ended there.
  • Ablations: all 30 arms turn red. Each is an exactly-once edit asserted by the script, run at the final head and restored from HEAD:
Deleted (or reverted) Red
A thread run named turn_id (substituted with tool_use_id) 1: records a spawned child by its thread, with its turn as the run
The child turn as the run handle (runId) 7: records a spawned child by its thread, with its turn as the run; reopens the same record for a follow-up turn on a finished child, as a new run; never carries a run's open call into the next run when its ending was lost; keeps what the child said last, and its usage, through to how it ended; files a message whose frame names no turn under the run that said it, never the next; reads a child waiting on the user from its own thread status; parity script
Settling a child on its turn ending 13: settles a child whose own turn ended completed as succeeded; settles a child whose own turn ended interrupted as cancelled; settles a child whose own turn ended failed as failed; settles a child whose own turn ended somethingNew as unknown; settles a working child whose turn ended with no turn/completed, by an error naming its turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by an error naming no turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by its thread closing, in the strip and the record together; keeps a child working through a retried error and a systemError status: its turn runs on; reopens the same record for a follow-up turn on a finished child, as a new run; keeps what the child said last, and its usage, through to how it ended; files a message whose frame names no turn under the run that said it, never the next; owns the child's persistent command, so a finished child reads monitoring while it runs; parity script (its per-step check)
interrupted → cancelled mapping 3: settles a child whose own turn ended interrupted as cancelled; reopens the same record for a follow-up turn on a finished child, as a new run; parity script
Waiting from the child's thread status 2: reads a child waiting on the user from its own thread status; parity script
Opening a tool call 2: says which tool the child has open, the way a CLI row names a Codex shell; parity script
Closing a tool call by item id 2: says which tool the child has open, the way a CLI row names a Codex shell; parity script
Pruning another run's open calls 1: never carries a run's open call into the next run when its ending was lost
Last message 3: keeps what the child said last, and its usage, through to how it ended; files a message whose frame names no turn under the run that said it, never the next; parity script
Usage 1: keeps what the child said last, and its usage, through to how it ended
Persistent command → command record 5: owns the child's persistent command, so a finished child reads monitoring while it runs; names the owner of a command launched before the host held its child's record; records the session's own persistent command with no owner; drops every record when the provider session ends; parity script
Persistent command exit → settled 2: owns the child's persistent command, so a finished child reads monitoring while it runs; parity script
Command owned by the child thread that launched it 2: owns the child's persistent command, so a finished child reads monitoring while it runs; parity script
Re-announcing owned work once its owner is recorded 1: names the owner of a command launched before the host held its child's record
Nested child owned by its spawner 1: names the child that spawned a nested child as its owner
session-ended on clear 4: drops every record when the provider session ends; parity script; drops the records when the provider exits unexpectedly; hands its subagents to the status sink under the session's own address
Delivery at the exit funnel 3: parity script; drops the records when the provider exits unexpectedly; hands its subagents to the status sink under the session's own address
Delivery after each emitted frame 4: delivers evidence only after the journal wrote the frame and the legacy row republished; parity script; drops the records when the provider exits unexpectedly; hands its subagents to the status sink under the session's own address
Runtime wiring to the host 1: hands its subagents to the status sink under the session's own address
An unchanged re-derivation sends nothing 1: records a spawned child by its thread, with its turn as the run
Both parent-turn guards together (substituted: a turn-end edge on the parent turn AND foreground residency) 3: records a spawned child by its thread, with its turn as the run; never settles a child on its PARENT turn ending: children outlive the turn; parity script
Reading a child turn ending at all (frame reader) 4: settles a working child whose turn ended with no turn/completed, by an error naming its turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by an error naming no turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by its thread closing, in the strip and the record together; parity script (its per-step check)
A non-retry error ends the child turn 3: settles a working child whose turn ended with no turn/completed, by an error naming its turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by an error naming no turn that Codex will not retry, in the strip and the record together; parity script (its per-step check)
thread/closed ends the child turn 1: settles a working child whose turn ended with no turn/completed, by its thread closing, in the strip and the record together
The executions ending the turn (tracker call) 4: settles a working child whose turn ended with no turn/completed, by an error naming its turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by an error naming no turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by its thread closing, in the strip and the record together; parity script (its per-step check)
No turn named: the running turn is the one that ended 2: settles a working child whose turn ended with no turn/completed, by an error naming no turn that Codex will not retry, in the strip and the record together; settles a working child whose turn ended with no turn/completed, by its thread closing, in the strip and the record together
A frame naming no turn is filed under the running turn 1: files a message whose frame names no turn under the run that said it, never the next
Old run filter restored (substituted: no stamping AND a no-turn fact counting for every run) 1: files a message whose frame names no turn under the run that said it, never the next
No record before the announcement (registered gate) 3: makes no record for a child whose turn began before its announcement, until it lands; names the owner of a command launched before the host held its child's record; parity script
Draining with no sink (the old sink-gated drain restored) 1: holds no evidence for a session with nowhere to deliver it
  • Regression.

    • At the final head, after the child-turn-ending fix: src/main/codex, src/shared/agent-status*, the structured runtime tests, src/main/native-chat/agent-session-wire, and the relay store-context test. 356 files, 3,431 tests: 3,414 passed, 1 expected-fail, 13 skipped, 3 failed. All 3 are timing tests:
      • refusal-retry host oracle and lease-renewer production interval pass when run alone.
      • codex-session-index-heal-state ("main thread free while walking a large audit ledger") fails identically with HEAD detached at the base fc7fd5d8ab, with the machine at load average 25–36. It imports only filesystem and ledger modules.
    • One commit earlier, the wider set that also covered src/main/claude, src/main/agent-hooks and src/main/orcad (none of which the fix touches): 607 files, 5,952 tests: 5,916 passed, 6 failed.
      • 5 are the real-Claude-binary files. They fail identically at the base (provider close unproven, timeouts).
      • The 6th is the refusal-retry timing test, which passes alone.
  • Quality.

    • The changed-code gate, scoped to this PR's base (ORCA_CODE_QUALITY_BASE=<base>), reports 0 new findings in every category.
    • Full oxlint on all 18 changed files exits 0. A 310-line in-tree probe proves it reports max-lines.
    • pnpm run audit:anti-slop exits 0.
    • pnpm-lock.yaml is absent from the branch range, and there are no new docs.
  • Platforms: tests ran on macOS. The code is platform-neutral.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Not verified:

  • No live Codex app-server capture was made for this PR. Frame shapes come from:

    • the existing adapter probe (a child's announcement arrives twice, with agentThreadId / agentPath);
    • fix(native-chat): record which Codex agent produced each journal row #22532's recorded two-child session (a child's turn frames precede its announcement);
    • the app-server protocol source (ThreadStatus::Active { activeFlags: waitingOnApproval | waitingOnUserInput }, and the commandExecution / mcpToolCall / fileChange item fields).

    Not captured: a child thread's thread/status/changed, error and thread/closed arriving on the parent's connection. The child's turn/* and item/* frames do arrive there, which the recorded session shows. If the status frame did not arrive, a waiting child would read working, as it does today.

  • No Electron run. Nothing renders the records yet, and feat(native-chat): the chat strip and the sidebar read the host's child records #22614 carries the $electron check.

  • A systemError status is deliberately not a turn end. This is verified in the Codex app-server source, and Orca's transcript already draws the same line (codex-structured-journal-provider-verdicts.ts):

    • Codex sets a thread to systemError for every error event, including the two kinds that leave the turn running: a refused steer and a failed rollback.
    • Every error that does end a turn also emits the non-retry error notification with the turn's id, which this PR now consumes.

    So systemError would add only false endings: a child still working would be recorded as failed. Residual: a child that stops with none of turn/completed, a fatal error, or thread/closed stays live until the session ends. I found no Codex path that does that. feat(native-chat): register Codex default-mode helpers as subagents #22619 subsequently found one: a helper shut down by the parent's closeAgent call may emit no child-side ending in either mode (schema-verified; whether thread/closed follows a close is live-unverified). feat(native-chat): register Codex default-mode helpers as subagents #22619 ends the helper's running turn on a finished closeAgent (outcome cancelled; the first ending stands). At this PR's head the residual stands as written.

  • The transcript's subagent group card is written by the journal roster, which re-serializes its row only on turn/started/turn/completed. The fatal-error/thread/closed endings this PR adds reach the executions through the tracker, so at this head the strip and the record settle while the transcript row stays "working" — a disagreement main does not have (on main, none of the three settle). feat(native-chat): register Codex default-mode helpers as subagents #22619, stacked on this PR, closes it by making the roster follow CodexSubagentExecutions.onExecutionChanged, the one owner of child turn state, so every ending settles all three together. (An earlier revision of this section deferred the fix to the fix(native-chat): record which Codex agent produced each journal row #22532 lane because fix(native-chat): record which Codex agent produced each journal row #22532 edits the same roster/translator files; the fix was routed to feat(native-chat): register Codex default-mode helpers as subagents #22619 instead.)

  • Late usage is dropped. Usage that arrives after a child's definite ending is not written, because the settled outcome latches.

  • Nested children need a non-default Codex setting and were not captured live. Only the owner rule is tested.

  • Not run: mobile tests (no mobile code reads records) and the relay typecheck (no relay module imports these files).

  • Per-frame cost is one store mutation per child fact change. I did not measure it; feat(native-chat): the chat strip and the sidebar read the host's child records #22614 must measure the publish rate before it publishes records.

Decisions and deviations:

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Wire / mixed versions: there is no wire change, no new opcode and no new published field. Records and aliases stay in the host's in-memory store, which is never persisted or transmitted. A restart forgets them, per the decision that child records live in memory. The turn_id alias kind is host-internal.
  • For feat(native-chat): the chat strip and the sidebar read the host's child records #22614: a Codex agent's view providerId is its thread id, the join key the journal linkage uses. Today's strip publishes codex-agent:<thread>. When the legacy shapes are derived from views, that prefix must be mapped (or the new id accepted, since the id is only a row key). Command records keep today's strip id, so their providerId is unchanged. The label is in description, where today's strip row carries it.
  • SSH / WSL / folder workspaces: evidence is decoded where the Codex app-server runs (the execution host) and ingested in the same process, under the parent's full execution scope. Nothing assumes a local host or a git worktree.
  • Mobile: nothing reads the records; subagents is unchanged.
  • Performance: one small map lookup per notification. One store mutation per child fact change: an item start/end, a usage frame, a status change, a turn edge. Streaming deltas produce nothing.
  • Boundary with the main-agent status lane: server-ingest-structured.ts, structured-agent-session-agent-status.ts, the fold and the renderer bridge are not touched.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

Two correctness gaps in the new producer's run fencing and lifecycle, both latent until the next PR starts reading the records.

Reviewed changes

This run reviewed the full 18-file diff at 1df7e358c0 (base brennanb2025/c3-claude-producer).

  • New Codex child-work producer (codex-child-work-evidence.ts, codex-child-work-translation.ts) — re-derives one child's whole observation per frame: open calls, last message, tokens, waiting, and the child's own turn/completed as the terminal edge. Decodes, shared reducer decides.
  • Tracker/command wiring (codex-background-task-tracker.ts, codex-background-command-tracker.ts) — observe now returns a started/ended command decision, adds threadTasks, and drains/publishes evidence via a session-bound sink.
  • Delivery ordering (adapter emit, handleCodexSessionExit, runtime) — evidence is delivered after the journal row and the legacy republish on every notification, and session-ended on every close path.
  • Contract change — new turn_id run-alias kind; a task runs under tool_use_id, a thread under turn_id; owner lookup still accepts only spawn calls. I traced this against the existing task path and the store's alias-key scoping and found it behavior-preserving there.
  • Tests — ran the three new files locally: 22/22 pass.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread src/main/codex/codex-child-work-evidence.ts Outdated
...(lastMessage ? { lastMessage } : {}),
...(totalTokens !== undefined ? { totalTokens } : {})
}))
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When the child's turn ends before its announcement, this branch emits only an ended edge and returns before requeueOwnedBy. The reducer can settle but never create from ended, so a child that started and finished before its subAgentActivity announcement gets no record at all, and any persistent command or nested child it launched stays ownerless.

Technical details
# A child that ends before its announcement is dropped

## Affected sites
- `src/main/codex/codex-child-work-evidence.ts:194-209` — ended branch emits `ended` and returns
- `:233-235` — `requeueOwnedBy` is reached only from the working branch
- `src/shared/agent-status-child-work-reconciliation.ts:49-51` — `applyEnded` returns when no record exists

## Required outcome
A child the producer has seen run a turn gets a record even if the turn ended before the announcement, and work it launched is re-announced with its owner.

## Suggested approach
On the first observation (`facts.published === undefined`) in the ended branch, emit a live edge (to admit the record) before the ended edge, and/or call `requeueOwnedBy` before returning.

## Open questions for the human
Is dropping such a child acceptable as matching today's strip, which never showed it? The PR body's "no record until the announcement lands" claim implies it should still land.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in the delta — one lifecycle observation inline; the earlier run-fencing concern is fixed.

Reviewed changes

This run reviewed the two commits since the prior pullfrog review (1df7e358c0 → f098d8db9e) against the full 18-file diff (base brennanb2025/c3-claude-producer).

  • Run fence fixed for turn-less child item frames — record now falls back to the child's tracked turn and ofTurn is strict, so an open call or last message recorded under one run can no longer leak into the next.
  • A child turn now ends without turn/completed — a new turn-ended frame kind ends the child's turn on a non-retry error (→ failed) or a thread/closed (→ unverifiable) through a new CodexSubagentExecutions.endTurn, so the strip and the record settle on the same frame; a systemError status deliberately does not end a turn.
  • Tests — new evidence tests for each ending (error naming a turn / naming none / thread close), first-ending-wins, retried-error and systemError keeping the turn running, and a turn-less message never leaking into the next run; plus a LINTER child in the parity script that ends on a fatal error.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +58 to +60
return event.method === 'thread/closed'
? { kind: 'turn-ended', threadId: event.threadId, turnId: null, state: 'unverifiable' }
: null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

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

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in the delta — the only net-new commit is a test-only parity assertion that passes; nothing to change.

Reviewed changes

Compared 08ca92b26c against the prior run's head (8e22ed0ad2). The branch was rebased onto a newer base, so 11 of the 12 PR commits are SHA churn; the only commit with no counterpart at the prior head is 08ca92b26c. All production files are unchanged.

  • Journal↔record run numbering pinned — codex-structured-child-work-producer.test.ts now stamps each child agentMessage journal row and asserts the journal's run attempt equals the record's invocation.generation across the child's first three runs, including the case where Codex reports the child's first turn before the spawn that announces it. The assertion is exact, so it fails if either numbering diverges (e.g. an unstamped run-2 row reads attempt: 1 against generation: 2); the file's 4 tests pass at this head.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025
brennanb2025 force-pushed the brennanb2025/c3-claude-producer branch from eada18c to 503eb83 Compare September 24, 2026 23:28
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 08ca92b to 358d1a9 Compare September 24, 2026 23:36
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c3-claude-producer branch from 503eb83 to fa71175 Compare September 25, 2026 04:50
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch 2 times, most recently from 1db34d1 to 36242df Compare September 25, 2026 05:53
…ods 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.
… 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.
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.
… 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.
…, 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.
…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.
…d 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.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 36242df to bc928a2 Compare September 25, 2026 10:47

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No code issues in the delta — one stale note in the PR description.

Reviewed changes

This run reviewed the net-new commit bc928a2949 since the prior pullfrog review; the branch was also rebased onto a newer c3-claude-producer (94e5159f52), whose session-ended handling now settles live children instead of erasing them. No production logic changed.

  • Session-end assertions updated to settlement — codex-child-work-evidence.test.ts and codex-structured-child-work-producer.test.ts now assert a live child and its persistent command settle unknown when the session ends, and that already-ended children keep their recorded outcome. clear()'s doc comment was updated to match. All 28 tests in the three Codex child-work files pass at this head.
  • Runtime test comment — structured-codex-child-work-runtime.test.ts now describes a provider exit as reporting the session's end rather than taking its children with it.

ℹ️ The PR description still says a session's end removes the records

The body's frame table ends with "provider exit / close → session-ended → the session's records are removed", and the Decisions and deviations list repeats "Session end removes records instead of settling them". The base branch inverted that rule (35f97191b5, settleLive in agent-status-child-work-reconciliation.ts:93-99 now settles every live owned record unknown), and this delta's tests assert the new behavior. The description should be updated so the next reviewer and the #22614 cutover are not working from the old contract.

Technical details
# Stale session-end decision in the PR description

## Affected sites
- PR description, frame table last row: "provider exit / close | tracker cleared | `session-ended` | the session's records are removed"
- PR description, "Decisions and deviations": "**Session end removes records instead of settling them** (the same rule #22536 applies)."
- Actual behavior: `src/shared/agent-status-child-work-reconciliation.ts:93-99` — `settleLive` settles every live owned record `unknown` on `session-ended`; settled records are trimmed at `STRUCTURED_CHILD_WORK_MAX_SETTLED = 32`.

## Required outcome
The description matches the merged base behavior: session end settles a still-live child (and its owned commands) as `unknown`, and keeps an already-ended child's outcome, rather than erasing the session's records.

## Suggested approach
Update the two description spots to describe settlement. No code change is needed; this delta already pins the behavior.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant