From b53afd4523a2c69581f88d59c01cab860ef9d3bf Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 20:39:22 +0800 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20=E9=83=A8=E7=BD=B2=E6=BC=82?= =?UTF-8?q?=E7=A7=BB=E6=A3=80=E6=9F=A5=E5=99=A8=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E6=98=AF=E4=B8=8D=E6=98=AF=E8=BF=98=E7=AD=89?= =?UTF-8?q?=E4=BA=8E=E4=BB=93=E5=BA=93=E3=80=81=E8=BF=9B=E7=A8=8B=E5=88=B0?= =?UTF-8?q?=E5=BA=95=E5=9C=A8=E8=B7=91=E5=93=AA=E4=BB=BD=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「部署脚本跑过了」不等于「线上跑的是当前代码」。三种各自独立的漂移,谁都不会 在意,直到出问题时才发现线上是几天前的行为: 1. 仓库改了、快照没重新部署(快照是独立副本) 2. **软链切了、进程没重启** —— `current` 只是个符号链接,切换它不会重载 已在跑的进程,进程持有的是启动那一刻加载进内存的代码 3. 单元/配置改了、没 daemon-reload 或没重装 `node deploy/check-deploy-drift.mjs`(`--self-check` / `--json`)把三件事变成 可判定的:逐字节比内容(**不用 diff** —— 本机 PATH 上那个是鸿蒙工具链里的, 对内容不同的文件仍返回 0)、读进程真实 argv、比进程启动时刻与软链切换时刻。 ## 判据必须按宿主真实的加载方式分开写(这是踩出来的) 第一版对四个宿主用同一套判据(「单元 ExecStart 指向 current」+「进程 argv 里有 快照路径」),结果 **opencode 与 dsh 双双假红**:它们的插件由**宿主进程**加载, argv 里永远只有宿主自己的可执行文件。永假条件在检查器里表现为「稳定的红灯」, 人会学会忽略它。 | 宿主 | 谁加载 | 从哪里读 | 切换后要重启吗 | |---|---|---|---| | pi / zcode | 自己的进程 | unit 的 ExecStart | 要(启动时加载) | | dsh | dsh 宿主 | profile `link:` → `node_modules` 软链 | 要(服务启动时) | | opencode | opencode 宿主 | `opencode.jsonc` 的 `plugin:` | 不要(会话创建时惰加载) | ## 判据自检也修了一次 第一版自检「宿主表:运行时加载的宿主必须标记需要重启」写的是 `h.restartOnSwitch !== false`,而 `undefined` 也被放过 —— 于是 pi/zcode 的 `restartOnSwitch` 缺省成 undefined、落进「惰加载」分支、判据**永真**。 现在这条判据抽成纯函数 `judgeRestart`,自检用**行为用例**盖住: 旧进程 + 启动时加载的宿主必须判红、惰加载的宿主不判红、切换后启动的一律放行、 读不到启动时刻时不据此判红、容差内不判红。 ## 扰动实验(在真实对象上证明判据会红) - 改一个仓库运行文件 → pi 的「① 运行文件与仓库一致」变红,恢复即绿 ✓ - 只把 pi 的软链 mtime 拨到刚刚(模拟「切了没重启」)→ 「④」变红并指出原因 ✓ - 把 dsh 与 opencode 的软链都拨动 → **dsh 红、opencode 绿**(惰加载该保持绿)✓ 全部拨动均已在实验后还原并复核为绿。 ## 当前结论 四个宿主都在跑当前代码(pi/opencode/dsh 的快照自 11:45 起未变,其间无提交 动过它们的运行文件;zcode 是 18:57 的快照)。「三桥加载规范化」的切换本身 早已完成,缺的是这条可复跑的判据 —— 补齐了。 另外修了 `redeploy-plugin.sh` 的一个静默问题:`usage()` 按**行号范围**截取头部 注释(`sed -n '2,40p'`),我这次的注释把退出码那段挤到 40 行之后,`--help` 就少了半页。已同步范围并就地记下这个陷阱。 --- deploy/check-deploy-drift.mjs | 448 ++++++++++++++++++++++++++++++++++ deploy/redeploy-plugin.sh | 17 +- 2 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 deploy/check-deploy-drift.mjs diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs new file mode 100644 index 0000000..1350144 --- /dev/null +++ b/deploy/check-deploy-drift.mjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node +/** + * 部署漂移检查:**已部署的快照 vs 仓库 HEAD**,以及进程到底跑的是哪份代码。 + * + * # 为什么需要它 + * + * 「部署脚本跑过了」不等于「线上跑的是当前代码」。有几种各自独立的漂移方式, + * 而且都不会有人在意 —— 直到出问题时才发现线上是几天前的行为: + * + * 1. **仓库改了、快照没重新部署**。快照是独立副本,改仓库不会影响它。 + * 2. **软链切换了、进程没重启**。`current` 只是个符号链接,切换它**不会** + * 重载已经在跑的进程 —— 进程持有的是启动那一刻加载进内存的代码。 + * 3. **单元/配置改了、没 daemon-reload 或没重装**,于是进程仍按旧路径加载。 + * + * # 判据必须按宿主真实的加载方式分开写(这一条是踩出来的) + * + * 第一版对四个宿主都用了同一套判据:「单元 ExecStart 指向 current」+ + * 「进程 argv 里有快照路径」。结果 **opencode 与 dsh 双双假红** —— 它们根本 + * 不是「自己起一个进程」那种宿主: + * + * | 宿主 | 谁加载插件 | 从哪里读 | 切换后需要重启吗 | + * |---|---|---|---| + * | pi | 自己的进程 | systemd unit 的 ExecStart | 需要(启动时加载) | + * | zcode | 自己的驱动进程 | systemd unit 的 ExecStart | 需要(启动时加载) | + * | dsh | dsh 宿主进程 | profile 的 `link:` → `node_modules` 软链 | 需要(服务启动时加载) | + * | opencode | opencode 宿主进程 | `opencode.jsonc` 的 `plugin:` | 不需要(会话创建时惰加载) | + * + * 用「进程 argv 里有快照路径」去量 opencode/dsh,永远为假 —— 它们的 argv 里 + * 只有自己的可执行文件。**判据必须与它量的对象同一维度**,否则就是一条永假条件, + * 而永假条件在检查器里表现为「稳定的红灯」,人会学会忽略它。 + * + * # 为什么不用 diff + * + * 本机 PATH 上的 `diff` 是鸿蒙 SDK 工具链里的那个:不认 `-q`,而且对 + * **内容不同的文件仍然返回 0**(`deploy/check-shared-libs.sh` 的头注释记了这件事)。 + * 所以这里一律自己算 sha256 比对。 + * + * # 用法 + * + * node deploy/check-deploy-drift.mjs # 人类可读 + * node deploy/check-deploy-drift.mjs --json # 机器可读 + * node deploy/check-deploy-drift.mjs --self-check # 先证明判据本身能发现差异 + * + * 退出码:0 无运行文件漂移;1 有漂移(或判据自检失败)。 + */ + +import { createHash } from 'node:crypto'; +import { + readFileSync, + readdirSync, + lstatSync, + existsSync, + mkdtempSync, + writeFileSync, + mkdirSync, + rmSync, + realpathSync +} from 'node:fs'; +import { join, relative, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = join(HERE, '..'); +const DEPLOY_ROOT = '/opt/agentmail/plugins'; + +/** 与 deploy/redeploy-plugin.sh 的排除清单**逐条对齐**。 + * 对不齐就会出现「脚本说一致、部署脚本却拷了别的」这种假绿。 */ +const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']); +const EXCLUDE_FILES = new Set(['.DS_Store']); +const EXCLUDE_SUFFIX = ['.log']; + +/** + * 宿主表。`load` 决定用哪套判据(见文件头那张表)。 + * + * - `own-process`:插件自己是个进程,argv 里应当有快照路径 + * - `host-package`:宿主经 node_modules 解析插件;服务启动时加载 ⇒ 切换后必须重启 + * - `host-config`:宿主从配置文件读插件路径;会话创建时惰加载 ⇒ 不强制重启 + */ +const HOSTS = [ + { host: 'pi', plugin: 'pi-mail-bridge', unit: 'pi-mail-bridge.service', load: 'own-process' }, + { host: 'zcode', plugin: 'zcode-mail-bridge', unit: 'zcode-mail-bridge.service', load: 'own-process' }, + { + host: 'dsh', + plugin: 'dsh-mail-bridge', + unit: 'dsh.service', + load: 'host-package', + configFile: '/root/.dsh/profiles/web/package.json', + configNeedle: `link:/opt/agentmail/plugins/dsh-mail-bridge/current`, + // 真正被 Node 解析的那条:profile 的 node_modules 软链 + resolvePath: '/root/.dsh/profiles/web/node_modules/dsh-mail-bridge' + }, + { + host: 'opencode', + plugin: 'opencode-mail-bridge', + unit: 'opencode-serve.service', + load: 'host-config', + configFile: '/root/.config/opencode/opencode.jsonc', + configNeedle: 'file:///opt/agentmail/plugins/opencode-mail-bridge/current' + } +]; + +/** 文档类扩展名:它们的差异是「快照里的说明比仓库旧」,不影响运行行为。 */ +const DOC_EXT = /\.(md|txt)$/i; + +export function collectFiles(root) { + const out = new Map(); + const walk = dir => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.has(entry.name)) continue; + walk(full); + } else if (entry.isFile()) { + if (EXCLUDE_FILES.has(entry.name)) continue; + if (EXCLUDE_SUFFIX.some(s => entry.name.endsWith(s))) continue; + const rel = relative(root, full); + if (rel.split('/').includes('.cache')) continue; + out.set(rel, createHash('sha256').update(readFileSync(full)).digest('hex')); + } + } + }; + walk(root); + return out; +} + +/** 比较两棵树,返回分类后的差异。导出是为了让自检能直接调它。 */ +export function diffTrees(repoFiles, snapFiles) { + const onlyRepo = [...repoFiles.keys()].filter(k => !snapFiles.has(k)).sort(); + const onlySnap = [...snapFiles.keys()].filter(k => !repoFiles.has(k)).sort(); + const changed = [...repoFiles.keys()] + .filter(k => snapFiles.has(k) && repoFiles.get(k) !== snapFiles.get(k)) + .sort(); + const classify = list => + list.reduce((acc, k) => (acc[DOC_EXT.test(k) ? 'doc' : 'runtime'].push(k), acc), { doc: [], runtime: [] }); + const a = classify(onlyRepo); + const b = classify(onlySnap); + const c = classify(changed); + return { + onlyRepo: { doc: a.doc, runtime: a.runtime }, + onlySnap: { doc: b.doc, runtime: b.runtime }, + changed: { doc: c.doc, runtime: c.runtime } + }; +} + +export function diffSummary(d) { + return { + runtimeDrift: d.onlyRepo.runtime.length + d.onlySnap.runtime.length + d.changed.runtime.length, + docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length + }; +} + +function processArgv(pid) { + try { + return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' '); + } catch { + return ''; + } +} + +function unitMainPid(unit) { + try { + return execFileSync('systemctl', ['show', '-p', 'MainPID', '--value', unit], { encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} + +function unitExecStart(unit) { + try { + const v = execFileSync('systemctl', ['show', '-p', 'ExecStart', '--value', unit], { encoding: 'utf8' }); + const m = v.match(/argv\[\]=([^;]+)/); + return m ? m[1] : v.trim(); + } catch { + return ''; + } +} + +/** 进程启动时刻(墙钟,ms)。 + * + * 用 `/proc//stat` 的第 22 字段(自开机起的时钟滴答)+ `/proc/stat` 的 `btime` + * 换算,而不是 `/proc/` 目录的 mtime —— 后者只是个碰巧相近的代理, + * 它的语义是「这个目录最后一次变动」,不是「进程何时启动」。 + */ +export function procStartMs(pid) { + try { + const fields = readFileSync(`/proc/${pid}/stat`, 'utf8').split(' '); + const startTicks = Number(fields[21]); + const hz = Number(execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8' }).trim()) || 100; + const btime = Number( + readFileSync('/proc/stat', 'utf8') + .split('\n') + .find(l => l.startsWith('btime ')) + .split(' ')[1] + ); + if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) throw new Error('字段解析失败'); + return (btime + startTicks / hz) * 1000; + } catch { + return 0; + } +} + +/** + * 判据 ④:进程是不是比快照还旧(= 线上跑的还是旧代码)。 + * + * 抽成纯函数是因为**第一版写错过**,而且自检没接住:`restartOnSwitch` 只有 + * dsh 显式设了 `true`,pi/zcode 是 `undefined` ⇒ 落进「惰加载」分支 ⇒ **永真**。 + * 当时自检用的是 `h.restartOnSwitch !== false`,`undefined` 也被放过。 + * 下面改成行为用例:自带一个「旧进程 + 自身进程宿主」必须判红。 + * + * @param {object} o + * @param {string} o.load 宿主加载方式(见文件头的表) + * @param {number} o.startedAt 进程启动时刻(ms,0 = 读不到) + * @param {number} o.switchAt 软链切换时刻(ms) + * @param {number} [o.toleranceMs] 容差:同一次部署里先切后重启,两者相差一两秒 + */ +export function judgeRestart({ load, startedAt, switchAt, toleranceMs = 2000 }) { + if (!startedAt) { + // 读不到就不要据此判红:不知道不能当成「不对」 + return { ok: true, note: '读不到进程启动时刻(不据此判定)' }; + } + if (startedAt >= switchAt - toleranceMs) return { ok: true, note: '切换之后才启动' }; + // 惰加载的宿主(opencode:会话创建时才读配置)不强制重启; + // 其余宿主在启动时就加载了插件,软链切换不会重载它 ⇒ 必须重启。 + if (load === 'host-config') { + return { ok: true, note: '惰加载:会话创建时才读配置,不强制重启' }; + } + return { + ok: false, + note: + `进程启动于 ${new Date(startedAt).toISOString()},切换发生在 ${new Date(switchAt).toISOString()} —— ` + + '软链切换不会重载已在跑的进程,需要重启该服务' + }; +} + +function checkHost(spec) { + const { host, plugin, unit, load } = spec; + const repoDir = join(REPO, 'plugins', plugin); + const linkPath = join(DEPLOY_ROOT, plugin, 'current'); + const result = { host, plugin, unit, load, checks: [], stale: false }; + const fail = (name, note) => { + result.checks.push({ name, ok: false, note }); + result.stale = true; + }; + const pass = (name, note = '') => result.checks.push({ name, ok: true, note }); + + if (!existsSync(repoDir)) return fail('仓库插件目录存在', repoDir), result; + if (!existsSync(linkPath)) return fail('已部署(current 存在)', `未部署:${linkPath}`), result; + + // ① 内容:快照 vs 仓库(四个宿主同一套) + const d = diffTrees(collectFiles(repoDir), collectFiles(linkPath)); + const { runtimeDrift, docDrift } = diffSummary(d); + if (runtimeDrift === 0) { + pass('① 运行文件与仓库一致', docDrift ? `一致(另有 ${docDrift} 个文档差异,不影响运行)` : '逐字节一致'); + } else { + fail( + '① 运行文件与仓库一致', + `漂移 ${runtimeDrift} 处:${[ + ...d.onlyRepo.runtime.map(f => `仓库独有 ${f}`), + ...d.onlySnap.runtime.map(f => `快照独有 ${f}`), + ...d.changed.runtime.map(f => `内容不同 ${f}`) + ] + .slice(0, 5) + .join(';')}` + ); + } + + // ② 加载路径指向快照 —— **按宿主真实的加载方式** + if (load === 'own-process') { + const execStart = unitExecStart(unit); + const atSnapshot = execStart.includes(`/opt/agentmail/plugins/${plugin}/current`); + const atRepo = execStart.includes(`/home/program/agentmail/plugins/${plugin}`); + if (!execStart) fail('② 单元入口指向快照', `读不到 ${unit} 的 ExecStart`); + else if (atRepo) fail('② 单元入口指向快照', `仍指向仓库工作区:${execStart}`); + else if (atSnapshot) pass('② 单元入口指向快照', '指向 current'); + else fail('② 单元入口指向快照', `指向别处:${execStart}`); + } else { + // host-package / host-config:查配置文件里那条引用,并尽量查它**实际解析到哪** + const cfg = spec.configFile; + let cfgText = ''; + try { + cfgText = readFileSync(cfg, 'utf8'); + } catch (e) { + fail('② 宿主配置指向快照', `读不到 ${cfg}:${e?.message || e}`); + } + if (cfgText) { + if (cfgText.includes(spec.configNeedle)) pass('② 宿主配置指向快照', `${cfg} 含 current`); + else fail('② 宿主配置指向快照', `${cfg} 里没有 ${spec.configNeedle}`); + } + if (spec.resolvePath) { + // 真正决定加载哪份代码的是这条软链(Node 按 node_modules 解析) + try { + const real = realpathSync(spec.resolvePath); + if (real.startsWith(`/opt/agentmail/plugins/${plugin}/`)) { + pass('② 实际解析到快照', `${real.split('/').slice(-2).join('/')}`); + } else { + fail('② 实际解析到快照', `${spec.resolvePath} → ${real}`); + } + } catch (e) { + fail('② 实际解析到快照', `${spec.resolvePath} 不可用:${e?.message || e}`); + } + } + } + + // ③ 进程身份(own-process 才看 argv;别的宿主 argv 里本来就没有插件路径) + const pid = unitMainPid(unit); + if (!pid || pid === '0') { + fail('③ 宿主进程在跑', `${unit} 没有主进程(未运行?)`); + return result; + } + if (load === 'own-process') { + const argv = processArgv(pid); + if (argv.includes(`/opt/agentmail/plugins/${plugin}/current`)) pass('③ 进程在跑快照里的代码', `pid=${pid}`); + else fail('③ 进程在跑快照里的代码', `pid=${pid} 的 argv 里没有快照路径:${argv.slice(0, 90)}`); + } else { + pass('③ 宿主进程在跑', `pid=${pid}(插件由宿主加载,argv 里本就没有插件路径)`); + } + + // ④ 进程启动时刻 vs 软链切换时刻。 + // 软链切换**不会**重载已在跑的进程 ⇒ 对「启动时加载」的宿主,这一条是硬判据。 + // 判定逻辑在纯函数 judgeRestart 里(它被自检的行为用例盖住)。 + const switchAt = lstatSync(linkPath).mtimeMs; + const startedAt = procStartMs(pid); + const j = judgeRestart({ load, startedAt, switchAt }); + result.checks.push({ name: '④ 进程启动不早于快照切换', ok: j.ok, note: j.note }); + if (!j.ok) result.stale = true; + + return result; +} + +/** 判据自检:**先证明这个检查器能发现差异**,再用它下结论。 + * 一个永远说「一致」的比较器看起来同样令人放心。 */ +export function selfCheck() { + const a = mkdtempSync(join(tmpdir(), 'drift-a-')); + const b = mkdtempSync(join(tmpdir(), 'drift-b-')); + const mk = (root, content) => { + mkdirSync(join(root, 'lib'), { recursive: true }); + writeFileSync(join(root, 'lib', 'x.mjs'), content); + writeFileSync(join(root, 'README.md'), 'doc'); + }; + const out = []; + try { + mk(a, 'same'); + mk(b, 'same'); + const same = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); + out.push({ name: '相同的树判为一致', ok: same.runtimeDrift === 0 && same.docDrift === 0 }); + + mk(b, 'DIFFERENT'); + const diff = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); + out.push({ name: '内容不同必须被发现', ok: diff.runtimeDrift === 1 }); + + mk(b, 'same'); + writeFileSync(join(b, 'README.md'), 'doc-changed'); + const docOnly = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); + out.push({ name: '文档差异不算运行漂移', ok: docOnly.runtimeDrift === 0 && docOnly.docDrift === 1 }); + + writeFileSync(join(b, 'lib', 'extra.mjs'), 'x'); + const extra = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); + out.push({ name: '快照多出运行文件必须被发现', ok: extra.runtimeDrift === 1 }); + + mkdirSync(join(a, 'test'), { recursive: true }); + writeFileSync(join(a, 'test', 't.mjs'), 'only-in-repo'); + const withTest = diffSummary(diffTrees(collectFiles(a), collectFiles(b))); + out.push({ name: 'test/ 不参与比较(与部署脚本一致)', ok: withTest.runtimeDrift === 1 }); + + // 判据 ④ 的行为用例。 + // + // 这一组来自一次**自检没接住的真错**:`restartOnSwitch` 只有 dsh 显式设了 true, + // pi/zcode 是 undefined ⇒ 落进「惰加载」分支 ⇒ 判据永真; + // 而当时自检写的是 `h.restartOnSwitch !== false`,undefined 也被放过了。 + // 所以现在不查字段,直接查**判定结果**。 + const SWITCH = 1_000_000_000_000; + const older = SWITCH - 60_000; // 进程比切换早一分钟 + const newer = SWITCH + 60_000; // 进程比切换晚一分钟 + out.push({ + name: '④ 启动时加载的宿主:旧进程必须判红', + ok: + judgeRestart({ load: 'own-process', startedAt: older, switchAt: SWITCH }).ok === false && + judgeRestart({ load: 'host-package', startedAt: older, switchAt: SWITCH }).ok === false + }); + out.push({ + name: '④ 惰加载的宿主:旧进程不判红(但也不是「已验过」)', + ok: judgeRestart({ load: 'host-config', startedAt: older, switchAt: SWITCH }).ok === true + }); + out.push({ + name: '④ 切换之后才启动的一律放行', + ok: ['own-process', 'host-package', 'host-config'].every( + l => judgeRestart({ load: l, startedAt: newer, switchAt: SWITCH }).ok === true + ) + }); + out.push({ + name: '④ 读不到启动时刻时不据此判红', + ok: judgeRestart({ load: 'own-process', startedAt: 0, switchAt: SWITCH }).ok === true + }); + out.push({ + name: '④ 容差内(同一次部署先切后重启)不判红', + ok: judgeRestart({ load: 'own-process', startedAt: SWITCH - 1000, switchAt: SWITCH }).ok === true + }); + + // 宿主表本身的自检:每个宿主的判据必须落在它能观测到的地方。 + const badSpec = HOSTS.filter(h => h.load !== 'own-process' && !h.configNeedle); + out.push({ name: '宿主表:非自有进程的宿主必须给出配置判据', ok: badSpec.length === 0 }); + const badOwn = HOSTS.filter(h => h.load === 'own-process' && h.configNeedle); + out.push({ name: '宿主表:自有进程的宿主不该用配置判据', ok: badOwn.length === 0 }); + } finally { + rmSync(a, { recursive: true, force: true }); + rmSync(b, { recursive: true, force: true }); + } + return out; +} + +function main() { + const json = process.argv.includes('--json'); + const wantSelfCheck = process.argv.includes('--self-check'); + + if (wantSelfCheck) { + const checks = selfCheck(); + if (json) console.log(JSON.stringify({ selfCheck: checks }, null, 2)); + else { + console.log('判据自检(先证明检查器能发现差异):'); + for (const c of checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}`); + } + const bad = checks.filter(c => !c.ok); + if (bad.length) { + console.error(`判据自检失败 ${bad.length} 项 —— 检查器本身不可信,不能用它的结论`); + process.exit(1); + } + } + + const results = HOSTS.map(checkHost); + if (json) { + console.log(JSON.stringify({ hosts: results }, null, 2)); + } else { + console.log('\n部署漂移检查(快照 vs 仓库 HEAD,以及进程到底在跑哪份代码):'); + for (const r of results) { + console.log(`\n ${r.host}(${r.plugin},加载方式 ${r.load})${r.stale ? ' ⚠️ 漂移' : ''}`); + for (const c of r.checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}${c.note ? ' — ' + c.note : ''}`); + } + const stale = results.filter(r => r.stale); + console.log(`\n 结论:${stale.length === 0 ? '四个宿主都在跑当前代码' : `${stale.length} 个宿主需要重新部署/重启`}`); + for (const r of stale) console.log(` ✗ ${r.host}:node deploy/redeploy-plugin.sh ${r.host}`); + } + process.exit(results.some(r => r.stale) ? 1 : 0); +} + +// 仅在被直接执行时跑 main(被 import 时只导出,供测试调用) +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main(); diff --git a/deploy/redeploy-plugin.sh b/deploy/redeploy-plugin.sh index 15bcd37..d7ceb8f 100755 --- a/deploy/redeploy-plugin.sh +++ b/deploy/redeploy-plugin.sh @@ -27,6 +27,19 @@ # → 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 @@ -37,7 +50,9 @@ DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins} STAGE_ONLY=0 PLUGIN="" -usage() { sed -n '2,40p' "$0"; } +# 头部注释的**行号范围**用来当 usage:改注释时必须同步上下文, +# 否则 `--help` 会默默截断(不报错,只是少一段)——这本身就是个静默漂移。 +usage() { sed -n '2,44p' "$0"; } for arg in "$@"; do case "$arg" in