Skip to content

bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460

Description

@LiuShiyuMath

Symptom

After #459 was filed, the user ran their toohot monitor and saw loadavg jump from 88 → 162.31 / 130.28 / 98.34 on the same 8-core M1 — a 20× oversubscription. Top processes were no longer claude.exe --bg-spare; they were 6 concurrent ~/.teamagent/hooks/bin-session-end.cjs invocations at 17-37% CPU each.

This is a second, distinct TeamBrain-side bug layered on top of the upstream bg-spare leak. Unlike #459 this one is in our code.

Root cause

bin-session-end.cjs (compiled from packages/cli/src/bin-session-end.ts):

  1. Foreground path receives a SessionEnd event, writes the payload to a /var/folders/.../teamagent-session-end-<ts>-<rand>.json tmp file, unconditionally spawns a detached child re-execing itself with TEAMAGENT_SESSION_END_PIPELINE=1, then returns.
  2. Detached child path calls runFullRescanPipeline(input) (packages/cli/src/bin-stop.ts:853) → runStopPipeline(input, { fullRescan: true, modeTag: "full" }). This runs the full embedder rescan + analyze/calibrate/compile chain — described as unbounded in bin-session-end.ts:84-85 ("Pipeline is unbounded — the harness kills us at its own ~300s timeout if needed").
  3. No concurrency gate on the spawn. A STOP_PIPELINE_LOCKS_DIR per-cwd singleton lock exists in bin-stop.ts:270 ("per-project singleton lock for detached pipeline child") with full stale-lock handling, but bin-session-end.ts:133 does not consult it before spawning. The only lock that fires inside the child is writeStopLock(cwd) at bin-stop.ts:421 which is comment-marked "best-effort — statusline will simply not show the indicator", i.e. a status flag, not a serializing lock.
  4. Result: when a single Claude Code daemon shuts down N worktree sessions at once, or /clear is issued in N tabs, N detached children all win the race and start full rescans in parallel.

Reproduction

# Have N ≥ 4 worktree sessions of any teamagent-initialized project open
# (cwd in this report: /Users/m1/projects/metrixMarkets).
# Trigger SessionEnd in all of them within a short window:
#   - close the windows, or
#   - run /clear in each, or
#   - kill the parent claude.exe daemon
#
# Watch:
ps -axwwwo pid,etime,pcpu,command | grep "bin-session-end.cjs" | grep -v grep
sysctl -n vm.loadavg
ls -lat /var/folders/*/T/teamagent-session-end-*.json 2>/dev/null | head

Expected: N concurrent detached children, each 17-36% CPU, loadavg climbs through 100+ on 8-core hardware, UI starvation (WindowServer 30%+).

Evidence captured 2026-05-14 11:40 local

loadavg                   = 162.31 / 130.28 / 98.34   # 8-core M1 → ~20× oversubscription
pmset -g therm            = normal (NOT thermal)
ncpu                      = 8

8 SessionEnd fires within a 223 ms window (timestamps from tmpFile names):
  ...1778729968306-d5xnxnrrsko.json
  ...1778729968333-i5vyjb0ltkq.json
  ...1778729968435-oybwca4fbkd.json
  ...1778729968460-jpqw8ndqwla.json
  ...1778729968477-b5ptxz93wi5.json
  ...1778729968503-32cr4rmql4l.json
  ...1778729968508-trn8wwy6jph.json
  ...1778729968529-53k07tflaia.json

A few seconds later, 9 concurrent children still running:
  PID    PPID    %CPU   COMMAND
  26429    1     25.9%  node bin-session-end.cjs <tmpFile-1>.json
  26440    1     35.9%  node bin-session-end.cjs <tmpFile-2>.json
  26441    1     30.0%  node bin-session-end.cjs <tmpFile-3>.json
  26442    1     31.0%  node bin-session-end.cjs <tmpFile-4>.json
  26443    1     32.4%  node bin-session-end.cjs <tmpFile-5>.json
  26444    1     17.3%  node bin-session-end.cjs <tmpFile-6>.json
  27345    1     19.0%  node bin-session-end.cjs <tmpFile-7>.json
  (+ 2 more detached children)

All cwds in the payloads = /Users/m1/projects/metrixMarkets
(so they would all collide on the SAME per-cwd lock if the lock were checked)

Hook binary size: bin-session-end.cjs = 7.94 MB / 52,602 lines (tsup bundle).
Each fire pays the full ~8 MB JS parse cost in a fresh node process.

Why the lock isn't gating

bin-session-end.ts:69-147 foreground handler:

// ❌ No lock check before this spawn
const child = spawn(process.execPath, [selfPath, tmpFile], {
  detached: true,
  stdio: "ignore",
  cwd: ctx.cwd,
  env: { ...process.env, [SESSION_END_ENV_KEY]: "1" },
  windowsHide: true,
});

The infrastructure for the gate already exists — bin-stop.ts:295 has ${cwdLockKey(cwd)}.stop-pipeline.lock with stale-lock handling at bin-stop.ts:304+. It just isn't called here.

Proposed scope (needs grill before driver dispatch)

  • Gate: have bin-session-end.ts foreground check STOP_PIPELINE_LOCKS_DIR/<cwdLockKey>.stop-pipeline.lock (existing, in bin-stop.ts) before spawning. If a live (non-stale) entry exists for this cwd, drop the new SessionEnd instead of spawning. This is the per-cwd singleton the code already documents wanting.
  • Cross-cwd backpressure: the per-cwd lock doesn't help when N different worktrees fire at once. Add a global semaphore (~/.teamagent/locks/global-pipeline.sem with N ≤ 2) that the foreground checks before spawn; if over the cap, drop or queue (drop preferred — incremental rescan from the next session covers the gap).
  • Bundle slim-down: bin-session-end.cjs is 7.94 MB. Lazy-load runFullRescanPipeline so the foreground spawn path doesn't have to parse the entire embedder/analyze/calibrate dep graph. Foreground path only needs spawn + 30 lines of envelope.
  • Observability: emit hook-session-end.dropped AttributionEvent when the lock gates a fire, so the user can see how often the storm is being absorbed.
  • Regression test: packages/cli/src/__tests__/bin-session-end.concurrency.test.ts fires N SessionEnds within 100ms in a temp project and asserts no more than 1 (or N ≤ semaphore cap) child enters runFullRescanPipeline.

Related

  • Issue #459 — the bg-spare worker leak from upstream @anthropic-ai/claude-code. Independent root cause; both can fire on the same machine and compound. (bug(perf): Mac overheats — 8 claude.exe bg-spare worker pairs leak (loadavg 88 on 8-core M1) #459 contributes baseline load 60-90; this issue spikes it to 160+.)
  • docs/adr/0013-inner-loop-on-ci.md — same scheduler-overload pathology (loadavg ≫ ncpu, thermal flag normal), different trigger (vitest fork burst). 3rd documented variant in this repo.
  • docs/debugging/toohot-many-bg-spare-workers.md — needs follow-up entry for "session-end storm" as a 4th trigger of the same pathology.
  • packages/cli/src/bin-session-end.ts:84-85 — code comment explicitly notes the pipeline is unbounded.
  • packages/cli/src/bin-stop.ts:247-300 — STOP_PIPELINE_LOCKS_DIR infrastructure already in place, just not wired into the SessionEnd foreground path.

Notification

User asked for @libz Slack mention when this is fixed. Tracked in the slack DM linked from #459.


Filed via /investigate skill (continuation) on 2026-05-14.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions