Skip to content

Journal what a Codex session streams, and prove it end to end - #13394

Closed
brennanb2025 wants to merge 8 commits into
brennanb2025/codex-structuredfrom
brennanb2025/codex-structured-journal
Closed

brennanb2025 wants to merge 8 commits into
brennanb2025/codex-structuredfrom
brennanb2025/codex-structured-journal

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part B of the structured agent-session stack: the layer that turns what a provider streams into the durable conversation a client reads back. Nothing here is reachable from the UI yet — the payoff is the last commit, one test that drives a structured Codex session end to end over agentSession.* with no renderer involved.

Six commits, each its own reviewable step:

  • Buffer the provider events an adapter streams before its journal exists — structured-agent-session-event-sink.ts. A provider starts streaming the moment its process exists, which is inside adapter.acquire, before the journal is open. The adapter now gets a deferred sink whose writes queue in arrival order and drain once the journal is bound. The sink lives for the session rather than for one acquisition, so an adapter can hold it across a re-acquire; it is bound last in onAttached so nothing streamed interleaves with the attach result the client is about to read, and a refused attach that never reached a journal closes it instead of leaving writes nothing will drain. This is the additive adapter extension — AgentSessionAdapter.acquire gains an optional events field; an adapter that ignores it behaves exactly as before, which is why the pre-existing adapter tests are unchanged. It has its own tests (structured-agent-session-event-sink.test.ts).
  • Coalesce streamed agent text on a short server-side window — agent-session-delta-coalescer.ts. One journal row and one stream frame per token costs thousands of appends and thousands of wakeups on a phone for a single long answer. Deltas accumulate and flush on a 60ms window, all streams share one timer, and the row carries a snapshot of the text so far rather than the increment — which is what makes dropping intermediate frames safe for a reconnecting client. Lifecycle events bypass the window by flushing first, so nothing is ever journaled ahead of the text that preceded it.
  • Key a Codex journal row by thread, turn, and ordinal — codex-structured-item-translation.ts, codex-structured-prompt-items.ts. Codex item ids are not stable across resume, so a journal keyed by them cannot reconcile a recovered thread against a live one. The durable key is the position the item holds: thread, turn, and ordinal within that turn, counting message items only. Non-message items get no ordinal rather than a number from a second counter — a counter a resumed history cannot reproduce is worse than no key, because it looks reconcilable and reconciles wrongly — and fall back to a live-session namespace a recovered journal will not contain, which is correct, since Codex does not persist them either.
  • Translate Codex app-server events into journal rows — codex-structured-journal-translation.ts. The one place Codex events become journal rows: streamed text folds through the window, lifecycle flushes it first, and every write is a highest-revision-wins upsert on the durable key, which is what lets a completed body land on the row its own deltas opened. Journal first, observers second — a tap must never see a row the journal has not been told about. A session with no sink still runs, unjournaled.
  • Resolve a Codex launch from the durable session record — codex-structured-launch-resolution.ts. Command, cwd, pinned account home, and the thread to resume all come out of the record the lease already wrote, never from the client-declared identity: a caller that could name the thread could point a session at someone else's conversation. It refuses rather than guesses — another execution host, another WSL distro, another provider, another account-home variable, or a workspace that no longer resolves all surface instead of falling back to a default.
  • Install the structured agent-session host on first use — structured-agent-session-runtime.ts, wired from orca-runtime.ts. setStructuredAgentSessionHost is constructed by the first agentSession.create/.ensure with journalRoot, claimKeyId, and probeOwner, because the user-data path is not final until Electron is ready and a runtime that never serves a structured session should not pay for a store it will never read. The memoized slot clears on failure so the next call retries. The capability gate runs first: building a store and spawning a child is an observable side effect a client without the capability must not be able to cause.

Integration proof

