Skip to content

fix(#288): derive-and-drop sub-agent state (stop retaining raw entries — 85% of heap)#322

Merged
Juliusolsson05 merged 2 commits into
mainfrom
fix/288-subagent-derive-and-drop
Jun 21, 2026
Merged

fix(#288): derive-and-drop sub-agent state (stop retaining raw entries — 85% of heap)#322
Juliusolsson05 merged 2 commits into
mainfrom
fix/288-subagent-derive-and-drop

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Problem (dominator evidence)

SubAgentWatcher retained up to 500 fully-parsed sub-agent JSONL entries per agent in entriesByAgent: Map<string, RawEntry[]>. A dominator-tree heap analysis (scripts/analyze-heapsnapshot.mjs --owners) proved that map was ~85% of a 227 MB main-process heap — each retained entry pins its full multi-MB tool-result / Read body via entry → message.content → huge string.

Prior fixes only shrank the retained entries: PR #300 capped the count at 500; PR #320/#321 added per-field byte clamps (truncateEntryBodies). None asked why we retained entries at all. We didn't need to — buildSubAgentState was a pure left-to-right fold over the entries, re-run on every emit, so the only reason to keep them was to re-fold them.

Fix: derive-and-drop accumulator

Added to subagentState.ts (next to the fold it mirrors):

  • SubAgentAccumulatorstartedAt, lastActivityAt, turnCount, totalToolUses, currentActivity, a bounded toolCalls ring (cap 60), openToolCallIds: Set, resolvedToolCallIds: Set.
  • createAccumulator()
  • accumulateSubAgentEntry(acc, entry) — the per-entry step of buildSubAgentState's loop, run as each line streams in.
  • buildSubAgentStateFromAccumulator(acc, toolUseId, agentId, meta, done, error) — the post-loop tail; yields the SAME SubAgentState the array builder did.

Memory is now O(open tool calls), not O(transcript length). This mirrors the Codex twin (codexSubagentState.ts, PR #317).

The 6 invariants (an adversarial review derived them)

  1. openToolCallIds is a standalone Set, persisted independently of the ring — a tool_result flips status / decrements the open count even if its display object was already evicted.
  2. Ring cap = 60. Measured max simultaneously-open tool calls across 318 transcripts = 8 → 7× headroom (ring ≥ max tool_usetool_result gap or a status flip is lost from the ring). Past 60 we evict the oldest (front) but keep its id in openToolCallIds if unresolved.
  3. turnCount / droppedToolCalls are monotonic fold counters, never derived from ring contents. turnCount++ per assistant entry; a true totalToolUses is kept; droppedToolCalls = max(0, totalToolUses − SUBAGENT_TOOL_CALLS_MAX).
  4. currentActivity is literal last-write-wins per block in arrival order: running <tool> on tool_use, cleared to null on tool_result, thinking on a thinking block. Not reinterpreted as "last open call."
  5. startedAt = first valid timestamp (set once); lastActivityAt = last valid timestamp (last-write-wins, NOT Math.max — 21 out-of-order cases in real data; matches the original plain assignment).
  6. Terminal done/error + status/currentActivity gating computed at BUILD time from done/error (from parentResult), NOT stored in the accumulator. emit() reads the live accumulator each call so a late parentResult flip still re-renders.

Wiring (SubAgentWatcher.ts)

  • entriesByAgent: Map<string, RawEntry[]>accByAgent: Map<string, SubAgentAccumulator>.
  • readAppended folds each parsed line via accumulateSubAgentEntry then lets it die — no truncate, no intern, no array, no 500-cap splice; nothing entry-shaped survives a loop iteration.
  • emit() builds via buildSubAgentStateFromAccumulator.
  • stop() clears accByAgent.

What becomes dead code (removed from SubAgentWatcher.ts)

truncateEntryBodies + clampDeep + clampString (the #320/#321 byte-cap machinery, only ever used at the retention site), the internEntryFields/makeStringPool usage + the intern field, and the MAX_ENTRY_FIELD_BYTES / MAX_RETAINED_ENTRIES_PER_AGENT / ENTRY_CLAMP_DEPTH constants.

internEntryFields / makeStringPool stay in internEntry.ts — still used by jsonlCoalescer.ts and historyLoader.ts (untouched).

Conscious behavior delta

Removing the 500-entry cap makes droppedToolCalls / turnCount true running totals rather than relative-to-a-500-tail — higher for very long agents (6/318 transcripts exceed 60 tool calls; max 79). That is more correct: the displayed timeline is still capped to SUBAGENT_TOOL_CALLS_MAX, only the "+N earlier" count grows to the real total.

Renderer contract

The SubAgentState / SubAgentToolCall IPC contract is unchanged.

Tests

SubAgentWatcher.test.ts updated: the 521-tool_use fixture now asserts droppedToolCalls === 481 (true total 521 − 40 visible) instead of 460 (500 retained − 40), with a comment explaining the delta. Passes. npx tsc -p tsconfig.node.json shows zero new errors in the changed files (the one pre-existing codexSubagentState.ts TS18047 is on main and untouched here).

Verification note

Real before/after heap numbers need a fresh --owners snapshot after a rebuild — the dominator chain that pinned entriesByAgent no longer exists, so the 85% should collapse to the small bounded accumulator footprint.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits June 21, 2026 19:29
…es in SubAgentWatcher (85% of heap)

SubAgentWatcher retained up to 500 fully-parsed sub-agent JSONL entries per
agent (`entriesByAgent: Map<string, RawEntry[]>`). A dominator-tree heap
analysis (scripts/analyze-heapsnapshot.mjs --owners) proved that map was ~85%
of a 227 MB main-process heap, because each retained entry pins its full
multi-MB tool-result/Read body. Prior fixes only SHRANK retained entries
(PR #300 count cap of 500; PR #320/#321 per-field byte clamps); none questioned
why we retained entries at all. We didn't need to: buildSubAgentState was a pure
left-to-right fold over the entries, re-run on every emit, so the only reason to
keep them was to re-fold them.

ACCUMULATOR (added to subagentState.ts, next to the logic it mirrors): a small
per-agent SubAgentAccumulator folded incrementally as each line streams in, then
the raw entry is dropped. createAccumulator() / accumulateSubAgentEntry(acc,
entry) (the per-entry step of buildSubAgentState's loop) /
buildSubAgentStateFromAccumulator(acc, …) (the post-loop tail). Memory is now
O(open tool calls), not O(transcript length). Mirrors the Codex twin
(codexSubagentState.ts, PR #317).

The 6 invariants an adversarial review derived, and how each is honored:
1. openToolCallIds is a STANDALONE Set, persisted independently of the toolCalls
   ring, so a tool_result flips status / decrements the open count even if the
   call's display object was already evicted from the ring.
2. Ring cap = 60. Measured max simultaneously-open tool calls across 318
   transcripts = 8; 60 is 7× headroom (ring ≥ max tool_use→tool_result gap or a
   status flip is lost FROM THE RING). Past 60 we evict the OLDEST (front) but
   keep its id in openToolCallIds if still unresolved.
3. turnCount and droppedToolCalls are MONOTONIC fold counters, never derived
   from ring contents. turnCount ++ per assistant entry. A true totalToolUses is
   maintained; droppedToolCalls = max(0, totalToolUses - SUBAGENT_TOOL_CALLS_MAX),
   matching the array builder's total-minus-40.
4. currentActivity is literal LAST-WRITE-WINS per block in arrival order: set to
   `running <tool>` on tool_use, cleared to null on tool_result, set to
   `thinking` on a thinking block. Not reinterpreted as "last open call".
5. startedAt = FIRST valid timestamp (set once); lastActivityAt = LAST valid
   timestamp (last-write-wins, NOT Math.max — 21 out-of-order cases in real data
   match the original builder's plain assignment).
6. Terminal done/error and the status/currentActivity gating stay computed at
   BUILD time from done/error (which come from parentResult), NOT stored in the
   accumulator. emit() reads the live accumulator each call so a late
   parentResult flip still re-renders.

WIRING (SubAgentWatcher.ts): entriesByAgent → accByAgent. readAppended folds
each parsed line via accumulateSubAgentEntry then lets it die — no truncate, no
intern, no array, no 500-cap splice. emit() builds via
buildSubAgentStateFromAccumulator. stop() clears accByAgent.

DEAD CODE REMOVED from SubAgentWatcher.ts: truncateEntryBodies + clampDeep +
clampString (the #320/#321 byte-cap machinery, only ever used at the retention
site) and the internEntryFields/makeStringPool usage + the intern field +
MAX_ENTRY_FIELD_BYTES / MAX_RETAINED_ENTRIES_PER_AGENT / ENTRY_CLAMP_DEPTH
constants. internEntryFields/makeStringPool STAY in internEntry.ts (still used by
jsonlCoalescer.ts and historyLoader.ts — untouched).

CONSCIOUS BEHAVIOR DELTA: removing the 500-entry cap makes droppedToolCalls and
turnCount TRUE running totals rather than relative-to-a-500-tail — higher for
very long agents (6/318 transcripts exceed 60 tool calls; max 79). That is MORE
correct: the displayed timeline is still capped to SUBAGENT_TOOL_CALLS_MAX, only
the "+N earlier" count grows to the real total. The SubAgentState/SubAgentToolCall
IPC contract is unchanged.

TEST: SubAgentWatcher.test.ts updated — the 521-tool_use fixture now asserts
droppedToolCalls === 481 (true total 521 - 40 visible) instead of 460
(500 retained - 40), with a comment explaining the delta. Passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nd resolvedToolCallIds — review fixes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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