#!/usr/bin/env bash # queue-drain-guard.sh — 佇列警察:**在接票的 session,池子還有票就不准收工**(inkstone/ISEP#33) # # ── 補的是哪一格 ──────────────────────────────────────────────── # leo 2026-09-13(判定 ISEP 失敗): # 「不會在一條任務完成後啓動另一個 loop」「任務完成自己去拿下一個任務⋯⋯它完全做不到,一直停工」 # # 官方文件(code.claude.com/docs/en/routines):Routine 每次觸發=**一個** cloud session, # 跑完就結束;session 裡沒有 approval prompt。⇒ 「做完一張接下一張」只可能有兩種來源: # ① 模型自己決定不停 ② Stop hook 以 exit 2 擋下收工(hooks 文件:「Prevents Claude from stopping」) # ISEP 的 12 支 Stop 閘**沒有一支看佇列**——它們量的是這回合有沒有 tool call、有沒有宣告下一步、 # 連續幾個乾回合、清單檔還剩幾行(那個清單檔要模型自己寫)。所以「做完一張、pick 還有票、 # 模型寫完回報就停」這個形狀,12 支全部放行。09-10/09-11/09-12 三趟 cloud-worker run # 都是這樣結束的(arcrun-rag#138 c6913、Arcrun#86 c6917、Arcrun#210 c6920)。 # # ── 判準:狀態,不是措辭 ──────────────────────────────────────── # 啟動條件(任一): # · 這個 session 真的**執行過** `ticket pick|claim|handback`(Bash tool_use,文字裡提到不算) # ⇒ 它在當工人。總管跟 leo 聊天的 session 不會被這支咬。 # · 環境變數 ISEP_DRAIN=1(雲端環境可設,讓第一回合就生效) # 量什麼:跑一次 `ticket pick --json`(跟工人用的是同一支、同一套規則,不另立判準) # 離開碼 0 = 有票 ⇒ 擋,把那張票號與認領指令交給它 # 離開碼 1 = 真的抓盡 ⇒ 放行(這是正常收工) # 其他 = 讀不到 ⇒ 擋兩次叫它重跑 pick(讀不到 ≠ 沒票),第三次放行並留痕 # # ── 為什麼不理 stop_hook_active ───────────────────────────────── # 別的 Stop 閘擋過一次就讓路,正是「擋一下、寫一段、照樣停」的來源。本閘改用兩道**有限**的保險: # · 同一張票連續被指名 3 次仍沒被認領 ⇒ 放行(它可能真的做不了,該把理由寫回票、改欄位) # · 一個 session 最多擋 40 次 # ⇒ 不可能鎖死,而且鎖不住的那一格會留在 log 裡。 # # ── log 寫在哪 ────────────────────────────────────────────────── # 不用 $CLAUDE_PROJECT_DIR:雲端沒有它,21 支閘的 log 因此靜默寫進 /.claude/hooks/(ISEP#130 c6873)。 # 狀態與 log 一律寫 ${ISEP_DRAIN_STATE_DIR:-${TMPDIR:-/tmp}},那裡在哪台都寫得進去。 # # 迴歸測試:hooks/tests/queue-drain-guard.test.sh set -u PAYLOAD=$(cat 2>/dev/null || echo '{}') PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="${ISEP_DRAIN_STATE_DIR:-${TMPDIR:-/tmp}}" STATE_DIR="${STATE_DIR%/}" LOG="$STATE_DIR/queue-drain-guard.log" STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?") note() { printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOG" 2>/dev/null || true; } # ① 這個 session 在不在接票?(只看真的執行過的 Bash 指令) INFO=$(printf '%s' "$PAYLOAD" | python3 -c ' import json, os, re, sys try: d = json.load(sys.stdin) except Exception: print("SKIP:bad-payload"); raise SystemExit sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64] if os.environ.get("ISEP_DRAIN") == "1": print("ON:" + sid); raise SystemExit tp = d.get("transcript_path") or "" if not tp or not os.path.exists(tp): print("SKIP:no-transcript:" + sid); raise SystemExit pat = re.compile(r"(^|[\s/\x22\x27])ticket\x22?\s+(pick|claim|handback)\b") try: with open(tp) as f: for line in f: try: r = json.loads(line) except Exception: continue if r.get("type") != "assistant": continue for b in (r.get("message") or {}).get("content") or []: if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("name") == "Bash": cmd = (b.get("input") or {}).get("command") or "" if isinstance(cmd, str) and pat.search(cmd): print("ON:" + sid); raise SystemExit except SystemExit: raise except Exception: print("SKIP:unreadable:" + sid); raise SystemExit print("OFF:" + sid) ' 2>/dev/null) || INFO="SKIP:crash" case "$INFO" in ON:*) SID="${INFO#ON:}" ;; OFF:*) exit 0 ;; *) note "⚪ 略過" "$INFO"; exit 0 ;; esac # ② 問佇列(同一支 pick、同一套規則),**有時限** # 2026-09-13 本機實量:Gitea 同一分鐘 curl 60s 逾時、urllib 17s/0.3s/42s 握手逾時, # 整趟 `pick --all` 跑了約 15 分鐘。hooks.json 給本閘 180s;閘被 Claude Code 砍掉=非阻擋錯誤=放行收工, # 正好又回到「讀不到就停」。⇒ 自己在 150s 收手,逾時當成「讀不到」(擋,叫它重跑 pick)。 if [ -n "${ISEP_DRAIN_PICK_CMD:-}" ]; then PICK_ARGV="sh -c \"\$ISEP_DRAIN_PICK_CMD\"" else PICK_ARGV="python3 \"$PLUGIN_ROOT/scripts/ticket\" pick --json" fi OUT=$(PICK_ARGV="$PICK_ARGV" python3 -c ' import os, subprocess, sys try: lim = float(os.environ.get("ISEP_DRAIN_PICK_TIMEOUT", "150")) except ValueError: lim = 150.0 try: p = subprocess.run(["sh", "-c", os.environ["PICK_ARGV"]], capture_output=True, text=True, timeout=lim) sys.stdout.write(p.stdout); sys.exit(p.returncode) except subprocess.TimeoutExpired: sys.exit(124) ' 2>/dev/null); RC=$? STATE="$STATE_DIR/.queue-drain-$SID.json" VERDICT=$(OUT="$OUT" RC="$RC" STATE="$STATE" python3 -c ' import json, os rc = int(os.environ["RC"]); out = os.environ.get("OUT", ""); path = os.environ["STATE"] try: st = json.load(open(path)) except Exception: st = {} blocks = int(st.get("blocks", 0)); same = int(st.get("same", 0)) last = st.get("last_ref"); miss = int(st.get("unreachable", 0)) MAX_BLOCKS, MAX_SAME, MAX_MISS = 40, 3, 2 def save(v): st.update(blocks=blocks, same=same, last_ref=last, unreachable=miss) try: json.dump(st, open(path, "w")) except Exception: pass print(v); raise SystemExit if rc == 1: same, last, miss = 0, None, 0 save("ALLOW:queue-empty") if rc != 0: miss += 1 if miss > MAX_MISS: save("ALLOW:unreachable-%d" % miss) blocks += 1 save("BLOCK_UNREACHABLE:%d" % miss) miss = 0 try: row = json.loads(out) ref, title = row.get("ref"), (row.get("title") or "")[:80] except Exception: save("ALLOW:unparsable") if not ref: save("ALLOW:no-ref") same = same + 1 if ref == last else 1 last = ref if same > MAX_SAME: save("ALLOW:stuck-on:%s" % ref) if blocks >= MAX_BLOCKS: save("ALLOW:max-blocks") blocks += 1 save("BLOCK:%s\t%s\t%d" % (ref, title, same)) ' 2>/dev/null) || VERDICT="ALLOW:crash" case "$VERDICT" in BLOCK:*) body="${VERDICT#BLOCK:}" REF=$(printf '%s' "$body" | cut -f1); TITLE=$(printf '%s' "$body" | cut -f2); N=$(printf '%s' "$body" | cut -f3) note "⛔ 擋下" "池子還有 $REF(第 $N 次指名)" cat >&2 < leo 2026-09-13:「任務完成自己去拿下一個任務,幫我消化任務」。 回報寫在票上就好,**不必在這裡寫收工摘要**——摘要等 pick 抓盡再寫一次。 這張其實做不了(要 leo 的手/要 Mac/前提不在)? 不要停在這裡:把「為什麼」寫回那張票,並改掉讓它可抓的欄位(s/* 或 Human), pick 就不會再回它,接著抓下一張。四題人閘命中的票,也是這樣留在票上,**不是停 session**。 (同一張連續指名 3 次仍沒動,本閘會放行並記進 log——那張需要總管看。) MSG exit 2 ;; BLOCK_UNREACHABLE:*) note "⛔ 擋下" "pick 讀不到佇列(第 ${VERDICT#BLOCK_UNREACHABLE:} 次)" cat >&2 <