雲端第一則 prompt 的主線是現在這條:兩份主線檔都不在時 refresh 去 Gitea 讀 InkStoneCo main 那份一次(inkstone/ISEP#140)

薄殼 SessionStart 跑在 bootstrap.sh 之前,InkStoneCo/ 還沒 clone、家目錄空 ⇒ 兩份都不在 ⇒
countdown.py 退到「期限最近的 milestone」⇒ 第一則 prompt 印逾期 341 小時的舊線。機制沒壞,是順序。

- hooks/lib/mainline.py:第三個來源 fetch_gitea()(本檔唯一會打網路的函式,只有 SessionStart 的
  `mainline refresh` 叫它;load()/line() 照舊零網路)。優先序寫進檔頭:家目錄 > repo 那份 > Gitea 那份。
  匿名優先,匿名被拒(401/403/404)且拿得到 token 才帶 token 再問一次;讀不到回 None,逾時有上限。
- scripts/mainline refresh:兩份都不在 ⇒ fetch_gitea 一次、只寫家目錄(不 mirror);token 拿不到不炸、不抱怨。
- hooks/tests/mainline-gitea-fallback.test.sh:31 條,假 Gitea 有 public/private/500/garbage/notitle/hang 六種回法。

假設(票上紅線寫「一律匿名不帶 token」,其前提「Gitea 匿名讀得到」實測為假):
inkstone/InkStoneCo 在 Gitea 是 private——匿名打 raw/contents/repo API 都 404,ISEP 同端點 200。
⇒ 照字面做這張票在真環境永遠讀不到。改成匿名優先、被拒才帶 token(同一條 SessionStart 鏈上
countdown-milestone-refresh.sh 本來就帶 GITEA_TOKEN_CLAUDE_CODE 打 Gitea;D20 管的是 GitHub)。
真跑:帶 token 2.06s 讀到 inkstone/Arcrun#48;拔 token rc 0、零檔案、「現在沒有主線」。

版本:待總管定版。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
This commit is contained in:
2026-09-07 06:19:39 +00:00
parent 1c0012107f
commit 48befe8906
7 changed files with 389 additions and 11 deletions
+101 -5
View File
@@ -27,10 +27,21 @@ Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 rep
· **成員判定** 票所屬 milestone 的**標題**與主線標題相同(不分 repo)
⇒ 「同名跨 repo」照舊能用,而「現在在做哪一條」仍然只有一個答案。
── 這支從不打網路 ────────────────────────────────────────────────
它被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
── `load()``line()` 從不打網路 ─────────────────────────────────
被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
都先等一次網路。網路那一半全部在 `scripts/mainline`setrefreshadopt),
本支讀寫 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
本支讀寫只碰 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
唯一的例外是 `fetch_gitea()`inkstone/ISEP#140,見下方第三個來源那段):
它會打一次 Gitea,但**只有 SessionStart 的 `scripts/mainline refresh` 叫它**
`load()``line()` 永遠不叫——所以每一則訊息的路徑上仍然沒有網路。
── 三個來源,優先序寫死:家目錄 > repo 那份 > Gitea main 那份 ───────
① 家目錄 `~/.claude/isep-countdown/mainline.json`(這台機器上 refresh 過、有進度)
② repo 那份 `InkStoneCo/system-dev/mainline.json`(隨 repo 走,inkstone/ISEP#130
③ Gitea 上 `inkstone/InkStoneCo` main 的同一個檔(inkstone/ISEP#140
①② 之間誰算數看 `set_at`(見 `load()`);③ **只在 ①② 都不存在時**被問一次,
抓到就寫進 ①,之後跟 ① 一樣對待。③ 讀的就是 ② 那個檔,只是從 Gitea 拿而不是從
工作樹拿——它不是第二套主線來源。
"""
import json
import os
@@ -106,15 +117,100 @@ def repo_path(for_write=False):
return None
def _valid(d):
"""一份主線檔長什麼樣才算數:dict、而且有 title。三個來源用同一把尺。"""
return d if (isinstance(d, dict) and d.get("title")) else None
def _read(p):
try:
with open(p) as f:
d = json.load(f)
except Exception:
return None
if not isinstance(d, dict) or not d.get("title"):
return _valid(d)
# ── Gitea main 上的那一份(inkstone/ISEP#140)────────────────────────
#
# 雲端薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh` 之前:那時
# `InkStoneCo/` 還沒 clone,家目錄又是空的 ⇒ 上面兩份都不存在 ⇒ `countdown.py` 退到
# 第二基準「期限最近的 milestone」⇒ 第一則 prompt 印出一條兩週前的舊線
# (票上實測:`🔴 主線 把管理這條線做對 已逾期 341 小時`+`🎯 現在沒有主線`)。
# 機制沒壞,是順序。而「哪一條是主線」這個指標本來就在 Gitea 上讀得到。
#
# 規矩(跟上面「repo 裡的那一份」那段接起來,不另立一套):
# · 只在家目錄與 repo 那份**都不存在**時問 Gitea,問一次;任一份在場就不問。
# · 只有 SessionStart 的 `scripts/mainline refresh` 會叫 `fetch_gitea()`;抓到就
# `save()` 進家目錄(不 mirror:雲端沒有 InkStoneCo/ 可寫,本機也不弄髒工作樹)。
# bootstrap 之後 repo 那份出現時,兩份 `set_at` 相同 ⇒ 家目錄那份算數(它有 refresh 過的進度)。
# · 匿名優先。匿名被拒(401/403/404)且環境拿得到 Gitea token 才帶 token 再問一次
# ——`inkstone/InkStoneCo` 在 Gitea 是 **private**2026-09-07 實測:匿名打 raw
# contentsrepo API 都 404),沒 token 就真的讀不到。
# · 讀不到(斷網/5xx/不是 JSON/沒 title/逾時)⇒ 回 None,退回現行行為,不報錯。
# 逾時有上限(`ISEP_MAINLINE_GITEA_TIMEOUT`,預設 4 秒,最多問兩次)。
# · 測試用:`ISEP_MAINLINE_GITEA_URL=none` 把這個來源整個拿掉;`TICKET_HOST` 換主機
# (既有測試都把它指到連不上的位址,所以它們照舊離線)。
GITEA_HOST_DEFAULT = "https://git.uncle6.me"
GITEA_REPO = "inkstone/InkStoneCo"
GITEA_REF = "main"
def gitea_url() -> str:
forced = os.environ.get("ISEP_MAINLINE_GITEA_URL", "").strip()
if forced == "none":
return ""
if forced:
return forced
host = (os.environ.get("TICKET_HOST", "").strip() or GITEA_HOST_DEFAULT).rstrip("/")
return "%s/api/v1/repos/%s/raw/%s?ref=%s" % (
host, GITEA_REPO, REPO_REL.replace(os.sep, "/"), GITEA_REF)
def fetch_gitea(token="", timeout=None):
"""去 Gitea 讀 `inkstone/InkStoneCo` main 上的 `system-dev/mainline.json`。
回一份跟 `_read()` 同樣通過 `_valid()` 的 dict,或 None。**永遠不 raise。**
`token`:字串,或一個回字串的函式(只在匿名被拒時才會被叫)。
🔴 這是本檔唯一會打網路的函式,只准 SessionStart 的 `scripts/mainline refresh` 叫;
`load()``line()` 走的是每一則訊息的關鍵路徑,不准碰它。
"""
url = gitea_url()
if not url:
return None
if timeout is None:
try:
timeout = float(os.environ.get("ISEP_MAINLINE_GITEA_TIMEOUT", "") or 4)
except Exception:
timeout = 4.0
import urllib.error
import urllib.request
def get(headers):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
try:
text = get({}) # 匿名優先(D20 的形狀:讀不帶憑證)
except urllib.error.HTTPError as e:
if e.code not in (401, 403, 404):
return None
# `token` 可以是字串,也可以是「被拒了才去拿」的函式——拿 token 可能要掃 git remote、
# 可能拿不到而想開口抱怨,匿名讀得到的時候根本不該走到那裡。
tok = token() if callable(token) else token
if not tok:
return None
try:
text = get({"Authorization": "token " + tok}) # private repo:帶 token 再問一次
except Exception:
return None
except Exception:
return None
try:
return _valid(json.loads(text))
except Exception:
return None
return d
def load():