structured-agent-session-integration.test.ts walks create → send → stream → approval → cancel → reconnect → page history against the real dispatcher, real schemas, real capability gate, real record store, journal, lease, adapter, and translation. Only the Codex child is scripted, and it answers the same JSON-RPC calls the real app-server does. It proves the three things the parts only claim individually: the provider's echo of a user message reconciles into the bubble the client already has instead of appending a second copy; a client reconnecting from its cursor replays only what it missed while a takeover advances the fence and reaps the old child; and the whole conversation reconstructs from the durable journal alone with no page overlapping another.

Review findings from #13381

The adversarial review of the parent PR posted six findings at head b6c98f8. All six are fixed; because they live in files this PR builds on, most landed on the parent branch and are already in #13381's diff, not this one:

# Finding Where fixed
1 Distinct approval callbacks collide under one itemId codex-structured-prompt-replies.ts on #13381 (registry keyed by approvalId ?? itemId) and in this PR: codex-structured-prompt-items.ts / codex-structured-journal-translation.ts now key the journal row by that prompt identity too, so two approvals from one tool item are two answerable rows
2 Child-thread traffic attributed to the root thread codex-structured-session-adapter.ts on #13381 — events carry the thread they came from, and only a root-thread turn/started answers the root turn waiter
3 Acquisition can publish a dead or stale session codex-structured-session-adapter.ts + codex-structured-acquisition-window.ts on #13381 — publication is one atomic step, and the failure path reaps only this attempt's child
4 stdin transport failure does not invalidate the lease codex-app-server-connection.ts on #13381 — stdin errors route through the terminal path, reap the child, and mark the connection closed
5 Unexpected-end handling can run twice codex-app-server-connection.ts on #13381 — once-only terminal guard that keeps the first, specific cause
6 Acquisition-time notifications dropped codex-structured-acquisition-window.ts on #13381 — traffic that arrives before the session is published is buffered in arrival order and drained at publication

Each fix has a test that fails against the pre-fix source (verified by reverting the source under the new test).

Screenshots

No visual change. There is nothing to screenshot: no renderer code is touched, and the structured path is unreachable from the UI until the mobile client lands (part C).

Testing

  • pnpm lint
  • pnpm typecheck
  • pnpm test
  • pnpm build
  • Added or updated high-quality tests that would catch regressions

New: structured-agent-session-event-sink.test.ts, agent-session-delta-coalescer.test.ts, codex-structured-item-translation.test.ts, codex-structured-prompt-items.test.ts, codex-structured-journal-translation.test.ts, codex-structured-launch-resolution.test.ts, structured-agent-session-runtime.test.ts, structured-agent-session-integration.test.ts.

Gate results

  • pnpm lint — pass.
  • pnpm typecheck — pass, all three tsc projects.
  • pnpm build — pass.
  • pnpm test — 48992 passed / 6 failed / 97 skipped across 4576 files. All six failures are environmental and none is in a file this branch touches: three in the root-directory guard test, whose shell script uses declare -A and so exits 2 under macOS's system bash 3.2 (reproduced by hand on a fresh temp repo; Linux CI has bash 4+); two in the relay agent-exec handler, where this machine's git environment contributes duplicate GIT_CONFIG_KEY_* entries so the expected count of 2 arrives as 4; and one 30s renderer timeout that passes when run alone.

AI Review Report

Reviewed for: ordering hazards between the coalescing window and lifecycle events (a row journaled ahead of the text it follows), rows a superseded child could still write after a takeover, journal keys that survive resume, prompt identity collisions, capability-gate bypass, and cross-platform behavior. What changed as a result: the translator is per-session-per-acquisition and disposed on exit so a reaped child cannot keep writing; lifecycle flushes the window before it emits; the completed body forgets any pending snapshot so a stale flush cannot land after it; and non-reproducible ordinals were removed rather than guessed. Cross-platform: this diff adds no shell invocation, no path literal, no keyboard handling, and no Electron platform branch — paths come from Electron's user-data path and path.join, the launch resolver refuses a record pinned to a different execution host or WSL distro instead of assuming local spawn, and the SSH/remote case is served by the same host-side modules since nothing here is renderer-resident. Remote wire compatibility: no new opcodes and no new required fields — the adapter extension is an optional field on an internal main-process interface, not on the wire.

