Files
ISEP/hooks/tests/parallel-lines-cap-guard.test.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

256 lines
10 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
# parallel-lines-cap-guard.sh 的迴歸測試(inkstone/ISEP#109)。
#
# **離線、不打網路、不花錢、每次結果一樣**——這支閘只讀檔案的存在與 mtime,
# 沒有語意判官,所以不需要像 ask-user-question-guard 那樣另開一支 live 測試量準度。
#
# 每個案例都自己搭一份假的 harness 佈局(跟真的同形狀):
# <root>/<sid>.jsonl 主 transcript
# <root>/<sid>/subagents/agent-<id>.meta.json harness 啟動那條線時寫的
# <root>/<sid>/subagents/agent-<id>.jsonl 那條線自己的 transcript
#
# 三群,兩個方向都要有證據:
# A 群「不該擋」——沒到上限、已收工、當掉的線、逃生門、不是派工的工具、
# 主 transcript 讀不到(**這群紅了比漏擋嚴重**:誤攔會讓整台機器派不了工)
# B 群「該擋」 ——到上限、超過上限、上限可調、巢狀也算
# C 群「訊息本身」——列得出現在有哪幾條在跑、不外洩 agentId、沒有被 shell 展開
#
# 用法:hooks/tests/parallel-lines-cap-guard.test.sh [hook 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="${1:-$(cd "$HERE/.." && pwd)/parallel-lines-cap-guard.sh}"
PASS=0; FAIL=0; N=0
ROOT=$(mktemp -d "${TMPDIR:-/tmp}/isep-parcap.XXXXXX")
SID="S-PARCAP-TEST"
ESC="/tmp/.parallel-ok-$SID"
clean() { rm -rf "$ROOT"; rm -f "$ESC"; }
trap clean EXIT
# reset —— 每個案例從空的佈局開始
reset() {
rm -rf "$ROOT/$SID" "$ROOT/$SID.jsonl"
mkdir -p "$ROOT/$SID/subagents"
: > "$ROOT/$SID.jsonl"
}
# line <id> <名字> <型別> <安靜幾秒> [tool_use_id]
# 造一條「在跑的線」:meta 在、jsonl 在、jsonl 的 mtime 是 <安靜幾秒> 前
line() {
local id="$1" name="$2" typ="$3" quiet="$4" tuid="${5:-toolu_$1}"
local d="$ROOT/$SID/subagents"
printf '{"agentType":"%s","description":"%s 的說明","name":"%s","toolUseId":"%s","spawnDepth":1}\n' \
"$typ" "$name" "$name" "$tuid" > "$d/agent-$id.meta.json"
printf '{"agentId":"%s"}\n' "$id" > "$d/agent-$id.jsonl"
python3 - "$d/agent-$id.jsonl" "$quiet" <<'PY'
import os, sys, time
p, q = sys.argv[1], int(sys.argv[2])
t = time.time() - q
os.utime(p, (t, t))
PY
}
# finished <id> —— 在主 transcript 補一則完工通知(實跡格式)
finished() {
printf '{"type":"queue-operation","operation":"enqueue","sessionId":"%s","content":"<task-notification>\\n<task-id>%s</task-id>\\n<tool-use-id>toolu_%s</tool-use-id>\\n"}\n' \
"$SID" "$1" "$1" >> "$ROOT/$SID.jsonl"
}
# launch_ack <tool_use_id> —— 背景 agent 剛啟動時的 tool_result(實跡文字)
launch_ack() {
python3 - "$1" <<'PY' >> "$ROOT_ENV/$SID_ENV.jsonl"
import json, sys
tuid = sys.argv[1]
print(json.dumps({"type": "user", "message": {"role": "user", "content": [
{"type": "tool_result", "tool_use_id": tuid,
"content": [{"type": "text", "text": "Async agent launched successfully. agentId: xyz"}]}]}},
ensure_ascii=False))
PY
}
# sync_result <tool_use_id> —— 同步 agent 交件了:tool_result 是真的結果,不是 launch ack
sync_result() {
python3 - "$1" <<'PY' >> "$ROOT_ENV/$SID_ENV.jsonl"
import json, sys
tuid = sys.argv[1]
print(json.dumps({"type": "user", "message": {"role": "user", "content": [
{"type": "tool_result", "tool_use_id": tuid,
"content": [{"type": "text", "text": "做完了,結論如下……"}]}]}},
ensure_ascii=False))
PY
}
export ROOT_ENV="$ROOT" SID_ENV="$SID"
# payload [tool_name] [transcript_path]
payload() {
python3 - "${1:-Agent}" "${2:-$ROOT/$SID.jsonl}" "$SID" <<'PY'
import json, sys
print(json.dumps({"session_id": sys.argv[3], "hook_event_name": "PreToolUse",
"transcript_path": sys.argv[2], "tool_name": sys.argv[1],
"tool_input": {"prompt": "【工單】inkstone/ISEP#109"}},
ensure_ascii=False))
PY
}
# t <期望 exit> <說明> [tool_name] [transcript_path] [env 前綴]
t() {
local want="$1" desc="$2" tool="${3:-Agent}" tpath="${4:-$ROOT/$SID.jsonl}" envs="${5:-}"
N=$((N+1))
local err rc
err=$(printf '%s' "$(payload "$tool" "$tpath")" | env $envs bash "$HOOK" 2>&1 >/dev/null); rc=$?
LAST_ERR="$err"
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$err" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
# has <該出現的字串> <說明>
has() {
N=$((N+1))
if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then
printf ' ✅ %s\n' "$2"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 訊息裡找不到「%s」\n' "$2" "$1"; FAIL=$((FAIL+1))
fi
}
# hasnt <不該出現的字串> <說明>
hasnt() {
N=$((N+1))
if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then
printf ' ❌ %s —— 訊息裡出現了「%s」\n' "$2" "$1"; FAIL=$((FAIL+1))
else
printf ' ✅ %s\n' "$2"; PASS=$((PASS+1))
fi
}
echo
echo "── A 群:不該擋(誤攔比漏擋嚴重——擋錯了整台機器派不了工)──"
reset
t 0 "① 一條線都沒派過(連 subagents 目錄都空)⇒ 放行"
reset; line a1 收檔那條 rag-collector 5
t 0 "② 只有 1 條在跑(上限 3)⇒ 放行"
reset; line a1 一號 rag-collector 5; line a2 二號 arcrun-engineer 5
t 0 "③ 2 條在跑(上限 3)⇒ 放行"
reset
for i in 1 2 3 4 5; do line "b$i" "第$i號" rag-collector 5; finished "b$i"; done
t 0 "④ 5 條都收到完工通知 ⇒ 放行(前一條收工後,下一條派得出去)"
reset
for i in 1 2 3 4 5; do line "c$i" "第$i號" rag-collector 3600; done
t 0 "⑤ 5 條的 transcript 都安靜超過 15 分(收工了或當掉)⇒ 放行,不會永久卡死"
reset; line d1 一號 rag-collector 5; line d2 二號 rag-collector 5; line d3 三號 rag-collector 5
t 0 "⑥ 到上限,但動作不是派工(Bash)⇒ 這支不管" Bash
reset; line e1 一號 rag-collector 5; line e2 二號 rag-collector 5; line e3 三號 rag-collector 5
touch "$ESC"
t 0 "⑦ 到上限但逃生門在 ⇒ 放行"
N=$((N+1))
if [ -f "$ESC" ]; then
printf ' ❌ ⑧ 逃生門用完沒被消耗——那會變成永久豁免\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧ 逃生門用掉就消失(不是永久豁免)\n'; PASS=$((PASS+1))
fi
reset; line f1 一號 rag-collector 5; line f2 二號 rag-collector 5; line f3 三號 rag-collector 5
finished f2
t 0 "⑨ 3 條裡有 1 條收工了 ⇒ 剩 2 條,放行"
reset; line g1 一號 rag-collector 5 toolu_G1; line g2 二號 rag-collector 5 toolu_G2; line g3 三號 rag-collector 5 toolu_G3
sync_result toolu_G2
t 0 "⑩ 同步 agent 的 tool_result 不是 launch ack ⇒ 算它交件了,放行"
reset; line h1 一號 rag-collector 5; line h2 二號 rag-collector 5
rm -f "$ROOT/$SID.jsonl"
t 0 "⑪ 主 transcript 讀不到,只靠安靜窗 ⇒ 2 條不到上限,放行"
reset; line i1 一號 rag-collector 5; line i2 二號 rag-collector 5; line i3 三號 rag-collector 5
t 0 "⑫ transcript_path 指到不存在的佈局 ⇒ 數不出來就放行(不擋在不確定上)" \
Agent "$ROOT/nowhere/NOPE.jsonl"
N=$((N+1))
brc=$(printf 'this is not json' | bash "$HOOK" >/dev/null 2>&1; echo $?)
if [ "$brc" -eq 0 ]; then
printf ' ✅ ⑬ payload 壞掉 ⇒ 放行(fail-open 是刻意的)\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑬ payload 壞掉卻擋下來了(exit=%s)——那會卡死整台機器\n' "$brc"; FAIL=$((FAIL+1))
fi
N=$((N+1))
bmsg=$(printf 'this is not json' | bash "$HOOK" 2>&1 >/dev/null)
if printf '%s' "$bmsg" | grep -q "沒有把關"; then
printf ' ✅ ⑭ 而且它會出聲說「這一次沒有把關」(不是靜默放行)\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑭ 出錯時靜默放行了——房規禁止靜默\n'; FAIL=$((FAIL+1))
fi
echo
echo "── B 群:該擋 ──"
reset; line j1 一號 rag-collector 5; line j2 二號 arcrun-engineer 5; line j3 三號 isep-guard 5
t 2 "⑮ 剛好 3 條在跑(上限 3)⇒ 擋第 4 條"
reset
for i in 1 2 3 4 5 6 7; do line "k$i" "第$i號" rag-collector 5; done
t 2 "⑯ 7 條在跑(就是 08-29 那天的形狀)⇒ 擋"
reset; line l1 一號 rag-collector 5
t 2 "⑰ 上限調成 1,1 條在跑 ⇒ 擋(上限可調)" Agent "$ROOT/$SID.jsonl" "ISEP_MAX_PARALLEL_LINES=1"
reset; line m1 一號 rag-collector 5; line m2 二號 rag-collector 5; line m3 三號 rag-collector 5
t 2 "⑱ Task 這條路也管得到(不是只認 Agent)" Task
reset; line n1 一號 rag-collector 5 toolu_N1; line n2 二號 rag-collector 5 toolu_N2; line n3 三號 rag-collector 5 toolu_N3
launch_ack toolu_N1; launch_ack toolu_N2; launch_ack toolu_N3
t 2 "⑲ 三條都只有 launch ack(還在跑)⇒ 擋"
reset
for i in 1 2 3; do line "o$i" "第$i號" rag-collector 5; done
python3 - "$ROOT/$SID/subagents/agent-o2.meta.json" <<'PY'
import json, sys
p = sys.argv[1]; d = json.load(open(p)); d["spawnDepth"] = 2
json.dump(d, open(p, "w"), ensure_ascii=False)
PY
t 2 "⑳ 巢狀派出去的線(spawnDepth 2)一樣算進去——它一樣吃記憶體"
reset; line p1 一號 rag-collector 5; line p2 二號 rag-collector 5; line p3 三號 rag-collector 5
touch "$ESC"
printf '%s' "$(payload)" | bash "$HOOK" >/dev/null 2>&1
t 2 "㉑ 逃生門用過一次之後,第二次照樣擋"
reset; line q1 一號 rag-collector 890; line q2 二號 rag-collector 890; line q3 三號 rag-collector 890
t 2 "㉒ 安靜 890 秒(還在 900 秒窗內)⇒ 仍算在跑,擋"
echo
echo "── C 群:訊息本身 ──"
reset
line r1 install-twice-163 rag-collector 5
line r2 dup-machine-icon-185 arcrun-engineer 5
line r3 parallel-cap-109 isep-guard 5
t 2 "㉓ 到上限 ⇒ 擋(下面幾條檢查這一次的訊息)"
has "現在有 3 條在跑" "㉔ 說得出現在有幾條在跑"
has "install-twice-163" "㉕ 列得出第一條的名字"
has "dup-machine-icon-185" "㉖ 列得出第二條的名字"
has "parallel-cap-109" "㉗ 列得出第三條的名字"
has "跑 2-3 條應該是極限了" "㉘ 說得出上限從哪來(leo 原話)"
has "touch /tmp/.parallel-ok-" "㉙ 講得出逃生門怎麼開"
has "ISEP_MAX_PARALLEL_LINES" "㉚ 講得出上限怎麼調"
has "沒有總管例外" "㉛ 講明它擋的是總管自己"
hasnt "agentId" "㉜ 不外洩 harness 說不要往外貼的內部 id"
hasnt '$(' '㉝ 訊息沒有被 shell 展開(錢字號括號不該留在輸出裡)'
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]