fix(deploy): 本機複本一個 domain 一把鎖,重啟閘門不等整輪判定

實測踩到兩件事,同一輪、同一個根因。

那一份本機複本一台機器只有一份,而技能規定五支 CLI 平行部署——平行是對的,
它們寫的是不同的外掛目錄。但複本不是:五支都會來 pull 同一個目錄,連只需要
讀 manifest 的那幾支也會(相依檢查從那裡讀)。git 對同一個存取庫的併發寫入
沒有保護,於是同一輪裡兩支撞在一起,一支拿不到 ORIG_HEAD.lock、一支的遠端
refs 換不上去。

後果是最難查的那一種:兩支的整輪判定都變成 fail,而外掛其實全部裝好了——
報告說失敗、實際成功,而真正的原因跟部署無關。

改成一個 domain 一把 mkdir 鎖:那是檔案系統這一層唯一原子的建立動作。等不到
就印一行 warn 改用磁碟上的內容,別人正在拉同一份,硬等下去只是排隊。上一輪
中途死掉留下的鎖用年紀判,門檻放寬到等待秒數的四倍。

複本已經在磁碟上而 pull 拉不動的那一種,也改成只印 warn、不判整輪失敗:
內容在,只是可能比遠端舊。但一定要印出來——安靜地裝一份舊內容,是這一組
工具最怕的那種失效。clone 不存在那一種照舊算失敗,磁碟上根本沒東西可裝。

第二件事更嚴重。原本的寫法是「整輪判定成功才掛重啟閘門」,於是那一輪的
fail 把閘門一起跳過了:外掛換了一半,而唯一沒有被告知要重啟的,剛好就是
正在跑那份剛被換掉的程式碼的那一支 CLI。一道只在成功時才生效的提醒,在最
需要它的那一次不會出現。改成 install 與 update 一律先掛,再判 result。

順帶補一支安全截斷:訊息截長度用的是 cut -c,那數的是位元組,多位元組字
剛好被切成兩半會留一個替代字元,而亂碼不影響結束碼、沒有人會來報。

