雲端工人做完一張就接下一張:佇列沒抓盡不准收工;自己帳號的票不再當成別人的(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:
2026-09-13 12:37:49 +08:00
parent 4d27132413
commit ec9f28d9e8
7 changed files with 340 additions and 16 deletions
+105
View File
@@ -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