Files
ISEP/hooks/queue-drain-guard.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

174 lines
8.0 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、同一套規則)
if [ -n "${ISEP_DRAIN_PICK_CMD:-}" ]; then
OUT=$(sh -c "$ISEP_DRAIN_PICK_CMD" 2>/dev/null); RC=$?
else
OUT=$(python3 "$PLUGIN_ROOT/scripts/ticket" pick --json 2>/dev/null); RC=$?
fi
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