雲端工人做完一張就接下一張:佇列沒抓盡不准收工;自己帳號的票不再當成別人的(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>
This commit is contained in:
@@ -368,6 +368,15 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/queue-drain-guard.sh",
|
||||
"timeout": 180
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/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、同一套規則)
|
||||
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
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user