乾跑那一路一步都不動,連鎖都不取——取鎖是建目錄,那已經是寫入。原本改完
之後乾跑會真的去 pull,這一版修回來了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 12:35:27 +08:00
co-authored by Claude Opus 5
parent 07c1c44e5a
commit cd832460ee
6 changed files with 99 additions and 13 deletions
+90 -6
View File
@@ -9,11 +9,16 @@
# cmd<TAB>{指令} 即將執行的指令
# exit<TAB>{結束碼}<TAB>{指令} 該指令的結束碼;dry-run 時結束碼印「-」
# skip<TAB>{domain}<TAB>{原因} 本地 clone 是開發中的樹,略過 git pull
# warn<TAB>{domain}<TAB>{原因} 相依版本不符,仍照樣更新的提醒
# warn<TAB>{domain}<TAB>{原因} 照樣裝下去、但要人知道的事:相依版本不符,
# 或本機複本這一輪沒有重新拉取(拿不到
# 更新鎖,或 git pull 回非零),那時候
# 裝的是磁碟上現有的內容,可能不是最新版
# compat<TAB>codex<TAB>{舊路徑}<TAB>{新路徑} codex 舊版快取路徑補成指向新版的相容連結
# note<TAB>{cli}<TAB>{原因} 非逐指令的說明(例:kiro 整批改走複製退路的理由)
# link<TAB>{domain}<TAB>{狀態}<TAB>{連結路徑}<TAB>{指向或原因} current 連結農場這一條的刷新結果
# restart<TAB>{路徑} 這次寫下的重啟狀態檔
# restart<TAB>{路徑} 這次寫下的重啟狀態檔。install 與 update
# 一律寫,整輪判定成 fail 也照寫:外掛
# 已經換了一部分,這時候更需要重啟
# requires<TAB>{domain}<TAB>{檢查結果} update 前的 jsc.requires 檢查
# result<TAB>{cli}<TAB>{mode}<TAB>{domain 清單}<TAB>{ok|fail}
# 結束碼:全部指令成功 0;任一指令失敗 1;參數錯誤 2。skip、warn、note、link 不算失敗,
@@ -381,21 +386,93 @@ local_hold() { # $1=存取庫路徑
[ "${ahead:-0}" -eq 0 ] || printf '有 %s 個未推送的 commit' "$ahead"
}
# 取一個 domain 的更新鎖。
#
# 這一份本機複本一台機器只有一份,而五支 CLI 的部署是平行跑的——平行是對的,它們寫的是
# 不同的外掛目錄。但這裡不是:五支都會來 pull 同一個目錄,而 git 對同一個存取庫的併發寫入
# 沒有保護。
# 2026-09-07 實測踩到:同一輪裡兩支 CLI 撞在一起,一支拿不到 ORIG_HEAD.lock、一支的遠端
# refs 換不上去,兩支的整輪判定都變成 fail——而外掛其實全部裝好了。那是最難查的一種失效:
# 報告說失敗,實際成功,而真正的原因跟部署無關。
# 一個 domain 一把鎖,用 mkdir 取:那是檔案系統這一層唯一原子的建立動作,兩支同時 mkdir
# 只有一支成功。等不到就改用磁碟上的內容——別人正在拉同一份,硬等下去只是排隊。
clone_lock() { # $1=鎖目錄 $2=最多試幾次(每次之間睡一秒,所以約等於秒數);0=拿到了
_try=0
while :; do
mkdir "$1" 2>/dev/null && return 0
_try=$((_try + 1))
[ "$_try" -ge "$2" ] && break
sleep 1
done
# 等不到還要分一種情形:上一輪中途死掉會留下一個沒有人放的鎖,那種鎖永遠等不到。
# 判準取鎖的年紀,門檻放寬到等待秒數的四倍——比任何一次正常的 pull 都久。
if [ -d "$1" ]; then
_age=$(( $(date +%s) - $(date -r "$1" +%s 2>/dev/null || date +%s) ))
if [ "$_age" -gt $(( $2 * 4 )) ]; then
rmdir "$1" 2>/dev/null || true
mkdir "$1" 2>/dev/null && return 0
fi
fi
return 1
}
# 截一段訊息到指定長度。cut -c 數的是位元組不是字元,一個多位元組字剛好被切成兩半時
# 尾巴會留一個替代字元,而亂碼不影響結束碼、沒有人會來報。截完再過一次 iconv -c 丟掉
# 那個不完整的序列;iconv 不在這台機器上就退回原樣。
clip1() { # $1=位元組上限;讀標準輸入
_raw=$(tr '\n' ' ' | cut -c"1-$1")
_cln=$(printf '%s' "$_raw" | iconv -c -f UTF-8 -t UTF-8 2>/dev/null)
if [ -n "$_cln" ]; then printf '%s' "$_cln"; else printf '%s' "$_raw"; fi
}
# 把某 domain 的存取庫抓到本地:有 .git 就 pull,沒有就 clone。
# 目標是開發中的樹時只印 skip,改用現地內容安裝,不 pull:這支腳本可以被指到任何
# 目錄,蓋掉維護者未提交或未推送的工作救不回來,安裝一份舊內容還能重跑。
sync_local() { # $1=domain
dir="$LOCAL_DIR/$1"
lock="$LOCAL_DIR/.lock-$1"
if [ -d "$dir/.git" ]; then
hold=$(local_hold "$dir")
if [ -n "$hold" ]; then
printf 'skip\t%s\t%s %s,未執行 git pull\n' "$1" "$dir" "$hold"
return 0
fi
run git -C "$dir" pull
else
run git clone "$REPO_BASE/$1.git" "$dir"
# 乾跑一步都不能動,連鎖都不取:取鎖是建目錄,那已經是寫入了。
if [ "$DRYRUN" = 1 ]; then
printf 'cmd\t%s\n' "git -C $dir pull"
printf 'exit\t-\t%s\n' "git -C $dir pull"
return 0
fi
if ! clone_lock "$lock" 30; then
printf 'warn\t%s\t%s\n' "$1" "$dir 的更新鎖等了 30 秒還拿不到,別的 CLI 正在拉同一份;這一輪改用磁碟上現有的內容,沒有重新拉取"
return 0
fi
printf 'cmd\t%s\n' "git -C $dir pull"
_out=$(git -C "$dir" pull 2>&1); _rc=$?
rmdir "$lock" 2>/dev/null || true
printf 'exit\t%s\t%s\n' "$_rc" "git -C $dir pull"
if [ "$_rc" -ne 0 ]; then
# 複本已經在磁碟上,拉不動不等於裝不了:安裝改用現有內容,那可能是舊版。
# 這裡刻意不記 FAILED。記了整輪會判成 fail,而 fail 那條路徑會連重啟閘門一起跳過
# ——外掛換了一半而沒有人被告知要重啟,比一句「拉不動」嚴重得多。
# 但一定要印出來:安靜地裝一份舊內容,是這一組工具最怕的那種失效。
printf 'warn\t%s\t%s\n' "$1" "git pull 回 $_rc,這一輪用磁碟上現有的內容安裝,可能不是最新版:$(printf '%s' "$_out" | clip1 160)"
fi
return 0
fi
if [ "$DRYRUN" = 1 ]; then
run git clone "$REPO_BASE/$1.git" "$dir"
return 0
fi
if ! clone_lock "$lock" 60; then
FAILED=1
printf 'warn\t%s\t%s\n' "$1" "$dir 還不存在,而 clone 鎖等了 60 秒拿不到,這個 domain 這一輪沒有本機複本可以裝"
return 1
fi
run git clone "$REPO_BASE/$1.git" "$dir"
_rc=$?
rmdir "$lock" 2>/dev/null || true
return "$_rc"
}
# claude、copilot、kiro-cli 共用的 plugin 指令組。
@@ -581,8 +658,15 @@ esac
refresh_links
# 重啟閘門一律先掛,不等整輪判定。
#
# 部分失敗代表磁碟上的外掛已經換了一部分,這時候更需要重啟,不是更不需要。
# 2026-09-07 實測踩到:一條與部署無關的 git pull 失敗把整輪判成 fail,而原本的寫法是
# 「判定成功才掛閘門」——於是那一支 CLI 沒有被掛上閘門,沒有人被告知要重啟,而它跑的
# 是舊版程式碼。一道只在成功時才生效的提醒,在最需要它的那一次不會出現。
mark_restart
if [ "$FAILED" -eq 0 ]; then
mark_restart
printf 'result\t%s\t%s\t%s\tok\n' "$CLI" "$MODE" "$DOMAINS"
exit 0
fi