Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1df3c7a08 | |||
| 544beefc5e | |||
| c0eec76b52 | |||
| 0d49c0dcd7 | |||
| b2d9141a7d | |||
| 26aee6dffb | |||
| ec9f28d9e8 | |||
| 4d27132413 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(86 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、62 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.29.0",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(87 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、62 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.30.1",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+14
-2
@@ -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 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
@@ -38,6 +38,17 @@
|
||||
> **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=61、
|
||||
> `grep -c '"command":' hooks/hooks.json`=85、`ls -p scripts | grep -v / | wc -l`=60)。
|
||||
|
||||
> 📌 **`0.30.0`(`inkstone/ISEP#33`,2026-09-13)+1 支、+1 條**:`queue-drain-guard.sh`(F 組,Stop)——
|
||||
> 唯一一支看「佇列」的收工閘:在接票的 session,`ticket pick` 還有票就不准收工。
|
||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||
|
||||
> 📌 **`0.30.1`(`inkstone/ISEP#130`,2026-09-13)+0 支、+0 條**:修補 0.30.0 的抓票入口——
|
||||
> `mainline set/refresh/adopt` 讀不到任何一條 `/dependencies` 時,名單標成不完整、`set` 離開碼 3 不印 ✅、
|
||||
> 保留舊快取;否則雲端工人會拿殘缺名單 `pick` 到「抓盡」而收工(09-13 實撞:hub 票底下三張整批漏收)。
|
||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||
|
||||
> 📌 **`inkstone/ISEP#147`(2026-09-07)+0 支、+1 條**:`line-needs-own-worktree.sh` 多掛 PostToolUse `Agent|Task`
|
||||
> ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。
|
||||
> 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117`/`InkStoneCo-wt-112`
|
||||
@@ -254,6 +265,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:
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+31
-5
@@ -364,18 +364,42 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
回 (members, info):
|
||||
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
|
||||
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
|
||||
"reached": bool, "capped": bool}
|
||||
"reached": bool, "capped": bool, "unread": [...], "complete": bool}
|
||||
🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
|
||||
(讀不到 ≠ 沒有成員)。
|
||||
🔴 `unread` = 讀不到 `/dependencies` 的票(重讀過仍失敗)。**只要有一張,名單就不完整**:
|
||||
那張底下的前置全部沒收到(inkstone/ISEP#130 c6973:Gitea 握手逾時,hub 票底下三張全漏,
|
||||
`set` 卻印 ✅)。`complete`=reached 且沒有 unread 且沒被上限截斷——呼叫端只准在它為真時
|
||||
把名單當成完整的寫出去。
|
||||
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
|
||||
"""
|
||||
import time
|
||||
import urllib.parse
|
||||
try:
|
||||
retries = max(0, int(os.environ.get("ISEP_MEMBER_RETRIES", "2")))
|
||||
except ValueError:
|
||||
retries = 2
|
||||
try:
|
||||
backoff = max(0.0, float(os.environ.get("ISEP_MEMBER_BACKOFF", "2")))
|
||||
except ValueError:
|
||||
backoff = 2.0
|
||||
|
||||
def read_list(path):
|
||||
"""讀一頁清單;拿不到 list 就重讀(握手逾時常常下一次就好)。仍拿不到回 None。"""
|
||||
for n in range(retries + 1):
|
||||
got = api(path)
|
||||
if isinstance(got, list):
|
||||
return got
|
||||
if n < retries and backoff:
|
||||
time.sleep(backoff * (n + 1))
|
||||
return None
|
||||
|
||||
owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id")
|
||||
title = ms.get("title") or ""
|
||||
info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms),
|
||||
"reached": False, "capped": False}
|
||||
"reached": False, "capped": False, "unread": [], "complete": False}
|
||||
q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100})
|
||||
rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q))
|
||||
rows = read_list("/repos/%s/%s/issues?%s" % (owner, repo, q))
|
||||
if not isinstance(rows, list):
|
||||
return [], info
|
||||
info["reached"] = True
|
||||
@@ -400,9 +424,10 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
parts = split_ref(cur)
|
||||
if not parts:
|
||||
continue
|
||||
deps = api("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
|
||||
deps = read_list("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
|
||||
if not isinstance(deps, list):
|
||||
continue # 這一張讀不到:跳過它,不當成「沒有相依」以外的事
|
||||
info["unread"].append(cur) # 讀不到 ≠ 沒有相依:記下來,名單就不算完整
|
||||
continue
|
||||
for d in deps:
|
||||
r = issue_ref(d)
|
||||
if not r or r in seen:
|
||||
@@ -412,6 +437,7 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
break
|
||||
seen.add(r); out.append(r); queue.append(r)
|
||||
info["via_deps"].append(r)
|
||||
info["complete"] = not info["unread"] and not info["capped"]
|
||||
return sorted(set(out)), info
|
||||
|
||||
|
||||
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/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
|
||||
@@ -95,7 +95,14 @@ class H(BaseHTTPRequestHandler):
|
||||
r = "%s/%s#%s" % m.groups()[:3]
|
||||
if r not in w["issues"]: return self._send(404, {"message": "not found"})
|
||||
if not m.group(4): return self._send(200, issue_obj(w, r))
|
||||
if m.group(4) == "/dependencies": return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
||||
if m.group(4) == "/dependencies":
|
||||
# inkstone/ISEP#130 c6973:Gitea 握手逾時時,票本身讀得到、它的 /dependencies 讀不到
|
||||
fo = os.path.join(TMP, "depsfail-once"); ff = os.path.join(TMP, "depsfail")
|
||||
if os.path.exists(fo) and r in open(fo).read().split():
|
||||
os.remove(fo); return self._send(500, {"message": "handshake timed out (test, once)"})
|
||||
if os.path.exists(ff) and r in open(ff).read().split():
|
||||
return self._send(500, {"message": "handshake timed out (test)"})
|
||||
return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
||||
return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs])
|
||||
return self._send(404, {"message": "not found", "path": p})
|
||||
def do_POST(self):
|
||||
@@ -299,6 +306,38 @@ OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)"
|
||||
rm -f "$TMP/down"
|
||||
|
||||
echo "── F. 讀不到任何一條 /dependencies ⇒ 不准寫出一份看起來完整的名單(inkstone/ISEP#130 → c6973)──"
|
||||
jget(){ python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get(sys.argv[2]); print(v if not isinstance(v,list) else ' '.join(v))" "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$1"; }
|
||||
export ISEP_MEMBER_BACKOFF=0
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" != 0 ] && ok "F1 hub 票的 /dependencies 讀不到 ⇒ set 離開碼非 0(實際 $RC)" || no "F1 set 當成成功" "$RC $OUT"
|
||||
printf '%s' "$OUT" | grep -q '✅' && no "F2 名單不完整卻印 ✅(c6973 重現的就是這個)" "$OUT" || ok "F2 名單不完整時不印 ✅"
|
||||
case "$OUT" in *"成員不完整"*"inkstone/InkStoneCo#44"*) ok "F3 訊息講出「成員不完整」並點名讀不到的是哪一張" ;; *) no "F3 沒講不完整/沒點名" "$OUT" ;; esac
|
||||
[ "$(jget members_complete)" = "False" ] && ok "F4 寫出去的主線檔標 members_complete=false(讀檔的人也看得出)" || no "F4 檔案沒標不完整" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
case " $(jget members_unread) " in *" inkstone/InkStoneCo#44 "*) ok "F5 members_unread 列出讀不到的票" ;; *) no "F5 沒列" "$(jget members_unread)" ;; esac
|
||||
[ "$(jget ref)" = "inkstone/Arcrun#48" ] && ok "F6 主線本身照樣標定(壞的只有成員那一格)" || no "F6 主線沒標上" "$(jget ref)"
|
||||
OUT=$(python3 "$TICKET" pick --mainline "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" 2>&1); RC=$?
|
||||
[ "$RC" != 1 ] && ok "F7 同樣讀不到時 ticket pick 不回離開碼 1(不會讓 queue-drain-guard 當成抓盡;實際 $RC)" || no "F7 pick 回了「抓盡」" "$OUT"
|
||||
rm -f "$TMP/depsfail"
|
||||
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail-once"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '✅' && ok "F8 只逾時一次 ⇒ 重讀一次就完整,set 照常 ✅" || no "F8 沒重試" "$RC $OUT"
|
||||
case " $(members) " in *" inkstone/arcrun-rag#104 "*) ok "F9 重讀後 hub 票底下的 arcrun-rag#104 收到了" ;; *) no "F9 還是漏收" "$(members)" ;; esac
|
||||
[ "$(jget members_complete)" = "True" ] && ok "F10 完整時 members_complete=true" || no "F10 完整卻沒標" "$(jget members_complete)"
|
||||
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
case "$OUT" in *"成員"*"張"*) ok "F11 refresh --members 讀得到時會印出成員張數(c6973:原本零輸出)" ;; *) no "F11 refresh --members 還是零輸出" "$OUT" ;; esac
|
||||
BEFORE=$(members)
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && ok "F12 refresh --members 讀不到也不炸(SessionStart 那條不准炸)" || no "F12 炸了" "$RC $OUT"
|
||||
[ "$(members)" = "$BEFORE" ] && ok "F13 不完整時保留舊快取,不拿殘缺名單蓋掉" || no "F13 快取被蓋掉" "$BEFORE → $(members)"
|
||||
case "$OUT" in *"成員不完整"*) ok "F14 refresh --members 講出成員不完整" ;; *) no "F14 靜默" "$OUT" ;; esac
|
||||
rm -f "$TMP/depsfail"
|
||||
unset ISEP_MEMBER_BACKOFF
|
||||
|
||||
echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──"
|
||||
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1)
|
||||
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT"
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/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'"
|
||||
|
||||
printf '0' > "$TMP/rc"; printf '%s' "$TICKET" > "$TMP/out"
|
||||
ISEP_DRAIN_PICK_CMD="sleep 5; sh $TMP/pick.sh" ISEP_DRAIN_PICK_TIMEOUT=1 run "★ pick 超過時限(Gitea 慢)→ 當成讀不到而擋,不是被砍掉後放行" 2 s10 worker.jsonl
|
||||
check "逾時那次訊息是「讀不到 ≠ 沒票」" "grep -q '讀不到' '$TMP/stderr'"
|
||||
|
||||
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
|
||||
+51
-7
@@ -252,21 +252,50 @@ def cmd_set(argv):
|
||||
"members": [],
|
||||
}
|
||||
refs, info = collect_members(ms)
|
||||
complete = bool(info.get("reached") and info.get("complete"))
|
||||
if not complete:
|
||||
# 名單不完整:同一條線重標時,舊快取裡有的成員先留著(讀不到 ≠ 沒有)
|
||||
prev = ML.load() or {}
|
||||
if prev.get("ref") == ms["ref"]:
|
||||
refs = sorted(set(refs) | set(prev.get("members") or []))
|
||||
ms["members"] = refs
|
||||
ms["members_complete"] = complete
|
||||
ms["members_unread"] = incomplete_what(info)
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
ms["set_at"] = ms["refreshed_at"] = now
|
||||
if not ML.save(ms, mirror=True):
|
||||
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
|
||||
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title))
|
||||
# 🔴 inkstone/ISEP#130 c6973:名單不完整時不准印 ✅——雲端工人靠這份名單抓票,
|
||||
# 漏收=以為抓盡=收工。主線本身照樣標定,壞的只有成員那一格,所以講清楚是哪一格。
|
||||
print("%s 主線已標定:%s/%s#%d「%s」%s" % (
|
||||
"✅" if complete else "⚠️", owner, repo, mid, title, "" if complete else "(🔴 成員不完整)"))
|
||||
if hub:
|
||||
print(" hub 票:%s" % hub)
|
||||
print(members_line(refs, info) if info.get("reached")
|
||||
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
|
||||
if info.get("capped"):
|
||||
print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP)
|
||||
if not complete:
|
||||
print(incomplete_line(info))
|
||||
print(mirror_line())
|
||||
print()
|
||||
cmd_show([])
|
||||
if not complete:
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def incomplete_what(info):
|
||||
"""名單不完整的原因,寫進主線檔(讀檔的人不必重跑也看得出)。完整時回 []。"""
|
||||
what = list(info.get("unread") or [])
|
||||
if not info.get("reached"):
|
||||
what.insert(0, "(hub 里程碑那一頁)")
|
||||
if info.get("capped"):
|
||||
what.append("(相依走到 %d 張上限)" % ML.MEMBER_WALK_CAP)
|
||||
return what
|
||||
|
||||
|
||||
def incomplete_line(info):
|
||||
return (" 🔴 成員不完整:讀不到 %s——那底下的前置沒收到。\n"
|
||||
" 雲端工人靠這份名單抓票;Gitea 恢復後重跑 `mainline refresh --members`(或同一行 set)補齊。"
|
||||
% "、".join(incomplete_what(info)))
|
||||
|
||||
|
||||
def mirror_line():
|
||||
@@ -328,6 +357,9 @@ def cmd_refresh(argv):
|
||||
except SystemExit:
|
||||
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
|
||||
if not isinstance(d, dict) or not d.get("title"):
|
||||
if "--members" in argv: # 明講要成員的人要看得到結果(c6973:原本零輸出)
|
||||
print(" ⚠️ 讀不到主線里程碑 %s——成員沒更新,沿用舊快取 %d 張"
|
||||
% (ML.ref_of(ms), len(ms.get("members") or [])))
|
||||
return # 讀不到 ≠ 主線不存在
|
||||
ms.update({
|
||||
"title": d.get("title") or ms["title"],
|
||||
@@ -342,12 +374,19 @@ def cmd_refresh(argv):
|
||||
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
|
||||
# `set`/`adopt` 會重收,`pick`/`has`/focus guard 本來就每次現問 Gitea,快取只餵 `show`。
|
||||
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
|
||||
# 🔴 inkstone/ISEP#130 c6973:只有**完整**的名單才准蓋掉快取;不完整就留舊的並講出來。
|
||||
try:
|
||||
refs, info = collect_members(ms)
|
||||
except SystemExit:
|
||||
refs, info = [], {"reached": False}
|
||||
if info.get("reached"):
|
||||
if info.get("reached") and info.get("complete"):
|
||||
ms["members"] = refs
|
||||
ms["members_complete"], ms["members_unread"] = True, []
|
||||
print(members_line(refs, info))
|
||||
else:
|
||||
ms["members_complete"], ms["members_unread"] = False, incomplete_what(info)
|
||||
print(" ⚠️ 成員不完整,沿用舊快取 %d 張(沒有拿殘缺名單蓋掉)" % len(ms.get("members") or []))
|
||||
print(incomplete_line(info))
|
||||
ML.save(ms)
|
||||
|
||||
|
||||
@@ -391,13 +430,18 @@ hub 里程碑裡現在有:%s
|
||||
T.add_dependency(vparts, (owner, repo, num))
|
||||
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
|
||||
refs, info = collect_members(ms)
|
||||
if info.get("reached"):
|
||||
complete = bool(info.get("reached") and info.get("complete"))
|
||||
if complete:
|
||||
ms["members"] = sorted(set(refs) | {ref})
|
||||
else:
|
||||
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
|
||||
# 不完整(c6973):新收到的與舊快取取聯集,不拿殘缺名單蓋掉
|
||||
ms["members"] = sorted(set(ms.get("members") or []) | set(refs) | {ref})
|
||||
ms["members_complete"], ms["members_unread"] = complete, incomplete_what(info)
|
||||
ML.save(ms, mirror=True)
|
||||
print("✅ 補收:%s → %s" % (ref, how))
|
||||
print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)")
|
||||
print(members_line(ms["members"], info) if complete else " (成員清單=舊快取+這次讀得到的+這一張)")
|
||||
if not complete:
|
||||
print(incomplete_line(info))
|
||||
print(mirror_line())
|
||||
|
||||
|
||||
|
||||
@@ -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'))"
|
||||
|
||||
+44
-13
@@ -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()):
|
||||
|
||||
Reference in New Issue
Block a user