Skip to content

feat(coding-agent): write a session record of facts, not replayed progress - #318

Merged
liuedcson merged 2 commits into
mainfrom
feat/event-durability
Oct 28, 2026
Merged

liuedcson merged 2 commits into
mainfrom
feat/event-durability

Conversation

@liuedcson

@liuedcson liuedcson commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Cause

A harness redirects the standalone CLI's --mode json stdout to stepcode.jsonl, so that file is the session event stream. Every event was written to it, including tool_execution_update — a cumulative snapshot of a subagent's in-flight progress that the parent rebuilt on every streamed child delta. messages changes only at message_end, so hundreds of consecutive lines carried a byte-identical transcript array.

A 40-turn, 150-delta child had 82 distinct snapshots and wrote 6,327 of them: 4,992 MiB of JSONL, 99.8% of it inside messages.

The defect was not that one event was too large. It was that one stream served two audiences — a UI refresh signal and a fact worth keeping — so every frame had to be small, and there was one per token. So events are classified, and the serialized transports write the facts.

Change

Classification. core/session-event-durability.ts declares message_update and tool_execution_update as frames: each is a snapshot whose successor (message_end / tool_execution_end) carries the value, so a reader that skips them reconstructs the run exactly. It is an allowlist of frames, so a new event type is durable by default. The successor test is also why bash_execution_update stays a fact — it is a delta, and no other event restates a user-issued command's output.

Enforcement in one place. toJsonEvent(event, { includeLiveEvents }) returns undefined for a frame the caller did not opt into, projects the boundary events below, and bounds an opted-in frame. Transports write if (projected) instead of filtering first: two decision points drift apart, and a third is how one policy silently becomes two. A new build gate, check:session-event-transports, fails when the projection is called outside a known transport, when a transport's own call does not pass { includeLiveEvents }, or when the classification is referenced outside the projection. It is a "forgot to classify" guard, not a security boundary, and documents what it does not catch.

transport writes why
--mode json facts a machine-readable record of one run; traces read message_end
--mode rpc facts a control channel, but its stdout redirects just as easily
in-process session.subscribe every event an in-process subscriber is a live consumer by construction

--include-live-events is the single opt-in, reachable from the CLI (and --help), from RpcClientOptions, and from the shipped examples. The subagent runner passes it to its child because that child's stdout is a pipe it reads frame by frame — the live lane list is kept, not traded away.

A boundary stops repeating what it closes over. turn_end carried the assistant message and its tool results, and agent_end carried every message of the run, all of it already written as message_start/message_end (a tool result also as tool_execution_end). Measured both ways on the same runs, those two events were 40.2% of a 100-task record (713,553 → 426,615 bytes) and 51.6% of a 20-task one whose answer is 40,000 characters (278,463 → 134,739 bytes). They now carry { messageRole, toolResultCount } and { willRetry, messageCount }.

messageCount is informational, not a truncation checksum: the loop can emit more message_end events than it lists (a resampled tool-call leak, a throwing shouldStopAfterTurn). And the record is not exactly-once — a tool result still travels on tool_execution_end plus its own message pair. docs/json.md states both rather than implying more than the code does.

The subagent result is a summary. messages is gone from details; the child session owns the transcript, referenced by sessionId/sessionFile. The 128 KiB final-field budget reserves a small floor for every record's task, output and failure reason, then divides what is left across the records still to project, so an early child's long answer cannot leave a later record unnamed, blank or without a reason. 20 agents × 12 KB keep all 20 identities and all 20 outputs; 200 × 5 KB keep every failure reason; the total does not grow with child output.

A live frame stays cheap. features/subagent/projection.ts projects records by explicit allowlist (no messages, short tails, a {previous}-expanded task clipped at both ends) and caps one roster by bytes with the tail counted in omittedResults, so a renderer still reports the real task count. Frames coalesce on a 100ms repaint budget. A written frame is capped at 256 KiB of serialized bytes — measured after JSON escaping, since a control character is a six-byte escape — keeping the text tail plus [live output truncated], dropping args/details, and clipping both identifiers so the shrink loop converges. Terminal events are never bounded.

agent-core independently keeps at most one frame in flight per tool, so a slow consumer cannot make the loop hold one promise per token.

Result

Same offline reproduction, --mode json default:

child before after frames dropped
20 turns × 60 deltas, 20 KB tool results 268.05 MiB 0.005 MiB 10
40 turns × 150 deltas, 40 KB tool results 4,992.61 MiB 0.005 MiB 10
80 turns × 300 deltas, 60 KB tool results — 0.005 MiB 13

The number does not move when the child quadruples: the bound is structural, not proportional. With --include-live-events the 40-turn run writes 0.010 MiB, of which 0.005 MiB is frames, and no frame carries a transcript.

