Repository navigation
feat(coding-agent): write a session record of facts, not replayed progress - #318
Merged
Merged
Conversation
liuedcson
requested review from
MelodyVAR,
Uking-xxx and
ZouR-Ma
as code owners
October 28, 2026 03:50
liuedcson
force-pushed
the
feat/event-durability
branch
from
October 28, 2026 07:13
4b28c3e to
165c481
Compare
…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
force-pushed
the
feat/event-durability
branch
from
October 28, 2026 07:20
165c481 to
b2a9146
Compare
This was referenced Oct 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cause
A harness redirects the standalone CLI's
--mode jsonstdout tostepcode.jsonl, so that file is the session event stream. Every event was written to it, includingtool_execution_update— a cumulative snapshot of a subagent's in-flight progress that the parent rebuilt on every streamed child delta.messageschanges only atmessage_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.tsdeclaresmessage_updateandtool_execution_updateas 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 whybash_execution_updatestays a fact — it is a delta, and no other event restates a user-issued command's output.Enforcement in one place.
toJsonEvent(event, { includeLiveEvents })returnsundefinedfor a frame the caller did not opt into, projects the boundary events below, and bounds an opted-in frame. Transports writeif (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.--mode jsonmessage_end--mode rpcsession.subscribe--include-live-eventsis the single opt-in, reachable from the CLI (and--help), fromRpcClientOptions, 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_endcarried the assistant message and its tool results, andagent_endcarried every message of the run, all of it already written asmessage_start/message_end(a tool result also astool_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 }.messageCountis informational, not a truncation checksum: the loop can emit moremessage_endevents than it lists (a resampled tool-call leak, a throwingshouldStopAfterTurn). And the record is not exactly-once — a tool result still travels ontool_execution_endplus its own message pair.docs/json.mdstates both rather than implying more than the code does.The subagent result is a summary.
messagesis gone fromdetails; the child session owns the transcript, referenced bysessionId/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.tsprojects records by explicit allowlist (nomessages, short tails, a{previous}-expanded task clipped at both ends) and caps one roster by bytes with the tail counted inomittedResults, 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], droppingargs/details, and clipping both identifiers so the shrink loop converges. Terminal events are never bounded.agent-coreindependently 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 jsondefault:The number does not move when the child quadruples: the bound is structural, not proportional. With
--include-live-eventsthe 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 rpclosemessage_update/tool_execution_update, andturn_end/agent_endno 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 onmessage_end. Trace reconstruction is unaffected. Documented indocs/json.md,docs/rpc.md.sessionId/sessionFile.contentand the chain's{previous}are unchanged; budgets apply only to what leaves the module.StepSubagentDetails.results[].messagesremoved, optionalomittedResultsadded.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,
omittedResultsandrosterFieldsare stopgaps with an explicit removal condition: when a live frame no longer carries a roster, all three are deleted. That follow-up is indocs/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_progressevent 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 areundefinedunless 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 jsonwrites no frames, restores them under the flag, and writes the boundary shapes.subagent-live-channel.test.ts: a real spawned--mode rpcchild; the parent receives a marker that exists only in a progress frame, no frame carries a transcript, and the final result has nomessages. The fixture honours--include-live-eventslike 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.mjsruns the transport guard's self-test, which pins both its detections and its documented non-guarantees.Full suites:
packages/agent-core542 passed / 1 skipped;packages/coding-agent5,025 passed / 58 skipped, with only the sixtest/completion-check-cli.test.tscases failing — they fail identically on pristinemainhere (local git-worktree precondition).pnpm run checkclean;git diff --checkclean.