From 4c2bf26c42f5949d6206a84248612b6f7a88be98 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 21:26:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20flock=20=E6=B2=A1=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E8=BF=9B=20AGENTMAIL=5FREQUIRE=EF=BC=88"=E7=BC=BA?= =?UTF-8?q?=E5=91=BD=E4=BB=A4"=E8=A2=AB=E6=8A=A5=E6=88=90"=E5=8F=A6?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E9=83=A8=E7=BD=B2=E5=9C=A8=E8=B7=91"?= =?UTF-8?q?=EF=BC=89+=20=E4=B8=AD=E6=96=AD=20trap=20+=20=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E6=AC=A0=E8=B4=A6=E5=85=A5=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **1. 自指缺口:新能力带的新依赖没登记回表(pi 抓到)** 我加"同时性"那一列时引入了 `flock`,**却没把 `flock` 加进三个脚本的 `AGENTMAIL_REQUIRE`**。 后果实测: PATH 里没有 flock ⇒ `flock: command not found`(127)⇒ `! flock` 为真 ⇒ 打印"**另一个部署正在跑(锁被占用)**" 退出码事后是对的(2),但**诊断是错的** —— 而照着它做的是"等另一个部署结束":**永远等不到**。 三个脚本各加一个词;并在 `env-defaults.sh` 的 ③b 注释里写明这条规矩 (**新增任何外部命令时回到 `AGENTMAIL_REQUIRE` 登记**)与这个实例。 → docs 第 19 条:「表与被表的东西不同步」。 **2. 第六列候选:中断(信号)—— 已按 pi 的建议修 `redeploy-gateway.sh`** 原子 `mv` 修的是"半截二进制",**没修"服务停着而脚本死了"**: 第 250 行 stop 与第 267 行 start 之间被外部信号打断(Ctrl-C、宿主杀进程、会话回收、OOM) ⇒ 脚本直接退出、**服务留在停止状态而什么也不说** ⇒ "邮件全停 + 无人告知"。 已加 `trap … INT TERM HUP`:进窗口前置位 `_SERVICE_STOPPED`,出窗口复位并摘 trap; **trap 只在"确实还停着"时才动手**(否则会多起一次服务);回滚分支也维护该标志。 **用 stub `systemctl` + 探针真喂过四个分支**: stopped=1 + SIGINT ⇒ 调了 `systemctl start`、退出码 130、打印点名 stopped=0 + SIGINT ⇒ **没有**调用 start(不误起) (探针里两次 harness 自身的错也一并记下:`sed`/`awk` 的区间端点选错, 把 `trap -` 也取进来,导致"trap 没生效"的假象 —— 是探针错,不是代码错。) ★ 顺带修掉自己写的一处:`printf '… $SERVICE …'` 用**单引号**包裹 ⇒ `$SERVICE` **不展开**, 原样打出字面量(探针里实测看到)。改双引号传参。这类"消息里有变量但没展开"会让读者 以为服务名真叫 `$SERVICE`。 **3. `install -d -m` 对已存在目录的行为:实测会改(pi 的疑问)** mkdir -p 建 755 → `install -d -m 0700 <同一目录>` → **700** 所以**下一次部署就会收紧** `/opt/agentmail/data` 与 `/etc/agentmail`,不需要额外的 `chmod 0700` 动作,也不必为此单开一次"人按一下"。 (我按这条如实回报,因为 pi 说过"若不会改就需要显式 chmod,且安全意义比 `user-question.js` 高" —— 结论是不需要。) **4. 两条欠账入 `docs/DEBTS.json`(按 pi 的界线:只修新机制自己引入且会误报的缺口)** · `deploy-space-prefix-fs`:空间列只铺了 `$TMPDIR`,没铺 `$PREFIX` 所在文件系统 (属"列内没铺满",不是新列)。 · `deploy-interrupt-trap-other-scripts`:trap 只在 `redeploy-gateway.sh`; `install.sh`/`redeploy-plugin.sh` 被打断同样会留半成品(没有"服务停着"那种后果,故低优先)。 → docs 第 20 条同时记下 trap 这条纪律与它的可喂判据写法。 验证:install.sh --check exit 0;npm test exit 0;prune 自检 22/22;drift 自检 35/0; check-shared-libs exit 0;全部 deploy 脚本 bash -n 通过;DEBTS.json 有效(13 条)。 --- deploy/install.sh | 2 +- deploy/lib/env-defaults.sh | 9 +++++++++ deploy/redeploy-gateway.sh | 36 +++++++++++++++++++++++++++++++++++- deploy/redeploy-plugin.sh | 2 +- docs/DEBTS.json | 14 ++++++++++++++ docs/DEV-TOOLING.md | 17 ++++++++++++++++- 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 0673c95..503297e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -17,7 +17,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。 # 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** —— # 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。 -AGENTMAIL_REQUIRE="git go npm npx node curl systemctl" +AGENTMAIL_REQUIRE="git go npm npx node curl systemctl flock" agentmail_env_report # 与 `redeploy-gateway.sh` / `reset-demo.sh` 同源(pi 评审 2026-09-14 对出来的): diff --git a/deploy/lib/env-defaults.sh b/deploy/lib/env-defaults.sh index e0b1ff2..78291ff 100644 --- a/deploy/lib/env-defaults.sh +++ b/deploy/lib/env-defaults.sh @@ -178,6 +178,15 @@ unset _am_avail_kb # # 调用者声明自己那组(`AGENTMAIL_REQUIRE`,空格分隔),缺一个就 exit 2。 # 与四次史的处理**同形**,只是对象从变量换成了命令。 +# +# ★ **新增任何外部命令时,回到调用者的 `AGENTMAIL_REQUIRE` 登记**(pi 评审 2026-09-14)。 +# 这条规矩不是形式主义,有一个我自己刚踩的实例:我加了"同时性"那一列(部署锁)、 +# 引入了新命令 `flock`,**却没回到这张表登记**。后果实测: +# PATH 里没有 flock ⇒ `flock: command not found`(127)⇒ `! flock` 为真 +# ⇒ 打印"**另一个部署正在跑(锁被占用)**" —— 退出码事后是对的(2), +# 但**诊断是错的**,而照着它做的是"等另一个部署结束":**永远等不到**。 +# 即"**表与被表的东西不同步**":新能力自带的依赖没有登记回表。 +# 所以每次给部署路径加一个新命令,都要回来加一个词。 if [ -n "${AGENTMAIL_REQUIRE:-}" ]; then _am_missing="" for _am_c in $AGENTMAIL_REQUIRE; do diff --git a/deploy/redeploy-gateway.sh b/deploy/redeploy-gateway.sh index d252018..ec97e8d 100755 --- a/deploy/redeploy-gateway.sh +++ b/deploy/redeploy-gateway.sh @@ -43,7 +43,7 @@ PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}" # 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。 # 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** —— # 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。 -AGENTMAIL_REQUIRE="git go npm node curl systemctl journalctl sqlite3 install" +AGENTMAIL_REQUIRE="git go npm node curl systemctl journalctl sqlite3 install flock" agentmail_env_report # ★ root 断言(pi 评审 2026-09-14 指出本脚本缺它,只有 install.sh 有): @@ -235,6 +235,35 @@ fi # ---------------------------------------------------------------- 6 原子替换 say "6. 停服 → 原子替换 → 起服" + +# ★ **信号兜底:服务停着而脚本死了,是这套流程最危险的窗口**(pi 评审 2026-09-14)。 +# +# 原子 `mv` 修的是"半截二进制",**没有修"服务停着没人知道"**: +# 第 250 行 stop、第 267 行 start 之间若被外部信号打断(Ctrl-C、宿主杀部署进程、 +# 会话被回收、OOM),脚本直接退出 ⇒ **服务留在停止状态,而脚本什么也不说** ⇒ +# 后果是"邮件全停 + 无人告知",比半截二进制更难被发现。 +# 所以:进入窗口前挂 trap,出了窗口就摘掉;trap 只在"确实还停着"时才动手。 +# +# 为什么这是第六列的候选(pi 的分类):它既不是变量、命令、空间、身份,也不是 +# 并发(那是"同时有两个部署"),而是**"过程被中断"** —— 环境前提表里没有这一类。 +_SERVICE_STOPPED=0 +_restore_on_signal() { + local sig="$1" + if [ "$_SERVICE_STOPPED" = "1" ]; then + # ⚠️ 用双引号:单引号会让 `$SERVICE` **不展开**、原样打出去(我第一版就是这样, + # 探针里实测看到字面量 `$SERVICE`)。这是"消息里带了变量但没展开"的形状 —— + # 看的人会以为服务名真的叫 `$SERVICE`。 + printf '\n [WARN] 收到 %s 且 %s 仍处于停止状态 —— 正在起回来(否则邮件会一直停)\n' \ + "$sig" "$SERVICE" >&2 + systemctl start "$SERVICE" 2>/dev/null || \ + printf ' [FAIL] 起服务失败:请手工 systemctl start %s\n' "$SERVICE" >&2 + printf ' 原子替换未完成;目标二进制可能仍是旧版(原子 mv 保证不会半截)。\n' >&2 + fi + exit 130 +} +trap '_restore_on_signal INT' INT +trap '_restore_on_signal TERM' TERM +trap '_restore_on_signal HUP' HUP # 为什么仍要 stop:SQLite 单写者,且换掉二进制后旧进程还在跑旧代码, # 与新库 schema 可能不一致。下面保证的是「文件替换本身」原子, # 不代表可以热换正在服务的进程。 @@ -248,6 +277,7 @@ say "6. 停服 → 原子替换 → 起服" # 对照:`redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(两者都在 `$DEST` 下、同 fs), # 同一个仓库里原先两套"原子切换",一套真、一套名义上的。 run "systemctl stop '$SERVICE'" +_SERVICE_STOPPED=1 # ← 从这里开始,"脚本死了但服务停着"就是事故 _NEW="$TARGET.new.$$" # 复制到**目标同目录**:这一步慢/失败都无所谓,因为 `$_NEW` 还没有任何人用。 if ! run "install -m 0755 '$STAGE' '$_NEW'"; then @@ -265,6 +295,8 @@ if ! run "mv -f '$_NEW' '$TARGET'"; then fi unset _NEW run "systemctl start '$SERVICE'" +_SERVICE_STOPPED=0 # ← 服务已回来,危险窗口结束 +trap - INT TERM HUP run "sleep 6" # ---------------------------------------------------------------- 7 后置验证 @@ -355,8 +387,10 @@ if [ "$CHECK_FAIL" -gt 0 ]; then say "结论: 验证有 $CHECK_FAIL 项失败 —— 正在回滚,不要「先上着再修」" if [ -n "$BINBAK" ]; then run "systemctl stop '$SERVICE'" + _SERVICE_STOPPED=1 run "install -m 0755 '$BINBAK' '$TARGET'" run "systemctl start '$SERVICE'" + _SERVICE_STOPPED=0 ok "已回滚到 $BINBAK" else warn "无旧二进制可回滚" diff --git a/deploy/redeploy-plugin.sh b/deploy/redeploy-plugin.sh index 58434e1..d36b03e 100755 --- a/deploy/redeploy-plugin.sh +++ b/deploy/redeploy-plugin.sh @@ -53,7 +53,7 @@ REPO=${REPO:-/home/program/agentmail} # 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。 # 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** —— # 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。 -AGENTMAIL_REQUIRE="git node npx systemctl journalctl sqlite3" +AGENTMAIL_REQUIRE="git node npx systemctl journalctl sqlite3 flock" agentmail_env_report # ★ **部署锁**(pi 评审 2026-09-14):环境前提里原先缺的第五列 —— **同时性**。 diff --git a/docs/DEBTS.json b/docs/DEBTS.json index acfe271..1c9976f 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -84,6 +84,20 @@ "kind": "scope", "due": "**下一次改 `deploy/` 下任一脚本时**必须一并堵(`redeploy-gateway.sh` 正在被另一条会话改 ⇒ 本条目就是给它接手时的入口)。堵法:给每个副作用步骤加 `|| { bad …; exit 2; }`,或在脚本上开 `set -e`;两者都要与既有的 2=环境 / 1=检查 约定对齐。", "where": "`deploy/redeploy-gateway.sh:84` 的 `run \"cp -r '$REPO/client/electron/dist/.' ...\"` —— 脚本只有 `set -uo pipefail`(**无 `-e`**),`run()` 内部 `eval` 的失败既不中断也不被调用点接收 ⇒ 前端产物没拷进去也继续往下走。同类已在 `deploy/redeploy-plugin.sh` 修掉(2026-09-14):那次的实测形状是 `mkdir`/`cp` 被拒后仍打出 `[ OK ] 已拷入 node_modules`,再打出 `[FAIL] staging 里没有入口` —— **一段输出里两个矛盾信号,且 OK 在前**。该文件现已在 `mkdir`/`cp`/`node_modules` 三处判失败并 exit 2。" + }, + { + "id": "deploy-space-prefix-fs", + "count": 1, + "kind": "判据铺得不满(不是新列)", + "due": "下一次因空间问题失败时;或有人愿意补一行 df 时", + "where": "deploy/lib/env-defaults.sh ②b 只判了 $TMPDIR,没判 $PREFIX 所在的文件系统" + }, + { + "id": "deploy-interrupt-trap-other-scripts", + "count": 1, + "kind": "只在 redeploy-gateway.sh 做了,另两个部署脚本没做", + "due": "下一次动 redeploy-plugin.sh / install.sh 时", + "where": "只有 redeploy-gateway.sh 有 INT/TERM/HUP trap;install.sh 与 redeploy-plugin.sh 在写系统目录期间被打断同样会留半成品(它们没有\"服务停着\"那种后果,所以优先级低)" } ] } diff --git a/docs/DEV-TOOLING.md b/docs/DEV-TOOLING.md index ba424ef..3b6979a 100644 --- a/docs/DEV-TOOLING.md +++ b/docs/DEV-TOOLING.md @@ -253,7 +253,22 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项, `bad: command not found`(127);`redeploy-plugin.sh` 没有 `$PREFIX`(它用 `$DEST_ROOT`)⇒ `PREFIX: unbound variable`(`set -u`);而 `install.sh` 的 `--check` 刻意允许无写权限运行 ⇒ 在那里建锁又变成 `Permission denied`。三处都是"复制粘贴的上下文假设"。 -19. **注释里的数字无法被判据守住。** —— 而不是只落到你想到的那一个。** +19. **新能力自带的新依赖,要回到那张登记表 —— 否则"表"与"被表的东西"不同步。** + 实例(pi 评审 2026-09-14):我刚给部署加了"同时性"那一列(`flock` 部署锁), + **却忘了把 `flock` 登记进三个脚本的 `AGENTMAIL_REQUIRE`**。后果(已实测): + `flock` 不在机器上 ⇒ `command not found`(127)⇒ `! flock` 为真 ⇒ + 打印"**另一个部署正在跑(锁被占用)**" —— 退出码事后是对的(2), + 但**诊断是错的**,而照着它做的是"等另一个部署结束":**永远等不到**。 + ⇒ 每次给部署路径加一个新命令,都要回去在 `AGENTMAIL_REQUIRE` 里加一个词。 +20. **"服务停着而脚本死了"是独立于"文件半截"的一类风险 —— 环境前提的第六列候选(中断)。** + `redeploy-gateway.sh` 在 `systemctl stop` 与 `systemctl start` 之间有窗口, + 外部信号(Ctrl-C、宿主杀进程、会话被回收、OOM)会让脚本直接退出 ⇒ + **服务留在停止状态而脚本什么都不说** ⇒ 后果是"邮件全停 + 无人告知", + 比半截二进制更难发现。原子 `mv` 只修了后者。 + 修法:进窗口前 `trap … INT TERM HUP`、出窗口摘掉,trap 只在"确实还停着"时动手 + (否则会多起一次服务)。判据可喂:用 stub `systemctl` + 探针脚本,给自己发 `SIGINT`, + 断言"停着 ⇒ 调了 start、退出码 130"与"没停 ⇒ 不调 start"。 +21. **注释里的数字无法被判据守住。** —— 而不是只落到你想到的那一个。** 实例(pi 评审 2026-09-14):我在 `env-defaults.sh` 的 `HOME` 上写了两条规则 ("`mkdir -p` 对已存在的不可写目录会返回成功 ⇒ 必须单独判 `-w`"、"判据落在能不能写、 不落在路径像不像"),**同一条规则没落到紧邻的 `TMPDIR` 上** —— 而 ENOSPC 正是这条链的