Blast radius

  • --mode json / --mode rpc lose message_update / tool_execution_update, and turn_end / agent_end no longer carry the messages they close over. Scripts that counted deltas or read those payloads need --include-live-events; the boundary values were always available on message_end. Trace reconstruction is unaffected. Documented in docs/json.md, docs/rpc.md.
  • The TUI keeps its live list (in-process subscription). Frames arrive on a 100ms repaint budget, so a state lasting under 100ms may not be painted; three existing tests asserted the old same-tick timing and now wait one budget, with identical assertions.
  • Subagent cards no longer show the child transcript; it is reachable through sessionId / sessionFile.
  • Model-visible content and the chain's {previous} are unchanged; budgets apply only to what leaves the module.
  • Contract change: StepSubagentDetails.results[].messages removed, optional omittedResults added.
  • No migration, no configuration, one new optional flag.

What this deliberately does not solve

Progress still travels as a session event. Excluding frames from storage removes the amplification, but the roster byte budget, omittedResults and rosterFields are stopgaps with an explicit removal condition: when a live frame no longer carries a roster, all three are deleted. That follow-up is in docs/research/2026-10-27-subagent-live-update-amplification/README.md §8, together with session-file compaction, stdout backpressure, cross-record fairness for the remaining duplicate copies, and type-level durability.

A dedicated subagent_progress event type is listed there as optional, not required now that frames are excluded from storage — the marginal gain is naming, the cost is a new type plus every consumer.

Validation

  • session-event-durability.test.ts: each type's classification, the allowlist bias (an unknown type is durable), and that every frame has a durable successor.
  • json-event-projection.test.ts: frames are undefined unless opted in; the boundary projection; a boundary with nothing to close over; a real run through a local tool asserting no frame, no boundary payload, and per-payload occurrence counts; a constant marginal cost per added character, so a repeated transcript fails.
  • json-mode-durability.test.ts: the real CLI under --mode json writes no frames, restores them under the flag, and writes the boundary shapes.
  • subagent-live-channel.test.ts: a real spawned --mode rpc child; the parent receives a marker that exists only in a progress frame, no frame carries a transcript, and the final result has no messages. The fixture honours --include-live-events like the CLI, so dropping the flag from the runner fails the test deterministically (verified).
  • json-event-backstop.test.ts: the 256 KiB cap under ASCII, control characters, quotes, CJK, a lone surrogate and emoji; a pathological id; terminal results never bounded.
  • agent-core/test/agent.test.ts: frames separated by a yield arrive in order; a burst may coalesce but never loses or reorders the newest frame; a listener failure surfaces through the awaited result and the prompt still settles.
  • scripts/guard-self-tests.test.mjs runs the transport guard's self-test, which pins both its detections and its documented non-guarantees.

Full suites: packages/agent-core 542 passed / 1 skipped; packages/coding-agent 5,025 passed / 58 skipped, with only the six test/completion-check-cli.test.ts cases failing — they fail identically on pristine main here (local git-worktree precondition). pnpm run check clean; git diff --check clean.

…ery one

A tool reports progress through `onUpdate`, and the loop forwarded each call by
pushing the emit promise into an array it awaited only once the tool returned. A
tool that streams for a minute therefore held every frame — and every consumer
slow enough to fall behind — for the whole call. That queue is a second,
transport-independent way for a large live payload to become an OOM, so it is
bounded here rather than at a caller.

At most one frame is now in flight per tool: a frame arriving during delivery
replaces the pending one, so a fast producer cannot queue a promise per token and
the newest snapshot is what the consumer sees. The drain that the tool result
awaits delivers whatever is pending before returning the result, so a consumer
sees progress and then completion, never the reverse.

A listener that throws does not escape as an unhandled rejection and is not
swallowed: the failure is retained and reported through the awaited path, and a
tool's own error keeps its message while a different listener failure is appended
to it. A listener that throws `undefined` is still recorded as a failure.

`AgentToolUpdateCallback` now states the contract it always had — an update is a
replacement snapshot, intermediate frames may be coalesced, and anything that
must survive belongs in the returned result.

Tests: frames separated by a yield all arrive in order; a synchronous burst may
coalesce but never loses or reorders the newest frame; a listener failure after a
successful drain still surfaces and lets the prompt settle.
…gress

A harness redirects the standalone CLI's `--mode json` stdout to
`stepcode.jsonl`, so that file *is* the session event stream. Every event was
written to it, including `tool_execution_update` — a cumulative snapshot of a
subagent's in-flight progress that the parent rebuilt on every streamed child
delta. `messages` changes only at `message_end`, so hundreds of consecutive lines
carried a byte-identical transcript array: a 40-turn, 150-delta child had 82
distinct snapshots and wrote 6,327 of them, 4,992 MiB of JSONL with 99.8% of it
inside `messages`.

The defect was not that one event was too large. It was that one stream served two
audiences — a UI refresh signal and a fact worth keeping — so the producer had to
keep every frame small, and there were one per token. Events are therefore
classified, and the serialized transports write the facts.

**Classification.** `core/session-event-durability.ts` declares
`message_update` and `tool_execution_update` as frames: each is a snapshot whose
successor (`message_end` / `tool_execution_end`) carries the value, so a reader
that skips them reconstructs the run exactly. It is an allowlist of frames, so an
event added later is durable by default. The successor test is also why
`bash_execution_update` stays a fact — it is a delta, and no other event restates
a user-issued command's output.

