#!/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"not json", "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 ]