一條線一份自己的工作目錄,同時跑的線有上限(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>
This commit is contained in:
Executable
+283
@@ -0,0 +1,283 @@
|
||||
#!/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/#164/Arcrun#176/#180/#185/#187/InkStoneCo#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
|
||||
Reference in New Issue
Block a user