雲端第一則 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:
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env bash
|
||||
# 主線第三個來源「Gitea main 上那份」的迴歸測試(inkstone/ISEP#140)
|
||||
#
|
||||
# ── 這支在守什麼 ──────────────────────────────────────────────────────
|
||||
# 雲端薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh` 之前:
|
||||
# `InkStoneCo/` 還沒 clone、家目錄又是空的 ⇒ 兩份主線檔都不存在 ⇒ `countdown.py` 退到
|
||||
# 「期限最近的 milestone」⇒ 第一則 prompt 印一條兩週前的舊線(票上實測:逾期 341 小時)。
|
||||
# 解法:`scripts/mainline refresh`(SessionStart)在兩份都不在時去 Gitea 讀
|
||||
# `inkstone/InkStoneCo` main 的 `system-dev/mainline.json` 一次、寫進家目錄。這裡驗:
|
||||
# A 薄殼形狀(兩份都沒有)+ Gitea 讀得到 ⇒ refresh 之後第一則 prompt 的 ⏱/🎯 就是那條線
|
||||
# B 匿名優先;InkStoneCo 是 private ⇒ 匿名 404 後有 token 才帶 token 再問一次,沒 token 就退回
|
||||
# C 家目錄或 repo 那份任一在場 ⇒ 不問 Gitea(優先序:家目錄 > repo > Gitea)
|
||||
# D 讀不到(5xx/不是 JSON/沒 title/掛住/連不上/被關掉)⇒ 退回現行行為,不掛、逾時有上限
|
||||
# E 抓到之後 bootstrap 才出現的 repo 那份,跟 ISEP#130 的 set_at 規則接得起來
|
||||
# F `load()`/UserPromptSubmit 那條路照舊零網路
|
||||
#
|
||||
# 用法:bash hooks/tests/mainline-gitea-fallback.test.sh
|
||||
# 🔴 全程離線:Gitea 是本機一個假伺服器(TICKET_HOST 指過去),它照真 Gitea 回:
|
||||
# public 時 raw 端點匿名 200;private 時匿名 404、帶 token 200(真 InkStoneCo 就是 private)。
|
||||
# 狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走 TMP 底下的假薄殼。不碰真家目錄、不碰真 Gitea。
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAINLINE="$ROOT/../scripts/mainline"
|
||||
HOOK="$ROOT/countdown-guard.sh"
|
||||
TMP=$(mktemp -d)
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
export ISEP_COUNTDOWN_NOW=1757203200 # 2026-09-07 00:00 UTC,讓主線那段「剩 X」穩定
|
||||
export ISEP_MAINLINE_GITEA_TIMEOUT=1
|
||||
unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE ISEP_MAINLINE_GITEA_URL ISEP_MILESTONE_DUE ISEP_MILESTONE_NAME
|
||||
unset GITEA_TOKEN_CLAUDE_CODE GITEA_TOKEN
|
||||
SHELL_ROOT="$TMP/shell"; mkdir -p "$SHELL_ROOT" # 薄殼根:沒有 InkStoneCo/,也不是 git repo
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# ── 假 Gitea:模式從 $TMP/mode 讀,每個請求記一行 `path|auth` 到 $TMP/log ──
|
||||
cat > "$TMP/stub.py" <<'PY'
|
||||
import json, os, sys, time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
TMP = sys.argv[1]; PORT = int(sys.argv[2])
|
||||
RAW = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json"
|
||||
DOC = {"ref": "inkstone/Arcrun#48", "owner": "inkstone", "repo": "Arcrun", "id": 48,
|
||||
"title": "Gitea 上那條現在的主線", "description": "測試用。", "due_on": "2026-09-12T23:59:59Z",
|
||||
"open_issues": 3, "closed_issues": 0, "members": ["inkstone/ISEP#140"],
|
||||
"set_at": "2026-09-07 07:30", "refreshed_at": "2026-09-07 08:18"}
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def _send(self, code, body=b"", ctype="application/json"):
|
||||
self.send_response(code); self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
|
||||
def do_GET(self):
|
||||
mode = open(os.path.join(TMP, "mode")).read().strip()
|
||||
auth = "auth" if self.headers.get("Authorization") else "anon"
|
||||
with open(os.path.join(TMP, "log"), "a") as f: f.write("%s|%s\n" % (self.path.split("?")[0], auth))
|
||||
if not self.path.startswith(RAW):
|
||||
return self._send(404, b'{"message":"Not found."}')
|
||||
if mode == "public": return self._send(200, json.dumps(DOC, ensure_ascii=False).encode())
|
||||
if mode == "private":
|
||||
if auth == "auth": return self._send(200, json.dumps(DOC, ensure_ascii=False).encode())
|
||||
return self._send(404, b"Not found.", "text/plain") # 真 Gitea 對 private repo 就回 404
|
||||
if mode == "500": return self._send(500, b"boom")
|
||||
if mode == "garbage": return self._send(200, b"<html>not json</html>", "text/html")
|
||||
if mode == "notitle": return self._send(200, b'{"ref":"x","set_at":"2026-09-07 07:30"}')
|
||||
if mode == "hang":
|
||||
time.sleep(8); return self._send(200, json.dumps(DOC).encode())
|
||||
return self._send(500, b"unknown mode")
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", PORT), H); srv.daemon_threads = True; srv.serve_forever() # 掛住的那筆不准堵住後面的請求
|
||||
PY
|
||||
PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1])")
|
||||
echo public > "$TMP/mode"; : > "$TMP/log"
|
||||
python3 "$TMP/stub.py" "$TMP" "$PORT" & SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||
for _ in $(seq 1 50); do python3 -c "import socket;socket.create_connection(('127.0.0.1',$PORT),1)" 2>/dev/null && break; sleep 0.1; done
|
||||
export TICKET_HOST="http://127.0.0.1:$PORT"
|
||||
|
||||
refresh(){ CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" refresh >"$TMP/refresh.out" 2>"$TMP/refresh.err"; echo $?; }
|
||||
show(){ CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" 2>&1 | head -1; }
|
||||
inject(){ # 第一則 prompt:UserPromptSubmit 給 countdown-guard.sh,回 additionalContext
|
||||
printf '{"hook_event_name":"UserPromptSubmit","session_id":"s140","transcript_path":""}' \
|
||||
| CLAUDE_PROJECT_DIR="$SHELL_ROOT" bash "$HOOK" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
|
||||
except Exception: print("")'
|
||||
}
|
||||
raw_hits(){ grep -c '/raw/system-dev/mainline.json|' "$TMP/log" 2>/dev/null || true; }
|
||||
reset(){ rm -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"; rm -rf "$SHELL_ROOT/InkStoneCo"; : > "$TMP/log"; }
|
||||
|
||||
echo "── A. 薄殼形狀(兩份都沒有)+ Gitea 讀得到 ⇒ 第一則 prompt 就是現在的主線 ──"
|
||||
reset; echo public > "$TMP/mode"
|
||||
printf '2026-08-25|把管理這條線做對\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due" # 第二基準:那條逾期的舊線
|
||||
OUT=$(inject)
|
||||
case "$OUT" in *"現在沒有主線"*) ok "① refresh 之前:兩份都沒有 ⇒ 🎯「現在沒有主線」(票上撞到的那個畫面,先確認測得到)" ;;
|
||||
*) no "① 前置狀態不對" "$OUT" ;; esac
|
||||
case "$OUT" in *"把管理這條線做對"*) ok "② refresh 之前:⏱ 退到第二基準,印的是逾期的舊線(同上)" ;;
|
||||
*) no "② 前置狀態不對(第二基準沒生效)" "$OUT" ;; esac
|
||||
RC=$(refresh)
|
||||
[ "$RC" = "0" ] && ok "③ mainline refresh 離開碼 0" || no "③ refresh 離開碼 $RC" "$(cat "$TMP/refresh.err")"
|
||||
[ -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "④ 家目錄那份被建出來(之後每一則訊息讀它,不再打網路)" \
|
||||
|| no "④ 家目錄沒有檔" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
|
||||
OUT=$(show)
|
||||
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "⑤ \`mainline\` 答得出 Gitea 上那條" ;; *) no "⑤ show 沒讀到" "$OUT" ;; esac
|
||||
OUT=$(inject)
|
||||
case "$OUT" in *"🎯 主線:inkstone/Arcrun#48「Gitea 上那條現在的主線」"*) ok "⑥ 第一則 prompt 的 🎯 那行是 Gitea 上那條(不是「沒有主線」)" ;;
|
||||
*) no "⑥ 🎯 沒講對" "$OUT" ;; esac
|
||||
case "$OUT" in *"主線 Gitea 上那條現在的主線 剩"*) ok "⑦ ⏱ 那行掛的是它的名字與期限(被標定的主線蓋過第二基準)" ;;
|
||||
*) no "⑦ ⏱ 沒掛主線" "$OUT" ;; esac
|
||||
case "$OUT" in *"把管理這條線做對"*) no "⑧ 舊線還在畫面上" "$OUT" ;; *) ok "⑧ 那條逾期 341 小時的舊線不再出現" ;; esac
|
||||
FIRST=$(head -1 "$TMP/log")
|
||||
[ "$FIRST" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|anon" ] && ok "⑨ 第一個請求匿名、打的是 InkStoneCo main 的 raw 檔(讀不帶憑證)" \
|
||||
|| no "⑨ 第一個請求不對" "$FIRST"
|
||||
[ "$(raw_hits)" = "1" ] && ok "⑩ 只問了一次" || no "⑩ 問了 $(raw_hits) 次" "$(cat "$TMP/log")"
|
||||
[ ! -e "$SHELL_ROOT/InkStoneCo" ] && ok "⑪ 沒有建出 InkStoneCo/(不 clone、不弄髒任何工作樹)" || no "⑪ 動了薄殼底下的樹" "$(ls "$SHELL_ROOT")"
|
||||
grep -q '"refreshed_at"' "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" && ok "⑫ 寫進家目錄的是完整那份(有 set_at/refreshed_at,之後 set_at 規則照用)" \
|
||||
|| no "⑫ 家目錄那份殘缺" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
|
||||
echo "── F. load()/每一則訊息那條路:零網路 ──"
|
||||
: > "$TMP/log"; show >/dev/null; show >/dev/null; inject >/dev/null
|
||||
[ "$(wc -l < "$TMP/log" | tr -d ' ')" = "0" ] && ok "⑬ show 兩次+UserPromptSubmit 一次,假 Gitea 零請求(關鍵路徑上沒有網路)" \
|
||||
|| no "⑬ 關鍵路徑打了網路" "$(cat "$TMP/log")"
|
||||
RC=$(refresh)
|
||||
[ "$(raw_hits)" = "0" ] && ok "⑭ 家目錄那份已在 ⇒ 再跑 refresh 也不問 Gitea 的檔(優先序:家目錄 > Gitea)" \
|
||||
|| no "⑭ 家目錄在場還去問" "$(cat "$TMP/log")"
|
||||
|
||||
echo "── B. private:匿名優先,被拒才帶 token;沒 token 就退回 ──"
|
||||
reset; echo private > "$TMP/mode"
|
||||
RC=$(refresh); OUT=$(show)
|
||||
case "$OUT" in *"現在沒有主線"*) ok "⑮ 匿名 404、沒 token ⇒ 退回「現在沒有主線」(不編、不炸;離開碼 $RC)" ;;
|
||||
*) no "⑮ 沒 token 卻讀到了?" "$OUT" ;; esac
|
||||
[ "$RC" = "0" ] && ok "⑯ 沒 token 時離開碼仍是 0(SessionStart 不准炸)" || no "⑯ 離開碼 $RC" "$(cat "$TMP/refresh.err")"
|
||||
[ "$(raw_hits)" = "1" ] && ok "⑰ 沒 token 只問一次就收手" || no "⑰ 問了 $(raw_hits) 次" "$(cat "$TMP/log")"
|
||||
reset
|
||||
RC=$(GITEA_TOKEN_CLAUDE_CODE=fake-token-for-test refresh); OUT=$(show)
|
||||
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "⑱ 匿名 404、環境有 token ⇒ 帶 token 再問一次 ⇒ 讀到(真 InkStoneCo 就是 private)" ;;
|
||||
*) no "⑱ 有 token 還是讀不到" "$OUT/$(cat "$TMP/log")" ;; esac
|
||||
[ "$(sed -n 1p "$TMP/log")" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|anon" ] \
|
||||
&& [ "$(sed -n 2p "$TMP/log")" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|auth" ] \
|
||||
&& ok "⑲ 順序:第一次匿名、第二次才帶 token(不是一上來就實名)" || no "⑲ 順序不對" "$(cat "$TMP/log")"
|
||||
|
||||
echo "── C. 家目錄或 repo 那份任一在場 ⇒ 不問 Gitea ──"
|
||||
reset; echo public > "$TMP/mode"
|
||||
mkdir -p "$SHELL_ROOT/InkStoneCo/system-dev"
|
||||
python3 - "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
json.dump({"ref": "inkstone/ISEP#1", "owner": "inkstone", "repo": "ISEP", "id": 1, "title": "bootstrap 之後 repo 裡那條",
|
||||
"due_on": "2026-09-20T23:59:59Z", "open_issues": 1, "closed_issues": 0, "members": [],
|
||||
"set_at": "2026-09-06 10:00", "refreshed_at": "2026-09-06 10:00"}, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
RC=$(refresh); OUT=$(show)
|
||||
[ "$(raw_hits)" = "0" ] && ok "⑳ repo 那份在 ⇒ 不問 Gitea(優先序:repo > Gitea)" || no "⑳ repo 在場還去問" "$(cat "$TMP/log")"
|
||||
case "$OUT" in *"bootstrap 之後 repo 裡那條"*) ok "㉑ 答的是 repo 那份(0.24.0 的行為一字不改)" ;; *) no "㉑ 沒答 repo 那份" "$OUT" ;; esac
|
||||
[ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "㉒ 只是讀 repo 那份,家目錄沒有被順手建出來(A32 ④ 沒被弄壞)" \
|
||||
|| no "㉒ 家目錄被寫了" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
|
||||
|
||||
echo "── D. 讀不到 ⇒ 退回現行行為,不掛、逾時有上限 ──"
|
||||
for mode in 500 garbage notitle; do
|
||||
reset; echo "$mode" > "$TMP/mode"
|
||||
RC=$(refresh); OUT=$(show)
|
||||
if [ "$RC" = "0" ] && [ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ]; then
|
||||
case "$OUT" in *"現在沒有主線"*) ok "㉓ Gitea 回 $mode ⇒ 什麼都不寫、「現在沒有主線」、離開碼 0" ;; *) no "㉓ $mode 之後答錯" "$OUT" ;; esac
|
||||
else no "㉓ $mode 之後狀態不對" "rc=$RC $(ls "$ISEP_COUNTDOWN_STATE_DIR")"; fi
|
||||
done
|
||||
reset; echo hang > "$TMP/mode"
|
||||
T0=$(date +%s); RC=$(GITEA_TOKEN_CLAUDE_CODE=fake refresh); T1=$(date +%s); OUT=$(show)
|
||||
if [ $((T1 - T0)) -le 5 ] && [ "$RC" = "0" ]; then
|
||||
case "$OUT" in *"現在沒有主線"*) ok "㉔ Gitea 掛住(8 秒不回)⇒ $((T1 - T0)) 秒內放棄、退回、離開碼 0(逾時 1 秒 × 最多兩次)" ;; *) no "㉔ 掛住之後答錯" "$OUT" ;; esac
|
||||
else no "㉔ 掛住時沒有在時限內放棄" "用了 $((T1 - T0)) 秒,rc=$RC"; fi
|
||||
reset; echo public > "$TMP/mode"
|
||||
RC=$(TICKET_HOST="http://127.0.0.1:9" refresh); OUT=$(show)
|
||||
case "$OUT" in *"現在沒有主線"*) [ "$RC" = "0" ] && ok "㉕ 連不上 Gitea(TICKET_HOST 指到死位址)⇒ 退回、離開碼 0" || no "㉕ 離開碼 $RC" "$(cat "$TMP/refresh.err")" ;;
|
||||
*) no "㉕ 連不上還答出主線" "$OUT" ;; esac
|
||||
reset
|
||||
RC=$(ISEP_MAINLINE_GITEA_URL=none refresh); OUT=$(show)
|
||||
[ "$(raw_hits)" = "0" ] && case "$OUT" in *"現在沒有主線"*) ok "㉖ ISEP_MAINLINE_GITEA_URL=none ⇒ 零請求(既有測試靠它把外界定住)" ;; *) no "㉖ none 之後答錯" "$OUT" ;; esac \
|
||||
|| no "㉖ none 沒生效" "$(cat "$TMP/log")"
|
||||
[ -z "$(cat "$TMP/refresh.err")" ] && ok "㉗ 以上每一種讀不到,stderr 都是空的(不報錯嚇人)" || no "㉗ stderr 有東西" "$(cat "$TMP/refresh.err")"
|
||||
|
||||
echo "── E. 抓到之後 bootstrap 才出現的 repo 那份:跟 ISEP#130 的 set_at 規則接得起來 ──"
|
||||
reset; echo public > "$TMP/mode"; RC=$(refresh)
|
||||
mkdir -p "$SHELL_ROOT/InkStoneCo/system-dev"
|
||||
cp "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" # bootstrap clone 下來的同一份
|
||||
OUT=$(show)
|
||||
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "㉘ bootstrap 之後 repo 那份出現、set_at 相同 ⇒ 仍是同一條(家目錄那份算數)" ;; *) no "㉘ 答錯" "$OUT" ;; esac
|
||||
python3 - "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); d["title"] = "後來在別台機器新標的"; d["set_at"] = "2026-09-08 09:00"
|
||||
json.dump(d, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
OUT=$(show)
|
||||
case "$OUT" in *"後來在別台機器新標的"*) ok "㉙ repo 那份 set_at 較新 ⇒ 用 repo 那份(A32 ⑤ 的規則沒被本票弄壞)" ;; *) no "㉙ 沒用較新的" "$OUT" ;; esac
|
||||
|
||||
echo; echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in New Issue
Block a user