Skip to content

fix(#288): cap sub-agent entry body retention (88% of heap) + dominator --owners analysis#320

Merged
Juliusolsson05 merged 2 commits into
mainfrom
fix/288-subagent-entry-body-cap
Jun 21, 2026
Merged

fix(#288): cap sub-agent entry body retention (88% of heap) + dominator --owners analysis#320
Juliusolsson05 merged 2 commits into
mainfrom
fix/288-subagent-entry-body-cap

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Root cause (dominator-tree heap analysis)

A dominator-tree retained-size analysis of the main-process heap snapshot proved the leak definitively: 7 live SubAgentWatcher instances retained 263 MB = 88% of the entire reachable main-process heap, via the chain

SubAgentWatcher.entriesByAgent (Map<agentId, RawEntry[]>)
  → entry → message.content → huge string

PR #300 capped the entry COUNT (MAX_RETAINED_ENTRIES_PER_AGENT=500) but not the per-entry BYTE SIZE. So each of the 500 retained entries per agent still pinned its FULL body — Read tool outputs, tool_result bodies, the skills-list attachment, [Truncated: PARTIAL view] system-reminders. 500 entries × multi-MB bodies × several agents × 7 watchers = 263 MB, and churning those multi-MB strings on every 600 ms poll fragmented V8 into ~1.5 GB RSS.

Part 1 — the fix: truncate-at-retention (zero visible change)

The mini-feed is a liveness affordance, not a transcript archive. buildSubAgentState only reads structure (block types, tool names, tool_use_id, is_error, timestamps) plus an ≤80-char headline per tool call — it never renders a full body. So clamping the large free-text payloads at retention to a cap well above what the renderer shows is invisible to the user while removing the bytes that caused the leak.

  • SubAgentWatcher.ts: new truncateEntryBodies(entry, 8KB) clamps the large free-text fields (text/thinking text, tool_result content/output in both string and array-of-{text} shapes, large tool_use input string values, skill attachment.content, system-reminder text) before pushing into entriesByAgent, at the single parse/push site. All structural fields buildSubAgentState reads are preserved. Each clamp appends an honest … [truncated N bytes — full body on disk] marker. 8 KB is ~100× the 80-char headline the renderer derives from any field, so no rendered value can ever be clipped; the durable full transcript stays on disk in agent-<id>.jsonl.
  • codexSubagentState.ts: dropped childEntriesByAgentId entirely. PR fix(#288): bound CodexSubAgentTracker retained entries + clear on stop (Codex twin of #300) #317 kept a bounded 500-entry raw-entry TAIL "for a future mini-feed," but nothing on the emit path (or anywhere) read it — write-only dead weight that still pinned every entry's full body in main-process heap. The derived SubAgentState in childStateByAgentId already carries everything emit() ships, so removing the tail is invisible. The fix(#288): bound CodexSubAgentTracker retained entries + clear on stop (Codex twin of #300) #317 full-read derivation logic (correct startedAt/turnCount/childMeta/tool counts) and the uncapped-count dirty signal are untouched.

Part 2 — the tool that proved it: --owners dominator mode

scripts/analyze-heapsnapshot.mjs gains a --owners mode. The default SUMMARY mode ranks by self_size — useless when the leak is a small container (a Map, a class instance) transitively pinning a huge subgraph. --owners builds the dominator tree (Cooper-Harvey-Kennedy) of the whole reachable graph, computes retained size per node ("how much memory is freed if this object is removed"), and prints (a) top individual retainers by retained size with their type:name and (b) retained size aggregated by constructor/owner name. Needs node --max-old-space-size=12288 for large snapshots.

Output on the #288 snapshot, naming the leak no self_size ranking could surface:

RETAINED by CONSTRUCTOR/owner name (sum over instances) — top:
    299.0 MiB 100.0%  x     1  (anon)
    265.6 MiB  88.8%  x   217  Map
    263.2 MiB  88.0%  x     7  SubAgentWatcher   ← the leak
TOP INDIVIDUAL RETAINERS (retained = freed if this node is removed):
    117.6 MiB  39.3%  object:SubAgentWatcher  [self 88 B]
    102.1 MiB  34.2%  object:SubAgentWatcher  [self 88 B]
     43.5 MiB  14.6%  object:SubAgentWatcher  [self 88 B]

Verification

  • npx tsc -p tsconfig.node.json → zero new errors in the touched files (only the pre-existing TS18047 in codexSubagentState.extractCodexChildMeta, untouched by this PR, remains).
  • node --check scripts/analyze-heapsnapshot.mjs passes; --owners confirms SubAgentWatcher ×7 = 263 MB / 88% as the top retainer.
  • Existing subagent unit tests pass (4/4).

Note: the real before/after needs a fresh snapshot taken after a rebuild — run --owners on it to confirm SubAgentWatcher is no longer near the top of the retained-by-owner table.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits June 21, 2026 17:30
…alysis

A dominator-tree heap analysis proved 7 live SubAgentWatcher instances
retained 263 MB = 88% of the entire reachable main-process heap, via
SubAgentWatcher.entriesByAgent (Map<agentId, RawEntry[]>) → entry →
message.content → huge string. PR #300 capped the entry COUNT
(MAX_RETAINED_ENTRIES_PER_AGENT=500) but NOT the per-entry BYTE SIZE, so
each retained entry still pinned its full body (Read outputs, tool_result
bodies, the skills-list attachment, [Truncated: PARTIAL view] reminders).
500 entries × multi-MB bodies × several agents × 7 watchers = 263 MB, and
churning those strings every 600 ms poll fragmented V8 into ~1.5 GB RSS.

PART 1 — truncate-at-retention (zero visible change):
- SubAgentWatcher: new truncateEntryBodies() clamps every large free-text
  field (text/thinking text, tool_result content/output, large tool_use
  input string values, skill attachment.content, system-reminder text) to
  8 KB BEFORE pushing into entriesByAgent, preserving all structural fields
  buildSubAgentState reads. 8 KB is ~100× the 80-char headline the renderer
  shows, so no rendered value can be clipped; the durable full transcript
  stays on disk.
- codexSubagentState: dropped childEntriesByAgentId entirely. PR #317 kept
  a bounded 500-entry raw tail "for a future mini-feed" that nothing read —
  write-only dead weight pinning full bodies. The derived SubAgentState in
  childStateByAgentId already carries everything emit() ships, so removing
  the tail is invisible. The #317 full-read derivation logic is untouched.

PART 2 — the tool that proved it:
- analyze-heapsnapshot.mjs: new --owners mode builds the dominator tree
  (Cooper-Harvey-Kennedy), computes retained size per node, and reports the
  top individual retainers plus retained-size aggregated by constructor.
  Self_size ranking is blind to small containers pinning huge subgraphs;
  retained size is what named SubAgentWatcher ×7 = 263 MB / 88%. Needs
  node --max-old-space-size=12288 for large snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ontent, and nested tool_use.input — close residual leak shaps found in review

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