pi 的四条,逐条实测: **1. `install(1)` 不是 rename —— 而那句话是整节设计的理由** 头部原话:"install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动"。 按他给的命令实测 `strace … install -m 0755 /bin/true /tmp/t`: 目标不存在:`openat(t, O_WRONLY|O_CREAT|O_EXCL)` 目标已存在:`unlinkat(t, 0)` → `openat(… O_CREAT|O_EXCL)` → 写入 **全程没有 rename/renameat**。即复制路径,**旧文件在新文件写完整之前就没了**。 中途失败实证:`ulimit -f 1` ⇒ 退出码 **153**(SIGXFSZ),目标变成 **1024 字节截断 ELF**, 原 14 字节内容**已被销毁** —— 正是本段前半句写的风险,`install` 并不免疫。 (第一次测时我把退出码经管道取到了 `head` 的 0 —— 正是 docs 第 6 条那个坑,重测才拿到 153。) ★ 他补的第二个坑也确认:`/tmp` 与 `/opt/agentmail` **不同文件系统** (实测设备号 40 vs 2049)⇒ 就算换成 `mv` 也不原子(跨 fs 退化成 copy+unlink)。 已改成真原子三步:**目标同目录**暂存 → `install`(动的是"还没人用的名字")→ 一次 `mv -f`。 对照 `redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(同 fs)—— 同一仓库原先两套"原子切换",一套真、一套名义上的。 **2. 缺并发锁(环境前提表的第五列:同时性)** 原表(变量/命令/空间/身份)漏了这一类,而它不是假设:工作区是多 agent 共用的。 两个部署同时跑 ⇒ 各自 stop(一次失败、状态没人看)→ 两次写同一目标(配合上面那条 ⇒ 真能留半截)→ 两次后置验证互相把对方的"验证不过"当自己结论 → 谁回滚不确定。 三个脚本都加 `flock`(**不是**"检查锁文件存在",那本身有竞态)。 实测:同一把锁上第二个进程 `flock -n` 失败;脚本形态下 `install.sh` 的 `--check` 不建锁 (干跑只读、且刻意允许无写权限运行)。 **3. journalctl 抽成可喂函数 —— 并且他对我那次"复现"的更正成立** 他说我复现的是**"空输出"支**,不是"读不到"支。实测确认: `journalctl -u 不存在的-unit` 退出码 **0** ⇒ 我测到的是 else 分支。 已抽成 `am_scan_logs <unit> <since> <pattern> [命令]`,输出三态 `clean`/`hit`/`unreadable:<码>`(与既有 `describeEnvError`、`judgeRestart` 同一做法: 把能被样本喂的部分抽出来)。**用 `/bin/false`、`/bin/true`、假"输出含 panic"的脚本 三个样本喂过**(不碰生产):`unreadable:1` / `clean` / `hit` —— 三条支路现在都有覆盖。 判据也随之分开:**"命令不在"由 `AGENTMAIL_REQUIRE` 兜、"命令在但读不到"由这个函数兜**, 两列在代码里分开,而不只是注释里分开。 **4. 低优先项里 umask 那条是真问题(我原来以为可忽略)** 实测 `install -d` 权限位受 umask 影响;而本机生产 `/opt/agentmail/data` = **755**、 `agentmail.db` = **644**(全局可读),同一脚本里 `agent-config`/`pi-config` 却是显式 `-m 0700` —— 同一脚本两套口径,而那个库里是全部往来邮件。 已改:`install -d -m 0700 "$PREFIX/data"`、`-m 0700 "$ETC"`(密码与密钥)。 (现有生产权限不在本次改动范围,属部署后生效。) **顺带修一处我自己的口径不一致**:`install.sh` 的 root 检查用 `exit 1`(判据失败), 而另外两个脚本与 `env-defaults.sh` 的"环境不足"都用 **2** —— 调用者无法据此区分 "该重跑"还是"该修代码"。已统一为 2。 ★ 加锁过程中我自己连踩三次"复制粘贴的上下文假设"(都已修,并记进 docs 第 18 条): `install.sh` 没有 `bad()` ⇒ 127;`redeploy-plugin.sh` 没有 `$PREFIX`(用 `$DEST_ROOT`)⇒ `unbound variable`;`install.sh --check` 无写权限 ⇒ 建锁 `Permission denied` 又变 127。 **同一份代码搬到另一个脚本里,能引用的变量和函数是不一样的。** 验证:install.sh --check exit 0;npm test exit 0;prune 自检 22/22;drift 自检 35/0; check-shared-libs exit 0;全部 deploy 脚本 bash -n 通过。
387 lines
19 KiB
Bash
Executable File
387 lines
19 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
#
|
||
# 把 JS 桥插件部署成**仓库外的快照**,并原子切换 —— 替代「让生产直接跑仓库工作区」。
|
||
#
|
||
# # 为什么必须有这个脚本
|
||
#
|
||
# 在此之前的实际形态(实测):
|
||
#
|
||
# pi systemd: ExecStart=node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
|
||
# opencode opencode.jsonc: "file:///home/program/agentmail/plugins/opencode-mail-bridge"
|
||
# dsh profiles/web/package.json: "dsh-mail-bridge": "link:/home/program/.../dsh-mail-bridge"
|
||
#
|
||
# 三个桥跑的都是**仓库工作区**。于是:
|
||
#
|
||
# - 一次编辑 + 重启 = 上线,没有构建、没有评审、没有版本;
|
||
# - 没有回滚目标:网关有 `.bak-<时间戳>`,三个桥一个都没有;
|
||
# - `git checkout` / `git stash` / 半成品编辑会**静默**改变线上行为;
|
||
# - 仓库同时兼作构建目录(`dist/`、`node_modules/` 都在里面)。
|
||
#
|
||
# 对照:homeagent 插件本来就是这个规范形态(跑的是
|
||
# `/home/newqqagent/plugins/homeagent-mail-bridge/plugin.bin` 部署副本),
|
||
# 所以这里做的不是发明新办法,而是把已有的那个形态推广到三个 JS 桥。
|
||
#
|
||
# # 纪律(与 redeploy-gateway.sh 同一套)
|
||
#
|
||
# 1 前置断言 → 2 staging 拷贝 → 3 门禁(语法/构建)→ 4 原子切换
|
||
# → 5 重启 → 6 后置验证(服务 active **且** 桥日志出现「已接入」)
|
||
# → 任一步失败即切回 .prev 并重启
|
||
#
|
||
# # 事后自查:`deploy/check-deploy-drift.mjs`
|
||
#
|
||
# 这个脚本管的是「这一刻切没切对」;那份快照**过一段时间之后**是否还等于
|
||
# 仓库 HEAD、以及进程是否真的在跑那份代码,是另一个问题,需要另一个检查:
|
||
#
|
||
# node deploy/check-deploy-drift.mjs # 四个宿主一起看
|
||
# node deploy/check-deploy-drift.mjs --self-check # 先证明判据能发现差异
|
||
#
|
||
# 它把三种**各自独立**的漂移变成可判定的:仓库改了没重部署;软链切了没重启
|
||
# (`current` 只是个符号链接,切换它**不会**重载已在跑的进程);单元/配置改了
|
||
# 没 daemon-reload 或没重装。四种宿主的插件加载方式不同,判据也按各自的来
|
||
# (自有进程看 argv,dsh 看 node_modules 软链,opencode 惰加载不强制重启)。
|
||
#
|
||
# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题
|
||
#
|
||
set -uo pipefail
|
||
|
||
REPO=${REPO:-/home/program/agentmail}
|
||
|
||
# 环境自足:一处给全(HOME/TMPDIR/GOMODCACHE/PATH)。原先这里只有一行内联兜底
|
||
# (`TMPDIR=${TMPDIR:-/tmp} npx tsc …`)—— 那正是"第四次"那种分散补丁的形状。
|
||
# 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 node npx systemctl journalctl sqlite3"
|
||
agentmail_env_report
|
||
|
||
# ★ **部署锁**(pi 评审 2026-09-14):环境前提里原先缺的第五列 —— **同时性**。
|
||
# 这台机器的工作区是多 agent 共用的(docs/DEV-TOOLING.md 自己记过),并发部署会互相踩:
|
||
# 两次写同一个目录/文件、两次后置验证互相把对方的"验证不过"当自己的结论、谁回滚不确定。
|
||
# 放在任何写操作之前;用 flock("检查文件存在"本身有竞态)。
|
||
|
||
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
|
||
DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins}
|
||
#
|
||
# 锁的位置放在**变量定义之后**:本脚本没有 `$PREFIX`(它用 `$DEST_ROOT`),
|
||
# 而且**没有 `bad()`**(它第 89 行才定义)—— 我第一版两样都照抄了别的脚本,
|
||
# 实测分别得到 `PREFIX: unbound variable`(`set -u`)与 `bad: command not found`(127),
|
||
# 把"锁没拿到"报成"脚本坏了"。同一份代码搬到不同脚本里,**能引用的变量和函数是不同的**。
|
||
_LOCK="$DEST_ROOT/.deploy.lock"
|
||
if ! exec 9>"$_LOCK"; then
|
||
echo " [FAIL] 环境不足:无法创建部署锁 $_LOCK" >&2
|
||
exit 2
|
||
fi
|
||
if ! flock -n 9; then
|
||
echo " [FAIL] 环境不足:另一个部署正在跑($_LOCK 被占用)—— 等它结束再跑" >&2
|
||
exit 2
|
||
fi
|
||
# 锁随进程退出自动释放(fd 9 关闭)。
|
||
|
||
STAGE_ONLY=0
|
||
PLUGIN=""
|
||
|
||
# 头部注释的**行号范围**用来当 usage:改注释时必须同步上下文,
|
||
# 否则 `--help` 会默默截断(不报错,只是少一段)——这本身就是个静默漂移。
|
||
usage() { sed -n '2,44p' "$0"; }
|
||
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
-h|--help) usage; exit 0 ;;
|
||
--stage-only) STAGE_ONLY=1 ;;
|
||
pi|opencode|dsh|zcode) PLUGIN="$arg" ;;
|
||
*) echo "未知参数: $arg" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
[ -n "$PLUGIN" ] || { echo "用法: $0 <pi|opencode|dsh|zcode> [--stage-only]" >&2; exit 2; }
|
||
|
||
say() { printf '\n=== %s\n' "$*"; }
|
||
ok() { printf ' [ OK ] %s\n' "$*"; }
|
||
bad() { printf ' [FAIL] %s\n' "$*"; }
|
||
warn() { printf ' [WARN] %s\n' "$*"; }
|
||
info() { printf ' %s\n' "$*"; }
|
||
|
||
TS=$(date +%Y%m%d-%H%M%S)
|
||
SRC="$REPO/plugins/$PLUGIN-mail-bridge"
|
||
DEST="$DEST_ROOT/$PLUGIN-mail-bridge"
|
||
SNAP="$DEST/$TS"
|
||
STAGING="$DEST/.$TS.staging"
|
||
|
||
# 每个桥的差异集中在这张表里:入口、宿主、构建需求。
|
||
#
|
||
# HOST 决定「切换之后拿什么判活」,两者完全不同:
|
||
# systemd → 重启单元 + 看网关库里有没有新心跳
|
||
# zcode → **没有我们的单元可重启**:ZCode 是宿主,它在会话开始时读
|
||
# `plugins.dirs`。于是判活改成「从快照起 MCP 服务器并走一遍握手」——
|
||
# 这与「宿主加载插件」走的是同一份入口代码。
|
||
case "$PLUGIN" in
|
||
pi) ENTRY="src/index.mjs"; UNIT="pi-mail-bridge"; HOST="systemd"; NEEDS_BUILD=0 ;;
|
||
opencode) ENTRY="index.js"; UNIT="opencode-serve"; HOST="systemd"; NEEDS_BUILD=0 ;;
|
||
dsh) ENTRY="dist/index.js"; UNIT="dsh"; HOST="systemd"; NEEDS_BUILD=1 ;;
|
||
zcode) ENTRY="src/index.mjs"; UNIT=""; HOST="zcode"; NEEDS_BUILD=0 ;;
|
||
esac
|
||
|
||
say "部署 $PLUGIN-mail-bridge → $SNAP"
|
||
|
||
# ── 1. 前置断言 ────────────────────────────────────────────────
|
||
[ -d "$SRC" ] || { bad "源目录不存在: $SRC"; exit 2; }
|
||
[ -f "$SRC/package.json" ] || { bad "缺少 package.json: $SRC"; exit 2; }
|
||
if [ "$HOST" = "systemd" ]; then
|
||
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
|
||
else
|
||
# zcode 的宿主是应用本身:要能跑 CLI 才能自查「插件被发现了吗」。
|
||
ZCODE_CLI=${ZCODE_CLI:-/opt/ZCode/resources/glm/zcode.cjs}
|
||
[ -f "$ZCODE_CLI" ] || { bad "找不到 ZCode CLI: $ZCODE_CLI"; exit 2; }
|
||
command -v node >/dev/null 2>&1 || { bad "缺少 node"; exit 2; }
|
||
fi
|
||
|
||
if [ "$NEEDS_BUILD" = 1 ]; then
|
||
[ -f "$SRC/tsconfig.json" ] || { bad "dsh 需要 tsconfig.json 才能构建"; exit 2; }
|
||
command -v npx >/dev/null 2>&1 || { bad "缺少 npx,无法构建 dsh 插件"; exit 2; }
|
||
fi
|
||
|
||
# ── 3. staging 拷贝(仓库外的快照)──────────────────────────────
|
||
mkdir -p "$DEST"
|
||
rm -rf "$STAGING"
|
||
# ★ 这两步必须**当场判失败**,不能靠后面"staging 里没有入口"来兜。
|
||
# 2026-09-14 实测(一次在沙箱里跑的部署):`mkdir`/`cp` 因权限被拒之后,
|
||
# 脚本照样往下走,于是先打出 `[ OK ] 已拷入 node_modules`(**其实一个字节都没拷**),
|
||
# 再打出 `[FAIL] staging 里没有入口 src/index.mjs` —— 一段输出里两个互相矛盾的信号,
|
||
# 而且"OK"在前。这正是本仓库反复记录的那个病:**从失败里产出一份看着正常的报告**。
|
||
# 权限/磁盘这类问题的确不属于"检查器的问题",所以按仓库约定用 2(环境问题)退出。
|
||
mkdir -p "$STAGING" || { bad "建不了 staging 目录: $STAGING(权限/磁盘?)"; exit 2; }
|
||
cp -a "$SRC/." "$STAGING/" || { bad "拷贝到 staging 失败: $SRC → $STAGING(权限/磁盘?)"; exit 2; }
|
||
|
||
# 整包复制,再剔除开发用目录。
|
||
#
|
||
# **不能白名单列出要拷什么。** 第一版白名单是 `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
|
||
#
|
||
# 白名单的失败模式天生如此:漏一个就等着启动时炸,而启动时旧版本已经被换掉了。
|
||
# 黑名单反过来 —— 默认带走,只排除明确不需要的。
|
||
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
|
||
if cp -a "$SRC/node_modules" "$STAGING/"; then
|
||
ok "已拷入 node_modules(生产不借用仓库的依赖)"
|
||
else
|
||
# 拷不动就别打 OK:依赖进不了 staging,生产启动时才会炸,而那时旧版已被换掉。
|
||
bad "node_modules 拷不进 staging(生产不借用仓库的依赖,缺了入口起不来)"
|
||
exit 2
|
||
fi
|
||
else
|
||
warn "源目录没有 node_modules —— 若入口依赖外部包,启动时会失败"
|
||
fi
|
||
|
||
# 需要编译的插件:**产出到 staging**,不写仓库里的 dist/。
|
||
#
|
||
# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 emit(tsc 默认
|
||
# noEmitOnError=false),于是仓库的 dist/ 被半成品覆盖;二是生产产物与
|
||
# 工作区之间多了一条看不见的耦合。
|
||
if [ "$NEEDS_BUILD" = 1 ]; then
|
||
if ( cd "$SRC" && TMPDIR=${TMPDIR:-/tmp} npx tsc -p tsconfig.json --outDir "$STAGING/dist" >/tmp/"$PLUGIN"-tsc.log 2>&1 ); then
|
||
ok "tsc 构建完成(产出到 staging)"
|
||
else
|
||
bad "tsc 构建失败(见 /tmp/$PLUGIN-tsc.log):"
|
||
sed 's/^/ /' /tmp/"$PLUGIN"-tsc.log | head -8 >&2
|
||
rm -rf "$STAGING"; exit 1
|
||
fi
|
||
fi
|
||
|
||
[ -f "$STAGING/$ENTRY" ] || { bad "staging 里没有入口 $ENTRY"; rm -rf "$STAGING"; exit 1; }
|
||
ok "staging 就绪: $STAGING"
|
||
|
||
# ── 4. 门禁:语法检查(不启动服务,因此不会碰生产)────────────
|
||
if node --check "$STAGING/$ENTRY" 2>/tmp/"$PLUGIN"-check.log; then
|
||
ok "入口语法检查通过($ENTRY)"
|
||
else
|
||
bad "入口语法检查失败:"; sed 's/^/ /' /tmp/"$PLUGIN"-check.log >&2
|
||
rm -rf "$STAGING"; exit 1
|
||
fi
|
||
|
||
# 逐个解析入口的 import 图,确认快照自足。
|
||
#
|
||
# **不能只比对 package.json 的 dependencies**:pi 的 dependencies 是 `{}`,
|
||
# 而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的。只查声明
|
||
# 等于空跑,而漏掉的代价出现在最糟的时刻(current 已切、服务重启、插件起不来,
|
||
# 旧版本已被换掉)。
|
||
if ! node "$REPO/deploy/check-plugin-snapshot.mjs" "$STAGING" "$ENTRY" 2>&1 | sed 's/^/ /'; then
|
||
bad "快照不自足(缺依赖),销毁 staging 且不切换"
|
||
rm -rf "$STAGING"
|
||
exit 1
|
||
fi
|
||
ok "快照自足(入口的 import 图全部可解析)"
|
||
|
||
if [ "$STAGE_ONLY" = 1 ]; then
|
||
# 干跑:把快照留在 .staging,**不切 current、不重启**。
|
||
say "干跑结束(--stage-only)"
|
||
info "快照留在: $STAGING"
|
||
info "未做: 原子切换${UNIT:+ / 重启 $UNIT} / 后置验证"
|
||
info "要真部署:$0 $PLUGIN"
|
||
exit 0
|
||
fi
|
||
|
||
# ── 5. 原子切换 ────────────────────────────────────────────────
|
||
PREV=""
|
||
[ -L "$DEST/current" ] && PREV=$(basename "$(readlink -f "$DEST/current")")
|
||
|
||
mv "$STAGING" "$SNAP" || { bad "staging → 快照 移动失败"; exit 1; }
|
||
ln -sfn "$TS" "$DEST/current"
|
||
ok "current → $TS${PREV:+(上一版 $PREV)}"
|
||
|
||
rollback() {
|
||
if [ -n "$PREV" ] && [ -d "$DEST/$PREV" ]; then
|
||
ln -sfn "$PREV" "$DEST/current"
|
||
[ -n "$UNIT" ] && systemctl restart "$UNIT" >/dev/null 2>&1
|
||
warn "已回滚到 $PREV${UNIT:+ 并重启 $UNIT}"
|
||
else
|
||
warn "无上一版可回滚(这是首次部署)—— 请手工处理"
|
||
fi
|
||
}
|
||
|
||
# ── 5b. zcode:宿主不是我们的 unit,判活方式不同 ──────────────
|
||
# 判据:**从快照里起一次 MCP 服务器并走完握手**。
|
||
# 这与 ZCode 加载插件走的是同一份入口代码,所以能发现「快照缺文件」
|
||
# 「相对导入断了」「清单被改坏」这类只有加载时才暴露的问题。
|
||
# 检查系统 MCP 握手(stdin 进、stdout 出),返回响应里的 name 字段个数(失败回声 0)。
|
||
#
|
||
# 计数必须用 `grep -o | wc -l`(数**次数**),不能用 `grep -c`(数**行**):
|
||
# 每一条响应只占一行,tools/list 的 11 个工具名全在同一行里,
|
||
# 用 -c 会得到 2(就那么两行),于是好快照也会被判失败(实测踩过)。
|
||
# 期望值:11 个工具 + initialize 里的 serverInfo.name = 12。
|
||
mcp_handshake() {
|
||
local root="$1"
|
||
printf '%s\n' \
|
||
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' \
|
||
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
||
| AGENTMAIL_AGENT_NAME=probe timeout 30 node "$root/mcp/server.mjs" 2>/dev/null \
|
||
| grep -o '"name":"' | wc -l | tr -d ' ' || true
|
||
}
|
||
MIN_NAMES=11
|
||
|
||
if [ "$HOST" = "zcode" ]; then
|
||
# 宿主是 ZCode 应用(不能重启它),但**驱动是我们自己的 unit** —— 必须重启它。
|
||
#
|
||
# 只切软链不重启驱动的后果是**静默跑旧代码**:进程持有的是启动那一刻加载进内存的
|
||
# 模块,`current` 指向哪对已启动的进程毫无影响。实测被 `check-deploy-drift.mjs`
|
||
# 当场抓到(它比进程启动时刻与软链切换时刻),而那时所有其它检查都是绿的。
|
||
if systemctl list-unit-files 2>/dev/null | grep -q '^zcode-mail-bridge.service'; then
|
||
if systemctl is-active --quiet zcode-mail-bridge 2>/dev/null; then
|
||
systemctl restart zcode-mail-bridge
|
||
sleep 4
|
||
if systemctl is-active --quiet zcode-mail-bridge; then
|
||
ok "已重启驱动 zcode-mail-bridge(否则它仍在跑切换前的代码)"
|
||
else
|
||
bad "重启 zcode-mail-bridge 失败(服务未 active)"
|
||
rollback
|
||
exit 1
|
||
fi
|
||
else
|
||
info "驱动 zcode-mail-bridge 未运行,跳过重启(需要时 systemctl start 它)"
|
||
fi
|
||
else
|
||
info "未安装 zcode-mail-bridge.service,跳过重启"
|
||
fi
|
||
|
||
say "后置验证(zcode:宿主是应用,不能重启它)"
|
||
TOOLS=$(mcp_handshake "$DEST/current")
|
||
if [ "${TOOLS:-0}" -ge "$MIN_NAMES" ]; then
|
||
ok "从快照起的 MCP 服务器握手成功,报出 $TOOLS 个 name 字段"
|
||
else
|
||
bad "快照里的 MCP 服务器握手失败(只报出 ${TOOLS:-0} 个 name 字段,期望 ≥ $MIN_NAMES)"
|
||
rollback
|
||
exit 1
|
||
fi
|
||
# 反向对照:握手判据必须能发现坏快照,否则「全绿」没有意义。
|
||
if [ "$(mcp_handshake "$DEST/__definitely_missing__")" -ge "$MIN_NAMES" ]; then
|
||
bad "握手判据无法发现坏路径 —— 判据本身失效,拒绝通过"
|
||
rollback
|
||
exit 1
|
||
fi
|
||
ok "握手判据自检通过(坏路径能被发现)"
|
||
|
||
# ZCode 从 plugins.dirs 发现插件;没有指向 current 就还没真的切换。
|
||
LIST=$(timeout 60 node "$ZCODE_CLI" plugins list 2>/dev/null || true)
|
||
if printf '%s' "$LIST" | grep -q "agentmail@inline"; then
|
||
WHERE=$(printf '%s' "$LIST" | grep -A 1 'agentmail@inline' | grep -o '/opt/[^ ]*' | head -1)
|
||
if [ "$WHERE" = "$DEST/current" ]; then
|
||
ok "ZCode 已从快照发现插件($WHERE)"
|
||
else
|
||
warn "ZCode 已发现 agentmail,但路径是 $WHERE(不是 $DEST/current)"
|
||
info "改这一处即可:~/.zcode/cli/config.json 的 plugins.dirs"
|
||
info " \"dirs\": [\"$DEST/current\"]"
|
||
info "改完要让 ZCode 重新读取(它会话开始时读;重启应用最保险)"
|
||
fi
|
||
else
|
||
warn "ZCode 还没发现 agentmail 插件(plugins.dirs 尚未指向 current?)"
|
||
info " \"dirs\": [\"$DEST/current\"]"
|
||
fi
|
||
|
||
say "结论: 快照已切换"
|
||
info "运行路径: $DEST/current/$ENTRY"
|
||
info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current'"
|
||
exit 0
|
||
fi
|
||
|
||
# ── 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; }
|
||
|
||
# 存活判据分两层:
|
||
#
|
||
# 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
|
||
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
|
||
|
||
ACTIVE=$(systemctl is-active "$UNIT" 2>/dev/null)
|
||
|
||
say "后置验证"
|
||
[ "$ACTIVE" = active ] && ok "$UNIT 处于 active" || bad "$UNIT 状态为 $ACTIVE"
|
||
if [ "$HEARTBEAT" = 1 ]; then
|
||
ok "网关收到 $PLUGIN 的新心跳(last_seen 晚于重启时刻)—— 桥真的连上了"
|
||
else
|
||
bad "90 秒内网关未收到 $PLUGIN 的新心跳 —— 桥没连上(进程活着不等于连上了)"
|
||
info "诊断:journalctl -u $UNIT --since '-3 minutes' | tail -30"
|
||
fi
|
||
|
||
if [ "$ACTIVE" != active ] || [ "$HEARTBEAT" != 1 ]; then
|
||
say "结论: 验证不过 —— 回滚,不要「先上着再修」"
|
||
rollback
|
||
exit 1
|
||
fi
|
||
|
||
say "结论: 部署成功"
|
||
info "运行路径: $DEST/current/$ENTRY(仓库不再是生产代码)"
|
||
info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current' && systemctl restart $UNIT"
|
||
exit 0
|