#!/usr/bin/env bash # 管什麼: 同時在跑的派工線已經到上限時,再派下一條就擋下來,並列出現在有哪幾條在跑。 # 為什麼: 2026-08-29 總管同時開七條線,8 GB 的 Mac 被吃爆重開機;同一天還派錯線一次、量錯東西六次、停掉正確的工作一次。leo:「跑 2-3 條應該是極限了」。在這之前這條規則只活在總管的回覆文字裡。 # 誤觸時怎麼關: `touch /tmp/.parallel-ok-` 後重送(用掉就消失,會留在指令歷史上)。上限本身用 `ISEP_MAX_PARALLEL_LINES` 調。 # # parallel-lines-cap-guard.sh — 同時跑的線有上限(PreToolUse: Agent / Task) # # ── 判準:數 harness 自己寫下來的檔案,不數我自己記的數字 ────────────── # 這支一個字都不看派工單的內容。它只回答一個機械問題: # # **現在有幾條線還在跑?** # # 一條線算「還在跑」,要三個條件同時成立: # ① `//subagents/agent-.meta.json` 存在 # —— harness 在啟動那條線的當下寫的,不是我寫的 # ② 主 transcript 裡**還沒有**它的完工通知 `` # ③ 它自己的 `agent-.jsonl` 在 IDLE 秒內還在被寫(預設 900 秒) # # ①②③ 都是別人寫的事實,這支只是去讀。**沒有任何一個計數器要我去減。** # # ── 為什麼不用計數器(PreToolUse +1 / SubagentStop −1)──────────────── # 減不掉就永遠卡死。而這張票的驗收條件第 2 條寫死: # 「前一條收工後,下一條派得出去——不能變成永久卡死」。 # 🔴 更關鍵的是**那個減法根本不會發生**(2026-08-29 實測,非推測): # # $ grep -oh '"hookEvent":[ ]*"[^"]*"' <本 session 的 transcript> | sort | uniq -c # 4545 "hookEvent":"PreToolUse" # 530 "hookEvent":"Stop" # 22 "hookEvent":"PostToolUse" # 20 "hookEvent":"SessionStart" # ⇒ **`SubagentStop` 在這個 session 一次都沒出現過。** # (hooks.json 目前有 7 條掛在 SubagentStop 上——那是另一張票的事,本票不動它們。) # # ── 為什麼不數行程 ──────────────────────────────────────────────── # 票上引的是「claude 12 支 923 MB」。2026-08-29 20:31 在同一台機器實測: # # $ ps -Ao pid,ppid,rss,comm | grep -i claude # ...一堆 /Applications/Claude.app/... 的 Electron helper... # 2196 2195 389584 .../claude-code/2.1.246/claude.app/Contents/MacOS/claude # # ⇒ **Claude Code 只有一個行程(pid 2196)**,那 12 支是 Claude Desktop 的 helper。 # subagent 不是獨立行程,**數行程數不到線**。(在別的 surface 可能不同——但這台是這樣。) # # ── 為什麼不掛在 PostToolUse(Agent) 上做「收工」的判斷 ───────────────── # 實測:PostToolUse:Agent 是在**送出後約 5 秒**觸發,那條線還在跑。 # 12:26:46 PreToolUse:Agent toolu_01SPDbSPvzXCXHgMehcy3UKq # 12:26:51 tool_result "Async agent launched successfully... agentId: a6019a…" # 12:26:51 PostToolUse:Agent (同一秒) # ⇒ agent 這時候才剛開始跑。真正的完工通知是 66 分鐘後才進 transcript 的 # ``(另一條線實測:10:35:59 送出 → 11:40:13 通知)。 # # ── 誤攔的方向被刻意壓在「漏擋」那一邊 ──────────────────────────────── # `.claude/branch-holds.md` 檔頭:**永遠在響的警報,等於訓練人忽略這個警報。** # 所以: # • 條件 ③ 的 IDLE 窗是**上限保險**——就算完工通知的格式哪天變了、②整個失效, # 一條線最多也只會被多算 15 分鐘,**不可能永久卡死**。 # • 讀不到 payload/算不出來 ⇒ **放行**,但**出聲**(把「這次沒把關」講給模型聽), # 不是靜默放行。房規要的是「內部錯誤不准變成**靜默**放行」, # 而在這支身上「算不出來就擋」會變成整台機器派不了工——那是比漏擋貴得多的失敗。 # # ── 誠實邊界(不補,補了就變成猜)──────────────────────────────────── # 1. **只認得這個 surface 的檔案佈局**(`~/.claude/projects///subagents/`)。 # 雲端 CC 的佈局沒實測過 ⇒ 那裡找不到目錄時這支等於不存在(會出聲說它數不出來)。 # 2. **同步 agent**(不背景跑的)靠「tool_result 不是 launch ack」判收工; # 這條路在本機沒有實跡可對(這個 session 的 81 次派工全是背景),只有測試涵蓋。 # 3. 它管的是**條數**,不是記憶體。真正吃掉 8 GB 的是什麼,這支不知道也不猜。 set -uo pipefail INPUT=$(cat) CAP="${ISEP_MAX_PARALLEL_LINES:-3}" IDLE="${ISEP_PARALLEL_IDLE_SEC:-900}" export ISEP_CAP="$CAP" ISEP_IDLE="$IDLE" OUT=$(printf '%s' "$INPUT" | python3 -c ' import json, os, re, sys, time def bail(msg=""): # 算不出來 ⇒ 放行,但把原因印出來(下面的 shell 會轉成 additionalContext) if msg: print("NOTE\t" + msg) raise SystemExit try: d = json.load(sys.stdin) except Exception: bail("並行閘讀不到 hook payload,這一次沒有把關。") tool = d.get("tool_name") or "" if tool not in ("Agent", "Task"): raise SystemExit # 不是派工,這支沒有立場說話 sid = d.get("session_id") or "" tp = d.get("transcript_path") or "" if not sid or not tp: bail("並行閘拿不到 session_id / transcript_path,這一次沒有把關。") # ── 找 subagents 目錄 ──────────────────────────────────────────── # 主 session 的 transcript: /.jsonl # 子 agent 的 transcript: //subagents/agent-.jsonl # 兩種都要能回推到 。 base = os.path.dirname(tp) if os.path.basename(base) == "subagents": base = os.path.dirname(os.path.dirname(base)) subdir = os.path.join(base, sid, "subagents") main = os.path.join(base, sid + ".jsonl") if not os.path.isdir(subdir): raise SystemExit # 一條線都還沒派過 ⇒ 沒得數,也沒什麼好說的 cap = int(os.environ.get("ISEP_CAP", "3")) idle = int(os.environ.get("ISEP_IDLE", "900")) now = time.time() # ── ① harness 寫下來的那些線 + ③ 還在動的那些 ───────────────────── cand = {} try: names = os.listdir(subdir) except OSError: bail("並行閘讀不到 subagents 目錄,這一次沒有把關。") for fn in names: if not (fn.startswith("agent-") and fn.endswith(".meta.json")): continue aid = fn[len("agent-"):-len(".meta.json")] jl = os.path.join(subdir, "agent-%s.jsonl" % aid) try: quiet = now - os.path.getmtime(jl) except OSError: continue # 連 transcript 都沒有 ⇒ 不是活的線 if quiet > idle: continue # ③ 安靜太久 ⇒ 收工了或死了,一律不算 try: meta = json.load(open(os.path.join(subdir, fn), encoding="utf-8")) except Exception: meta = {} try: started = now - os.path.getmtime(os.path.join(subdir, fn)) except OSError: started = 0 cand[aid] = { "name": meta.get("name") or meta.get("description") or "(沒取名字)", "type": meta.get("agentType") or "?", "desc": meta.get("description") or "", "tuid": meta.get("toolUseId") or "", "quiet": int(quiet), "age": int(started), } if not cand: raise SystemExit # ── ② 主 transcript 裡的完工通知 ───────────────────────────────── # 實跡:{"type":"queue-operation","operation":"enqueue", ... " # a76283ec07f38ed24toolu_…"} done = set() byuid = {v["tuid"]: a for a, v in cand.items() if v["tuid"]} acked = {} if os.path.isfile(main): marks = {a: "%s" % a for a in cand} try: with open(main, encoding="utf-8", errors="replace") as f: for line in f: for a, mk in marks.items(): if a in done: continue if mk in line: done.add(a) if not byuid or "tool_result" not in line: continue if not any(u in line for u in byuid): continue try: e = json.loads(line) except Exception: continue msg = e.get("message") if not isinstance(msg, dict): continue for b in (msg.get("content") or []): if not isinstance(b, dict) or b.get("type") != "tool_result": continue a = byuid.get(b.get("tool_use_id")) if not a: continue txt = json.dumps(b.get("content"), ensure_ascii=False) # launch ack = 這條線才剛開始;別的內容 = 同步 agent 已經交件 acked[a] = ("Async agent launched successfully" in txt) except OSError: pass # 主 transcript 讀不到 ⇒ 只靠 ③ 的 IDLE 窗,仍然不會卡死(誠實邊界 1) running = [a for a in cand if a not in done and acked.get(a, True)] if len(running) < cap: raise SystemExit running.sort(key=lambda a: -cand[a]["age"]) def human(s): if s < 90: return "%d 秒" % s if s < 5400: return "%d 分" % (s // 60) return "%.1f 小時" % (s / 3600.0) rows = [] for i, a in enumerate(running, 1): c = cand[a] # 🔴 只印名字/類型/時間,**不印 agentId**——那是 harness 明文說不要往外貼的內部值 rows.append(" %d. %s(%s)|已跑 %s|最後一個動作 %s前" % (i, c["name"], c["type"], human(c["age"]), human(c["quiet"]))) print("BLOCK\t%d\t%d" % (len(running), cap)) print("\n".join(rows)) ' 2>/dev/null) || { cat >&2 <<'EOM' ⚠️ 並行閘(parallel-lines-cap-guard)自己出錯了,這一次**沒有把關**。 放行是刻意的(算不出來就擋 = 整台機器派不了工),但它不該安靜地過去。 請把這一行連同當時的動作記到 inkstone/ISEP#109。 EOM exit 0 } [ -n "${OUT:-}" ] || exit 0 KIND=$(printf '%s' "$OUT" | head -1 | cut -f1) if [ "$KIND" = "NOTE" ]; then MSG=$(printf '%s' "$OUT" | head -1 | cut -f2-) printf '⚠️ %s\n 放行是刻意的,但它不該安靜地過去(inkstone/ISEP#109)。\n' "$MSG" >&2 exit 0 fi N=$(printf '%s' "$OUT" | head -1 | cut -f2) CAPN=$(printf '%s' "$OUT" | head -1 | cut -f3) ROWS=$(printf '%s' "$OUT" | tail -n +2) # ── 逃生門:用掉就消失,留在指令歷史上 ──────────────────────────────── ESC="/tmp/.parallel-ok-${CLAUDE_SESSION_ID:-}" if [ -z "${CLAUDE_SESSION_ID:-}" ]; then ESC=$(printf '%s' "$INPUT" | python3 -c ' import sys, json try: print("/tmp/.parallel-ok-" + (json.load(sys.stdin).get("session_id") or "nosid")) except Exception: print("/tmp/.parallel-ok-nosid") ' 2>/dev/null || echo /tmp/.parallel-ok-nosid) fi if [ -f "$ESC" ]; then rm -f "$ESC" printf '🔓 並行閘:現在有 %s 條線在跑(上限 %s),逃生門已用掉,這一次放行。\n' "$N" "$CAPN" >&2 exit 0 fi cat >&2 <(安靜窗 ISEP_PARALLEL_IDLE_SEC,預設 900 秒)。 🔴 這道閘擋的是總管自己,**沒有總管例外**。 EOF exit 2