Files
ISEP/hooks/tests/queue-drain-guard.test.sh
T
Leo ec9f28d9e8 雲端工人做完一張就接下一張:佇列沒抓盡不准收工;自己帳號的票不再當成別人的(inkstone/ISEP#33 → 6924)
leo 09-13 判定 ISEP 失敗:「不會在一條任務完成後啓動另一個 loop」。實查三個機械根因:

1. 沒有任何 Stop 閘看佇列。Routine 一次觸發=一個 session,模型一停就結束;
   12 支 Stop 閘量的是 tool call 數/宣告句/乾回合/自寫清單,
   「做完一件、pick 還有票、寫完回報就停」全部放行。
   ⇒ 新增 hooks/queue-drain-guard.sh:跑過 ticket pick|claim|handback(或 ISEP_DRAIN=1)
   的 session,pick 有票就 exit 2 交回票號;抓盡放行;同張 3 次/讀不到 3 次/40 次上限放行並留痕。
   log 寫 TMPDIR,不靠雲端沒有的 CLAUDE_PROJECT_DIR。
2. pickable() 把指派給 claude-code 的 s/todo 當成「別人的」,而雲端工人就是 claude-code。
   09-13 實查主線 Arcrun#176=s/todo+指派 claude-code;09-11 run(Arcrun#86 c6917)
   就是以此判定「主線沒票」收工。⇒ 只指派給自己的 s/todo 算可抓;s/doing/Human 照擋。
3. api() 一次 TLS 握手逾時就讓 pick 離開碼 2(09-13 本機實撞,curl 同時 200)。
   ⇒ 只有 GET 對連線層失敗重試(預設 3 次,退避 2s×n);寫入不重試。

測試:queue-drain-guard 24/24、ticket-pick 58/58(+5)、ticket-repo-arg 7/7、
handoff-writeback 49/49;members-by-dependency 56/57,唯一紅的 ㊽ 在 main 上同樣紅(macOS 暫存路徑形狀)。
數字在這棵樹上實數:62 支閘(ls hooks/*.sh)/87 條註冊(grep -c '"command":')。
假設:工人與總管共用 claude-code 帳號維持現狀(改帳號是跨專案結構,交總管)。
版本號留 0.29.0,由總管併 main 後定版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:37:49 +08:00

106 lines
5.4 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.
#!/bin/bash
# queue-drain-guard.sh 的迴歸測試(inkstone/ISEP#33
#
# 全程離線:`ticket pick` 換成一支假的(ISEP_DRAIN_PICK_CMD),離開碼與輸出由測試指定。
# 兩個方向都測:該擋的(在接票、池子有票)與不該擋的(聊天 session、抓盡、鎖死保險)。
cd "$(dirname "$0")/../.." || exit 1
H=hooks/queue-drain-guard.sh
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
unset ISEP_DRAIN CLAUDE_PLUGIN_ROOT
cat > "$TMP/pick.sh" <<'SH'
#!/bin/sh
cat "$STUB_DIR/out" 2>/dev/null
exit "$(cat "$STUB_DIR/rc")"
SH
export STUB_DIR="$TMP" ISEP_DRAIN_PICK_CMD="sh $TMP/pick.sh"
stub() { printf '%s' "$1" > "$TMP/rc"; printf '%s' "${2:-}" > "$TMP/out"; }
TICKET='{"ref":"inkstone/Arcrun#176","title":"查一份筆記關係的人","group":"主線"}'
# transcript <檔名> <Bash 指令或空> [純文字]
transcript() {
python3 - "$TMP/$1" "$2" "${3:-}" <<'PY'
import json, sys
path, cmd, text = sys.argv[1], sys.argv[2], sys.argv[3]
rows = [{"type": "user", "message": {"content": "開工"}}]
content = []
if text: content.append({"type": "text", "text": text})
if cmd: content.append({"type": "tool_use", "name": "Bash", "input": {"command": cmd}})
rows.append({"type": "assistant", "message": {"content": content}})
open(path, "w").write("\n".join(json.dumps(r, ensure_ascii=False) for r in rows))
PY
}
run() { # run <說明> <want exit> <session> <transcript 檔> [stop_hook_active]
export ISEP_DRAIN_STATE_DIR="$TMP/state-$3"; mkdir -p "$ISEP_DRAIN_STATE_DIR"
payload=$(printf '{"session_id":"%s","transcript_path":"%s","stop_hook_active":%s}' "$3" "$TMP/$4" "${5:-false}")
printf '%s' "$payload" | bash "$H" > "$TMP/stdout" 2> "$TMP/stderr"; got=$?
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
}
check() { # check <說明> <shell 條件>
if eval "$2"; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf '%s\n' "$1"
}
transcript worker.jsonl 'python3 /x/scripts/ticket claim inkstone/Arcrun#175 --name cloud-worker'
transcript handback.jsonl 'python3 "$CLAUDE_PLUGIN_ROOT/scripts/ticket" handback inkstone/Arcrun#175 --to claude-code'
transcript chat.jsonl 'git status'
transcript textonly.jsonl '' '我等一下會跑 ticket pick 看看'
echo "── ① 該擋:在接票、池子還有票 ──"
stub 0 "$TICKET"
run "★ 跑過 ticket claim pick 有票 → 擋" 2 s1 worker.jsonl
check "訊息點名下一張票號" "grep -q 'inkstone/Arcrun#176' '$TMP/stderr'"
check "訊息給出認領指令(claim" "grep -q 'claim inkstone/Arcrun#176' '$TMP/stderr'"
check "訊息給出「做不了」的出路(寫回票+改欄位),不是叫它停" "grep -q '寫回那張票' '$TMP/stderr'"
run "引號包住路徑的 handback 也算在接票" 2 s2 handback.jsonl
run "★ stop_hook_active=true 仍擋(別的閘擋過一次就讓路,正是停工的來源)" 2 s3 worker.jsonl true
ISEP_DRAIN=1 run "ISEP_DRAIN=1:還沒跑過任何 ticket 指令也擋" 2 s4 chat.jsonl
check "log 寫在狀態目錄(不靠 CLAUDE_PROJECT_DIR" "grep -q '擋下' '$TMP/state-s4/queue-drain-guard.log'"
echo
echo "── ② 不該擋 ──"
stub 0 "$TICKET"
run "★ 沒跑過 ticket 指令的 session(跟 leo 聊天)→ 放行" 0 s5 chat.jsonl
run "文字裡提到 ticket pick、沒真的執行 → 放行(看動作不看措辭)" 0 s6 textonly.jsonl
stub 1 ""
run "★ pick 離開碼 1(真的抓盡)→ 放行" 0 s7 worker.jsonl
printf 'not json' | bash "$H" >/dev/null 2>&1; got=$?
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=0 got=%s 壞掉的 payload → 放行\n' "$got"
echo
echo "── ③ 鎖死保險 ──"
stub 0 "$TICKET"
run "同一張第 1 次 → 擋" 2 s8 worker.jsonl
run "同一張第 2 次 → 擋" 2 s8 worker.jsonl
run "同一張第 3 次 → 擋" 2 s8 worker.jsonl
run "★ 同一張第 4 次仍沒被認領 → 放行(它可能真的做不了)" 0 s8 worker.jsonl
check "放行理由記進 log(那張要總管看)" "grep -q 'stuck-on:inkstone/Arcrun#176' '$TMP/state-s8/queue-drain-guard.log'"
stub 0 '{"ref":"inkstone/Arcrun#197","title":"另一張"}'
run "換了一張 → 計數重來,擋" 2 s8 worker.jsonl
stub 2 ""
run "讀不到佇列第 1 次 → 擋(讀不到 ≠ 沒票)" 2 s9 worker.jsonl
check "訊息叫它重跑 pick" "grep -q 'pick' '$TMP/stderr' && grep -q '讀不到' '$TMP/stderr'"
run "讀不到第 2 次 → 擋" 2 s9 worker.jsonl
run "★ 讀不到第 3 次 → 放行並留痕" 0 s9 worker.jsonl
check "log 有 unreachable" "grep -q 'unreachable' '$TMP/state-s9/queue-drain-guard.log'"
echo
echo "── ④ 真的 pick(不帶假指令)接得上 ──"
unset ISEP_DRAIN_PICK_CMD
export ISEP_DRAIN_STATE_DIR="$TMP/state-real"; mkdir -p "$ISEP_DRAIN_STATE_DIR"
printf '{"session_id":"r1","transcript_path":"%s"}' "$TMP/worker.jsonl" \
| TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x ISEP_API_RETRIES=0 ISEP_MAINLINE_FILE="$TMP/none.json" \
CLAUDE_PLUGIN_ROOT="$PWD" bash "$H" >/dev/null 2>"$TMP/stderr"; got=$?
if [ "$got" = 2 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=2 got=%s 真的 scripts/ticket pick 讀不到主線 → 當成讀不到(擋),不是當成抓盡\n' "$got"
echo
echo "通過 $PASS 條,失敗 $FAIL 條"
[ "$FAIL" = 0 ] || exit 1