fix(deploy): 快照改整包复制 + 存活判据改看网关心跳 —— 修两个会毁掉生产的缺陷

切换三个桥时这两个缺陷都真的触发了,记下来避免重犯。

# 缺陷一:白名单拷贝漏文件 → 服务直接起不来

第一版 staging 用白名单:`package.json lib src index.js dist`,漏掉了 dsh 的
`cordis.patch.yml`(dsh 读它做 overlay 配置)。后果不是「少个文件」而是**服务崩溃循环**:

    Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT

白名单的失败模式天生如此:**默认不带**,漏一个就等启动时炸,而那时旧版本已经被换掉。
改为整包复制 + 剔除明确不需要的(`test/`、`.git`、`node_modules/.cache`、`*.log`)。

# 缺陷二:存活判据写成了某一个平台特有的措辞

第一版后置验证 grep 日志里的「已接入」。那句话**只有 pi 与 opencode 会打印**,
dsh 启动时只输出 `dsh web: http://127.0.0.1:3080` —— 于是**一次成功的 dsh 部署
被判成失败**,脚本按设计回滚……回滚到了缺 cordis.patch.yml 的坏快照,
把 dsh 推进崩溃循环。

改为查网关库(平台无关,且是真的端到端):

    SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT'

**时刻必须用 UTC**:`agents.last_seen` 是 UTC(CURRENT_TIMESTAMP 语义),
而 `date` 默认给本地时间 —— 拿 11:44 去比 03:44 会永远为假,于是每次部署都被判成
「桥没连上」并回滚,**门禁主动破坏生产**。已用 `date -u`。

判据双向验证过:以「3 分钟前」为重启时刻 → 命中 1;以未来时刻 → 命中 0。

# 本次切换结果

  pi       /opt/agentmail/plugins/pi-mail-bridge/current/src/index.mjs
  opencode /opt/agentmail/plugins/opencode-mail-bridge/current/index.js
  dsh      /opt/agentmail/plugins/dsh-mail-bridge/current/dist/index.js

三处配置已迁移(备份在 /root/config-backups/pre-snapshot-20260912-113826/):
pi 的 unit、opencode.jsonc 的 plugin 项、dsh profile 的 link:(含 pnpm install 重建软链)。

验证:三桥进程**打开仓库文件数均为 0**;快照与仓库文件 inode 不同(独立副本);
四个 Agent 心跳新鲜;dsh 读 cordis.patch.yml 走的就是该软链(坏快照时它起不来,
好快照时它 active —— 这条是最硬的证据)。
This commit is contained in:
2026-09-12 11:47:32 +08:00
parent 0549975555
commit cbe5ef5cec

View File

