From cbe5ef5cecf944c0f3d2e21c89470e4b53ec5197 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 11:47:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20=E5=BF=AB=E7=85=A7=E6=94=B9?= =?UTF-8?q?=E6=95=B4=E5=8C=85=E5=A4=8D=E5=88=B6=20+=20=E5=AD=98=E6=B4=BB?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E6=94=B9=E7=9C=8B=E7=BD=91=E5=85=B3=E5=BF=83?= =?UTF-8?q?=E8=B7=B3=20=E2=80=94=E2=80=94=20=E4=BF=AE=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E4=BC=9A=E6=AF=81=E6=8E=89=E7=94=9F=E4=BA=A7=E7=9A=84=E7=BC=BA?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切换三个桥时这两个缺陷都真的触发了,记下来避免重犯。 # 缺陷一:白名单拷贝漏文件 → 服务直接起不来 第一版 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 —— 这条是最硬的证据)。 --- deploy/redeploy-plugin.sh | 60 ++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/deploy/redeploy-plugin.sh b/deploy/redeploy-plugin.sh index b50101e..063d828 100755 --- a/deploy/redeploy-plugin.sh +++ b/deploy/redeploy-plugin.sh @@ -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` 是 UTC(SQLite 的 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