Files
MailUI4Agents/deploy/redeploy-gateway.sh
JianFeeeee 2f17f62871 feat(deploy): 前端源码更新「仅为注释」时不再卡住部署
## 起因(实测,同一天两次)

`redeploy-gateway.sh` 用 mtime 比对源码与 dist:

    newer=$(find src -type f -newer dist/index.html)
    [ -n "$newer" ] && exit 1

mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。

2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。

## 为什么不是拆掉那道闸

2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。

本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:

    误拦的代价 = 多构建一次(几十秒)
    误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)

## 判定口径(deploy/web-comment-only.mjs)

对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。

- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。

## 判据(7 格)

`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。

**变异验证**:

    去掉注释分支(所有行算真改动)  → 红 7(部署会被那一行注释继续拦)
    isCodeChange 恒 false           → 红 7(把 2026-09-14 的静默失败放回来)

第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。

## 真实场景验证(不是只跑单测)

    把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
      ⇒ commentOnly=true,理由写明「差异仅为注释」
    临时往 sse.ts 追加一行真代码
      ⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
    bash deploy/redeploy-gateway.sh --dry-run
      ⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建

## 顺带

`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
2026-10-02 13:58:42 +08:00

559 lines
32 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# redeploy-gateway.sh —— Gateway 二进制热替换(原子化,带备份与后置验证)
#
# 为什么需要这个脚本:日常改后端只需要换二进制,跑整个 install.sh 太重
# (它会重装 npm 依赖、重写 systemd 单元、重新生成 env)。而手工
# 「systemctl stop → cp → start」有两个隐患:
#
# 1. cp 是就地写入,会改坏**正在运行中进程**的可执行映像。
# 即使先 stop 了,中途失败也会留下一个半截二进制且旧的已被覆盖。
# ★ 更正(pi 评审 2026-09-14,已用 strace 实测):**`install(1)` 不是 rename。**
# 实测 `strace … install -m 0755 /bin/true /tmp/t`:
# openat(/tmp/t, O_WRONLY|O_CREAT|O_EXCL) ← 目标不存在时
# unlinkat(/tmp/t, 0) → openat(… O_CREAT|O_EXCL) ← 目标已存在时**先删旧再建新**
# 全程没有 rename/renameat。即"复制"路径:**旧文件在新文件写完整之前就没了**。
# 实证"中途失败留半截":`ulimit -f 1` 下安装 /bin/true ⇒ 退出码 **153**(SIGXFSZ),
# 目标变成 **1024 字节的截断 ELF**,原来那 14 字节内容**已被销毁** ——
# 正是本段前半句写的那个风险,`install` 并不免疫,它只是不再覆盖旧文件(旧的已先删)。
# 所以真正原子的写法是**目标同目录 + 最后一次 mv**(见第 6 节)。
#
# 2. 不备份数据库。SQLite 在 WAL 模式下 cp 会拿到不一致快照
# (主库文件与 -wal 不同步),恢复时可能丢最近写入甚至损坏。
# 必须用 sqlite3 .backup,它走的是官方在线备份 API。
#
# 纪律来自 git-release-discipline skill 第五章:
# 发布终点不是「推上去了」,而是后置验证清单全绿。任一项不过就回滚,
# 不要「先上着再修」。
#
# 用法:
# bash deploy/redeploy-gateway.sh # 完整流程(含测试)
# bash deploy/redeploy-gateway.sh --skip-tests # 跳过测试(急救时用)
# bash deploy/redeploy-gateway.sh --dry-run # 只打印将执行的动作
#
# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题
set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
# 环境自足:一处给全(理由与四次历史见该文件头注释)
# shellcheck source=./lib/env-defaults.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-defaults.sh"
# 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。
# 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** ——
# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。
agentmail_require git go npm node curl systemctl journalctl sqlite3 install flock
agentmail_env_report
# ★ root 断言(pi 评审 2026-09-14 指出本脚本缺它,只有 install.sh 有):
# 本脚本要往 $PREFIX(默认 /opt/agentmail,系统路径)写 —— 非 root 必然失败在写权限上。
# 而失败时的报错来自 `install`/`cp`,看起来像**工程问题**。
# 与 env-defaults.sh 里那条"别把环境问题报成代码问题"是同一条纪律:
# 与其让它晚一点、以晦涩的方式失败,不如在这里一行说清。
# 用退出码 2(环境/权限),与 env-defaults.sh 的口径一致,不冒充判据失败(1)。
[ "$(id -u)" = "0" ] || {
printf '\n [FAIL] 环境不足:本脚本要写 %s,需要 root。\n' "$PREFIX" >&2
printf ' 药方:sudo bash deploy/redeploy-gateway.sh\n' >&2
exit 2
}
TARGET="$PREFIX/agentmail-gateway"
DB="$PREFIX/data/agentmail.db"
SERVICE="agentmail-gateway"
HEALTH_URL="${AGENTMAIL_HEALTH_URL:-http://127.0.0.1:8180/health}"
TS="$(date +%Y%m%d-%H%M%S)"
SKIP_TESTS=0
DRY_RUN=0
SYNC_WEB=1
while [ $# -gt 0 ]; do
case "$1" in
--skip-tests) SKIP_TESTS=1; shift ;;
--skip-web) SYNC_WEB=0; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) echo "未知参数: $1" >&2; exit 2 ;;
esac
done
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
say() { printf '\n=== %s\n' "$*"; }
ok() { printf ' [ OK ] %s\n' "$*"; }
bad() { printf ' [FAIL] %s\n' "$*"; }
warn() { printf ' [WARN] %s\n' "$*"; }
run() {
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] %s\n' "$*"
return 0
fi
printf ' $ %s\n' "$*"
eval "$@"
}
[ -d "$REPO/server" ] || { bad "找不到 $REPO/server"; exit 2; }
# $PREFIX 必须**先**判存在:下面建锁要往它里面写文件(首次安装时它可能还没有)。
[ -d "$PREFIX" ] || { bad "找不到 $PREFIX(先跑 deploy/install.sh)"; exit 2; }
# ★ **部署锁**(pi 评审 2026-09-14):这是我那张"环境前提"表里原先没有的一类 ——
# 不是变量、不是命令、不是空间、不是身份,而是**同时性**。
# 而这台机器上它**不是假设**:`docs/DEV-TOOLING.md` 自己记过"这个工作区是多 agent 共用的,
# 另一条会话正在改 deploy/*.sh"。两个部署同时跑会怎样:
# 各自 `systemctl stop`(其中一次的 stop 失败,而它的状态**没人看**)→
# 两次写同一个 `$TARGET`(配合"install 非原子"⇒ 真能留下半截)→
# 两次后置验证互相把对方的"验证不过"当成自己的结论 → **谁回滚谁不确定**。
# 放在任何写操作之前(这里)。用 flock,不是"检查文件存在"——后者本身有竞态。
# 判据:两个同时启动 ⇒ 第二个 exit 2 并点名。
_LOCK="$PREFIX/.deploy.lock"
exec 9>"$_LOCK" || { bad "无法创建部署锁 $_LOCK"; exit 2; }
if ! flock -n 9; then
bad "环境不足:另一个部署正在跑($_LOCK 被占用)—— 不要并发部署,等它结束"
exit 2
fi
# 锁随进程退出自动释放(fd 9 关闭);不需要手工 rm。
say "0. 计划"
printf ' 仓库 : %s\n' "$REPO"
printf ' 目标 : %s\n' "$TARGET"
printf ' 数据库 : %s\n' "$DB"
printf ' 服务 : %s\n' "$SERVICE"
[ "$DRY_RUN" = 1 ] && printf ' 模式 : DRY-RUN(不落地)\n' || printf ' 模式 : 真实执行\n'
# ---------------------------------------------------------------- 1 前端产物
if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/client/electron/dist/assets" ]; then
say "1. 同步前端产物进 go:embed 目录"
# 只清构建产物:placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库带走。
# ★ 每一步都要**接收退出码**(还清 `DEBTS.json: redeploy-script-unguarded-steps`)。
# `run()` 内部是 `eval`,调用点不接的话失败既不中断也不上报 —— 实测形状
# (该欠账原文):`mkdir`/`cp` 被拒后仍打出 `[ OK ] 已拷入`,紧接着又打出
# `[FAIL] staging 里没有入口`:**一段输出里两个矛盾信号,且 OK 在前**。
# 这里 `cp` 尤其要紧:前端产物没拷进去,go:embed 会把**旧界面**打进二进制,
# 而所有单测仍是绿的(2026-09-14 就是这么踩的,见下面那段注释)。
# 退出码用 2(环境),与文件头的 0/1/2 约定一致 —— 这不是"检查不过",是"这一步没做成"。
run "rm -rf '$REPO/server/internal/static/static/assets'" || { bad "清不掉旧 assets(权限?)"; exit 2; }
run "rm -f '$REPO/server/internal/static/static/index.html'" || { bad "删不掉旧 index.html"; exit 2; }
run "cp -r '$REPO/client/electron/dist/.' '$REPO/server/internal/static/static/'" \
|| { bad "拷不进前端产物 —— 继续下去会把旧界面嵌进二进制"; exit 2; }
ok "前端产物已同步"
# 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是
# /opt/agentmail/bin/service-failure-notify.mjs,不是仓库路径。
# 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效(2026-09-14 修的就是这个)。
# 前端产物必须比源码新,否则会把旧界面打进二进制。
# 2026-09-14 实测踩过:改了 src/lib/appearance.ts 的请求路径却没跑 vite build,
# 部署脚本照样"同步成功",服务出去的还是旧 bundle —— 表现为接口 404
# (/api/v1/api/v1/… 双前缀),而所有单测都是绿的。
if [ -d "$REPO/client/electron/src" ] && [ -d "$REPO/client/electron/dist" ]; then
newer=$(find "$REPO/client/electron/src" -type f -newer "$REPO/client/electron/dist/index.html" 2>/dev/null | head -20)
if [ -n "$newer" ]; then
# ★ 2026-10-02:mtime 只能说明「文件被碰过」,不能说明「它变了什么」。
#
# 共享工作树里别人改一行**注释**就会卡住部署 —— 那行注释不进 bundle,
# 重建产物与现有 dist 逐字节相同。实测同一天因此多花了两轮 npm run build
# (client/electron/src/api/client.ts 的线程树说明,另一会话的在制品)。
#
# 但 mtime 那道闸不能拆:2026-09-14 改了 appearance.ts 的请求路径却没重建,
# 部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
# (/api/v1/api/v1/…)而**所有单测都是绿的**。那种失败是沉默的。
#
# 所以:先问「差异是否只是注释」,是则放行,否则维持拦截。
# 拿不准时偏向拦截(误拦 = 多构建一次;误放 = 把旧界面打进二进制)。
verdict=$(REPO="$REPO" node "$REPO/deploy/web-comment-only.mjs" $newer 2>/dev/null)
if [ "$(printf '%s' "$verdict" | sed -n 's/.*"commentOnly":\([a-z]*\).*/\1/p')" = "true" ]; then
warn "前端源码被更新,但差异**仅为注释** ⇒ 不重建"
printf ' %s\n' "$(printf '%s' "$verdict" | sed -n 's/.*"reason":"\([^"]*\)".*/\1/p')"
warn "(产物内容不受影响;下次真改代码时仍需 npm run build)"
else
echo " [FAIL] 前端 dist 比源码旧(先跑:cd client/electron && npm run build)"
echo " 更新的文件:$(echo "$newer" | tr '\n' ' ')"
[ -n "$verdict" ] && echo " 原因:$(printf '%s' "$verdict" | sed -n 's/.*"reason":"\([^"]*\)".*/\1/p')"
exit 1
fi
else
echo " [ OK ] 前端产物比源码新"
fi
fi
else
say "1. 跳过前端同步"
[ "$SYNC_WEB" = 0 ] && ok "--skip-web" || warn "client/electron/dist 不存在,先跑 cd client/electron && npm run build"
fi
# 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是
# /opt/agentmail/bin/service-failure-notify.mjs,不是仓库路径。
# 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效(2026-09-14 修的就是这个)。
#
# ★ 这一段**必须在 if/else 之外**(pi 评审 2026-09-14 抓到,实测确认):
# 它原先夹在 `if SYNC_WEB…` 分支里(在 `echo " [ OK ] 前端产物比源码新"` 之后、
# `else` 之前),于是 `--skip-web`、或 `client/electron/dist/assets` 不存在时,
# **运行时脚本根本不装** —— 而它跟前端产物没有任何关系,只是恰好被写进了同一支。
# 后果是"改了仓库里的通知脚本、用 --skip-web 部署 ⇒ 生产还是旧的那份",
# 而判据 ③ 只判"在不在、有没有执行位",不判**是哪一份** ⇒ 全绿。
# (已把 ③ 一并改成比内容。)
# ★ `--dry-run` 的语义是"只打印将执行的动作"(见文件头用法),但下面两条 `install`
# 原先**无守卫**:`--dry-run` 会真的建目录、真的把一个 21KB 脚本写进安装根。
# 实测(AGENTMAIL_PREFIX=/tmp/fp2 bash deploy/redeploy-gateway.sh --dry-run):
# /tmp/fp2/bin/service-failure-notify.mjs 21453 字节 ← 不该出现的文件
# 这与 `run()` 的既有口径也不一致(其它写操作都走 run 并被 DRY_RUN 拦下)。
# ⇒ 用 run() 包住;`--dry-run` 只剩打印。
run "install -d '$PREFIX/bin'" || { bad "建不了 $PREFIX/bin"; exit 2; }
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] install -m 0755 %s %s\n' \
"$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"
elif ! install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"; then
bad "装不了故障通知脚本"
exit 2
else
ok "故障通知脚本已装到 /opt/agentmail/bin/"
fi
# 边界工具 am-sandbox(Landlock)—— 只有**本机真的能建立边界**时才装。
#
# 为什么不无条件装:`am-sandbox` 自己 fail closed(套不上边界就拒绝执行命令,退出码 126),
# 所以在没有 Landlock 的机器上装它等于给"档位=workspace"发一张空头支票 —— 装一个
# 用不了的工具比不装更坑。这里先真跑一遍边界判据(19 项:界内可写/界外被拒/软链接
# 逃逸/子进程继承/对照),绿了才 install。
#
# 不把判据失败当致命错误:它可能只是内核老 —— 那种机器仍然可以部署网关,
# 只是沙箱接线会在运行时 fail closed(那一步自己会叫)。
say "1b. 边界工具(Landlock 沙箱)"
if [ "$DRY_RUN" = 1 ]; then
# 同 1. 的理由:`--dry-run` 不该真的构建、更不该真的 install。
# 这里原来会**真跑一次 go build + 19 项内核判据**(数秒)并写入 $PREFIX/bin。
printf ' [dry-run] go build -o %s ./cmd/am-sandbox && check-sandbox.sh && install -m 0755 → %s/bin/am-sandbox\n' \
"/tmp/am-sandbox-build-$TS" "$PREFIX"
else
SANDBOX_STAGE="/tmp/am-sandbox-build-$TS"
rm -f "$SANDBOX_STAGE"
if ( cd "$REPO/server" && go build -trimpath -o "$SANDBOX_STAGE" ./cmd/am-sandbox ); then
if AM_SANDBOX="$SANDBOX_STAGE" bash "$REPO/deploy/check-sandbox.sh" >/tmp/am-sandbox-check.log 2>&1; then
install -m 0755 "$SANDBOX_STAGE" "$PREFIX/bin/am-sandbox"
ok "边界工具已装(判据 $(grep -c '\[ OK \]' /tmp/am-sandbox-check.log) 项全绿)"
else
warn "本机建立不起 Landlock 边界:不装 am-sandbox(见 /tmp/am-sandbox-check.log)"
fi
else
warn "am-sandbox 构建失败(不影响网关本身)"
fi
rm -f "$SANDBOX_STAGE"
fi
# ---------------------------------------------------------------- 2 静态检查与测试
say "2. 构建前检查"
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] go vet ./... && go test ./...\n'
else
( cd "$REPO/server" && go vet ./... ) || { bad "go vet 不过,终止"; exit 1; }
ok "go vet 通过"
if [ "$SKIP_TESTS" = 1 ]; then
warn "--skip-tests:跳过测试(急救模式,事后必须补跑)"
else
( cd "$REPO/server" && timeout 280 go test ./... -timeout 250s ) \
|| { bad "测试不过,终止部署"; exit 1; }
ok "go test 全包通过"
fi
fi
# ---------------------------------------------------------------- 3 构建
say "3. 构建二进制"
STAGE="/tmp/agentmail-gateway-build-$TS"
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
# ★ `-trimpath`:Go 默认把源文件的**绝对路径**编进二进制。2026-09-14 实测:本机
# 历史二进制都是 trimpath 的(里面一处源码路径都没有),19:05 那次不是 ——
# 于是生产件 /opt/agentmail/agentmail-gateway 里躺着 57 处 /home/program/agentmail/…。
# 改标准目录部署是为了"运行时不再依赖源码目录";没有 -trimpath 时这条只做到一半:
# 依赖确实没了,但**源仓库位置还印在产物上**。判据在 check-deploy-drift(标准目录 ⑤)。
run "rm -f '$STAGE'"
# ★ 构建时的**工作树清单** —— 比"一个 modified 位"可归因(pi 2026-09-25 提出,我实测确认)。
#
# 为什么不是一个 bool:`go build` 在脏树上**照样**写 `vcs.revision=<HEAD>`,只额外记
# `vcs.modified=true` ⇒ 那个位只说"含未提交代码",**不说是谁的什么**。而本仓是
# **多 agent 并发**:那些未提交改动通常**不是部署者的**,是别人的在飞工作。
# ⇒ 这不是理论:本仓为此**已经付过代价**(check-deploy-drift 判据⑥ 注释里那笔账)——
# 2026-09-14 `pool.mjs` 一行未提交的 `let missingSessionCount = 0;` 被"从脏工作区做的"
# 快照原样带进生产,而当时判据① 报的是「逐字节一致」。
# ⇒ 处置取**第三种**:既不判红(脏树在本仓是常态,判红=总在亮),也不只留一个不可归因的位,
# 而是**把"这次构建带着哪些未提交文件"打印出来**。
# ★ 判红与否两种选择都改变不了这个事实:**清单有名字,bool 没有** ——
# 出了事能直接看出"带进去的是谁的那两个文件",而不是只能回答"当时是脏的"。
# 位置贴紧构建步:上面那句 `go build` 读的就是此刻的工作树。
if [ -n "$(git -C "$REPO" status --porcelain 2>/dev/null)" ]; then
warn "工作树不干净 —— 下面这份未提交清单会被**编进这次构建**(多 agent 并发时通常不是你的改动):"
git -C "$REPO" status --porcelain 2>/dev/null | sed 's/^/ /'
else
ok "工作树干净(构建件不含未提交代码)"
fi
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] (cd server && go build -trimpath -o %s ./cmd/server)\n' "$STAGE"
else
( cd "$REPO/server" && go build -trimpath -o "$STAGE" ./cmd/server ) \
|| { bad "构建失败"; exit 1; }
ok "构建完成 $(du -h "$STAGE" | cut -f1)"
fi
# ---------------------------------------------------------------- 4 备份数据库
say "4. 备份数据库(sqlite3 .backup,不能用 cp —— WAL 下 cp 会拿到不一致快照)"
DBBAK=""
if [ -f "$DB" ]; then
DBBAK="/tmp/agentmail-pre-deploy-$TS.db"
run "sqlite3 '$DB' \".backup '$DBBAK'\"" || { bad "备份失败,终止部署"; exit 1; }
if [ "$DRY_RUN" != 1 ]; then
integ="$(sqlite3 "$DBBAK" 'PRAGMA integrity_check;' 2>&1 | head -1)"
if [ "$integ" = "ok" ]; then
ok "备份完成 $DBBAK($(du -h "$DBBAK" | cut -f1)),integrity_check=ok"
else
bad "备份完整性异常: $integ"; exit 1
fi
fi
else
warn "数据库不存在(首次部署?): $DB"
fi
# ---------------------------------------------------------------- 5 旧二进制留档
say "5. 旧二进制留档"
BINBAK=""
if [ -f "$TARGET" ]; then
BINBAK="${TARGET}.bak-${TS}"
run "install -m 0755 '$TARGET' '$BINBAK'"
ok "旧二进制留档 $BINBAK"
else
warn "目标不存在(首次安装)"
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 可能不一致。下面保证的是「文件替换本身」原子,
# 不代表可以热换正在服务的进程。
#
# ★ **为什么不能直接 `install "$STAGE" "$TARGET"`**(pi 评审 2026-09-14,实测):
# ① `install` 不是 rename(见头部更正)⇒ 中途失败会留下**半截二进制**、旧文件已先被 unlink;
# ② 就算换成 `mv` 也不原子:`$STAGE` 在 `/tmp`(tmpfs,设备号 40),
# `$TARGET` 在 `/opt/agentmail`(设备号 2049)—— **不同文件系统**,
# coreutils 的 `mv` 跨 fs 会退化成 copy+unlink,同样不是原子的。
# 真原子的三步:**同目录**暂存 → 复制(慢没关系,动的是"还没人用的名字")→ 一次 `mv -f`。
# 对照:`redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(两者都在 `$DEST` 下、同 fs),
# 同一个仓库里原先两套"原子切换",一套真、一套名义上的。
run "systemctl stop '$SERVICE'" || {
# ★ stop 失败必须当场停手(欠账 `redeploy-script-unguarded-steps` 点名的形状:
# "其中一次的 stop 失败,而它的状态**没人看**")。
# 为什么这条尤其不能放过:`systemctl start` 对**已在运行**的服务是 no-op ⇒
# stop 没成功时,后面那句 start 什么也不做,于是**旧进程继续跑旧代码**,
# 而脚本一路走到后置验证、报"部署成功" —— 正是本文件头列的第 2 类漂移
# ("部署脚本跑过了 ≠ 线上跑的是当前代码")被脚本**自己在内部**造出来。
# (新加的判据 ⑤b 能事后发现它,但那是补救;源头在这里。)
bad "停不下 $SERVICE —— 继续的话,后面的 start 对运行中的服务是 no-op,会留下'以为换了、其实没换'"
exit 2
}
_SERVICE_STOPPED=1 # ← 从这里开始,"脚本死了但服务停着"就是事故
_NEW="$TARGET.new.$$"
# 复制到**目标同目录**:这一步慢/失败都无所谓,因为 `$_NEW` 还没有任何人用。
if ! run "install -m 0755 '$STAGE' '$_NEW'"; then
bad "暂存到目标目录失败($_NEW)"
run "rm -f '$_NEW'"
run "systemctl start '$SERVICE'"
exit 1
fi
# 唯一需要原子的那一步:同 fs ⇒ 真 rename,要么全换、要么原样不动。
if ! run "mv -f '$_NEW' '$TARGET'"; then
bad "原子替换失败(mv -f $_NEW $TARGET)—— 旧二进制未被动过"
run "rm -f '$_NEW'"
run "systemctl start '$SERVICE'"
exit 1
fi
unset _NEW
run "systemctl start '$SERVICE'"
_SERVICE_STOPPED=0 # ← 服务已回来,危险窗口结束
trap - INT TERM HUP
run "sleep 6"
# ---------------------------------------------------------------- 7 后置验证
say "7. 后置验证清单(发布终点是这张单子全绿,不是「换上去了」)"
if [ "$DRY_RUN" = 1 ]; then
cat <<'EOF'
[dry-run] 真实执行时逐项校验:
[ ] 服务 active
[ ] /health 可达
[ ] 近 2 分钟无 panic/fatal
[ ] 四个 Agent 桥的 SSE 重新连上
EOF
echo
echo " DRY-RUN 结束。确认无误后去掉 --dry-run 重跑。"
exit 0
fi
CHECK_FAIL=0
if systemctl is-active --quiet "$SERVICE"; then
ok "服务 $SERVICE active"
else
bad "服务未 active"; CHECK_FAIL=$((CHECK_FAIL+1))
fi
if curl -sf -m 5 "$HEALTH_URL" >/dev/null 2>&1; then
ok "健康检查通过 $HEALTH_URL"
else
bad "健康检查失败 $HEALTH_URL"; CHECK_FAIL=$((CHECK_FAIL+1))
fi
# A: 日志读不到不等于没有 panic(pi 评审 2026-09-14 指出这两处是假绿,实测复现)。
#
# 原写法 fc="$(journalctl ... 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)",
# journalctl 失败时(无权限读日志、unit 不存在、dbus 不通)错误被 2>/dev/null 吞掉
# => grep 读空输入 => 输出 0、退出 1 => || true => fc="0" => 报"无 panic"。
# 实测复现:journalctl -u 不存在的-unit | grep -icE 'panic' => fc=[0]。
# 下面 sse 那条同形,后果更坏:它把"读不到日志"归因成"插件没连上",
# 提示人去查密钥,而问题在日志读不到。
#
# 实测:PIPESTATUS 也分不开(命令不存在与"存在但无匹配"都给 1),
# 所以不能靠管道状态区分,必须先把日志取出来、成功后再 grep。
# 命令是否存在已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2);
# 这里处理的是"命令在、但读不到内容"。
# 用法: am_scan_logs <unit> <since> <grep 模式> [命令,默认 journalctl]
# 输出: `clean`(读到了、没命中) | `hit`(读到了、命中) | `unreadable:<码>`(**读不到**)
#
# ★ 抽成函数是为了**能被样本喂**(pi 建议 2026-09-14),与既有的 `describeEnvError`、
# `judgeRestart` 是同一个做法:把"能被样本喂的部分"抽出来,否则这条分支永远只能靠真部署触发。
# pi 同时指出我原先那次"复现"其实测的是**另一支**:`journalctl -u 不存在的-unit`
# 退出码是 **0**(实测确认),走的是"命令在但输出为空";真正的"读不到"(`_jrc != 0`)
# 仍然没被走过。所以要靠第 4 个参数(命令)来喂失败。
#
# 用 if/else 而不是 `printf | grep`:管道有 SIGPIPE 的边角(读端提前退出时写端可能被信号杀掉),
# 而 `grep -q` 会提前退出 —— 这里要的是**确定的**三种输出,不要边角。
am_scan_logs() {
local unit="$1" since="$2" pattern="$3" cmd="${4:-journalctl}" out rc
out="$("$cmd" -u "$unit" --since "$since" --no-pager 2>/dev/null)"; rc=$?
if [ "$rc" != "0" ]; then printf 'unreadable:%s' "$rc"; return; fi
if printf '%s' "$out" | grep -qiE "$pattern"; then printf 'hit'; else printf 'clean'; fi
}
_scan="$(am_scan_logs "$SERVICE" '2 min ago' 'panic|fatal|SIGSEGV')"
case "$_scan" in
unreadable:*) warn "读不到 $SERVICE 的日志(journalctl 退出码 ${_scan#unreadable:})—— 无法据此判断 panic/fatal" ;;
hit) bad "近 2 分钟出现 panic/fatal(见 journalctl -u $SERVICE)"; CHECK_FAIL=$((CHECK_FAIL+1)) ;;
*) ok "近 2 分钟无 panic/fatal(已读到日志)" ;;
esac
# 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。
# 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。
_scan2="$(am_scan_logs "$SERVICE" '1 min ago' 'Client connected')"
case "$_scan2" in
unreadable:*) warn "读不到日志(journalctl 退出码 ${_scan2#unreadable:})—— 无法据此判断 SSE 是否重连" ;;
hit) ok "已有 SSE 客户端重新连上" ;;
# 读到了、但没有:这时才该提示去查密钥。
*) warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)" ;;
esac
unset _scan _scan2 2>/dev/null || true
# 部署后的**同一次**校验:装上去的那份二进制就是当前 HEAD 建的。
#
# ★ 为什么必须有这一条(pi 2026-09-25 提出,我这边已实现为 check-deploy-drift 判据 ⑤b):
# §7 上面那几项(服务 active / /health 200 / 无 panic / SSE 重连)**全都与代码版本无关** ——
# 用一个**旧二进制**跑这套清单会**全绿**。2026-09-25 实证:线上跑的是 09-19 的二进制,
# 而 §7 当时全过、`/health` 200 ⇒ 没有任何一项能说出"装的不是当前代码"。
# 当时唯一的发现手段是人手动跑 `go version -m` —— 一个**不在任何门禁里**的动作。
# ⇒ 这里把那个动作自动化:`vcs.revision` 是 Go 以明文编进二进制的(默认 -buildvcs=auto)。
#
# 三态(与 ⑤b 同口径,别改成两态):
# · 相等 ⇒ 绿
# · 不等 ⇒ **红**(就地回滚)
# · 取不到任一侧 ⇒ 不判(`-buildvcs=false`/老构建/非 git 构建是合法的;判红会恒红)
# 不判时**必须说出来**,否则读者分不清"比过且相同"与"根本没比"。
_binrev="$(go version -m "$TARGET" 2>/dev/null | sed -n 's/.*vcs\.revision=//p' | head -1)"
_headrev="$(git -C "$REPO" rev-parse HEAD 2>/dev/null)"
if [ -z "$_binrev" ] || [ -z "$_headrev" ]; then
warn "无法比对二进制版本(二进制 revision='${_binrev}',HEAD='${_headrev}')—— **不判**,不等于一致"
elif [ "$_binrev" = "$_headrev" ]; then
# ★ ⑬′:绿也要写**负向清单** —— 否则"绿"会被读成"这份二进制没问题"。
# 本判据只答一个问题:"内嵌 revision 是否等于 HEAD";不覆盖: ①从脏树构建(下一条只披露)
# ②部署后被手工替换/修改(只看内嵌 revision)。
ok "已装二进制 = 当前 HEAD(${_binrev%"${_binrev#????????}"})—— 不覆盖: 从脏树构建 / 部署后手工替换"
else
bad "已装二进制是 ${_binrev%"${_binrev#????????}"},HEAD 是 ${_headrev%"${_headrev#????????}"} —— 装的不是当前代码"
CHECK_FAIL=$((CHECK_FAIL+1))
fi
# ★ 上一句的**假绿洞**(pi 2026-09-25 指出,我实测复现):`revision == HEAD` **不等于**
# "二进制就是那份提交的代码" —— 树脏时 Go 照样写 `vcs.revision=<HEAD>`,
# 只是额外记一个 `vcs.modified=true`。
# 实测(/tmp/vm2:HEAD=2931050a,改一行**不提交**再 build):
# revision=2931050a == HEAD ⇒ 上面那句判**绿**
# 但二进制里是未提交的代码 ⇒ **假绿**
# 线上那份正是 `vcs.modified=true` ⇒ 它**不对应任何已提交版本**,只比 revision 会漏掉这一点。
#
# ⚠️ 但**不把它判红**:本仓是多 agent 并发编辑,脏树是常态,而"总在亮的判据会被学会忽略"
# (本仓既有的立场,见 check-deploy-drift 判据⑥:「工作区干净度 —— **WARN,不参与退出码**」)。
# 同一个事实在本仓已有既定严重度,这里就必须同为 WARN —— 否则同仓同一个量两套红绿。
# ⇒ 说出来,但不改结论:**"等于 HEAD" 与 "不含未提交代码" 是两个量**,别让前者冒充后者。
_binmod="$(go version -m "$TARGET" 2>/dev/null | sed -n 's/.*vcs\.modified=//p' | head -1)"
if [ "$_binmod" = "true" ]; then
warn "该二进制记着 vcs.modified=true ⇒ **含未提交代码**,不对应任何已提交版本(revision 相等也不代表就是那份提交)"
fi
unset _binrev _headrev _binmod 2>/dev/null || true
echo
echo " 仍需人工确认(脚本无法代替):"
echo " [ ] 真实发一封邮件端到端跑通(不是只看进程起来了)"
echo " [ ] WebUI 能登录且列表正常"
echo
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 "无旧二进制可回滚"
fi
[ -n "$DBBAK" ] && echo " 如需恢复数据库(先停服务): install -m 0644 '$DBBAK' '$DB'"
exit 1
fi
say "结论: 自动项全绿"
# 构建暂存收尾。这份 24MB 副本只是为了「构建 → install」那一步的原子性,装完就没
# 用了;原先没人删 ⇒ 每次部署在 `$TMPD` 留一份。2026-09-14 实测:7 份 / 162MB,
# 而 `$TMPD` 是 9.8G 的 tmpfs —— 占满后**连部署自己的第一步都跑不动**
# (go build 报 ENOSPC)。存量由 `deploy/prune-deploy-artifacts.sh` 按窗口收。
# 只在这个**成功分支**上删:失败/回滚时那份二进制正是要留的现场,见上面的回滚分支。
rm -f "$STAGE"
ok "构建暂存已收($STAGE)"
echo " 回滚命令(留档 24 小时内有效):"
[ -n "$BINBAK" ] && echo " install -m 0755 '$BINBAK' '$TARGET' && systemctl restart '$SERVICE'"
[ -n "$DBBAK" ] && echo " install -m 0644 '$DBBAK' '$DB' # 先 systemctl stop"
exit 0