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
+4 -2
View File
@@ -95,6 +95,8 @@ Nothing passed in → run every step as written below.
On update, `{CLI_ROOT}/tools/check-requires.sh {cli} {manifest}` checks each domain's `jsc.requires` before that domain is updated. Exit 0 updates the domain as usual. Exit 1 — a missing or too-old required jsc plugin — prints a `warn` line and the domain **is still updated**: skipping it would leave a behind domain permanently unable to reach the version its dependency needs. The block lives one layer up, at skill invocation time, where `jsc-hooks/hooks/version-guard.sh` stops that domain's skills. **That last mention is a description of where the block happens, not a call this skill makes** — nothing here runs `version-guard.sh` except step 1.2, which is written as `{JSC_ROOT}/jsc-hooks/hooks/version-guard.sh`, so do not read it as a call site that was left un-prefixed. Exit 4 — the manifest is unreadable, is not valid JSON, or python3 is missing — prints a `note` line and also still updates the domain: no verdict is not the same fact as behind, so it gets its own line rather than a `warn` that would send the operator hunting for a version problem that is not there. Exit 2 or any other code — a `check-requires.sh` usage error or a broken script — prints a `skip` line and leaves that domain untouched, because a checker that failed outright is not a pass. Codex update preserves old `jsc-cli` and `jsc-hooks` cache version paths as symlinks to the newest installed version, so a still-running Codex deploy can keep using its helper scripts and a still-running Codex session whose hook_run_id points at the old cache can finish without `No such file`. Antigravity cannot install from a Gitea URL, so the script clones each domain into the local plugin directory (`JSC_LOCAL_PLUGINS`, default `$JSC_HOME/plugins`) and installs from that path — keep that clone, because update pulls the same one. That default deliberately avoids a development checkout: when the directory holds uncommitted changes or unpushed commits, the script prints a `skip` line, leaves the tree untouched, and installs the on-disk content.
**That one clone is shared by all five CLIs, and this step runs them in parallel, so the script takes a per-domain lock around its `git pull` and a `warn` line is what a contended or failed pull looks like.** Every CLI reaches that clone: the four that install from it, and `check-requires.sh`, which reads each domain's manifest there. Measured: two CLIs collided inside one round, one could not create `ORIG_HEAD.lock` and the other could not move its remote refs, and **both runs came back `fail` while every plugin had in fact installed** — a report saying failure over a machine that succeeded, for a reason that has nothing to do with deploying. So a pull that cannot get the lock within thirty seconds, or that exits non-zero on a clone already on disk, prints `warn` and **does not fail the run**: the content is there, it may simply be older than the remote. Read every such `warn` line into the report as "this CLI installed what was already on disk" — it is the one line between that and a silent install of a stale version. A `git clone` that fails is different and still fails the run: nothing is on disk to install.
**The `link` lines say where `{JSC_ROOT}` now points, and they are the reason step 0's literal paths keep working.** `{JSC_ROOT}` is a farm of version-free symbolic links, one per plugin, each pointing at that plugin's versioned directory in a CLI's plugin cache. Every literal absolute path this skill builds rests on it, so a link left on an old version silently runs an old script — and a script that old version never shipped is simply absent, which stops a heartbeat without printing anything. `deploy.sh` refreshes the whole farm at the end of its run: install and update repoint every link at the version just installed, uninstall clears the ones whose target is gone.
Only one CLI's run touches the farm. The machine has one farm and five CLIs hold five copies, so the script picks the first of claude, codex, copilot, kiro that is installed and lets only that run write; every other CLI prints one `link<TAB>-<TAB>skip` line naming the base CLI, which is how a deliberate skip is told apart from a farm nobody refreshed. Read the base CLI's sub agent output for the real result. Each line is `link<TAB>{domain}<TAB>{status}<TAB>{link path}<TAB>{target or reason}`.
@@ -110,7 +112,7 @@ Nothing passed in → run every step as written below.
A failed link never fails the deploy. By the time the farm is refreshed the plugins are installed and working, and marking the round failed would skip the restart gate and the `result` line too, handing the operator a fully deployed machine described as a failure. The stale link is a real defect, but its remedy is a line the operator can see and act on.
Done when every detected CLI has reported an exit code and a `result` line, every skipped domain has a checker-failure reason or a local-tree reason, every `warn` domain is named with the version it still has to catch up to, and every `link` line has been read — with each `ok` target in hand for step 7 and each `fail` or domain-level `skip` named as an operator action.
Done when every detected CLI has reported an exit code and a `result` line, every skipped domain has a checker-failure reason or a local-tree reason, every `warn` domain is named with either the version it still has to catch up to or the reason its local clone was not refreshed this round, and every `link` line has been read — with each `ok` target in hand for step 7 and each `fail` or domain-level `skip` named as an operator action.
5. After install or update, call `jsc-hooks:hooks-install` and **hand it the CLI list from step 1.1**, so it does not probe the same five executables a second time. `hooks-install` still detects for itself when it receives no list — that fallback is what keeps it usable on its own.
@@ -133,7 +135,7 @@ Nothing passed in → run every step as written below.
State the farm explicitly: name `{JSC_ROOT}` and, per plugin, the version directory its link now points at, taken from the `ok` targets. That one list is what lets the next round's operator check by eye that a documented path leads to the version just deployed, instead of finding out through a heartbeat that quietly stopped.
For install or update, the same block ends with the restart instruction, in these words: 「請關閉目前的工作階段並重新啟動,新的技能內容才會載入」. `deploy.sh` recorded this round in `$JSC_HOME/restart-required.d/{cli}` — one file per CLI — and prints its path on a `restart` line; `jsc-hooks` reads only that CLI's own file and keeps reminding until that CLI restarts, with `JSC_RESTART_GATE=off` as the escape hatch. Restarting one CLI clears its own file and leaves the others' gates standing. Name the two guide paths from step 6 in that same closing block, so the operator knows where this machine's update and removal commands now live.
For install or update, the same block ends with the restart instruction, in these words: 「請關閉目前的工作階段並重新啟動,新的技能內容才會載入」. `deploy.sh` recorded this round in `$JSC_HOME/restart-required.d/{cli}` — one file per CLI — and prints its path on a `restart` line. **It writes that file on every install and update, including a run it judged `fail`**: a partial failure means part of what is on disk changed, which makes a restart more necessary, not less. It used to write it only on success, and one round proved the cost — an unrelated `git pull` failure turned the run into a `fail`, that branch never reached the gate, and the one CLI running the freshly replaced code was the only one never told to restart. Report a `restart` line missing from a `fail` run as a defect in that script rather than raising the gate by hand; `jsc-hooks` reads only that CLI's own file and keeps reminding until that CLI restarts, with `JSC_RESTART_GATE=off` as the escape hatch. Restarting one CLI clears its own file and leaves the others' gates standing. Name the two guide paths from step 6 in that same closing block, so the operator knows where this machine's update and removal commands now live.
Done when every detected CLI appears in the report with its `result` status, every plugin's refreshed link target is named, and — for install or update — the restart instruction is printed with both guide paths named, or step 6's failure is repeated in their place.