Security Audit

Reviewed input handling, command execution, path handling, and IPC. The launch resolver is the notable surface and is deliberately restrictive: command, cwd, account home, and resume target come from the durable record the lease wrote, never from client-supplied parameters, so a client that can name a thread id cannot point a session at another conversation, another workspace, or another account's home. Prompt option ids are encoded/decoded with an explicit separator scheme so a question id containing the separator round-trips instead of forging another question's answer. The capability gate runs before any store construction or child spawn, so an unauthorized client cannot cause the side effect. Journal writes are append-only upserts keyed by identity, so a late frame cannot rewrite history under a different key. No secrets are logged; the stderr tail carried into an exit error is truncated. No new dependencies.

Notes

  • Stacked. Base is brennanb2025/codex-structured (Run a Codex session over the structured agent-session wire #13381) so the diff is only this part; it retargets to brennanb2025/native-chat-structured-base once Run a Codex session over the structured agent-session wire #13381 merges.
  • Deliberately deferred: translating a resumed thread's turn.items into journal rows, because attachJournal recovers the conversation from the durable journal and the rollout file is the provider's own record — wiring both would give two writers for the same rows. Also deferred: the spawn-token process scan that would let a reservation which named no process be freed automatically; until it exists, that case reports indeterminate and routes to manual recovery, which is the safe direction.
  • Owner probing is scoped to this host on purpose: checking another host's pid against this machine's process table is exactly how a live owner gets declared dead, so a foreign owner reports indeterminate rather than being reaped.
  • No mobile files are touched by this PR; the mobile client is part C.

A provider starts streaming the moment its process exists, and that moment
is inside `adapter.acquire` — before the journal is open and before the host
has registered the session. The adapter now receives a deferred sink whose
writes queue in arrival order and drain once the journal is bound.

One sink lives for the session rather than for one acquisition, so an adapter
can hold it across a re-acquire without noticing the new journal object. It is
bound last in `onAttached` so nothing the provider streamed can interleave with
the attach result the client is about to read, and a refused attach that never
reached a journal closes it instead of leaving writes nothing will drain.

The host was one line under the 300-line cap before this, so the mutation
admission pipeline moved to its own module. That is a straight extraction:
recompute fingerprint, admit through the ledger, check the lease, run the
plan — the same route, now somewhere a new method cannot sit next to.
Providers emit one notification per token. Journaling each one writes a row
and wakes every subscriber per token, so one long answer costs thousands of
appends and thousands of stream frames on a phone.

Deltas accumulate and flush on a 60ms window, and the row carries a SNAPSHOT
of the text so far rather than the increment — which is also what makes
dropping intermediate frames safe for a reconnecting client. All streams share
one timer, so the deadline bounds latency the same way at one wakeup per
window instead of one per stream. Lifecycle bypasses the window by flushing
first, so nothing can be journaled ahead of the text that preceded it.
Codex item ids are not stable across resume, so a journal keyed by them
cannot reconcile a recovered thread against a live one: the same message
would arrive under a new id and append a second copy of itself.

The durable key is instead the position the item holds — thread, turn, and
ordinal within that turn, counting message items only. Non-message items get
no ordinal at all rather than a number from a second counter, because a
counter a resumed history cannot reproduce is worse than no key: it looks
reconcilable and reconciles wrongly. Those rows fall back to a live-session
namespace that a recovered journal simply will not contain, which is correct,
since Codex does not persist them either.

Approvals and questions become journal rows the same way, naming the command
the item already announced so the user is not asked to approve an opaque id,
and one row per question rather than one row per request.
The adapter now writes what Codex streams straight into the session's
journal, so nothing between the child and the durable record has to remember
state: streamed text folds through the coalescing window, lifecycle events
flush it first, and every write is a highest-revision-wins upsert on the
durable key, which is what lets a completed body land on the row its own
deltas opened.

Journal first, observers second — a tap must never see a row the journal has
not been told about. A session with no sink still runs, unjournaled, which is
how the existing adapter tests keep working unchanged.

Starting or resuming the thread moved into its own module on the way past the
adapter's line cap; the check it carries is unchanged, and still refuses a
resume that lands on a different thread, since recording that would make the
durable handle chain lie about what the session proved.
Command, cwd, pinned account home, and the thread to resume all come back
out of the record the lease already wrote, never from the client-declared
identity: a caller that could name the thread could point a session at
someone else's conversation.

It refuses rather than guesses — a record pinned to another execution host or
a WSL distro is not this host's to spawn for, a record for another provider or
another account-home variable is not this adapter's to speak for, and a
workspace that no longer resolves surfaces instead of falling back to a
default cwd.
The record store and the journals live under the profile's user-data path,
which is not final until Electron is ready, and a runtime that never serves a
structured session should not pay for a store it will never read. So the host
is built by the first `agentSession.create` or `.ensure` rather than at
startup, and the memoized slot clears on failure so the next call retries.

The capability gate runs first: building the store and spawning a child is an
observable side effect, and a client that cannot read structured sessions must
not be able to cause it.

Owner probing is scoped to this host. Checking another host's pid against this
machine's process table is exactly how a live owner gets declared dead, so a
foreign owner reports `indeterminate` and routes to manual recovery instead.
So does a reservation that named no process — freeing it needs positive proof
that nothing spawned under its token, and this host has no spawn-token process
scan yet.
One test walks create, send, stream, approval, cancel, reconnect, and paged
history against the real dispatcher, the real schemas, the real capability
gate, the real record store, journal, lease, adapter, and translation. Only
the Codex child is scripted, and it answers the same JSON-RPC calls the real
app-server does.

It is the first thing in the stack that proves the parts fit: that the
provider's echo of a user message reconciles into the bubble the client
already has instead of appending a second copy, that a client reconnecting
from its cursor replays only what it missed while a takeover advances the
fence and reaps the old child, and that the whole conversation reconstructs
from the durable journal alone with no page overlapping another.

@brennanb2025 brennanb2025 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed exact head 76cce58 against stacked base 4d70b18. Same-model internal adversarial pass used; four actionable correctness/performance findings are pinned below. No branch changes or push made.

const ordinals = new CodexTurnOrdinals()
/** Identity assigned when an item was announced, reused by its deltas and by
* its completion so all three upsert one row. */
const identities = new Map<string, AgentJournalItemIdentity>()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P1] Scope translator state by thread. The adapter deliberately forwards root and subagent threads over one connection, but identities, coalescer keys, and command details use only positional item.id, while currentTurnId is one session-wide scalar. Interleaved threads that both emit item-0 can therefore merge text or write the child body under the root durable key, and a child turn/completed clears the root turn. Use composite thread/item keys, current turns per thread, and thread+turn ordinal state; also use readCodexTurnId here so the supported envelope-level turnId shape is not discarded. Add an interleaved two-thread/same-item-ID regression test.

