From 06fb2c59c8113bcd75d4c2d256c5c629be277cfc Mon Sep 17 00:00:00 2001 From: richblack Date: Fri, 18 Sep 2026 13:58:56 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9B=B2=E7=AB=AF=E5=B7=A5=E4=BA=BA=E5=BE=9E?= =?UTF-8?q?=20open=20=E7=A5=A8=E6=B1=A0=E6=8C=81=E7=BA=8C=E9=A0=98?= =?UTF-8?q?=E7=A5=A8=EF=BC=8C=E5=8F=AF=E6=8A=93=200=20=E5=BC=B5=E6=99=82?= =?UTF-8?q?=E8=AC=9B=E5=87=BA=E6=B1=A0=E5=AD=90=E5=8E=BB=E5=93=AA=E4=BA=86?= =?UTF-8?q?=EF=BC=88inkstone/ISEP#130=20c7801=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:pick 只看主線成員與逾期里程碑;org 299 張 open 裡 254 張沒掛里程碑, 舊規則實跑可抓 0 張,09-18 六趟 run 都印「正常結果,不是故障」收工。 - 第三層:主線與逾期都抓盡、且是接票的環境(--pool 或 ISEP_DRAIN=1), 再看整個 open 票池裡 s/* 恰好是 s/todo 或 s/backlog 的票(不限里程碑)。 Human/human/*、指派給別人(含 Leo)、hub、PR、s/triage、沒有 s/* 一格不放寬。 - 本機沒設 ISEP_DRAIN 時不讀票池:總管跑過 handback 不會被佇列閘叫去抓票池(避免誤攔)。 - claim 同規則;可抓 0 張時列池子大小與每種不能抓的理由各幾張,不再說成正常。 - 分頁讀到空頁才停(伺服器每頁上限可能小於 50)。 假設(寫在這裡等總管 review):s/backlog 算「決定要做」可抓;沒有 s/* 的 72 張算未驗傷不抓、只計數。 實測:真 Gitea 改前可抓 0/改後 --pool 可抓 101;test-ticket-pick 80/80。 待總管定版。 Co-Authored-By: Claude Opus 5 --- docs/TESTING.md | 15 +++- docs/hooks-inventory.md | 2 +- hooks/queue-drain-guard.sh | 3 + scripts/test-ticket-pick.sh | 77 ++++++++++++++++-- scripts/ticket | 152 ++++++++++++++++++++++++++++++------ 5 files changed, 217 insertions(+), 32 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 0f3f993..4860c55 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -429,12 +429,12 @@ bash hooks/tests/gitea-arm-check.test.sh . - ①③⑤⑥ 任一紅 ⇒ 收手收得太晚,暫存區已經開始長東西(磁碟風險回來了) - ② 紅 ⇒ 訊息沒給出走得通的那一行(本票整張票在講的就是這件事) - ⑧⑨⑩ 任一紅 ⇒ **誤攔/複製錯**:正常用法被弄壞,或複本裡混進了 `hooks/` 以外的東西 -### A31 — 雲端工人抓票的規則是機械可判的:53 條 +### A31 — 雲端工人抓票的規則是機械可判的:80 條 ``` bash scripts/test-ticket-pick.sh ``` -**該看到**:`通過 53 條,失敗 0 條`。**全程離線**:假池子(3 個 repo、3 個 open milestone、 -12 張票、1 條相依邊)餵給換掉的 `api()`,時鐘走 `ISEP_COUNTDOWN_NOW`,`TICKET_HOST` 指到連不上的位址 +**該看到**:`通過 80 條,失敗 0 條`。**全程離線**:假池子(5 個 repo、3 個 open milestone、 +52 張票、1 條相依邊;`big` 那個 repo 的假伺服器每頁只給 30 張,驗分頁)餵給換掉的 `api()`,時鐘走 `ISEP_COUNTDOWN_NOW`,`TICKET_HOST` 指到連不上的位址 ——任何一條真的走到網路會當場炸,不會靜靜變成假綠。**不開票、不改票、不留任何東西在 Gitea 上。** **它在守什麼**(`inkstone/ISEP#131`):Routine 08-06 起讀 `journeys.md` 本 sprint 段, @@ -447,6 +447,9 @@ Gitea 主線里程碑抓票(`inkstone/InkStoneCo#118`),但那是一段給 「在主線上」=hub 里程碑裡、或被主線成員當相依(跨 repo 邊,inkstone/ISEP#133); 別 repo 裡同名的里程碑**不算**主線。主線抓盡才抓「逾期的 open 里程碑」(一個里程碑物件一組); backlog/沒里程碑且沒有邊的一律不抓 + ⇒ 帶 --pool 或環境 ISEP_DRAIN=1(接票的環境):兩層都抓盡後再看整個 open 票池, + s/* 恰好是 s/todo 或 s/backlog 就可抓(不限里程碑),其他格一格不放寬(inkstone/ISEP#130) + 可抓 0 張時列出池子大小與每種「不能抓的理由」各幾張,不再印「正常、不是故障」 認領 claim = 指派 + s/doing + 第一行【身份】的留言,一個動作 完成 handback --label s/review(本來就有的那半,不重做) ``` @@ -464,6 +467,12 @@ Gitea 主線里程碑抓票(`inkstone/InkStoneCo#118`),但那是一段給 - ③「該擋」六條任一紅 ⇒ 規則只長在 `pick` 這端,手動 `claim` 一張 Human 的票照樣過 - ③「身份名不在名單上 → 一通都沒打」紅 ⇒ 名單以外的名字碰到了 Gitea - ④ 第一條紅 ⇒ 連不上 Gitea 時回了離開碼 1,雲端會把「斷線」當「沒票」收工 +- ⑤「主線與逾期都抓盡、帶 --pool → 回 mira#25」紅 ⇒ 又回到 09-18 的形狀:幾百張 open 票,工人說沒事做 +- ⑤「--all 票池恰好是這幾張」紅 ⇒ 要嘛放寬了不該碰的(Human/指派 Leo/triage/沒 s/*/hub),要嘛漏了該抓的 +- ⑤「ISEP_DRAIN=1 = --pool」紅 ⇒ 雲端工人照 `cloud-worker.md` 打 `ticket pick`、佇列閘自己跑的 pick,都看不到票池 +- ②「沒帶 --pool → 票池一通都沒讀」紅 ⇒ **誤攔**:本機總管跑過 `handback` 後每次收工都會被佇列閘點名去抓票池 +- ⑤「可抓 0 張時列理由表、不說正常」紅 ⇒ 「0 張」又被說成正常收工(c7797) +- ⑤「分頁」紅 ⇒ 伺服器每頁上限比 50 小時,第二頁之後的票看不到 📌 **真實 Gitea 唯讀實跑**(2026-09-07,isep-hand,不改任何票): `ticket pick --mainline InkStoneCo/system-dev/mainline.json --all` 在主線 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 7ca10b6..6debc82 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -284,7 +284,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 次就放行並留痕,不會鎖死。 | 🛑 擋(有上限) | +| `queue-drain-guard.sh` | **在接票的 session(跑過 `ticket pick/claim/handback`,或環境設了 `ISEP_DRAIN=1`),池子裡還有現在就能抓的票,就不准收工**——它自己跑一次 `ticket pick`,有票就把票號和認領指令交回去。「池子」多大看環境:設了 `ISEP_DRAIN=1`(雲端工人)時 pick 會看到整個 open 票池的 `s/todo`/`s/backlog`;本機沒設時只看主線與逾期里程碑,所以總管跑過 `handback` 也不會被叫去抓票池(`inkstone/ISEP#130`)。這是 ISEP 唯一一支看「佇列」而不是看「這回合寫了什麼」的收工閘(`inkstone/ISEP#33`:雲端三趟 run 都是做完一件、還有票就停)。做不了的票要把理由寫回票、改欄位,pick 就不再回它。跟 leo 聊天的 session 不會被咬;同一張連續指名 3 次、讀不到佇列 3 次、或一個 session 擋滿 40 次就放行並留痕,不會鎖死。 | 🛑 擋(有上限) | | `baton-handback-guard.sh` | **背景派出去的工人交件時**(主對話收到完工通知),檢查那張工單票有沒有把棒子交回來(指派/標籤/下一步三格),缺就擋一次要 AI 補。工人還在跑的時候一聲都不出(`inkstone/ISEP#153`)。同一條線只點名一次;補完三格就不再出現。前景派工的那一半在下面 H 組。 | 🛑 擋(每條線至多一次) | > 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。 diff --git a/hooks/queue-drain-guard.sh b/hooks/queue-drain-guard.sh index 6213bb1..91693de 100755 --- a/hooks/queue-drain-guard.sh +++ b/hooks/queue-drain-guard.sh @@ -33,6 +33,9 @@ # 不用 $CLAUDE_PROJECT_DIR:雲端沒有它,21 支閘的 log 因此靜默寫進 /.claude/hooks/(ISEP#130 c6873)。 # 狀態與 log 一律寫 ${ISEP_DRAIN_STATE_DIR:-${TMPDIR:-/tmp}},那裡在哪台都寫得進去。 # +# 「池子」多大由 pick 決定:環境 ISEP_DRAIN=1 時 pick 會往下看到整個 open 票池(inkstone/ISEP#130); +# 本機沒設時只看主線與逾期——總管跑過 handback 不會因此被叫去抓票池。 +# # 迴歸測試:hooks/tests/queue-drain-guard.test.sh set -u diff --git a/scripts/test-ticket-pick.sh b/scripts/test-ticket-pick.sh index e259d97..689c333 100755 --- a/scripts/test-ticket-pick.sh +++ b/scripts/test-ticket-pick.sh @@ -21,7 +21,7 @@ PASS=0; FAIL=0 export TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR" export ISEP_COUNTDOWN_NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,9,7,0,0,tzinfo=d.timezone.utc).timestamp()))') -unset ISEP_MAINLINE_FILE ISEP_IDENTITY CLAUDE_PROJECT_DIR +unset ISEP_MAINLINE_FILE ISEP_IDENTITY CLAUDE_PROJECT_DIR ISEP_DRAIN ISEP_WORKER_LOGIN MAIN="$TMP/mainline.json" cat > "$MAIN" <<'JSON' @@ -57,9 +57,20 @@ POOL = { "Arcrun": [I("Arcrun", 6, ["s/todo", "p/high"], "main", created="2026-08-27T00:00:00Z"), # ★ 主線裡最優先 I("Arcrun", 7, ["s/todo"], "late"), # 逾期里程碑:主線抓盡才輪到 I("Arcrun", 8, ["s/todo"], "future")], # 不是主線也沒逾期:永遠不抓 - "mira": [I("mira", 9, ["s/todo", "s/doing"], "main")], # 09-07 實查 mira#6 的形狀:有人在做 + "mira": [I("mira", 9, ["s/todo", "s/doing"], "main"), # 09-07 實查 mira#6 的形狀:有人在做 + # ── 票池那一層才看得到的(inkstone/ISEP#130):都沒有里程碑 ── + I("mira", 20, ["s/triage"], None), # 還沒決定要不要做:不抓 + I("mira", 21, [], None), # 沒有 s/*(還沒驗傷):不抓,但要被點名 + I("mira", 22, ["s/todo"], None, assignees=("Leo",)), # 指派 Leo:不抓 + I("mira", 23, ["s/backlog", "Human"], None), # Human:不抓 + I("mira", 24, ["s/review"], None), # 等總管:不抓 + I("mira", 25, ["s/backlog", "p/high"], None, created="2026-09-05T00:00:00Z"), # ★ 票池裡最優先 + I("mira", 26, ["s/todo", "hub"], None)], # hub:容器不是任務 + # 分頁:伺服器每頁上限 30(比 pick 要的 50 小)——只看「不滿一頁」就停的寫法會漏掉第 31 張之後的 + "big": [I("big", n, [], None) for n in range(1, 31)] + [I("big", 31, ["s/backlog"], None)], } MSN = {"ISEP": ["main", "late", "future"], "Arcrun": ["main", "late", "future"], "mira": ["main"]} +SERVER_PAGE_MAX = 30 # 相依邊(inkstone/ISEP#133):主線錨在 Arcrun#48,Arcrun#6 把 ISEP#1 當相依 ⇒ ISEP#1 在主線上; # ISEP#13 掛在 ISEP 的同名里程碑但沒有任何邊 ⇒ 不在主線上。mira#9 同理(同名、沒邊)。 DEPS = {"Arcrun#6": ["ISEP#1"]} @@ -84,9 +95,13 @@ def fake_api(path, payload=None, method=None): repo = path.split("/")[3] import urllib.parse q = urllib.parse.parse_qs(path.split("?", 1)[1]) - want = q.get("milestones", [""])[0] - return [i for i in POOL[repo] if (i["milestone"] or {}).get("title") == want - and f"{repo}#{i['number']}" not in DROP] + rows = [i for i in POOL[repo] if f"{repo}#{i['number']}" not in DROP] + if "milestones" in q: + want = q["milestones"][0] + return [i for i in rows if (i["milestone"] or {}).get("title") == want] + # 不帶里程碑=整個 repo 的 open 票(票池那一層),照 Gitea 的分頁 + page = int(q.get("page", ["1"])[0]); per = min(int(q.get("limit", ["50"])[0]), SERVER_PAGE_MAX) + return rows[(page - 1) * per: page * per] if path.endswith("/labels?limit=100"): return [{"name": n, "id": k} for k, n in enumerate(["s/todo", "s/doing", "s/review", "p/high", "Human"], 1)] @@ -182,7 +197,10 @@ export DROP='["Arcrun#6","ISEP#1"]' p "★ 主線兩張都被拿走後 → 回逾期里程碑的 Arcrun#7" "True" "'inkstone/Arcrun#7' in out and rc is None" "$ARGS" export DROP='["Arcrun#6","ISEP#1","Arcrun#7"]' p "★ 主線與逾期都抓盡 → 離開碼 1(真的沒有,不是故障)" "1" "rc" "$ARGS" -p "訊息明講「不要去抓 backlog」(出路是收工回報,不是換規則)" "True" "'backlog' in out and '收工' in out" "$ARGS" +p "沒帶 --pool 時訊息明講「這一趟沒有看票池」並給出 --pool 這條路(ISEP#130:不再說成正常收工)" "True" \ + "'沒有看票池' in out and '--pool' in out and '不是故障' not in out" "$ARGS" +p "沒帶 --pool、主線與逾期都抓盡 → 票池一通都沒讀(本機總管不會因此被點名去抓票池)" "False" \ + "any('page=' in p for _,p,_ in rec)" "$ARGS" p "抓盡時**沒有**回 Arcrun#8(未來里程碑)" "False" "'Arcrun#8' in out" "$ARGS" unset DROP @@ -236,6 +254,53 @@ p "擋下時訊息講得出理由與出路(pick)" "True" "'Human' in out and p "身份名不在名單上 → 在打任何 API 之前就擋(一通都沒打)" "True" "rc==2 and len(rec)==0" \ "[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--name\",\"路人\"]" +echo +echo "── ⑤ 票池那一層(inkstone/ISEP#130 c7801:幾百張 open 票,工人卻回「沒事做」)──" +POOLA="[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\"]" +POOLALL="[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\",\"--all\",\"--json\"]" +p "pickable:s/backlog 在票池那一層可抓" "True" "m.pickable(I('ISEP',4,['s/backlog'],None),None,None,m.POOL_STATUSES)[0]" +p "pickable:s/triage 在票池那一層仍不可抓(要不要做還沒決定)" "False" "m.pickable(I('x',1,['s/triage'],None),None,None,m.POOL_STATUSES)[0]" +p "pickable:沒有 s/* 仍不可抓,理由講得出「沒有 s/*」" "True" "'沒有 s/*' in m.pickable(I('x',1,[],None),None,None,m.POOL_STATUSES)[1]" +p "pickable:票池裡的 backlog 掛 Human → 不可抓(不該碰的一格都沒放寬)" "False" "m.pickable(I('x',1,['s/backlog','Human'],None),None,None,m.POOL_STATUSES)[0]" +p "pickable:票池裡指派給 Leo → 不可抓" "False" "m.pickable(I('x',1,['s/todo'],None,assignees=('Leo',)),None,'claude-code',m.POOL_STATUSES)[0]" +p "順序:同優先序 s/todo 先於 s/backlog" "True" \ + "m.pick_order(I('a',9,['s/todo'],None)) < m.pick_order(I('a',1,['s/backlog'],None))" +p "主線有票時帶 --pool 也照樣先回主線的 Arcrun#6,票池一通都沒讀(能省就省)" "True" \ + "'inkstone/Arcrun#6' in out and not any('page=' in p for _,p,_ in rec)" "$POOLA" +export DROP='["Arcrun#6","ISEP#1","Arcrun#7"]' +p "★ 主線與逾期都抓盡、帶 --pool → 回票池裡最優先的 mira#25(p/high 的 backlog)" "True" \ + "'inkstone/mira#25' in out and rc is None" "$POOLA" +p "★ --all:票池可抓的恰好是這幾張,照 p/high → s/todo → s/backlog 排(Human/指派 Leo/triage/沒 s/*/review/s/doing/hub/PR 都不在)" \ + "['inkstone/mira#25', 'inkstone/ISEP#5', 'inkstone/Arcrun#8', 'inkstone/ISEP#13', 'inkstone/ISEP#4', 'inkstone/big#31']" \ + "[r['ref'] for r in json.loads(out) if r['group']=='票池']" "$POOLALL" +p "★ 分頁:伺服器每頁只給 30 張時,第 31 張(big#31)照樣讀得到" "True" "'inkstone/big#31' in out" "$POOLALL" +p "票池那一層也只讀不寫" "True" "all(mm=='GET' for mm,_,_ in rec)" "$POOLALL" +export ISEP_DRAIN=1 +p "★ 環境 ISEP_DRAIN=1(雲端工人的環境)= 帶 --pool,指令不必改" "True" \ + "'inkstone/mira#25' in out and rc==0" "[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--json\"]" +unset ISEP_DRAIN +unset DROP +export DROP='["Arcrun#6","ISEP#1","Arcrun#7","mira#25","Arcrun#8","ISEP#13","ISEP#5","ISEP#4","big#31"]' +p "★ 票池也抓盡 → 離開碼 1" "1" "rc" "$POOLA" +p "★ 可抓 0 張時列出池子大小與「不能抓的理由」各幾張(沒 s/* 的 31 張被點名),而且不說成「正常/不是故障」" "True" \ + "'open 票池' in out and '31 張' in out and '沒有 s/*' in out and '不是故障' not in out and '正常' not in out" "$POOLA" +p "--json 抓盡時同一張理由表寫到 stderr(stdout 仍是 null,佇列閘照舊解析得動)" "True" \ + "out.startswith('null') and '不能抓' not in out.split(chr(10))[0] and '沒有 s/*' in out" \ + "[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\",\"--json\"]" +unset DROP + +echo +echo " (認領票池裡的票)" +CLP="[\"cmd_claim\",\"inkstone/ISEP#4\",\"--mainline\",\"$MAIN\",\"--name\",\"isep-hand\",\"--pool\"]" +p "★ --pool 認領 backlog 的 ISEP#4 → 過,s/backlog 換成 s/doing" "True" \ + "rc is None and [sorted(pl['labels']) for mm,p,pl in rec if mm=='PUT' and p.endswith('/issues/4/labels')]==[[2]]" "$CLP" +p "認領留言寫明是從 open 票池抓的、狀態從 s/backlog 變 s/doing" "True" \ + "any('open 票池' in pl['body'] and 's/backlog → s/doing' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" "$CLP" +blocked "--pool 也擋掛 Human 的 mira#23" inkstone/mira#23 --pool +blocked "--pool 也擋指派 Leo 的 mira#22" inkstone/mira#22 --pool +blocked "--pool 也擋 s/triage 的 mira#20" inkstone/mira#20 --pool +blocked "--pool 也擋沒有 s/* 的 mira#21" inkstone/mira#21 --pool + echo echo "── ④ 動詞接得上(不帶假池子,走到網路才炸)──" out=$(python3 "$T" pick --mainline "$MAIN" 2>&1); got=$? diff --git a/scripts/ticket b/scripts/ticket index fab0e98..43141e9 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -30,7 +30,8 @@ leo 2026-08-16 三句話,本工具就是它們的機械化: 雲端工人抓票(inkstone/ISEP#131,規則機械可判,不靠 prompt 叮嚀): - ticket pick [--mainline <主線檔>] [--all] [--json] 回一張現在可以抓的票(主線抓盡才抓逾期) + ticket pick [--mainline <主線檔>] [--all] [--json] [--pool] + 回一張現在可以抓的票(主線 → 逾期里程碑 → --pool 時 open 票池) ticket claim <票> [--as <登入名>] [--name <身份>] 認領=指派+s/doing+【身份】留言,一個動作 ticket pick --claim … 上面兩件連著做 @@ -894,17 +895,20 @@ def subtask_example(parent=None, draft=None): # 用法裡寫死 `ticket` 或 `scripts/ticket`,讀的人照著貼就又踩回這張票的坑。 USAGE = { "pick": - "用法:{bin} pick [--mainline <主線 json 檔>] [--all] [--json] [--claim [--as <登入名>] [--name <身份>]]\n" - " 回**一張**現在可以抓的票(主線抓盡才輪到逾期里程碑;不抓 backlog)。主線的成員=hub 里程碑裡的票+\n" + "用法:{bin} pick [--mainline <主線 json 檔>] [--all] [--json] [--pool] [--claim [--as <登入名>] [--name <身份>]]\n" + " 回**一張**現在可以抓的票:主線 → 主線抓盡才輪到逾期里程碑 → 帶 --pool(或環境 ISEP_DRAIN=1)時\n" + " 再輪到整個 open 票池的 s/todo/s/backlog(不限里程碑;Human、指派給別人、hub 照樣不抓,inkstone/ISEP#130)。\n" + " 可抓 0 張時列出池子裡每種「不能抓的理由」各幾張。主線的成員=hub 里程碑裡的票+\n" " hub 票+它們的相依(跨 repo)——別 repo 的同名里程碑不算主線(inkstone/ISEP#133)\n" " --mainline 不給時依序找:$ISEP_MAINLINE_FILE → ~/.claude/isep-countdown/mainline.json" " → $CLAUDE_PROJECT_DIR/system-dev/mainline.json\n" " 離開碼:0 = 有票(印在 stdout);1 = 真的沒有可抓的票;2 = 讀不到(讀不到 ≠ 沒有)", "claim": "用法:{bin} claim [--as <登入名,預設本 token 的使用者>] [--name <身份名,預設 subagent>]" - " [--mainline <主線 json 檔>]\n" + " [--mainline <主線 json 檔>] [--pool]\n" " 認領=指派給自己 + 換成 s/doing + 第一行【身份】的留言,**一個動作**。\n" - " 抓票規則在這裡再驗一次:不是 s/todo、已有人、掛 Human、不在主線也不逾期 ⇒ 擋下(離開碼 2)", + " 抓票規則在這裡再驗一次:不是 s/todo、已有人、掛 Human、不在主線也不逾期 ⇒ 擋下(離開碼 2);\n" + " 帶 --pool(或 ISEP_DRAIN=1)時 open 票池裡的 s/todo/s/backlog 也認領得了", "subtask": "用法:{bin} subtask <母票 owner/repo#N> --title \"\" -F <內文檔>\n" " [--repo <收件 repo,預設跟母票同一個>] [--label ]\n" @@ -1270,7 +1274,12 @@ def cmd_loose(argv): # # 規則(全部是 Gitea 欄位,沒有一格在猜文字): # 可抓 s/* 狀態恰好是 {s/todo} + 沒有 assignee + 沒有 Human/human/* + 不是 hub + 有里程碑 -# 且里程碑 = 主線;主線抓盡才抓「逾期的 open 里程碑」;backlog/沒里程碑一律不抓 +# 且里程碑 = 主線;主線抓盡才抓「逾期的 open 里程碑」 +# ⇒ 兩層都抓盡、且是接票的環境(--pool/ISEP_DRAIN=1)⇒ 第三層:整個 open 票池裡 +# s/* 恰好是 s/todo 或 s/backlog 的(不限里程碑),其他格一格不放寬(inkstone/ISEP#130 c7801) +# 🔴 2026-09-18 實量:org 299 張 open、254 張沒掛里程碑;舊規則只看主線成員與逾期里程碑, +# 實跑可抓 0 張 ⇒ 09-18 六趟 run 全部「可接 0 張、正常收工」。加上票池這一層,同一刻可抓 101 張。 +# 主線/逾期是**先抓誰**,不是**只抓誰**。 # 認領 指派 + s/doing +【身份】留言,一個動作(`claim`) # 完成 分支 + 證據 + s/review + `handback`——那半本來就有,不重做 # @@ -1326,7 +1335,25 @@ def human_gated(labels): return any(n == "Human" or n.startswith("human/") for n in labels) -def pickable(issue, milestone_title, me=None): +# 票池那一層接受的狀態(inkstone/ISEP#130 c7801):labels.yaml 裡「決定要做、沒人在做」的只有這兩個。 +# s/todo 已排進 milestone,等人領票 +# s/backlog 驗過了、確定要做,但還沒排進任何 sprint +# 其他狀態都有人或有事在管:s/triage=還沒決定要不要做(總管/leo 的事)、s/doing=有人在做、 +# s/review/s/stage=等總管或 leo、s/pending=在等外部。**沒有 s/* 的票**=還沒被驗傷,一樣不抓, +# 但會被點名計數(見 pool_candidates),不再沉默地消失在「可抓 0 張」後面。 +MAINLINE_STATUSES = ("s/todo",) +POOL_STATUSES = ("s/todo", "s/backlog") + + +def pool_mode(argv=()): + """要不要往下看到整個 open 票池。`--pool` 或環境 `ISEP_DRAIN=1`(雲端工人的環境本來就設它)。 + + 為什麼不是預設全開:`queue-drain-guard` 在本機只要總管跑過 `ticket handback` 就會問 pick—— + 票池永遠有票,總管每次收工都會被點名去抓票,那是誤攔。接票的環境自己宣告,不靠猜。""" + return "--pool" in argv or os.environ.get("ISEP_DRAIN") == "1" + + +def pickable(issue, milestone_title, me=None, statuses=MAINLINE_STATUSES): """這張票現在能不能被雲端工人抓走。回 (ok, 理由)。**純函式,不打網路。** `milestone_title`=要求它掛在哪個里程碑底下(主線,或輪到的那個逾期里程碑)。 @@ -1336,14 +1363,15 @@ def pickable(issue, milestone_title, me=None): 指派 `claude-code` ⇒ 舊規則說「已經指派給 claude-code」⇒ **工人把自己當成別人**, 09-11 那趟 run(Arcrun#86 c6917)就是用這條理由判定「主線沒有可抓的票」然後收工。 `s/todo` 的意思本來就是「沒人在做」;有人在做的形狀是 `s/doing`,那格照擋。 + `statuses`=接受哪些 s/*(主線/逾期只收 s/todo;票池另收 s/backlog,見 POOL_STATUSES)。 理由是給人看的:`pick --all` 與 `claim` 擋下時都印它,一張票為什麼不能抓要說得出來。 """ if issue.get("pull_request"): return False, "這是 PR,不是票" labels = _label_names(issue) st = sorted(n for n in labels if n.startswith("s/")) - if st != ["s/todo"]: - return False, "狀態不是單一的 s/todo(現在是 %s)" % (st or "沒有 s/*") + if len(st) != 1 or st[0] not in statuses: + return False, "狀態不是單一的 %s(現在是 %s)" % ("/".join(statuses), st or "沒有 s/*") 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")] @@ -1371,11 +1399,12 @@ def pickable(issue, milestone_title, me=None): def pick_order(issue): - """同一個里程碑裡先抓誰:p/high → 沒標 → p/low;再看開票時間(舊的先);最後票號。 - 結果要**可重現**——同一個池子問兩次要拿到同一張。""" + """同一組裡先抓誰:p/high → 沒標 → p/low;同優先序 s/todo 先於 s/backlog(排進 sprint 的先); + 再看開票時間(舊的先);最後票號。結果要**可重現**——同一個池子問兩次要拿到同一張。""" labels = _label_names(issue) prio = 0 if "p/high" in labels else (2 if "p/low" in labels else 1) - return (prio, issue.get("created_at") or "", issue.get("number") or 0) + status = 1 if "s/backlog" in labels else 0 + return (prio, status, issue.get("created_at") or "", issue.get("number") or 0) def _ref_of(issue, owner=None, repo=None): @@ -1463,6 +1492,54 @@ def _fetch_issue(ref): return it +def _open_issues(owner, repo): + """一個 repo 的全部 open 票(分頁讀完;Gitea 每頁上限 50,只讀一頁會漏)。""" + out, page = [], 1 + while page <= 40: + q = urllib.parse.urlencode({"state": "open", "type": "issues", "limit": 50, "page": page}) + rows = [it for it in (api("/repos/%s/%s/issues?%s" % (owner, repo, q)) or []) if isinstance(it, dict)] + for it in rows: + it.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)}) + if not rows: # 讀到空頁才停:伺服器的每頁上限可能比 50 小,看「不滿一頁」會提早收手 + break + out += rows + page += 1 + return out + + +def pool_candidates(owner, me, seen=()): + """第三層:整個 org 的 open 票池(inkstone/ISEP#130 c7801)。回 (可抓的票…, 池子大小, {不能抓的理由: 張數})。 + + leo 09-18:「我看到 Gitea 有 446 張票,要它自己領工作就是持續消化工作,它說沒事做?」 + ⇒ 主線與逾期里程碑只是**先抓誰**,不是**只抓誰**。它們抓盡之後,池子裡「決定要做、沒人在做」的票 + (POOL_STATUSES)照樣可抓。不該碰的一格都沒放寬:Human/human/*、指派給別人(含 Leo)、hub、PR。 + 「要 Mac/要 prod」沒有欄位可判——那是工人讀票後走 cloud-worker.md「做不了的票」:寫回票+改欄位, + pick 下一輪就不再回它(改的是 s/*,不是猜文字)。 + + `seen`=前兩層已經列過的票號,不重複。理由表一併回傳:**可抓 0 張時要說得出池子去哪了**。""" + found, why_count, total = [], {}, 0 + for r in _repos(owner): + for it in _open_issues(owner, r): + if it.get("pull_request"): + continue + total += 1 + ref = _ref_of(it) + if ref in seen: + continue + ok, why = pickable(it, None, me, POOL_STATUSES) + if ok: + found.append(it) + else: + why_count[why] = why_count.get(why, 0) + 1 + return sorted(found, key=pick_order), total, why_count + + +def _me(): + """抓票者自己是誰:只指派給自己的 s/todo 票算可抓(inkstone/ISEP#33,見 pickable) + 問不到(舊 token、測試替身沒有 /user)⇒ None ⇒ 照舊規則,不因為這一格讓 pick 整個死掉""" + return os.environ.get("ISEP_WORKER_LOGIN") or ((api_soft("/user") or {}).get("login")) + + def mainline_members(ms): """主線的成員(inkstone/ISEP#133):hub 里程碑裡的票+hub 票+它們的相依(跨 repo)。 判準在 `hooks/lib/mainline.py::collect_members`,這裡只是把本檔的 `api` 遞進去。""" @@ -1481,9 +1558,7 @@ 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")) + me = _me() refs, info = mainline_members(ms) found = [] @@ -1524,8 +1599,15 @@ def cmd_pick(argv): ms, src = load_mainline(argv) as_json = "--json" in argv show_all = "--all" in argv + pool = pool_mode(argv) + pool_total, pool_why = None, {} try: groups = pick_candidates(ms) + # 第三層只在前兩層抓盡(或要看全部)時才讀——它要翻整個 org,能省就省 + if pool and (show_all or not any(its for _, _, its in groups)): + seen = {_ref_of(it) for _, _, its in groups for it in its} + found, pool_total, pool_why = pool_candidates(ms.get("owner") or ORG, _me(), seen) + groups.append(("票池", "open 票池(%s,不限里程碑)" % "/".join(POOL_STATUSES), found)) except (urllib.error.URLError, OSError) as e: die("🔴 拿不到 Gitea(%s)。讀不到 ≠ 沒有可抓的票——先修連線,不要空轉也不要抓別的。" % e) @@ -1535,17 +1617,17 @@ def cmd_pick(argv): "labels": _label_names(it), "created_at": it.get("created_at")} for g, t, it in flat] print(json.dumps(rows if show_all else (rows[0] if rows else None), ensure_ascii=False, indent=2)) + if not rows: + _explain_empty(pool, pool_total, pool_why, sys.stderr) sys.exit(0 if rows else 1) print("🎯 主線:「%s」(來源:%s)" % (ms["title"], src)) for g, t, its in groups: - if g == "主線" or its or show_all: + if g in ("主線", "票池") or its or show_all: print(" %s「%s」:可抓 %d 張" % (g, t, len(its))) print() if not flat: - print("⚪ 沒有可以抓的票——主線與逾期里程碑都抓盡了。") - print(" 出路:收工回報(這是正常結果,不是故障)。") - print(" 🔴 不要去抓 backlog/沒里程碑/掛 Human/已有人的票——那些不在規則裡,抓了就是跳線。") + _explain_empty(pool, pool_total, pool_why, sys.stdout) sys.exit(1) shown = flat if show_all else flat[:1] @@ -1559,11 +1641,27 @@ def cmd_pick(argv): cmd_claim([_ref_of(top)] + [a for a in argv if a != "--claim"]) return print("📌 認領(指派+s/doing+【身份】留言,一個動作):") - print(" %s claim %s --name <你的名字>" % (self_path(), _ref_of(flat[0][2]))) + print(" %s claim %s --name <你的名字>%s" % (self_path(), _ref_of(flat[0][2]), + " --pool" if flat[0][0] == "票池" and "--pool" in argv else "")) print(" 做完:分支+證據 → `%s handback %s --to %s --label s/review --next \"…\" --evidence `" % (self_path(), _ref_of(flat[0][2]), HANDBACK_TO)) +def _explain_empty(pool, total, why, out): + """可抓 0 張時講清楚池子去哪了。**不准說成「正常、不是故障」**(inkstone/ISEP#130 c7797: + 09-18 六趟 run 都把「可抓 0 張」寫成正常收工,而池子裡有幾百張 open 票)。""" + w = lambda line="": print(line, file=out) + if not pool: + w("⚪ 主線與逾期里程碑可抓 0 張——**這一趟沒有看票池**,所以這不代表沒事做。") + w(" 要看整個 open 票池:加 `--pool`(雲端工人的環境設了 ISEP_DRAIN=1 就自動看)。") + return + w("⚪ 可抓 0 張:主線、逾期里程碑、open 票池(%d 張)都沒有一張符合抓票規則。" % (total or 0)) + w(" 這不是「沒事做」,是池子裡的票全都卡在下面這些格子——把這張表原樣放進收工摘要/載體票,交總管分診:") + for k, n in sorted(why.items(), key=lambda kv: (-kv[1], kv[0]))[:12]: + w(" %4d 張 %s" % (n, k)) + w(" 🔴 不要自己改欄位去湊一張可抓的票(把 s/triage 改 s/todo、摘 Human、改指派)——那是總管/leo 的判斷。") + + def cmd_claim(argv): """認領=指派 + s/doing +【身份】留言,**一個動作**。抓票規則在這裡再驗一次。""" if not argv or argv[0] in ("-h", "--help"): @@ -1596,21 +1694,31 @@ def cmd_claim(argv): if not (due and due < _now()): ok, why = False, "里程碑「%s」既不是主線「%s」也還沒逾期(別 repo 的同名里程碑不算主線)" % ( ims.get("title"), ms["title"]) + tier = "(主線)" if on_mainline else "(逾期里程碑,主線已抓盡)" + if not ok and pool_mode(argv): + # 第三層:open 票池(inkstone/ISEP#130)。規則同 pick 那一層,一格不放寬 Human/指派/hub。 + pok, pwhy = pickable(issue, None, who, POOL_STATUSES) + if pok: + ok, tier = True, "(open 票池,主線與逾期里程碑已抓盡)" + else: + why = pwhy if not ok: die("""🚫 %s/%s#%d 現在不能抓:%s 抓票規則(inkstone/ISEP#131,全部是 Gitea 欄位): s/* 恰好是 s/todo + 沒有 assignee + 沒有 Human/human/* + 在主線上(hub 里程碑或相依;主線抓盡才抓逾期) + 帶 --pool(或環境 ISEP_DRAIN=1)時另收 open 票池裡的 s/todo/s/backlog(inkstone/ISEP#130),其他格照舊 出路:`%s pick` 會回一張符合規則的票;這張如果真的該你做,回票上問總管改欄位,不要硬抓。""" % ( owner, repo, num, why, self_path())) api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"assignees": [who]}, method="PATCH") + before = sorted(n for n in _label_names(issue) if n.startswith("s/")) keep = [n for n in _label_names(issue) if not n.startswith("s/")] + ["s/doing"] _set_labels(owner, repo, num, keep) lines = [ident, "", "🙋 **認領** → `%s`" % who, "", - "- 里程碑:「%s」%s" % (ims.get("title") or "(無,靠相依在主線上)", "(主線)" if on_mainline else "(逾期里程碑,主線已抓盡)"), - "- 狀態:s/todo → s/doing", + "- 里程碑:「%s」%s" % (ims.get("title") or ("(無,靠相依在主線上)" if on_mainline else "(無)"), tier), + "- 狀態:%s → s/doing" % "、".join(before), "- 做完:分支+證據 → `s/review` + `ticket handback`(不推 main、不出貨、不改里程碑)"] api("/repos/%s/%s/issues/%d/comments" % (owner, repo, num), {"body": "\n".join(lines)}) print("✅ 認領 %s/%s#%d → `%s`" % (owner, repo, num, who))