Files
MailUI4Agents/deploy/check-deploy-drift.mjs
JianFeeeee b53afd4523 chore(deploy): 部署漂移检查器 —— 快照是不是还等于仓库、进程到底在跑哪份代码
「部署脚本跑过了」不等于「线上跑的是当前代码」。三种各自独立的漂移,谁都不会
在意,直到出问题时才发现线上是几天前的行为:

  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`
就少了半页。已同步范围并就地记下这个陷阱。
2026-09-12 20:39:22 +08:00

449 lines
20 KiB
JavaScript
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 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/<pid>/stat` 的第 22 字段(自开机起的时钟滴答)+ `/proc/stat` 的 `btime`
* 换算,而不是 `/proc/<pid>` 目录的 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 进程启动时刻ms0 = 读不到)
* @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();