let chain: Promise<void> = Promise.resolve()

const enqueue = (operation: SinkOperation): void => {
const bound = target

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P1] Unbind the sink during replacement acquisition. On a second-fence attach, eventSinkFor returns the sink still targeting the old journal; the new adapter drains its acquisition-window events before performAttach opens and binds the replacement journal, so enqueue captures this old target. Those rows can be rejected at the stale fence, race the new journal open, or remain absent from the new in-memory snapshot/subscriber because their publish callback also targets the old journal. Add an explicit rebinding/unbound phase (or a fresh acquisition sink) and test a prompt/notification emitted during thread/resume.

if (!identity) {
return
}
deps.sink.appendItem(identity, codexStreamingMessageBody(text))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P2] Avoid appending the full accumulated answer every 60 ms. The coalescer emits a snapshot, and this append-only journal stores every prior snapshot verbatim, making disk growth quadratic in response length; a long streamed answer can exhaust the 256 MiB per-session budget, after which later snapshots and even the final body are refused and dropped by the sink. Persist bounded/incremental deltas or compact/geometrically checkpoint snapshots while preserving reconnect semantics, and add a long-stream byte-growth test.


/** Drops the host and reaps every Codex child under it. Runtime teardown and
* test isolation take the same path, so neither can leave a live app-server. */
export async function stopStructuredAgentSessionRuntime(): Promise<void> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P1] Wire this stop path into production teardown. stopStructuredAgentSessionRuntime is currently referenced only by tests, and src/main/index.ts's will-quit barrier never calls it. After any structured session has been used, quitting Orca can therefore orphan the long-lived codex app-server child, which may continue running a turn or command after the UI/runtime exits. Import it in the main quit path and join it to settleTeardownWithinDeadline (after fixing the part-A in-flight acquisition gap), with a teardown registration test.

