雲端工人從 open 票池持續領票,可抓 0 張時講出池子去哪了(inkstone/ISEP#130 c7801)
根因: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 <noreply@anthropic.com>
This commit is contained in:
+130
-22
@@ -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 <owner/repo#N> [--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 \"<User Story>\" -F <內文檔>\n"
|
||||
" [--repo <收件 repo,預設跟母票同一個>] [--label <s/xxx,預設 s/todo>]\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 <URL>`"
|
||||
% (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))
|
||||
|
||||
Reference in New Issue
Block a user