Files
MailUI4Agents/deploy/redeploy-plugin.sh
JianFeeeee 630b5bfdd7 fix(bridges): 续谈失败静默 + duplicate_relay 静默挂死(两个都是「人那边什么都收不到」)
同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。

## 1)pi 的续谈失败不回失败信(实测缺口)

模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。

修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。

**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。

顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。

## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策

网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。

- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
  `DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
  那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
  (各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。

## 3)新增接线断言(带判据自检)

`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。

三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
  「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
  改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
  第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。

而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。

## 4)部署脚本:zcode 分支现在会重启驱动

`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。

## 复查

四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。

另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
2026-09-12 23:13:48 +08:00

344 lines
16 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
#
# 把 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 或没重装。四种宿主的插件加载方式不同,判据也按各自的来
# (自有进程看 argvdsh 看 node_modules 软链opencode 惰加载不强制重启)。
#
# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题
#
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=""
# 头部注释的**行号范围**用来当 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"
mkdir -p "$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
#
# 白名单的失败模式天生如此:漏一个就等着启动时炸,而启动时旧版本已经被换掉了。
# 黑名单反过来 —— 默认带走,只排除明确不需要的。
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/"
ok "已拷入 node_modules生产不借用仓库的依赖"
else
warn "源目录没有 node_modules —— 若入口依赖外部包,启动时会失败"
fi
# 需要编译的插件:**产出到 staging**,不写仓库里的 dist/。
#
# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 emittsc 默认
# 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` 是 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; }
# 存活判据分两层:
#
# 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