From 7517566156fe9b66daa2a2f901d2ba8150c58496 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 27 Aug 2026 18:49:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(restart-gate):=20=E9=87=8D=E5=95=9F?= =?UTF-8?q?=E9=96=98=E9=96=80=E7=8B=80=E6=85=8B=E6=AA=94=E6=94=B9=E7=82=BA?= =?UTF-8?q?=E4=B8=80=E6=94=AF=20CLI=20=E4=B8=80=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What:`hooks/restart-gate.sh` 的狀態檔從全機器單一檔案 `$JSC_HOME/restart-required` 改為狀態目錄 `$JSC_HOME/restart-required.d/{CLI 代號}`,一支 CLI 一份,檔名就是 CLI 代號,四行 key=value 的格式不變。新增 `cli_code()` 取當前 CLI 代號(含不能當檔名的髒值防護)與 `state_line()` 統一 `report` 的輸出格式;`require` 只寫自己那一份、hook 判定只讀自己那一份、`clear` 只刪自己那一份、`report` 一支 CLI 一行印出還沒重啟的是哪幾支。舊格式的單一檔案存在時一律擋,訊息標明是舊格式紀錄,`clear` 會一併刪掉它。檔頭註解同步改寫,新增「report 輸出格式」與「舊檔相容(過渡用)」兩節。`hooks/session-timer.sh` 程式碼不動,兩處註解補上「清除範圍只有跑到這支腳本的那一支 CLI」。 Why:這道閘門 2026-08-27 才上線,部署現場實測抓到兩個缺陷。第一,`require` 用 `>` 覆寫單一檔案,並行部署互相覆蓋——kiro 寫入 9 秒後被 codex 蓋掉,`domains=` 與 `cli=` 不再代表 kiro。第二,`clear` 用 `rm -f` 刪整個檔案,任一支 CLI 重啟就解除全部五支的閘門,其餘四支沒重啟卻不再被擋。這台機器就裝了五支 CLI,閘門在多 CLI 環境等於半失效。閘門管的是「這一支 CLI 的行程還在跑舊版」,那是每支 CLI 各自的事實,狀態檔本來就不該共用。 How:寫入、判定、清除三件事都只碰自己那一份,別支那幾份一律不看,`clear` 的範圍也就收斂成呼叫端那一支。取不到 CLI 代號時比照既有的「查不到就放行」原則:hook 模式放行,`require` 回 exit 2 並講明這次沒有掛上閘門——寫到讀不到的檔名等於沒掛,不能讓部署以為掛上了。代號會直接拿去當檔名,所以含 `/`、以 `.` 開頭、或出現 `[A-Za-z0-9._-]` 以外字元的值一律當成取不到,狀態檔就寫不到目錄外面去。舊檔沒有 per-CLI 資訊,分不出是哪一支寫的,判定寧可擋多不擋少;`clear` 只在新工作階段被呼叫,呼叫到就代表確實有一支重新啟動過了,舊檔留著會讓五支一路被擋到有人手動刪,所以一併刪掉。這段相容邏輯與可移除的時機(所有機器都跑過一次寫狀態目錄的部署與重啟之後)寫在檔頭「舊檔相容」與 README。`session-timer.sh` 不必跟著改:狀態檔的路徑、範圍與格式只留在 `restart-gate.sh`,那裡只負責判斷新舊工作階段。 Who:`jsc-hooks` 的部署後強制重啟閘門(R15),以及它與 `session-timer.sh` 的清除分工。 --- hooks/restart-gate.sh | 151 ++++++++++++++++++++++++++++++++--------- hooks/session-timer.sh | 5 +- 2 files changed, 123 insertions(+), 33 deletions(-) diff --git a/hooks/restart-gate.sh b/hooks/restart-gate.sh index 0172cd0..4fd556e 100755 --- a/hooks/restart-gate.sh +++ b/hooks/restart-gate.sh @@ -5,15 +5,19 @@ # 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。 # # 用法: -# restart-gate.sh hook 模式:狀態檔存在就擋下該次技能呼叫(exit 2) +# restart-gate.sh hook 模式:當前 CLI 那份狀態檔存在就擋下該次技能 +# 呼叫(exit 2)。別支 CLI 那幾份不看。 # restart-gate.sh require {模式} [{domain}...] -# 寫入狀態檔,掛上閘門。由 jsc-cli:deploy 在 install 或 -# update 收尾時呼叫;模式為 install 或 update,之後接 -# 這次更新的 domain 清單。 -# exit 0 = 已掛上;exit 2 = 寫不進去(沒寫成等於沒掛)。 -# restart-gate.sh clear 清除狀態檔,放下閘門。由 session-timer.sh 在判定為 -# 新工作階段時呼叫(見下方「清除時機」)。檔案不存在也算成功。 -# restart-gate.sh report 印出狀態檔內容;沒有狀態檔就不印,一律 exit 0。 +# 寫入當前 CLI 那份狀態檔,掛上這一支的閘門。由 +# jsc-cli:deploy 在 install 或 update 收尾時呼叫;模式為 +# install 或 update,之後接這次更新的 domain 清單。 +# exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去 +# (兩種都等於沒掛上)。 +# restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由 +# session-timer.sh 在判定為新工作階段時呼叫(見下方 +# 「清除時機」)。檔案不存在也算成功。 +# restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方 +# 「report 輸出格式」);一份都沒有就不印,一律 exit 0。 # # require、clear、report 都不讀標準輸入,只有 hook 模式讀。理由與 sdlc-gate.sh 相同: # read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。新增子命令照這個 @@ -21,14 +25,42 @@ # # --- 狀態檔格式 --- # -# $JSC_HOME/restart-required(JSC_HOME 未設定時為 ~/.jsc),純文字 key=value,一行一欄位, -# 順序不拘,不認得的鍵一律忽略。格式壓到最簡,jsc-hooks 與 jsc-cli 兩邊各自實作也對得上。 +# $JSC_HOME/restart-required.d/{CLI 代號}(JSC_HOME 未設定時為 ~/.jsc),一支 CLI 一份, +# 檔名就是 CLI 代號(claude、codex、copilot、antigravity、kiro)。內容為純文字 key=value, +# 一行一欄位,順序不拘,不認得的鍵一律忽略。格式壓到最簡,jsc-hooks 與 jsc-cli 兩邊各自 +# 實作也對得上。 # at={ISO 時間} 部署收尾時間,UTC # mode={install|update} 這次部署的模式 # domains={domain 清單} 這次更新到的 domain,空白分隔 -# cli={CLI 代號} 執行部署的 CLI -# 欄位只用在擋人訊息上。判定看的是「檔案在不在」——檔案存在就是還沒重啟過的證據,欄位缺了 -# 只讓訊息少幾個字,不影響判定。 +# cli={CLI 代號} 執行部署的 CLI,與檔名相同 +# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟 +# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。 +# +# 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。 +# 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉, +# 欄位不再代表先寫的那一支),以及任一支 CLI 重啟就把五支的閘門一起解除(其餘四支沒重啟卻 +# 不再被擋,閘門等於半失效)。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。 +# +# --- report 輸出格式 --- +# +# 一行一份狀態檔,欄位以空白分隔,domains 可能含空白所以擺最後: +# {CLI 代號} at={ISO 時間} mode={install|update} domains={domain 清單} +# 有幾行就代表有幾支 CLI 還沒重啟。欄位缺值時只留鍵名(例如 domains=)。第一欄印 legacy 的 +# 那一行代表舊格式的單一狀態檔(見下方「舊檔相容」),它不屬於任何一支 CLI。 +# +# --- 舊檔相容(過渡用) --- +# +# 舊版把狀態寫進 $JSC_HOME/restart-required 單一檔案。改用狀態目錄的第一輪部署,機器上可能 +# 還留著那份舊檔:完全不認它,那一輪的閘門會整輪漏掉,檔案本身也會永遠留著變垃圾。所以: +# 判定:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」。舊檔沒有 per-CLI 資訊, +# 分不出是哪一支寫的,寧可擋多不擋少。擋人訊息會標明這是舊格式紀錄。 +# 清除:clear 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:clear 只在新工作階段被 +# 呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支 +# CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法。代價是同一輪部署的其他 CLI 少擋 +# 一次,只影響改用狀態目錄的那一輪。 +# 這一段是過渡用的:所有機器都跑過一次寫狀態目錄的部署與重啟之後,舊檔不會再被寫出來,屆時 +# 可以整段移除——LEGACY_STATE、hook 判定裡的舊檔分支、clear 裡的舊檔刪除、report 的 +# legacy 行、以及本節。 # # --- 清除時機 --- # @@ -36,11 +68,13 @@ # 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。 # 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段 # (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。 +# 清除的範圍就是呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。 # # --- 判定原則 --- # # 比照 version-guard.sh:只擋確定違規,查不到基礎資訊一律放行(exit 0)。狀態檔讀不到、 -# 技能名取不到、工具名不是 Skill,三種都放行——沒有證據時擋下等於停掉每一次技能呼叫。 +# CLI 代號取不到、技能名取不到、工具名不是 Skill,四種都放行——沒有證據時擋下等於停掉每一次 +# 技能呼叫。 # # 豁免(這些技能永遠放行,改動前想清楚後果): # jsc-cli:deploy 部署入口本身,也是唯一能把技能組換成新版的路徑,擋了會死鎖 @@ -64,12 +98,32 @@ # 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。 HERE=$(dirname "$0"); . "$HERE/lib.sh" -STATE="$JSC_HOME/restart-required" +STATE_DIR="$JSC_HOME/restart-required.d" +# 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。 +LEGACY_STATE="$JSC_HOME/restart-required" -# 從狀態檔取一個欄位;檔案讀不到或欄位不存在就不輸出。 -state_field() { # $1=鍵名 - [ -f "$STATE" ] && [ -r "$STATE" ] || return 0 - sed -n "s/^$1=//p" "$STATE" 2>/dev/null | head -n1 +# 當前 CLI 代號;取不到就不輸出,由呼叫端決定怎麼降級。取法與其他 hook 一致(JSC_CLI 優先, +# 其次 lib.sh 的 cli_name)。不像代號的值一併當成取不到:這個值直接拿去當檔名,帶斜線或 +# 點號開頭的值會把檔案寫到狀態目錄外面去。 +cli_code() { + _c=$(cli_name) + case "$_c" in + ""|unknown) return 0 ;; + .*|*[!A-Za-z0-9._-]*) return 0 ;; + esac + printf '%s' "$_c" +} + +# 從指定狀態檔取一個欄位;檔案讀不到或欄位不存在就不輸出。 +state_field() { # $1=狀態檔 $2=鍵名 + [ -f "$1" ] && [ -r "$1" ] || return 0 + sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n1 +} + +# 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔 +state_line() { + printf '%s at=%s mode=%s domains=%s\n' "$1" \ + "$(state_field "$2" at)" "$(state_field "$2" mode)" "$(state_field "$2" domains)" } case "${1:-}" in @@ -77,19 +131,37 @@ case "${1:-}" in _mode="${2:-update}" _domains="" if [ "$#" -gt 2 ]; then shift 2; _domains="$*"; fi - mkdir -p "$JSC_HOME" 2>/dev/null || true + _cli=$(cli_code) + if [ -z "$_cli" ]; then + # 取不到代號就不知道該寫哪一份,寫成別的檔名也沒用:hook 模式同樣取不到代號,那一份 + # 永遠不會被讀到。沒掛上就要講出來,不能讓部署以為掛上了。 + printf '[jsc][重啟閘門][ERR]:取不到可用的 CLI 代號(JSC_CLI 未設定,或值不是代號),這次部署沒有掛上重啟閘門。\n' >&2 + exit 2 + fi + mkdir -p "$STATE_DIR" 2>/dev/null || true printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\n' \ - "$(now_iso)" "$_mode" "$_domains" "$(cli_name)" > "$STATE" 2>/dev/null || { + "$(now_iso)" "$_mode" "$_domains" "$_cli" > "$STATE_DIR/$_cli" 2>/dev/null || { # 寫不進去要講出來:沒寫成就沒有閘門,部署卻以為掛上了。 - printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE" >&2 + printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2 exit 2 } exit 0 ;; clear) - rm -f "$STATE" 2>/dev/null || true + _cli=$(cli_code) + # 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。 + [ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null + # 舊檔一併刪,取捨與可移除時機見檔頭「舊檔相容」。 + rm -f "$LEGACY_STATE" 2>/dev/null || true exit 0 ;; report) - if [ -f "$STATE" ] && [ -r "$STATE" ]; then cat "$STATE" 2>/dev/null || true; fi + # 目錄裡一份都沒有時,未展開的樣式字串會由 -f 判斷擋掉。 + for _f in "$STATE_DIR"/*; do + [ -f "$_f" ] && [ -r "$_f" ] || continue + state_line "$(basename "$_f")" "$_f" + done + if [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then + state_line legacy "$LEGACY_STATE" + fi exit 0 ;; "") ;; # 落到下面的 hook 模式 *) exit 0 ;; # 不認得的子命令一律安靜放行,不中斷宿主 CLI @@ -119,26 +191,41 @@ case "$skill" in exit 0 ;; esac -# 狀態檔讀不到就放行:沒有「剛部署過」的證據,就沒有擋人的理由。 -[ -f "$STATE" ] && [ -r "$STATE" ] || exit 0 +# CLI 代號取不到就放行:不知道現在跑的是哪一支,就不知道該讀哪一份狀態檔,等同沒有證據。 +cli=$(cli_code) +[ -n "$cli" ] || exit 0 -at=$(state_field at) -mode=$(state_field mode) -domains=$(state_field domains) +# 只看自己那一份;沒有才退回看舊檔。兩份都沒有就放行——沒有「剛部署過」的證據,就沒有擋人的 +# 理由。別支 CLI 那幾份一律不看:那些是別的行程,重啟與否跟這一支無關。 +state="$STATE_DIR/$cli" +legacy=no +if [ -f "$state" ] && [ -r "$state" ]; then + : +elif [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then + state="$LEGACY_STATE"; legacy=yes +else + exit 0 +fi + +at=$(state_field "$state" at) +mode=$(state_field "$state" mode) +domains=$(state_field "$state" domains) # 重啟方式依實際 CLI 給。印別的 CLI 的執行檔名等於沒給指示。 -bin=$(cli_bin "$(cli_name)") -[ "$bin" = unknown ] && bin="目前的 CLI" +bin=$(cli_bin "$cli") # 訊息裡的部署資訊逐段接起來,缺欄位就少一段,不會留下空括號或多餘的逗號。 info="" [ -n "$at" ] && info="$at" [ -n "$mode" ] && info="${info}${info:+,}模式 $mode" [ -n "$domains" ] && info="${info}${info:+,}domain:$domains" +# 舊格式紀錄要標出來:它分不出是哪一支 CLI 部署的,所以每一支都擋,看到訊息的人才不會以為 +# 系統認定就是這一支剛部署過。 +[ "$legacy" = yes ] && info="${info}${info:+,}舊格式紀錄,分不出是哪一支 CLI 部署的" printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \ "${info:+($info)}" "$bin" >&2 printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ - "$bin" "$STATE" >&2 + "$bin" "$state" >&2 printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完) | 確定要略過閘門:JSC_RESTART_GATE=off\n' >&2 exit 2 diff --git a/hooks/session-timer.sh b/hooks/session-timer.sh index 5c7895f..c89cb37 100755 --- a/hooks/session-timer.sh +++ b/hooks/session-timer.sh @@ -16,11 +16,14 @@ # (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。 # 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始, # 所以清除掛在這裡,不在 restart-gate.sh 裡自己再判一次。 +# 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定, +# 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。 HERE=$(dirname "$0"); . "$HERE/lib.sh" read_stdin sid=$(session_id) -# 放下部署後的重啟閘門。狀態檔的路徑與格式只留在 restart-gate.sh,這裡不碰檔案。 +# 放下這一支 CLI 的部署後重啟閘門。狀態檔的路徑、範圍與格式只留在 restart-gate.sh, +# 這裡不碰檔案,所以改成一支 CLI 一份狀態檔之後這裡不用跟著改。 # 一律 /dev/null || true