Repository navigation
Conversation
…the transcript A blocking `subagent` call mirrored every child event into one `tool_execution_update`. `parseJsonEvent` fires on each streamed delta, and each update carried a copy of the child's whole `messages` array. Only `message_end` changes that array, so every update between two of them was a byte-identical repeat, and the `json`/`rpc` modes write one JSONL line per event — the parent's event log became the transcript squared. Measured with a local rpc child streaming 40 turns x 150 deltas with 40 KB tool results: 4,992 MiB of JSONL, 6,327 updates, 82 distinct snapshots, 99.8% of the bytes inside `messages`, largest single line 1.63 MiB. That matches the benchmark logs (P95 4.28 GiB, max 11.7 GiB, 2.8 MB updates). Three bounds, each closing a different part of the hole: - The live projection carries no `messages`; it keeps only a short tail of the streamed text and tool output. The transcript is published once, in the final result. - Updates are coalesced to one per 100 ms (the bash tool's cadence). Status transitions still publish immediately, so a live list never misses a change, and a trailing update is flushed before the call returns. - The final result keeps the newest messages that fit 64 KiB. `activeText`, `sessionId` and `sessionFile` keep the outcome reachable. `toJsonEvent` also caps a single `tool_execution_update` at 256 KiB and replaces an oversized one with the tail of its text plus a marker. That is a generic backstop for any tool that re-sends accumulated state; the terminal event is never bounded. Same scenario after the change: 0.31 MiB, 3 updates, 0.06 MiB final result.
…ery field
Review follow-up. The first pass removed `messages` from live updates and
coalesced them, but three paths still carried data the bound did not cover:
- The live record was `{...record, ...}` with a few fields patched, so any
unbounded field passed through. A chain splices the previous step's whole
answer into the next step's task, and every update during that step repeated
it: 10 Hz x 500 KB is unbounded regardless of the transcript fix.
- The final record bounded only `messages`. A runner reporting a 300 KB final
answer put it in `activeText`, which several terminal events then echo.
- The wire backstop counted characters instead of serialized bytes, so a string
of control characters (six-byte escapes) still exceeded 256 KiB.
Live and final records are now explicit allowlists with per-field budgets, the
final `activeText` falls back to the full transcript so trimming can never render
as "(no output)", and the wire bound measures the serialized event and halves
the retained tail until it fits (`tool_execution_end` is still never bounded).
Emitter ownership is closed too: a consumer that throws is attributed to the
call instead of escaping a timer, late callbacks after return are dropped, and
`mapWithConcurrency` settles every worker before returning so a fail-fast
rejection cannot leave lanes publishing into a closed emitter.
Tests now cover the real path (a spawned `--mode rpc` child through the rpc
adapter, parser, orchestrator, agent loop and json projection) plus chain task
splicing, coalescing cadence, the truncation marker, the single-oversized-message
fallback, late callbacks, and consumer failures. 19 cases total.
…bounds Second review round. Three findings were real: - `boundLiveUpdate` could spin forever. At `kept.length === 1`, `slice(-0)` returns the whole string, so the halving loop never advanced. Truncating the envelope was also missing, so the loop could not converge at all once `toolCallId` alone exceeded the budget. - Status transitions bypassed the coalescing window. Each transition re-sent the whole roster, so a parallel batch was O(tasks^2): 600 tasks wrote 530 MiB of live payload in one call. Every update now shares the 100ms tick, the cadence the bash tool uses, and the final flush still guarantees the terminal state. Measured after the change: 200 tasks, < 60 publishes, < 128 KiB each, < 2 MiB total. - The roster itself was unbounded (`MAX_PARALLEL_TASKS` is 1000). Live records now stop at 48 KiB of serialized bytes and report `omittedResults`; the background lane merges by index so an omitted tail never erases known lanes. Also from review: `finalRecord` was still a spread, so unbounded identity/ path fields could reach the terminal details — it is now bounded too, records share one 256 KiB per-call budget, and `activeText` goes back to 50k characters so Ctrl+O does not silently shrink. The wire cap clips ids, measures serialized bytes, and the dispatch/flush error order keeps the real failure. Contract docs updated (`json.md`, `rpc.md`): oversized updates drop `args`/`details` and keep a text tail, stdout-only, extension handlers unaffected. Three existing tests asserted the old immediate-publish timing; they now wait for one tick, which is the new documented cadence, with the same assertions.
Third review round. Two regressions came from the previous commit: - `tailChars(value, max)` returned the whole value when `max <= 1` (`slice(-(max - 1))` is `slice(-0)` is the whole string). The final details share one per-call budget, so after the first records consume it the allowance drops to zero and every later record kept its full text: 200 tasks with 300 KB answers produced 48.94 MiB of details, and 1000 tasks extrapolate to ~1 GiB. A zero allowance now yields an empty value, and a field the budget rejects is dropped rather than kept anyway. - The final record was still a spread, so unbounded identity/path fields reached the details. It is now the same explicit allowlist as the live projection. `omittedResults` was written but never read: a 600-task batch displayed "174/174 complete" because renderers counted the truncated roster. The widget header, the "more" footer and `subagentListSignature` now count the real total, and the background lane drops the flag after index-merging (its roster is complete again, so keeping it would double-count). Also corrected the docs that still described the previous behaviour: status transitions are coalesced (a sub-100ms transition may not reach the wire), the final text cap is 50k, flush happens outside `finally`, and the final bound is 256 KiB of heavy fields plus O(tasks) metadata. New regressions: 60 tasks x 300 KB answers stay inside the per-call budget (the exhausted-allowance path), and a byte-bounded roster reports the real task count and republishes when the count changes.
…ring it A live roster stops at its byte budget, and `mergeResults` can only recover records the lane already saw — never the ones it has not been sent yet. Clearing `omittedResults` after merging therefore froze a 600-task lane at its published prefix: the background header reported "0/202 complete" for 600 tasks, and the shared multi-lane widget under-counted for the same reason. The count is now recomputed as `updateTotal - merged.length`, and the combined lane widget sums it across lanes. A 200-task background lane now reports "/200 complete" in every published header, with the rest shown as "not shown" rather than counted as failures. Also: the live projection had lost `cancelledPrompts` in the allowlist rewrite, `task`/`stderr` now honour the budget's rejection instead of keeping the full value, and the widget's failed count no longer treats an omitted record as one.
…reaches zero `rosterFields` returned only `results` once the merge recovered the whole roster, but the lane spreads the update's own details underneath it. The update's `omittedResults` therefore survived and counted records that had just been recovered: a complete 3-task roster followed by a "1 record + 2 omitted" update reported 5 tasks. The key is now always written, with `undefined` at zero, and the merged roster is capped at the current turn's total so an older, larger roster cannot leak in on a reused lane. Covered by a unit test for both directions: merge recovers everything (count cleared) and lane starts empty (count recomputed).
…budget Two fields were still rendered from a bare `tailChars` and never charged to the shared budget, so a batch with large control-character tool output produced details that ignored the bound: 1000 tasks reached 9.13 MiB. They now go through the same `take()` gate as every other heavy field (1000 tasks: 456 KiB). Failed records also claim their `stderr`/`errorMessage` before the progress text. Previously the budget was spent in record order, so later failures carried no reason at all and the lane summary printed "no error detail captured".
…ount CI caught two test problems, no product change: - The parallel-batch regression asserted a fixed publish count, but the batch spends one tick per 100ms of wall clock, so a 2-core runner legitimately publishes more than a laptop for the same 200 tasks (121 vs <60). It now bounds publishes by `elapsed / 100ms + slack`, which still catches the quadratic regression on any hardware, and a second case asserts that an instantly finished batch publishes at most a handful of times. That second case is clock-independent and is the direct guard for per-status publishing. - `settle` in the idle-TTL test waited on virtual time only, but the lane's notification awaits real file I/O that virtual time cannot advance, so the 500 fast iterations could finish before the read resolved. It now also holds a real deadline and hands control back to the event loop between ticks. The assertion is unchanged: the notification must arrive. The idle-TTL test already failed this way on `main` (CI run 119736908986), so the hardening is not a behaviour change.
|
Superseded by #318. That PR kept live progress on the persisted stream and bounded each producer's payload: per-field allowlists, a 100ms window, a 48 KiB roster cap and a 256 KiB terminal budget. It took the log from 4,992 MiB to 0.22 MiB, but the mechanism itself produced four review-blocking defects — a infinite loop, an O(tasks^2) publish path, a budget that restored full values once exhausted, and a lane counter that froze. All four were in the throttling machinery, not in the data it was throttling. #318 removes the need for it: and are declared non-durable frames, and / write facts only by default. Progress keeps its live audience through an explicit opt-in, which the subagent runner uses for its own child. The same reproduction now writes 0.008 MiB for a 20-, 40- or 80-turn child — independent of the child's size rather than proportional to it. |
|
Superseded by #318. That PR kept live progress on the persisted stream and bounded each producer's payload: per-field allowlists, a 100ms window, a 48 KiB roster cap and a 256 KiB terminal budget. It took the log from 4,992 MiB to 0.22 MiB, but the mechanism itself produced four review-blocking defects: a #318 removes the need for it: |
Review follow-ups on the frame/fact split. The classification and the transport filter were right; three things around them were not. The final details share one 128 KiB budget, so plain in-order allocation let early records spend it: 10 of 20 agents in a review batch lost `activeText` entirely, and because `messages` no longer exists on the projection the renderer fell through to `(no output)` while the heading still read "20/20 complete". At 200 tasks, 158 records also lost their task, so a large batch showed unnamed rows. Each record now reserves a small floor for its task, output, and failure diagnostics outside the shared budget, and the rest is divided across the records still to project (`remaining / recordsLeft`) instead of being claimed first-come. 20 x 12 KB and 200 x 5 KB batches keep every identity, keep output on every record that reported it, keep a reason on every failure, and stay bounded at 134 KiB / 190 KiB with the total independent of child output. The 256 KiB wire backstop was documented in `docs/json.md`, `docs/rpc.md` and the research note but never moved out of the closed #314, so an opted-in frame was unbounded (a 4 MiB update produced a 12 MiB line). `toJsonEvent` now bounds only `tool_execution_update`, measures serialized bytes after JSON escaping, keeps the text tail with `[live output truncated]`, drops `args`/`details`, and clips both identifiers so the shrink loop always converges. Terminal results are untouched, and in-process subscribers never pass through it. The opt-in had no entry point: `--include-live-events` was absent from `printHelp`, `RpcClient` had no typed way to request frames, and the RPC example plus its README snippets spawn `--mode rpc` directly and had silently stopped streaming. All four now request it explicitly, and the RPC event table marks the two frame types as opt-in rather than listing them as ordinary events. Also: export `sessionEventDurability` / `isEphemeralSessionEvent` and state the "every serialized transport filters on this" invariant where the next author looks, so a new host cannot reimplement it; count omitted live records in lane headlines and progress without inventing statuses for them; observe a background frame-delivery rejection and report it through the awaited tool result instead of leaving an unhandled promise; document that a tool update is a replacement snapshot; and drop the unrelated `generatedAt` churn in the provider manifest.
Review follow-ups on the frame/fact split. The classification and the transport filter were right; three things around them were not. The final details share one 128 KiB budget, so plain in-order allocation let early records spend it: 10 of 20 agents in a review batch lost `activeText` entirely, and because `messages` no longer exists on the projection the renderer fell through to `(no output)` while the heading still read "20/20 complete". At 200 tasks, 158 records also lost their task, so a large batch showed unnamed rows. Each record now reserves a small floor for its task, output, and failure diagnostics outside the shared budget, and the rest is divided across the records still to project (`remaining / recordsLeft`) instead of being claimed first-come. 20 x 12 KB and 200 x 5 KB batches keep every identity, keep output on every record that reported it, keep a reason on every failure, and stay bounded at 134 KiB / 190 KiB with the total independent of child output. The 256 KiB wire backstop was documented in `docs/json.md`, `docs/rpc.md` and the research note but never moved out of the closed #314, so an opted-in frame was unbounded (a 4 MiB update produced a 12 MiB line). `toJsonEvent` now bounds only `tool_execution_update`, measures serialized bytes after JSON escaping, keeps the text tail with `[live output truncated]`, drops `args`/`details`, and clips both identifiers so the shrink loop always converges. Terminal results are untouched, and in-process subscribers never pass through it. The opt-in had no entry point: `--include-live-events` was absent from `printHelp`, `RpcClient` had no typed way to request frames, and the RPC example plus its README snippets spawn `--mode rpc` directly and had silently stopped streaming. All four now request it explicitly, and the RPC event table marks the two frame types as opt-in rather than listing them as ordinary events. The record also stops repeating what a boundary only closes over. `turn_end` carries the assistant message and its tool results, `agent_end` carries every message of the run, and all of it had already travelled as its own `message_start`/`message_end` (a tool result also as `tool_execution_end`). Projected 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. They now carry `{ messageRole, toolResultCount }` and `{ willRetry, messageCount }`. `messageCount` is the length of that run's messages array and is documented as informational, not a checksum: the loop can emit more `message_end` events than it lists. It is not an exactly-once record — a tool result still travels on `tool_execution_end` plus its message pair, which the docs state rather than hide. The projection now owns the policy. `toJsonEvent(event, { includeLiveEvents })` returns `undefined` for a frame the caller did not opt into, and both transports write `if (projected)` instead of filtering first. Two call-site filters were tolerable; a third pair is how one policy silently becomes two, so `check:session-event-transports` fails the build when `toJsonEvent` is called outside a known transport, when a transport's own call does not pass `{ includeLiveEvents }` as its second argument, or when the classification is referenced outside the projection. It is a "forgot to classify" guard, not a security boundary, and says so. Also: export `sessionEventDurability` / `isEphemeralSessionEvent` and state the "every serialized transport filters on this" invariant where the next author looks; count omitted live records in lane headlines and progress without inventing statuses for them; observe a background frame-delivery rejection and report it through the awaited tool result instead of leaving an unhandled promise; document that a tool update is a replacement snapshot; and drop the unrelated `generatedAt` churn in the provider manifest. Tests that claimed to prove more than they did were tightened. The live-channel regression accepted any `activeText` containing a token, which a durable `message_end` also provides, and the fixture emitted frames regardless of the flag; it now asserts a marker that exists only in a progress frame, the fixture honours `--include-live-events` like the CLI and can pace itself past the 100ms repaint window, and removing the flag from the subagent runner fails the test deterministically. The projection test measures a local tool instead of a child process that never spawned, and pins the marginal bytes per added character instead of a loose multiple.
Problem
A blocking
subagentcall re-sent the child's entire transcript on every child event, and every event is one persisted line.parseJsonEventcallsonUpdatefor each streamed delta and each tool chunk;execute.emitthen builtdetails.results[].messages = [...record.messages]; the Agent Loop turns that into atool_execution_update; and--mode json/--mode rpcwrite one JSONL line per event.messagesonly changes atmessage_end, so every update in between repeats an identical array byte for byte.Local reproduction (offline, no benchmark harness, no network)
docs/research/2026-10-27-subagent-live-update-amplification/— a fake--mode rpcchild streams turns/deltas/tool results; the parent is a realAgentSessionwith the realstep-subagentextension, and the log is the realtoJsonEventoutput. Only the model (faux provider) and the child's token stream are test doubles.Before: 99.8% of the bytes sit inside
messages, from 82 distinct snapshots emitted 6,327 times. That matches the benchmark logs (single files up to 11.7 GiB, P95 4.28 GiB, 2.8 MB updates).Change
Four bounds, each closing a different part of the hole:
messagesnever appears in a live update; a chain's{previous}-expanded task is clipped.omittedResults) becauseMAX_PARALLEL_TASKSis 1000; the background lane recomputes the omitted count from each update's own total so the header always reports the real task count.activeTextfalls back to the full transcript so trimming can never render(no output). Model-facingcontentis untouched and still goes through the existing 50 KiB retention.modes/json-event.tsadds a generic backstop: onetool_execution_updateover 256 KiB of serialized bytes keeps only the tail of its text plus a marker. It measures after JSON escaping (a control character is six bytes), clips a pathological id so the loop always converges, and never applies totool_execution_end.Same treatment competitors already apply: ephemeral/durable event typing, a transient blacklist at the storage boundary, history updates only at
message_end/tool_execution_end, and a child transcript that lives in its own session rather than inside the parent's progress events.Blast radius
execute.emit; the lane widget updates at 10 Hz and its header counts every task.activeTextthen the newest assistant text; both are retained (50k characters, the renderer's own truncation point).StepSubagentDetailsgains optionalomittedResultsandmessagesTruncated.docs/json.md/docs/rpc.mddocument the oversized-update shape (stdout only; extension handlers see the original event).Validation
packages/coding-agent: 5002 passed / 58 skipped. Only stable failures are 6completion-check-clicases that fail identically on pristinemainhere (git worktree precondition).subagent-invocationis a known intermittent CLI-startup timeout; it passes standalone.subagent-live-update-rpc.test.ts(real spawned rpc child),subagent-live-update-budget.test.ts,json-event-live-update-budget.test.ts, plus roster-count regressions insubagent-list-widget.test.tsandsubagent-background-tui.test.ts. 44 cases across the five files./600 completethroughout.pnpm run checkpasses (biome, boundary checks,tsgo --noEmit, browser smoke);git diff --checkclean.Deliberately not in this PR
tool_execution_endcarries the complete result.