You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
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.
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").
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.
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 spawnconstchild=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.
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.
Symptom
After #459 was filed, the user ran their
toohotmonitor 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 longerclaude.exe --bg-spare; they were 6 concurrent~/.teamagent/hooks/bin-session-end.cjsinvocations 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 frompackages/cli/src/bin-session-end.ts):/var/folders/.../teamagent-session-end-<ts>-<rand>.jsontmp file, unconditionally spawns a detached child re-execing itself withTEAMAGENT_SESSION_END_PIPELINE=1, then returns.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 inbin-session-end.ts:84-85("Pipeline is unbounded — the harness kills us at its own ~300s timeout if needed").STOP_PIPELINE_LOCKS_DIRper-cwd singleton lock exists inbin-stop.ts:270("per-project singleton lock for detached pipeline child") with full stale-lock handling, butbin-session-end.ts:133does not consult it before spawning. The only lock that fires inside the child iswriteStopLock(cwd)atbin-stop.ts:421which is comment-marked "best-effort — statusline will simply not show the indicator", i.e. a status flag, not a serializing lock./clearis issued in N tabs, N detached children all win the race and start full rescans in parallel.Reproduction
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
Why the lock isn't gating
bin-session-end.ts:69-147foreground handler:The infrastructure for the gate already exists —
bin-stop.ts:295has${cwdLockKey(cwd)}.stop-pipeline.lockwith stale-lock handling atbin-stop.ts:304+. It just isn't called here.Proposed scope (needs grill before driver dispatch)
bin-session-end.tsforeground checkSTOP_PIPELINE_LOCKS_DIR/<cwdLockKey>.stop-pipeline.lock(existing, inbin-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.~/.teamagent/locks/global-pipeline.semwith 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).bin-session-end.cjsis 7.94 MB. Lazy-loadrunFullRescanPipelineso the foreground spawn path doesn't have to parse the entire embedder/analyze/calibrate dep graph. Foreground path only needsspawn+ 30 lines of envelope.hook-session-end.droppedAttributionEvent when the lock gates a fire, so the user can see how often the storm is being absorbed.packages/cli/src/__tests__/bin-session-end.concurrency.test.tsfires N SessionEnds within 100ms in a temp project and asserts no more than 1 (or N ≤ semaphore cap) child entersrunFullRescanPipeline.Related
@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.