雲端工人做完一張就接下一張:佇列沒抓盡不准收工;自己帳號的票不再當成別人的(inkstone/ISEP#33 → 6924)

leo 09-13 判定 ISEP 失敗:「不會在一條任務完成後啓動另一個 loop」。實查三個機械根因:

1. 沒有任何 Stop 閘看佇列。Routine 一次觸發=一個 session,模型一停就結束;
   12 支 Stop 閘量的是 tool call 數/宣告句/乾回合/自寫清單,
   「做完一件、pick 還有票、寫完回報就停」全部放行。
   ⇒ 新增 hooks/queue-drain-guard.sh:跑過 ticket pick|claim|handback(或 ISEP_DRAIN=1)
   的 session,pick 有票就 exit 2 交回票號;抓盡放行;同張 3 次/讀不到 3 次/40 次上限放行並留痕。
   log 寫 TMPDIR,不靠雲端沒有的 CLAUDE_PROJECT_DIR。
2. pickable() 把指派給 claude-code 的 s/todo 當成「別人的」,而雲端工人就是 claude-code。
   09-13 實查主線 Arcrun#176=s/todo+指派 claude-code;09-11 run(Arcrun#86 c6917)
   就是以此判定「主線沒票」收工。⇒ 只指派給自己的 s/todo 算可抓;s/doing/Human 照擋。
3. api() 一次 TLS 握手逾時就讓 pick 離開碼 2(09-13 本機實撞,curl 同時 200)。
   ⇒ 只有 GET 對連線層失敗重試(預設 3 次,退避 2s×n);寫入不重試。

測試:queue-drain-guard 24/24、ticket-pick 58/58(+5)、ticket-repo-arg 7/7、
handoff-writeback 49/49;members-by-dependency 56/57,唯一紅的 ㊽ 在 main 上同樣紅(macOS 暫存路徑形狀)。
數字在這棵樹上實數:62 支閘(ls hooks/*.sh)/87 條註冊(grep -c '"command":')。
假設:工人與總管共用 claude-code 帳號維持現狀(改帳號是跨專案結構,交總管)。
版本號留 0.29.0,由總管併 main 後定版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 12:37:49 +08:00
parent 4d27132413
commit ec9f28d9e8
7 changed files with 340 additions and 16 deletions
+44 -13
View File
@@ -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 那趟 runArcrun#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()):