Files
ISEP/hooks/queue-drain-guard.sh
T
Leo 26aee6dffb queue-drain-guard:pick 自己 150s 收手,逾時當成讀不到(inkstone/ISEP#33)
09-13 本機實量 Gitea 同一分鐘 curl 60s 逾時、整趟 pick --all 約 15 分鐘;
hooks.json 給本閘 180s,被砍掉=非阻擋錯誤=放行收工。實跑真 Gitea:150s 逾時 → exit 2、log 寫入。
測試 26/26。

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

189 lines
8.8 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
# 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-1009-1109-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 17s0.3s42s 握手逾時,
# 整趟 `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 <<MSG
🔁 佇列警察:**這個 session 在接票,而池子裡還有一張現在就能抓的票——還不是收工的時候。**
下一張:$REF $TITLE
認領:python3 "$PLUGIN_ROOT/scripts/ticket" claim $REF --name <你的名字>
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 <<MSG
🔁 佇列警察:**讀不到佇列 ≠ 佇列是空的。**
\`ticket pick\` 這次沒拿到 Gitea(連線或主線檔)。重跑一次:
python3 "$PLUGIN_ROOT/scripts/ticket" pick
抓得到就認領;真的抓盡(離開碼 1)就可以收工。連續讀不到 3 次,本閘放行並留痕。
MSG
exit 2 ;;
*)
note "✅ 放行" "$VERDICT"
exit 0 ;;
esac