@brennanb2025 brennanb2025 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Follow-up adversarial pass on exact head 76cce58 found four additional provider-shape gaps; each is pinned below. No branch changes or push made.

return items
}

function questionOptions(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P1] Preserve a free-text answer path for Codex questions. The app-server permits questions with options: null and options marked isOther, but this maps missing options to an empty offered set; performPrompt then rejects every optionId because it must exactly match prompt.options. Such a request is journaled but can never be answered, leaving the turn blocked forever. Represent and validate an Other/free-text response through the journal and mutation contract, with null-options and isOther tests.

: input.method === CODEX_COMMAND_APPROVAL_METHOD
? 'Run a command?'
: 'Approve this action?',
detail: readString(params, 'reason') ?? input.detail,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P1] Prefer the approval request's own command/detail. Bridge command approvals can re-ask for distinct subcommands under one parent item, and those requests carry the exact command being approved; using only reason or the earlier item detail makes multiple approval rows show the same parent command, so the user may authorize a different command than the UI displays. Read the request command first (and include file-change grantRoot/changes when no reason exists), with a repeated-subcommand test.

if (!Array.isArray(content)) {
return []
}
const blocks: NativeChatBlock[] = []

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P2] Translate image content parts in provider user-message echoes. turnInputFor sends image and localImage blocks, but this echo path retains only text; because an accepted submission aliases to the provider key, the echo then revises the optimistic row and replaces its complete mixed text/image body with text only. Map image/localImage back to image-ref blocks and test reconciliation of mixed content.

state: commandState(item)
}
}
if (item.type === 'reasoning') {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P2] Handle array-shaped reasoning content. Current app-server reasoning items expose summary/content as arrays, so readString returns null and the item is silently skipped. Join and bound the supported array elements into the status body, and add a real-wire-shape fixture so reasoning does not disappear from the journal.

@brennanb2025 brennanb2025 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Final cleanup pass on exact head 76cce58 found one additional close-boundary loss; pinned below. No branch changes or push made.

@@ -352,6 +349,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
this.sessions.delete(sessionId)
session.prompts.clear()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[P1] Flush coalesced text before gracefully closing a published session. closeSession calls translator.dispose directly, which cancels the 60 ms timer and clears dirty streams without emitting; only unexpected exit takes the ended path that flushes first. A normal closeAll or fence supersession during streaming therefore loses the last token burst from the durable journal. Add an explicit flush-before-dispose path for published sessions (while retaining discard semantics for failed acquisitions) and a close-before-window-expiry regression test.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Superseded: this stack part is fully contained in the consolidated big PR (#13438 for Codex, #13584 for Claude) per the two-big-PRs plan. History and review evidence preserved on this PR.

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