#!/usr/bin/env bash # parallel-lines-cap-guard.sh 的迴歸測試(inkstone/ISEP#109)。 # # **離線、不打網路、不花錢、每次結果一樣**——這支閘只讀檔案的存在與 mtime, # 沒有語意判官,所以不需要像 ask-user-question-guard 那樣另開一支 live 測試量準度。 # # 每個案例都自己搭一份假的 harness 佈局(跟真的同形狀): # /.jsonl 主 transcript # //subagents/agent-.meta.json harness 啟動那條線時寫的 # //subagents/agent-.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 <名字> <型別> <安靜幾秒> [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 —— 在主 transcript 補一則完工通知(實跡格式) finished() { printf '{"type":"queue-operation","operation":"enqueue","sessionId":"%s","content":"\\n%s\\ntoolu_%s\\n"}\n' \ "$SID" "$1" "$1" >> "$ROOT/$SID.jsonl" } # launch_ack —— 背景 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 —— 同步 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 ]