**Enforcement in one place.** `toJsonEvent(event, { includeLiveEvents })` returns
`undefined` for a frame the caller did not opt into, projects the boundary events
below, and bounds an opted-in `tool_execution_update`. Transports write
`if (projected)` instead of filtering first: two decision points drift apart, and
a third is how one policy silently becomes two. `check:session-event-transports`
fails the build when the projection is called outside a known transport, when a
transport's own call does not pass `{ includeLiveEvents }`, or when the
classification is referenced outside the projection. It is a "forgot to classify"
guard, not a security boundary, and says so.

| transport | writes | why |
| --- | --- | --- |
| `--mode json` | facts | a machine-readable record of one run; traces read `message_end` |
| `--mode rpc` | facts | a control channel, but its stdout redirects just as easily |
| in-process `session.subscribe` | every event | an in-process subscriber is a live consumer by construction |

`--include-live-events` is the single opt-in, reachable from the CLI, from
`RpcClientOptions`, and from the shipped examples. The subagent runner passes it
to its child because that child's stdout is a pipe it reads frame by frame — the
live lane list is kept, not traded away.

**A boundary stops repeating what it closes over.** `turn_end` carried the
assistant message and its tool results, and `agent_end` carried every message of
the run, all of it already written as `message_start`/`message_end` (a tool result
also as `tool_execution_end`). Measured both ways on the same runs, those two
events were 40.2% of a 100-task record (713,553 -> 426,615 bytes) and 51.6% of a
20-task one whose answer is 40,000 characters (278,463 -> 134,739 bytes). They now
carry `{ messageRole, toolResultCount }` and `{ willRetry, messageCount }`:
the boundary says where the turn and the run ended, not what they contained.
`messageCount` is informational — the loop can emit more `message_end` events than
it lists, so it is not a truncation checksum. The record is not exactly-once: a
tool result still travels on `tool_execution_end` plus its own message pair, which
`docs/json.md` states rather than hides.

**The subagent result is a summary.** `messages` is gone from `details` — the
child session owns the transcript, referenced by `sessionId`/`sessionFile`. The
128 KiB final-field budget reserves a small floor for every record's task, output
and failure reason, then divides what is left across the records still to project,
so an early child's long answer cannot leave a later record unnamed, blank or
without a reason: 20 agents x 12 KB keep all 20 identities and all 20 outputs,
200 x 5 KB keep every failure reason, and the total does not grow with child
output.

**A live frame stays cheap.** `features/subagent/projection.ts` projects a record
by explicit allowlist (no `messages`, short tails, a `{previous}`-expanded task
clipped at both ends) and caps one roster by bytes with the tail counted in
`omittedResults`, so a renderer still reports the real task count. Frames are
coalesced on a 100ms repaint budget, and `agent-core` keeps at most one in flight
per tool. A written frame is further capped at 256 KiB of serialized bytes
(measured after JSON escaping, since a control character is a six-byte escape):
the text tail plus `[live output truncated]`, with `args`/`details` dropped and
both identifiers clipped so the shrink loop converges. Terminal events are never
bounded.

Same offline reproduction, `--mode json` default: 268.05 MiB -> **0.005 MiB** for
the 20-turn child and 4,992.61 MiB -> **0.005 MiB** for the 40-turn one. The number
does not move when the child quadruples; the bound is structural. With
`--include-live-events` the 40-turn run writes 0.010 MiB and no frame carries a
transcript.

Blast radius: `--mode json` / `--mode rpc` lose the frames and the two boundary
payloads (their values remain on `message_end` / `tool_execution_end`); a TUI state
that lasts under 100ms may not be painted; `StepSubagentDetails.results[].messages`
is removed and `omittedResults` added. No migration, no configuration, one new
optional flag. Documented in `docs/json.md`, `docs/rpc.md`,
`docs/tool-output-retention.md` and the research note.

Live progress still rides the session-event stream: excluding frames from storage
removes the amplification, but the roster byte budget, `omittedResults` and
`rosterFields` are stopgaps with a stated removal condition, recorded with
session-file compaction, stdout backpressure and type-level durability in
`docs/research/2026-10-27-subagent-live-update-amplification/README.md` §8.

Tests pin the properties rather than the implementation: the classification and
its allowlist bias; frames absent by default and restored under the flag, on a
real CLI run and on a real spawned rpc child that asserts a progress-only marker
reaches the parent; the boundary projection; per-payload occurrence counts and a
constant marginal cost per added character, so a repeated transcript fails; the
wire cap across ASCII, control characters, quotes, CJK, a lone surrogate and
emoji; and the guard's own detections and non-guarantees.
@liuedcson
liuedcson force-pushed the feat/event-durability branch from 165c481 to b2a9146 Compare October 28, 2026 07:20
@liuedcson liuedcson changed the title feat(coding-agent): separate live frames from persisted session facts feat(coding-agent): write a session record of facts, not replayed progress Oct 28, 2026
@liuedcson
liuedcson merged commit 7487124 into main Oct 28, 2026
2 checks passed
@liuedcson
liuedcson deleted the feat/event-durability branch October 28, 2026 08:02
@liuedcson
liuedcson restored the feat/event-durability branch October 31, 2026 03:42
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