From c0eec76b52b49f8dd6d0f1ec34a5fa6cc259857b Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 13 Sep 2026 13:57:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E7=B7=9A=E6=88=90=E5=93=A1=E8=AE=80?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=E4=BB=BB=E4=BD=95=E4=B8=80=E6=A2=9D=20/depen?= =?UTF-8?q?dencies=20=E6=99=82=E4=B8=8D=E5=87=86=E5=8D=B0=E5=87=BA?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=90=8D=E5=96=AE=EF=BC=9Aset=20=E4=B8=8D?= =?UTF-8?q?=E5=8D=B0=20=E2=9C=85=20=E4=B8=A6=E9=9B=A2=E9=96=8B=E7=A2=BC=20?= =?UTF-8?q?3=E3=80=81refresh=EF=BC=8Fadopt=20=E4=BF=9D=E7=95=99=E8=88=8A?= =?UTF-8?q?=E5=BF=AB=E5=8F=96=E4=B8=A6=E8=AC=9B=E5=87=BA=E4=BE=86=EF=BC=88?= =?UTF-8?q?inkstone/ISEP#130=20=E2=86=92=20c6973=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 總管 09-13 實撞:Gitea 握手逾時,hub 票 InkStoneCo#132 本身讀得到、它的 /dependencies 讀不到, `mainline set` 仍印「✅ 成員 6 張」,底下三張前置全漏;`refresh --members` 零輸出、名單沒變。 雲端工人靠這份名單抓票,漏收=以為抓盡=收工,等於 v0.30.0 換個入口又停。 - collect_members:清單讀取拿不到就重讀(ISEP_MEMBER_RETRIES 預設 2、退避 2s×n); 仍讀不到的票記進 info["unread"],info["complete"]=沒有 unread 且沒被上限截斷 - set:不完整時印 ⚠️(🔴 成員不完整)+點名讀不到的票,主線檔寫 members_complete/members_unread, 同一條線保留舊快取成員,離開碼 3(主線本身照樣標定) - refresh --members:完整才蓋快取並印成員張數;不完整保留舊快取並講出來;讀不到里程碑也講 - adopt:不完整時與舊快取取聯集 重現(改之前跑新測試):F1–F5、F9–F11、F13–F14 共 11 條紅——set 印 ✅、arcrun-rag#104 漏收、 refresh --members 零輸出且把快取蓋成少兩張的名單。 修後:mainline-members-by-dependency 70/71(唯一紅的 ㊽ 在 main 上同樣紅)、repo-mirror 17/17、 gitea-fallback 31/31、focus-guard 24/24、ticket-pick 58/58、debt-worklist 47/47。 真 Gitea 唯讀跑(不寫主線檔):InkStoneCo#65 成員 9 張、complete=True、hub #132 底下三張都收到。 沒動 InkStoneCo 的 system-dev/mainline.json。 Co-Authored-By: Claude Opus 5 --- hooks/lib/mainline.py | 36 ++++++++++-- .../mainline-members-by-dependency.test.sh | 41 ++++++++++++- scripts/mainline | 58 ++++++++++++++++--- 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/hooks/lib/mainline.py b/hooks/lib/mainline.py index 12f9058..5523524 100644 --- a/hooks/lib/mainline.py +++ b/hooks/lib/mainline.py @@ -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 diff --git a/hooks/tests/mainline-members-by-dependency.test.sh b/hooks/tests/mainline-members-by-dependency.test.sh index 3d1a610..a5b1daf 100755 --- a/hooks/tests/mainline-members-by-dependency.test.sh +++ b/hooks/tests/mainline-members-by-dependency.test.sh @@ -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" diff --git a/scripts/mainline b/scripts/mainline index 68a525a..8e45847 100755 --- a/scripts/mainline +++ b/scripts/mainline @@ -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())