26aee6dffb
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>
189 lines
8.8 KiB
Bash
Executable File
189 lines
8.8 KiB
Bash
Executable File
#!/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 <<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
|