Files
ISEP/hooks/parallel-lines-cap-guard.sh
T
Leo f69ea11a58 一條線一份自己的工作目錄,同時跑的線有上限(inkstone/ISEP#109 → comment 5391)
兩支新閘,兩個方向的測試都有:

line-needs-own-worktree.sh(PreToolUse: Bash,35 條測試)— 治本那半
  一條線想在共用的工作目錄裡切分支就擋,叫它開自己的 worktree。
  判準是 git 自己回答的兩個問題(會不會動 HEAD/是不是主工作目錄),
  不靠任何人登記。擋下來時順手 prune 掉指向空氣的登記。
  實測:products/arcrun-rag 清掉 4 筆、ISEP 清掉 13 筆,
  還在的 worktree 與分支一根寒毛都沒動。

parallel-lines-cap-guard.sh(PreToolUse: Agent|Task,33 條測試)— 治標那半
  同時在跑的線超過上限(預設 3)就派不出下一條,訊息列出現在有哪幾條。
  條數是從 harness 自己寫的 subagents/*.meta.json + 主 transcript 的完工通知
  數出來的,不是計數器 —— 沒有任何東西要人去減,所以不會永久卡死。

三個機械前提是實測的,不是推測的:
  · SubagentStop 在實測 session 裡一次都沒觸發(不能拿它當減法)
  · PostToolUse:Agent 在送出後 5 秒就觸發(不是收工)
  · subagent 不是獨立行程(ps 只有一個 claude-code 行程)
  · Agent 的原生 isolation:"worktree" 只做頂層 repo,
    而 products/ matrix/ 是 gitignore 掉的子 repo,worktree 裡根本沒有它們
    ⇒ 原生那條路解不了這張票的問題

盤點數字在自己的樹上重數:62 支 / 85 條(main 上的 60/82 沒算到這兩支)。

🔴 版本沒動 = 沒有人吃得到。這條分支沒有定版,
   plugin.json 仍是 0.16.1(版本一致性檢查要求它等於最新 tag)。
   合併時要跟著一個新版本號,不然這兩支閘到不了任何人手上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:52:13 +08:00

284 lines
13 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 管什麼: 同時在跑的派工線已經到上限時,再派下一條就擋下來,並列出現在有哪幾條在跑。
# 為什麼: 2026-08-29 總管同時開七條線,8 GB 的 Mac 被吃爆重開機;同一天還派錯線一次、量錯東西六次、停掉正確的工作一次。leo:「跑 2-3 條應該是極限了」。在這之前這條規則只活在總管的回覆文字裡。
# 誤觸時怎麼關: `touch /tmp/.parallel-ok-<session_id>` 後重送(用掉就消失,會留在指令歷史上)。上限本身用 `ISEP_MAX_PARALLEL_LINES` 調。
#
# parallel-lines-cap-guard.sh — 同時跑的線有上限(PreToolUse: Agent / Task
#
# ── 判準:數 harness 自己寫下來的檔案,不數我自己記的數字 ──────────────
# 這支一個字都不看派工單的內容。它只回答一個機械問題:
#
# **現在有幾條線還在跑?**
#
# 一條線算「還在跑」,要三個條件同時成立:
# ① `<projects>/<sid>/subagents/agent-<id>.meta.json` 存在
# —— harness 在啟動那條線的當下寫的,不是我寫的
# ② 主 transcript 裡**還沒有**它的完工通知 `<task-id><id></task-id>`
# ③ 它自己的 `agent-<id>.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 的
# `<task-notification>`(另一條線實測:10:35:59 送出 → 11:40:13 通知)。
#
# ── 誤攔的方向被刻意壓在「漏擋」那一邊 ────────────────────────────────
# `.claude/branch-holds.md` 檔頭:**永遠在響的警報,等於訓練人忽略這個警報。**
# 所以:
# • 條件 ③ 的 IDLE 窗是**上限保險**——就算完工通知的格式哪天變了、②整個失效,
# 一條線最多也只會被多算 15 分鐘,**不可能永久卡死**。
# • 讀不到 payload/算不出來 ⇒ **放行**,但**出聲**(把「這次沒把關」講給模型聽),
# 不是靜默放行。房規要的是「內部錯誤不准變成**靜默**放行」,
# 而在這支身上「算不出來就擋」會變成整台機器派不了工——那是比漏擋貴得多的失敗。
#
# ── 誠實邊界(不補,補了就變成猜)────────────────────────────────────
# 1. **只認得這個 surface 的檔案佈局**`~/.claude/projects/<slug>/<sid>/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 <projects>/<sid>.jsonl
# 子 agent 的 transcript <projects>/<sid>/subagents/agent-<id>.jsonl
# 兩種都要能回推到 <projects>。
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", ... "<task-notification>
# <task-id>a76283ec07f38ed24</task-id><tool-use-id>toolu_…</tool-use-id>"}
done = set()
byuid = {v["tuid"]: a for a, v in cand.items() if v["tuid"]}
acked = {}
if os.path.isfile(main):
marks = {a: "<task-id>%s</task-id>" % 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 <<EOF
🚦 同時跑的線已經到上限:**現在有 $N 條在跑,上限 $CAPN**。
現在在跑的是這幾條:
$ROWS
【上限從哪來】leo 2026-08-29 原話:「因為剛剛造成 Mac 重啓,它很少這樣⋯⋯
**跑 2-3 條應該是極限了**」——取他自己講的上限 3,所以第 4 條才擋。
這台機器:Mac14,2 / M2 / 記憶體 8 GB / Swap 已用 914 MB。
【它花掉過什麼(2026-08-29,這張閘的來由)】
同時開七條線(arcrun-rag#163#164Arcrun#176#180#185#187InkStoneCo#104
⇒ leo 的 Mac **重新開機**。而且同一天:派錯線 1 次、量錯東西 6 次、
停掉正確的工作 1 次(MISTAKE 134)。
⇒ **平行度超過盯得住的範圍時,錯誤率跟著上去。這條閘同時在省機器與省判斷力。**
【出路,挑一條】
① **等**——上面某一條收工,這條就派得出去(收工通知一進 transcript 就解鎖,不用等 15 分鐘)。
同一個回合可以先做別的不需要開新線的事。
② **這條更急** ⇒ 用 SendMessage 把某一條停下來/收掉,再派。
③ **真的非同時不可** ⇒ 在終端機跑
touch $ESC
然後重送。用掉就消失,會留在指令歷史上。
④ 上限本身要調 ⇒ ISEP_MAX_PARALLEL_LINES=<n>(安靜窗 ISEP_PARALLEL_IDLE_SEC,預設 900 秒)。
🔴 這道閘擋的是總管自己,**沒有總管例外**。
EOF
exit 2