perf(delegation): finished delegate children no longer pin their transcripts in the parent heap

A parent that fanned out 1,320 subagents over 13h reached 2.6 GB RSS
(1.9 GB anonymous heap). Every closed child AIAgent stayed reachable and
still owned a copy of its full message history. gc.get_referrers on a
finished child (30-child fan-out bench, evals/fanout_resource_bench.py)
showed two retainers:

1. bind_subagent_parent() stored the agent strongly in the
   `hermes_subagent_lifecycle_parent` ContextVar. Each child binds ITSELF
   for its own turn, and every asyncio Handle/Future scheduled during
   that turn (LSP reader loops, kernel pipe transports) snapshots the
   Context — 56 live Contexts held 14 finished children after the bench.
   The ContextVar now holds a weakref (non-weakrefable doubles fall back
   to a closure); get_active_subagent_parent() dereferences it.

2. AIAgent.close() cleared _session_messages but not the
   _db_flush_scan_prefix snapshot (a `messages[:]` shallow copy taken on
   every successful DB flush) nor _streamed_assistant_text_parts, so the
   agent — kept alive by (1) — retained every message dict. close() now
   drops both.

The delegate_task result entry never carried `messages`; a pin test
confirms the per-child result JSON is unchanged.

Bench (30 children / 10 worktrees, ~100 KB final replies so retention is
visible): post-fan-out live child AIAgents 14 -> 0; RSS after fan-out
636 MB -> 556 MB. With the harness' tiny default replies both runs sit at
~192-194 MB (the children's transcripts were never the dominant cost
there; the leaked objects were).
This commit is contained in:
Teknium
2026-09-03 01:45:40 -07:00
parent c3b411dfb7
commit c96568f66c
3 changed files with 169 additions and 3 deletions
+8
View File
@@ -5176,6 +5176,14 @@ class AIAgent:
# still holds the closed agent (e.g. a draining background task).
try:
self._session_messages = []
# Shadow copies of the same transcript: the DB-flush settled-prefix
# snapshot (a shallow copy of the whole list, see
# _flush_session_to_db) and the streamed-text accumulator. On a
# closed delegate child these were the only remaining owners of
# every message dict, so a retained child kept its full history
# alive in the parent's heap.
self._db_flush_scan_prefix = None
self._streamed_assistant_text_parts = []
except Exception:
pass