@ -32,6 +32,7 @@
set -uo pipefail
REPO=${REPO:-/home/program/agentmail}
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins}
STAGE_ONLY=0
PLUGIN=""
@ -73,6 +74,7 @@ say "部署 $PLUGIN-mail-bridge → $SNAP"
[ -d "$SRC" ] || { bad "源目录不存在: $SRC"; exit 2; }
[ -f "$SRC/package.json" ] || { bad "缺少 package.json: $SRC"; exit 2; }
command -v systemctl >/dev/null 2>&1 || { bad "缺少 systemctl"; exit 2; }
[ -f "$GATEWAY_DB" ] || { bad "找不到网关库 $GATEWAY_DB(无法验证桥是否连上)"; exit 2; }
if ! systemctl cat "$UNIT" >/dev/null 2>&1; then
bad "找不到 systemd 单元 $UNIT(插件要先有一个运行宿主才能谈部署)"; exit 2
fi
@ -87,11 +89,20 @@ mkdir -p "$DEST"
rm -rf "$STAGING"
mkdir -p "$STAGING"
# 拷运行时需要的东西。测试与构建脚本不进生产快照 ——
# 快照的意义就是「冻结成一份能跑的东西」,不是把仓库复制一遍。
for item in package.json lib src index.js; do
[ -e "$SRC/$item" ] && cp -a "$SRC/$item" "$STAGING/"
done
# 整包复制,再剔除开发用目录。
#
# **不能白名单列出要拷什么。** 第一版白名单是 `package.json lib src index.js dist`
# 漏掉了 dsh 的 `cordis.patch.yml`dsh 读它做 overlay 配置)。后果不是"少个文件"
# 而是**服务起不来**,而且只在重启那一刻才暴露:
#
# Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT
#
# 白名单的失败模式天生如此:漏一个就等着启动时炸,而启动时旧版本已经被换掉了。
# 黑名单反过来 —— 默认带走,只排除明确不需要的。
cp -a "$SRC/." "$STAGING/"
rm -rf "$STAGING/test" "$STAGING/.git" "$STAGING/node_modules/.cache" \
"$STAGING/.DS_Store" "$STAGING/dist.old" 2>/dev/null
find "$STAGING" -maxdepth 1 -name '*.log' -delete 2>/dev/null
# 依赖必须进快照:仓库外没有 node_modules 可借,缺了它入口根本起不来。
if [ -d "$SRC/node_modules" ]; then
cp -a "$SRC/node_modules" "$STAGING/"
@ -167,16 +178,32 @@ rollback() {
}
# ── 6. 重启 + 后置验证 ─────────────────────────────────────────
# 记下重启时刻,后面用「网关库里的 last_seen 是否比它新」判断桥真的连上了。
# **必须用 UTC。** `agents.last_seen` 是 UTCSQLite 的 CURRENT_TIMESTAMP 语义),
# 而 `date` 默认给本地时间。拿本地时间(如 11:44去比 UTC 值03:44会**永远为假**
# 于是每一次部署都被判成"桥没连上"并回滚 —— 判据写错会让门禁主动破坏生产。
RESTART_AT=$(date -u '+%Y-%m-%d %H:%M:%S')
systemctl restart "$UNIT" || { bad "重启 $UNIT 失败"; rollback; exit 1; }
# 只看 is-active 不够:桥可能进程活着却没连上 Gateway密钥过期、
# Gateway 未起、依赖缺失在惰加载时才暴露)。必须以**日志出现「已接入」**为准。
DEADLINE=$(( $(date +%s) + 45 ))
CONNECTED=0
# 存活判据分两层:
#
# 1. `systemctl is-active` —— 进程在不在。**不够**:桥可能进程活着却没连上
# Gateway密钥过期、Gateway 未起、依赖在惰加载时才暴露)。
# 2. **网关库里 `last_seen` 是否比重启时刻新** —— 平台无关,且是真的端到端
# (桥 → 心跳 → 服务端落库)。这一条替代了第一版的「日志出现『已接入』」:
# 那句话只有 **pi 与 opencode** 会打印dsh 启动时只输出
# `dsh web: http://127.0.0.1:3080` —— 照那个判据,**一次成功的 dsh 部署
# 会被判成失败并回滚**(实测就是这么把 dsh 弄进崩溃循环的:回滚到缺
# cordis.patch.yml 的坏快照)。
#
# 教训:判据里不要写某一个平台特有的措辞。
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3无法验证桥是否连上网关"; rollback; exit 1; }
DEADLINE=$(( $(date +%s) + 90 ))
HEARTBEAT=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
if journalctl -u "$UNIT" --since "-50 seconds" --no-pager 2>/dev/null | grep -q '已接入'; then
CONNECTED=1; break
fi
SEEN=$(sqlite3 "$GATEWAY_DB" \
"SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT';" 2>/dev/null)
if [ "${SEEN:-0}" != "0" ]; then HEARTBEAT=1; break; fi
sleep 3
done
@ -184,13 +211,14 @@ ACTIVE=$(systemctl is-active "$UNIT" 2>/dev/null)
say "后置验证"
[ "$ACTIVE" = active ] && ok "$UNIT 处于 active" || bad "$UNIT 状态为 $ACTIVE"
if [ "$CONNECTED" = 1 ]; then
ok "桥日志出现「已接入」(连上 Gateway"
if [ "$HEARTBEAT" = 1 ]; then
ok "网关收到 $PLUGIN 的新心跳last_seen 晚于重启时刻)—— 桥真的连上了"
else
bad "45 秒内未见「已接入」—— 插件可能起不来或连不上 Gateway"
bad "90 秒内网关未收到 $PLUGIN 的新心跳 —— 桥没连上(进程活着不等于连上了)"
info "诊断journalctl -u $UNIT --since '-3 minutes' | tail -30"
fi
if [ "$ACTIVE" != active ] || [ "$CONNECTED" != 1 ]; then
if [ "$ACTIVE" != active ] || [ "$HEARTBEAT" != 1 ]; then
say "结论: 验证不过 —— 回滚,不要「先上着再修」"
rollback
exit 1