雲端工人做完一張就接下一張:佇列沒抓盡不准收工;自己帳號的票不再當成別人的(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:
+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