From ec9f28d9e8dd2467b1a82243920b1d80d814fdd9 Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 13 Sep 2026 12:37:49 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9B=B2=E7=AB=AF=E5=B7=A5=E4=BA=BA=E5=81=9A?= =?UTF-8?q?=E5=AE=8C=E4=B8=80=E5=BC=B5=E5=B0=B1=E6=8E=A5=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E5=BC=B5=EF=BC=9A=E4=BD=87=E5=88=97=E6=B2=92=E6=8A=93=E7=9B=A1?= =?UTF-8?q?=E4=B8=8D=E5=87=86=E6=94=B6=E5=B7=A5=EF=BC=9B=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E5=B8=B3=E8=99=9F=E7=9A=84=E7=A5=A8=E4=B8=8D=E5=86=8D=E7=95=B6?= =?UTF-8?q?=E6=88=90=E5=88=A5=E4=BA=BA=E7=9A=84=EF=BC=88inkstone/ISEP#33?= =?UTF-8?q?=20=E2=86=92=206924=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- docs/hooks-inventory.md | 5 +- hooks/hooks.json | 9 ++ hooks/queue-drain-guard.sh | 173 ++++++++++++++++++++++++++ hooks/tests/queue-drain-guard.test.sh | 105 ++++++++++++++++ scripts/test-ticket-pick.sh | 5 + scripts/ticket | 57 +++++++-- 7 files changed, 340 insertions(+), 16 deletions(-) create mode 100755 hooks/queue-drain-guard.sh create mode 100755 hooks/tests/queue-drain-guard.test.sh diff --git a/README.md b/README.md index b6e0618..b5f9bfa 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | | 數量 | 是什麼 | |---|---|---| -| `hooks/` | 61 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 86 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | +| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 87 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | | `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | | `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … | | `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` | diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index f939b52..7b753d2 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 61 支閘,白話盤點表 +# 62 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,7 +7,7 @@ ## 一句話結論 -`hooks/` 底下有 **61 個 `.sh` 檔**,`hooks.json` 實際掛上 **86 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **87 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 @@ -254,6 +254,7 @@ | `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 | | `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 | | `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 | +| `queue-drain-guard.sh` | **在接票的 session(跑過 `ticket pick/claim/handback`,或環境設了 `ISEP_DRAIN=1`),池子裡還有現在就能抓的票,就不准收工**——它自己跑一次 `ticket pick`,有票就把票號和認領指令交回去。這是 ISEP 唯一一支看「佇列」而不是看「這回合寫了什麼」的收工閘(`inkstone/ISEP#33`:雲端三趟 run 都是做完一件、還有票就停)。做不了的票要把理由寫回票、改欄位,pick 就不再回它。跟 leo 聊天的 session 不會被咬;同一張連續指名 3 次、讀不到佇列 3 次、或一個 session 擋滿 40 次就放行並留痕,不會鎖死。 | 🛑 擋(有上限) | > 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。 > 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58: diff --git a/hooks/hooks.json b/hooks/hooks.json index 86b8dfe..3890112 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -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": [ diff --git a/hooks/queue-drain-guard.sh b/hooks/queue-drain-guard.sh new file mode 100755 index 0000000..d79dfcb --- /dev/null +++ b/hooks/queue-drain-guard.sh @@ -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 < + +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 < "$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 <檔名> [純文字] +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 <說明> [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 <說明> + 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 diff --git a/scripts/test-ticket-pick.sh b/scripts/test-ticket-pick.sh index fa0266b..e259d97 100755 --- a/scripts/test-ticket-pick.sh +++ b/scripts/test-ticket-pick.sh @@ -143,6 +143,11 @@ p "hub(scope 容器,09-07 實查 ISEP#31–35 的形狀)→ 不可抓" "Fa p "PR 不是票 → 不可抓" "False" "m.pickable(I('ISEP',10,['s/todo'],'main',pr=True),'主線 M')[0]" p "里程碑不是要求的那個 → 不可抓" "False" "m.pickable(I('Arcrun',8,['s/todo'],'future'),'主線 M')[0]" p "里程碑標題只差空白/大小寫 → 仍算同一個(norm 只有 mainline.py 那一份)" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),' 主線 m ')[0]" +p "★ 只指派給自己的 s/todo(總管 handback 回池子的 Arcrun#176 形狀)→ 可抓(ISEP#33)" "True" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]" +p "指派給自己+別人 → 不可抓" "False" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code','Leo')),'主線 M','claude-code')[0]" +p "指派給自己但 s/doing(真的有人在做)→ 不可抓" "False" "m.pickable(I('Arcrun',197,['s/doing'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]" +p "不知道自己是誰(me=None)→ 照舊不可抓,不猜" "False" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code',)),'主線 M')[0]" +p "指派給自己但掛 Human → 不可抓(Human 照擋)" "False" "m.pickable(I('ISEP',130,['s/todo','Human'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]" p "理由講得出「為什麼不能抓」(不是只回 False)" "True" "'Human' in m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[1]" p "順序:p/high 先於沒標,沒標先於 p/low" "True" \ "m.pick_order(I('a',1,['s/todo','p/high'],'main')) < m.pick_order(I('a',2,['s/todo'],'main')) < m.pick_order(I('a',3,['s/todo','p/low'],'main'))" diff --git a/scripts/ticket b/scripts/ticket index 340f8e6..fab0e98 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -137,17 +137,36 @@ _TOLERATED = object() # api() 吞掉時回的哨兵——不是 None,None def api(path, payload=None, method=None): + """打一次 Gitea。**只有讀(GET)遇到連線層失敗才重試**(inkstone/ISEP#33)。 + + 2026-09-13 實撞:同一分鐘 curl 打 Gitea 回 200,這支的 urllib 卻兩次 + `_ssl.c:1063: The handshake operation timed out` ⇒ `pick` 離開碼 2 + ⇒ 訊息叫工人「先修連線,不要空轉」⇒ 在雲端那就是**這趟 run 收工**。 + 一次握手逾時不該等於一整天停工。寫入不重試:POST 留言重送會變成兩則。 + """ + import time url = path if path.startswith("http") else f"{HOST}/api/v1{path}" data = json.dumps(payload).encode() if payload is not None else None - req = urllib.request.Request( - url, data=data, method=method or ("POST" if data else "GET"), - headers={"Authorization": f"token {token()}", "Content-Type": "application/json"}) + verb = method or ("POST" if data else "GET") try: - return json.load(urllib.request.urlopen(req, timeout=40)) - except urllib.error.HTTPError as e: - if e.code in _TOLERATE: - return _TOLERATED - die(f"🔴 Gitea {e.code}:{e.read().decode()[:300]}") + retries = max(0, int(os.environ.get("ISEP_API_RETRIES", "3"))) + except ValueError: + retries = 3 + tries = 1 + (retries if verb == "GET" else 0) + for n in range(tries): + req = urllib.request.Request( + url, data=data, method=verb, + headers={"Authorization": f"token {token()}", "Content-Type": "application/json"}) + try: + return json.load(urllib.request.urlopen(req, timeout=40)) + except urllib.error.HTTPError as e: + if e.code in _TOLERATE: + return _TOLERATED + die(f"🔴 Gitea {e.code}:{e.read().decode()[:300]}") + except (urllib.error.URLError, OSError): + if n + 1 >= tries: + raise + time.sleep(float(os.environ.get("ISEP_API_BACKOFF", "2")) * (n + 1)) def parse_ref(s): @@ -1307,10 +1326,16 @@ def human_gated(labels): return any(n == "Human" or n.startswith("human/") for n in labels) -def pickable(issue, milestone_title): +def pickable(issue, milestone_title, me=None): """這張票現在能不能被雲端工人抓走。回 (ok, 理由)。**純函式,不打網路。** `milestone_title`=要求它掛在哪個里程碑底下(主線,或輪到的那個逾期里程碑)。 + `me`=抓票者自己的登入名。**只指派給自己的 s/todo 票算可抓**(inkstone/ISEP#33): + 雲端工人與總管用的是同一個機器帳號 `claude-code`,總管把票 handback 回池子時 + 指派欄就留著 `claude-code`。2026-09-13 實查主線:`Arcrun#176` 是 `s/todo`、 + 指派 `claude-code` ⇒ 舊規則說「已經指派給 claude-code」⇒ **工人把自己當成別人**, + 09-11 那趟 run(Arcrun#86 c6917)就是用這條理由判定「主線沒有可抓的票」然後收工。 + `s/todo` 的意思本來就是「沒人在做」;有人在做的形狀是 `s/doing`,那格照擋。 理由是給人看的:`pick --all` 與 `claim` 擋下時都印它,一張票為什麼不能抓要說得出來。 """ if issue.get("pull_request"): @@ -1322,7 +1347,9 @@ def pickable(issue, milestone_title): if issue.get("assignees") or issue.get("assignee"): who = [a.get("login") for a in (issue.get("assignees") or []) if isinstance(a, dict)] \ or [(issue.get("assignee") or {}).get("login")] - return False, "已經指派給 %s" % "、".join(str(w) for w in who if w) + others = [w for w in who if w and w != me] + if others or not me: + return False, "已經指派給 %s" % "、".join(str(w) for w in who if w) if human_gated(labels): return False, "掛著 %s——要 leo 親手做的" % "、".join(n for n in labels if n == "Human" or n.startswith("human/")) if "hub" in labels: @@ -1454,13 +1481,17 @@ def pick_candidates(ms, now=None): owner = ms.get("owner") or ORG anchor = "%s/%s#%s" % (ms.get("owner"), ms.get("repo"), ms.get("id")) + # 抓票者自己是誰:只指派給自己的 s/todo 票算可抓(inkstone/ISEP#33,見 pickable) + # 問不到(舊 token、測試替身沒有 /user)⇒ me=None ⇒ 照舊規則,不因為這一格讓 pick 整個死掉 + me = os.environ.get("ISEP_WORKER_LOGIN") or ((api_soft("/user") or {}).get("login")) + refs, info = mainline_members(ms) found = [] for ref in refs: it = _fetch_issue(ref) if not it or (it.get("state") or "open") != "open": continue - ok, _ = pickable(it, None) + ok, _ = pickable(it, None, me) if ok: found.append(it) groups = [("主線", ms["title"], sorted(found, key=pick_order))] @@ -1478,7 +1509,7 @@ def pick_candidates(ms, now=None): for it in _issues_in(owner, r, m.get("title")): if str((it.get("milestone") or {}).get("id")) != str(m.get("id")): continue - ok, _ = pickable(it, m.get("title")) + ok, _ = pickable(it, m.get("title"), me) if ok: found.append(it) groups.append(("逾期里程碑", "%s(%s/%s#%s)" % (m.get("title"), owner, r, m.get("id")), @@ -1559,7 +1590,7 @@ def cmd_claim(argv): ref = "%s/%s#%d" % (owner, repo, num) # 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題 on_mainline = ref in set(mainline_members(ms)[0]) - ok, why = pickable(issue, None if on_mainline else (ims.get("title") or "")) + ok, why = pickable(issue, None if on_mainline else (ims.get("title") or ""), who) if ok and not on_mainline: due = ML.due_of(ims) if not (due and due < _now()):