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
I applied a local-only hot-fix on @LiuShiyuMath's dev machine to mitigate the SessionEnd hook storm documented in #460 (loadavg 162 on 8-core M1 with 8 concurrent full-rescan pipelines).
This issue captures the diff applied to disk so the maintainer can decide whether to fold the same gate into packages/cli/src/bin-session-end.ts directly, or take a different approach (per-cwd lock check + global semaphore — see #460 fix scope).
The hot-fix is not in the TeamBrain repo. It lives in two user-level files:
~/.teamagent/hooks/bin-session-end-gated.cjs — new 30-line gating wrapper
~/.claude/settings.json — hooks.SessionEnd[0].hooks[0].command rewired to call the wrapper instead of the real hook
Gating policy implemented
Two layers, both stdlib-only (no deps, no DB):
Per-cwd dedupe (60 s window) — for the same cwd, if a SessionEnd was already kicked off in the last 60 s, drop the new fire. Uses /tmp/teamagent-session-end-gate/<sha256(cwd)[0..16]>.gate mtime as the canary.
Global concurrency cap (≤ 2 active) — across all cwds, at most 2 full-rescan pipelines may run concurrently. Counts gate files whose mtime is within the TTL window.
When dropped, the wrapper logs to /tmp/teamagent-session-end-gate.log (one line per drop, ISO timestamp + reason + cwd) and exits 0, so session close is never blocked.
When not dropped, the wrapper writes the gate file, then spawn execs ~/.teamagent/hooks/bin-session-end.cjs with the same stdin payload pipe-forwarded. Real hook's detached child still runs runFullRescanPipeline exactly as before — gate is purely a spawn-time admission control.
Patches applied
Patch 1 — new file ~/.teamagent/hooks/bin-session-end-gated.cjs
#!/usr/bin/env node
/** * TeamBrain bug #460 hot-fix wrapper (local-only, not in repo). * * Gates SessionEnd hook spawns to at most 1 concurrent rescan per cwd within * 60 s, plus a global cap of 2 concurrent rescans across all cwds. Drops the * fire (exit 0) when either gate is held, so Claude Code's session close is * never blocked. */constfs=require("node:fs");constcrypto=require("node:crypto");constpath=require("node:path");const{ spawn }=require("node:child_process");constLOCK_DIR="/tmp/teamagent-session-end-gate";constCWD_LOCK_TTL_MS=60_000;constGLOBAL_CAP=2;constREAL_HOOK="/Users/m1/.teamagent/hooks/bin-session-end.cjs";// ... 80-line gate logic, full source in https://github.com/libz-renlab-ai/TeamBrain/issues/461 (this issue)
Full source mirrored verbatim from the local file (4017 bytes) — see the Full source code block at the bottom of this issue.
settings.json was backed up before edit to ~/.claude/settings.json.bak-<unix-ts> so rollback is mv away.
Why this is a hot-fix, not a real fix
Hash function differs from upstream code. Upstream cwdLockKey in packages/cli/src/bin-stop.ts:281 uses node:crypto sha256 too, but encodes / truncates differently. The gate doesn't collide with the upstream STOP_PIPELINE_LOCKS_DIR, so both can coexist without races, but my gate file is not the same file the upstream code writes.
Lock TTL is wall-clock based, not handshake-based. If a real rescan runs > 60 s, a concurrent fire could slip through. Real fix should use the upstream lock with PID + started_at JSON (already implemented at bin-stop.ts:304+).
Global cap of 2 is hardcoded. Should be configurable via env var.
No regression test on the gate itself (the wrapper isn't in the repo's test surface).
Manual install only. teamagent init will overwrite ~/.teamagent/hooks/bin-session-end.cjs cleanly but won't redeploy the wrapper or repair ~/.claude/settings.json. Anyone else who hits the same storm has to follow the install steps below.
Full wrapper source (verbatim copy of ~/.teamagent/hooks/bin-session-end-gated.cjs)
#!/usr/bin/env node
/** * TeamBrain bug #460 hot-fix wrapper (local-only, not in repo). * * Gates SessionEnd hook spawns to at most 1 concurrent rescan per cwd within * 60 s, plus a global cap of 2 concurrent rescans across all cwds. Drops the * fire (exit 0) when either gate is held, so Claude Code's session close is * never blocked. * * Original (ungated): bash -c '[ -f "$1" ] || exit 0; exec node "$1"' _ * /Users/m1/.teamagent/hooks/bin-session-end.cjs * Gated: this script wraps that exec. * * Why this exists: bin-session-end.cjs always spawns a detached child running * runFullRescanPipeline. N SessionEnds within 100 ms → N concurrent full * rescans, each 17-36% CPU on a fresh ~8 MB tsup-bundle parse. See issue #460. * * Remove when issue #460 lands a proper fix in packages/cli/src/bin-session-end.ts. */constfs=require("node:fs");constcrypto=require("node:crypto");constpath=require("node:path");const{ spawn }=require("node:child_process");constLOCK_DIR="/tmp/teamagent-session-end-gate";constCWD_LOCK_TTL_MS=60_000;constGLOBAL_CAP=2;constREAL_HOOK="/Users/m1/.teamagent/hooks/bin-session-end.cjs";functionsafeMkdir(p){try{fs.mkdirSync(p,{recursive: true,mode: 0o755});}catch{}}functionreadMtimeMs(p){try{returnfs.statSync(p).mtimeMs;}catch{return0;}}functionreadPayload(){letraw="";try{raw=fs.readFileSync(0,"utf-8");}catch{}returnraw;}functionextractCwd(raw){try{constobj=JSON.parse(raw);if(obj&&typeofobj.cwd==="string"&&obj.cwd)returnobj.cwd;}catch{}returnprocess.cwd();}functioncwdHash(cwd){returncrypto.createHash("sha256").update(cwd).digest("hex").slice(0,16);}functioncountActiveGlobal(){try{constnow=Date.now();returnfs.readdirSync(LOCK_DIR).filter((f)=>f.endsWith(".gate")).filter((f)=>now-readMtimeMs(path.join(LOCK_DIR,f))<CWD_LOCK_TTL_MS).length;}catch{return0;}}functiondropSilently(reason){try{fs.appendFileSync("/tmp/teamagent-session-end-gate.log",`${newDate().toISOString()} drop ${reason}\n`,{mode: 0o644});}catch{}process.exit(0);}functionmain(){safeMkdir(LOCK_DIR);constraw=readPayload();constcwd=extractCwd(raw);consth=cwdHash(cwd);constgateFile=path.join(LOCK_DIR,`${h}.gate`);constlastMs=readMtimeMs(gateFile);if(lastMs>0&&Date.now()-lastMs<CWD_LOCK_TTL_MS){dropSilently(`per-cwd lock held cwd=${cwd}`);return;}constactive=countActiveGlobal();if(active>=GLOBAL_CAP){dropSilently(`global cap ${active}/${GLOBAL_CAP} held cwd=${cwd}`);return;}try{fs.writeFileSync(gateFile,`${Date.now()}\n${cwd}\n`,{mode: 0o644});}catch{}if(!fs.existsSync(REAL_HOOK)){process.exit(0);}constchild=spawn(process.execPath,[REAL_HOOK],{stdio: ["pipe","inherit","inherit"],env: process.env,});child.stdin.write(raw);child.stdin.end();child.on("error",()=>process.exit(0));child.on("exit",(code)=>process.exit(typeofcode==="number" ? code : 0));}main();
What this issue tracks
I applied a local-only hot-fix on @LiuShiyuMath's dev machine to mitigate the SessionEnd hook storm documented in #460 (loadavg 162 on 8-core M1 with 8 concurrent full-rescan pipelines).
This issue captures the diff applied to disk so the maintainer can decide whether to fold the same gate into
packages/cli/src/bin-session-end.tsdirectly, or take a different approach (per-cwd lock check + global semaphore — see #460 fix scope).The hot-fix is not in the TeamBrain repo. It lives in two user-level files:
~/.teamagent/hooks/bin-session-end-gated.cjs— new 30-line gating wrapper~/.claude/settings.json—hooks.SessionEnd[0].hooks[0].commandrewired to call the wrapper instead of the real hookGating policy implemented
Two layers, both stdlib-only (no deps, no DB):
/tmp/teamagent-session-end-gate/<sha256(cwd)[0..16]>.gatemtime as the canary.When dropped, the wrapper logs to
/tmp/teamagent-session-end-gate.log(one line per drop, ISO timestamp + reason + cwd) and exits 0, so session close is never blocked.When not dropped, the wrapper writes the gate file, then
spawnexecs~/.teamagent/hooks/bin-session-end.cjswith the same stdin payload pipe-forwarded. Real hook's detached child still runsrunFullRescanPipelineexactly as before — gate is purely a spawn-time admission control.Patches applied
Patch 1 — new file
~/.teamagent/hooks/bin-session-end-gated.cjsFull source mirrored verbatim from the local file (4017 bytes) — see the Full source code block at the bottom of this issue.
Patch 2 —
~/.claude/settings.jsonSessionEnd hook"SessionEnd": [ { "hooks": [ { "type": "command", - "command": "bash -c '[ -f \"$1\" ] || exit 0; exec node \"$1\"' _ /Users/m1/.teamagent/hooks/bin-session-end.cjs", + "command": "bash -c '[ -f \"$1\" ] || exit 0; exec node \"$1\"' _ /Users/m1/.teamagent/hooks/bin-session-end-gated.cjs", "timeout": 30 } ] } ]settings.json was backed up before edit to
~/.claude/settings.json.bak-<unix-ts>so rollback ismvaway.Why this is a hot-fix, not a real fix
cwdLockKeyinpackages/cli/src/bin-stop.ts:281uses node:crypto sha256 too, but encodes / truncates differently. The gate doesn't collide with the upstream STOP_PIPELINE_LOCKS_DIR, so both can coexist without races, but my gate file is not the same file the upstream code writes.bin-stop.ts:304+).bin-session-end.ts:133instead of folding the gate into that file. The proper fix is to editbin-session-end.tsdirectly per bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460's scope.teamagent initwill overwrite~/.teamagent/hooks/bin-session-end.cjscleanly but won't redeploy the wrapper or repair~/.claude/settings.json. Anyone else who hits the same storm has to follow the install steps below.Install / rollback recipe
Install (already done on @LiuShiyuMath's machine):
Rollback:
Verification
After installing the wrapper:
bin-session-end.cjsdetached children, not N.tail -f /tmp/teamagent-session-end-gate.logshows drop reasons (per-cwd lock held,global cap 2/2 held) instead of fires going through.sysctl -n vm.loadavgshould stabilize at < ncpu × 2 on the same hardware.(Live verification on the user's machine is captured in the Slack DM thread; loadavg 1-min figure will settle 60-90 s after install.)
Asks for the maintainer
packages/cli/src/bin-session-end.ts(bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460's proper fix scope: gate the spawn on the existingSTOP_PIPELINE_LOCKS_DIR/<cwdLockKey>.stop-pipeline.lockwith stale handling already atbin-stop.ts:304+, plus a configurable global semaphore).packages/cli/src/__tests__/bin-session-end.concurrency.test.tsper bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460's scope item.packages/cli/scripts/so other users hitting bug(perf): SessionEnd hook spawns N concurrent full-rescan pipelines — load 162 on 8-core M1 #460 can install it without copy-pasting from an issue, OR delete the wrapper once the proper fix lands.Full wrapper source (verbatim copy of
~/.teamagent/hooks/bin-session-end-gated.cjs)Related
docs/debugging/toohot-many-bg-spare-workers.md— needs an entry for "session-end storm" as a 4th trigger of the same scheduler-overload pathologydocs/adr/0013-inner-loop-on-ci.md— 3rd variant of the same pathology, vitest fork burstFiled via /investigate (continuation) on 2026-05-14.