Files
MailUI4Agents/deploy/check-deploy-drift.mjs
JianFeeeee 2d936893f5 fix(deploy): /tmp 占满把部署自己卡死了 —— 收构建暂存 + 构建带 -trimpath + 判据 ⑤
起因是用户让「清理一下」那批带仓库路径的残留。照着清理策略走时撞上更大的事实:
本机 /tmp 是 9.8G 的 tmpfs,**已 100% 满、可用 0 字节**,我自己的 `go build` 当场
ENOSPC 失败 —— 而部署的第一步就是构建。

## 1 谁把 /tmp 占满的(agentmail 自己的那份)

`redeploy-gateway.sh` 把网关构建到 /tmp 再 install 过去(为了原子替换),**用完没人删**:
每次部署留一个 24MB,实测 7 份 / 162MB。加上电子打包的中间物(squashfs-root 283MB、
pkgcheck/deb 291MB)、go-build-agentmail 缓存 172MB、4 个孤儿 go-build 工作目录 50MB
—— agentmail 名下约 960MB。另有别的产品的 /tmp/gocache 4.6G(TrueAgent 的
rebuild-plugins.sh 里 `export GOCACHE=/tmp/gocache`),不是本项目的,没动。

- prune-deploy-artifacts.sh 新增一类「构建暂存」,窗口 KEEP_BUILD_STAGES=1
  (正常路径下部署脚本自己会收,留下的只可能是失败那次,正好留现场)。
  自检 +1 项、变异验证过(把删除改成永不删 → 恰好那一项红)。
- 本次实际收:删除 8 项 / 释放 164MB(另加手动清 623MB 不可再生的中间物)。
- redeploy-gateway.sh 成功分支上收掉 $STAGE;失败/回滚分支**不删**(要留现场)。

## 2 残留里还藏着两处「旧真相」

- /etc/systemd/system/zcode.service.bak-20260912-145744(+ 同一次改动的
  zcode.service.d/10-dbus.conf.bak-…)里躺着 /home/program/agentmail/deploy/
  service-failure-notify.mjs —— 就是我上一封报「/etc/systemd 引用仓库 = 0 个文件」时
  **判据自己划掉了的那一类**(walk 里 `!name.includes('.bak')`)。已删(在线单元与
  deploy/systemd/ 逐字节一致,sha256 核对过),另外 4 个是别的产品的,没动。
- 判据 ① 因此放宽到含 .bak,并补了坏样本(.bak 里引用仓库路径必须判红)。
  上一封那句「0 个文件」的边界现在写进判据里了 —— 边界不说出口,就等于报了个假的 0。

## 3 -trimpath:标准目录部署只做了一半

Go 默认把源文件绝对路径编进二进制。对照实验(同一份源码、同一个 go,只差标志):
带 -trimpath 0 处,不带 57 处 —— 而 19:05 那次部署产出的
/opt/agentmail/agentmail-gateway 里就有 57 处 /home/program/agentmail/…。
依赖确实没了,但**源仓库位置还印在产物上**。两个构建点都加上 -trimpath,
并新增判据 ⑤(已安装二进制不得含源码路径,两侧样本都验)。

判据 ⑤ 现在**是红的**,这是存量产物的实情:磁盘上那份要等下一次
redeploy-gateway.sh 才会被换掉。我没替它单独重启网关 —— 会掐断正在跑的会话。

(工作区是多会话共用的,本次只 add 了上面这 4 个 deploy/ 文件。)
2026-09-14 20:06:19 +08:00

777 lines
38 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 { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } 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 进程启动时刻(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 **仓库工作区当前内容**(四个宿主同一套)
//
// ★ 比的是工作区**当前**内容,不是 git HEAD —— 仓库脏(未提交的改动)时
// 这条判据照样会绿。实证(2026-09-14):`pool.mjs` 里有一行未提交的
// `let missingSessionCount = 0;`(模块顶层、无人读),被 17:20 那次
// 「从脏工作区做的」快照原样带进了生产,而这条判据当时报的是「逐字节一致」
// —— 工作区与快照确实一致,只是两者都不等于 HEAD。判据没错,是它的**量纲**
// 只覆盖「仓库→快照」这一跳,覆盖不到「HEAD→工作区」那一跳。
// 想连那一跳一起判,得单独比 `git diff --stat`,别指望这一条。
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;
}
/**
* 把「临时目录写不进去」翻译成人看得懂的话。
*
* ★ 为什么要有这个函数(不是为了好看,是为了**覆盖全部写点**):
*
* `selfCheck()` 里要在临时目录造两棵小树,写点有**五处**(`mk()` 里两处、
* 后面 `README.md`/`extra.mjs`/`test/t.mjs` 三处)。第一版只在 `mk()` 里包了
* try/catch —— 于是后面那几处撞上 ENOSPC 时,异常冒到 `main()` 的 catch,
* 退出码是对的(2),但打印的是**原始英文 `ENOSPC: no space left on device, write`
* 加一段指向本文件的堆栈** —— 也就是"看起来像检查器坏了"这个信号,**恰恰在
* 最需要它的那些路径上还在**。集中在这里换一次,以后再加写点也不用管。
*/
function describeEnvError(e, what) {
const isEnospc = e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message)));
if (!isEnospc) return e;
const err = new Error(
`环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— ${what}。` +
'这是环境问题,不是检查器的问题。药方:TMPDIR=<有空间的目录> 后再跑。'
);
err.code = 'ENOSPC';
err.cause = e;
return err;
}
/**
* 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
* 一个永远说「一致」的比较器看起来同样令人放心。
*
* ★ **本函数自己保证"环境不足说人话",与从哪个入口调它无关。**
*
* 上一版把"覆盖全部写点"那一层放在 `main()` 的 catch 里 —— 但本函数是**导出的**
* (导出的用途就是被别人直接调,例如 `--self-check` 之外的自检脚本)。任何绕过
* `main()` 的调用者,在那几处裸写撞上 ENOSPC 时拿到的仍是原始英文 + 本文件堆栈。
* pi 评审时指出:**"一处覆盖全部写点"成立的前提是"从 main() 进来"** ——
* 而"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根。
*
* 所以这里把整段包一层(`body()` + 统一 catch),主进程那个 catch 退化为
* 冗余的第二道 —— 冗余是故意的。
*
* 为什么不去用 `plugins/pi-mail-bridge/lib/env-error.mjs` 的 `translateEnvError`
* (判据相同、措辞不同,看起来该合并):**`deploy/` 的独立性比去重值钱**。
* 本文件头整段就在讲"服务不该依赖仓库是否存在",`deploy/` 下的工具同理 ——
* 让它 import 插件目录里的模块,等于把部署工具绑死在插件的目录结构上。
* 判据(`code === 'ENOSPC'` / 消息里含 no space left)是稳定的,措辞各自合适即可。
* **若哪天出现第三份拷贝,再考虑共用** —— 两份还撑得住。
*
* 写点计数口径(免得与别处的说法对不上):本函数共 **6 处写**
* = `mkdtempSync` ×2 + `mk()` 内 `writeFileSync` ×2 + 三处裸写
* (`b/README.md`、`b/lib/extra.mjs`、`a/test/t.mjs`)。
*/
export function selfCheck() {
try {
return body();
} catch (e) {
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
}
function body() {
// `mkdtempSync` 本身就是一个写点:临时目录满的时候(2026-09-14 实测 `bavail`
// 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外,于是那条路径连退出码 2 都拿不到。
const a = mkdtempSync(join(tmpdir(), 'drift-a-'));
const b = mkdtempSync(join(tmpdir(), 'drift-b-'));
const mk = (root, content) => {
mkdirSync(join(root, 'lib'), { recursive: true });
// ★ 自检要在临时目录里造两棵小树。临时目录满了时这里会抛 ENOSPC ——
// 而它是一个**未捕获的异常**,堆栈指向本文件的 `mk()`,看起来像检查器自己坏了。
// 真相是环境不足。翻译成说得清的错,交给 main() 报 2(环境问题)而不是崩栈。
try {
writeFileSync(join(root, 'lib', 'x.mjs'), content);
writeFileSync(join(root, 'README.md'), 'doc');
} catch (e) {
// 交给最外层那个统一 catch(见函数头注释)——**不要**在这里单独翻译,
// 否则"覆盖范围"又变成"取决于哪一处写",就是这套 bug 的病根。
throw e;
}
};
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;
}
}
/**
* 标准目录部署检查(2026-09-14)。
*
* 用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。
* 当时有三处实证:① 失败通知钩子执行的是**仓库里**的脚本(仓库一挪,故障通知静默
* 失效);② opencode 服务的 cwd 就是源码目录;③ 仓库里的 `deploy/*.service` 是旧的
* 源码目录版本,而机器上的已被改过 —— 谁跑一次 install.sh 就把部署退回源码目录。
*
* 现在:单元与 drop-in 的唯一真相是 `deploy/systemd/`(镜像 systemd 目录结构),
* 运行时脚本装在 `/opt/agentmail/bin/`,服务不依赖仓库是否存在。
*
* @param {object} [inject] 注入点(判据自检时喂假文件系统)
*/
export function checkLayout(inject = {}) {
const readdir = inject.readdir ?? readdirSync;
const readFile = inject.readFile ?? readFileSync;
const exists = inject.exists ?? existsSync;
const stat = inject.stat ?? statSync;
const out = [];
// 每条检查带一个**显式 id**。原先只有第 ⑥ 条自带编号前缀,于是读者会去找
// 一个不存在的第 ⑤ 条 —— 编号要么全有、要么全无,没有第三条路。
// 现在编号只出现在 id 里,`name` 是纯人名的展示串。
let checkSeq = 0;
const push = (name, ok, note = '') => out.push({ id: String(++checkSeq), name, ok, note });
const SYS = '/etc/systemd/system';
const REPO = '/home/program/agentmail';
// 自有进程必须住在安装根下;其余宿主有它们自己的标准位置(不是本项目的源码目录)
const HOST_ALLOW = {
'homeagent.service': '/home/newqqagent',
'dsh.service': '/usr/bin/dsh',
'zcode.service': '/opt/ZCode'
};
// ① 任何 unit/drop-in 都不得引用源码目录
//
// ★ `.bak` 也算在内。原先这里把它排除了,理由是"systemd 不加载 .bak"——
// 运行时确实不加载,但后果是:2026-09-14 我向用户报"`/etc/systemd` 引用仓库 = 0 个文件",
// 而 `/etc/systemd/system/zcode.service.bak-20260912-145744` 里就躺着两行
// `/home/program/agentmail/deploy/service-failure-notify.mjs`。
// 那句话只对我自己划的那个圈成立 —— **判据的边界没说出口,就等于报了个假的 0**。
// 留着 .bak 的代价也不是零:它们是"过期的旧真相",`grep` 到它的人会以为改动没生效。
const offenders = [];
const walk = dir => {
let entries = [];
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = `${dir}/${e.name}`;
if (e.isDirectory()) walk(full);
else if (/\.(conf|service|timer)(\.bak.*)?$/.test(e.name)) {
let text = '';
try { text = String(readFile(full, 'utf8')); } catch { continue; }
if (text.includes(REPO)) offenders.push(full);
}
}
};
walk(SYS);
push('没有任何 unit/drop-in/.bak 引用源码目录', offenders.length === 0, offenders.join(' '));
// ② 已安装单元与仓库副本一致(仓库是唯一真相)
const drift = [];
const repoUnits = inject.repoUnits ?? new URL('../systemd', import.meta.url).pathname.replace(/\/$/, '');
const compare = dir => {
let entries = [];
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = `${dir}/${e.name}`;
if (e.isDirectory()) compare(full);
else {
const rel = full.slice(repoUnits.length + 1);
const live = `${SYS}/${rel}`;
if (!exists(live)) { drift.push(`${rel}(缺)`); continue; }
let a = '', b = '';
try { a = String(readFile(full, 'utf8')); b = String(readFile(live, 'utf8')); } catch { continue; }
if (a !== b) drift.push(rel);
}
}
};
compare(repoUnits);
push('已安装单元与 deploy/systemd/ 一致', drift.length === 0, drift.join(' '));
// ③ 通知脚本在标准位置且可执行
const script = '/opt/agentmail/bin/service-failure-notify.mjs';
let scriptOk = false;
let note = script;
try {
const st = stat(script);
scriptOk = st.isFile() && (st.mode & 0o111) !== 0;
if (!scriptOk) note = `${script} 缺执行位`;
} catch { note = `${script} 不存在`; }
push('故障通知脚本装在 /opt/agentmail/bin/ 且可执行', scriptOk, note);
// ④ 自有服务的 cwd / ExecStart 不得落在源码目录
const badHosts = [];
for (const unit of ['agentmail-gateway.service', 'pi-mail-bridge.service', 'opencode-serve.service',
'zcode-mail-bridge.service', 'homeagent.service', 'dsh.service', 'zcode.service']) {
let text = '';
try { text = String(readFile(`${SYS}/${unit}`, 'utf8')); } catch { continue; }
const cwd = (text.match(/WorkingDirectory=(.+)/) || [])[1]?.trim() ?? '';
const exec = (text.match(/ExecStart=(.+)/) || [])[1]?.trim() ?? '';
const allow = HOST_ALLOW[unit];
if (allow) {
// 用 includes 而不是 startsWith:zcode 的 ExecStart 是
// `dbus-run-session -- xvfb-run … /opt/ZCode/zcode …`,宿主路径在中间。
if (!text.includes(allow)) badHosts.push(`${unit}(不在 ${allow})`);
continue;
}
if (cwd.startsWith(REPO) || exec.startsWith(REPO)) badHosts.push(`${unit}(${cwd || exec})`);
}
push('各服务的工作目录/可执行文件不在源码目录', badHosts.length === 0, badHosts.join(' '));
// ⑤ 生产二进制里不得嵌源码路径(-trimpath)。
//
// Go 默认把源文件的**绝对路径**编进二进制。2026-09-14 实测:换到标准目录部署之后,
// `/opt/agentmail/agentmail-gateway` 里仍有 57 处 `/home/program/agentmail/…` ——
// 构建脚本漏了 `-trimpath`(对照实验:同一份源码、同一个 go,带标志 0 处、不带 57 处)。
// 这条是"运行时不再依赖源码目录"的**后半句**:依赖确实没了,但源仓库位置还印在产物上,
// 而且它会把"这个二进制是从哪份源码建的"变成只能靠推断的事。
const BIN = '/opt/agentmail/agentmail-gateway';
let binHits = -1;
let binNote = `读不到 ${BIN}(标准位置没有网关二进制)`;
try {
const text = String(readFile(BIN));
binHits = text.split(REPO).length - 1;
binNote = binHits === 0
? `${BIN} 里一处都没有`
: `${BIN} 里有 ${binHits} 处 ${REPO}/…(重新构建即可清零:跑一次 redeploy-gateway.sh)`;
} catch { /* binHits 保持 -1 = 读不到 */ }
push('已安装的网关二进制不含源码路径(构建带 -trimpath)', binHits === 0, binNote);
// ⑥ 工作区干净度 —— **WARN,不参与退出码**。
//
// 判据 ① 比的是「仓库工作区 → 快照」这一跳。它覆盖不到「HEAD → 工作区」
// 那一跳:工作区脏(有未提交改动)时,① 照样会绿 —— 工作区与快照一致,
// 只是两者都不等于 HEAD。2026-09-14 实证:`pool.mjs` 里一行未提交的
// `let missingSessionCount = 0;` 被 17:20 那次「从脏工作区做的」快照原样
// 带进了生产,而 ① 报的是「逐字节一致」。
//
// 为什么只 WARN:开发中间态脏是正常的。把它做成红灯就造出一条**总在亮**的
// 判据 —— 正是本文件头注释骂过的病("人会学会忽略它")。所以:说出来,
// 但不改变结论、不影响 exit code。
const gitOut = (() => {
try {
const run = inject.git ?? ((args) => execFileSync('git', args, { cwd: REPO, encoding: 'utf8' }));
return run(['status', '--porcelain']);
} catch { return null; } // 不是 git 仓库/没有 git:不判
})();
if (gitOut === null) {
push('工作区干净(仅提示,不影响结论)', true, '读不到 git 状态,不据此判定');
} else {
const dirty = gitOut.split('\n').map(l => l.trim()).filter(Boolean);
push(
'工作区干净(仅提示,不影响结论)',
true,
dirty.length === 0
? '干净 —— 快照就是 HEAD 的内容'
: `脏 ${dirty.length} 处(快照会是"工作区 + HEAD 都不是"的第三种东西):${dirty.slice(0, 5).join(';')}`
);
}
return out;
}
/** 标准目录那组自检:坏样本必须红、干净样本必须绿(证明它不是恒真)。 */
export function layoutSelfCheck() {
const fake = map => ({
readdir: dir => map[dir] ?? [],
readFile: p => {
if (!(p in map)) throw new Error('ENOENT');
return map[p];
},
exists: p => p in map,
stat: () => ({ isFile: () => true, mode: 0o755 }),
repoUnits: '/repo/systemd'
});
// ⑥ 的 git 读取也要能被喂样本,否则它是一条测不到的判据(读不到就放行 ⇒ 恒绿)。
const fakeGit = porcelain => ({ git: () => porcelain });
const bad = checkLayout(fake({
'/etc/systemd/system': [{ name: 'x.service', isDirectory: () => false }],
'/etc/systemd/system/x.service': 'ExecStart=/home/program/agentmail/bin/x',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}));
// ① 的 .bak 分支:旧备份单元里躺着仓库路径,也必须报出来(原先它被过滤掉了)。
const badBak = checkLayout(fake({
'/etc/systemd/system': [{ name: 'z.service.bak-20260101-000000', isDirectory: () => false }],
'/etc/systemd/system/z.service.bak-20260101-000000': 'ExecStopPost=-/usr/bin/node /home/program/agentmail/deploy/x.mjs',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}));
// ⑤ 的坏样本:二进制里嵌着源码路径。
const badBin = checkLayout(fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'ELF…/home/program/agentmail/server/cmd/server/main.go…'
}));
const good = checkLayout(fake({
'/etc/systemd/system': [{ name: 'y.service', isDirectory: () => false }],
'/etc/systemd/system/y.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'ELF…github.com/agentmail/gateway/cmd/server…'
}));
const dirty = checkLayout({ ...fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}), ...fakeGit(' M src/pool.mjs\n') });
const clean = checkLayout({ ...fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}), ...fakeGit('') });
const unreadable = checkLayout({ ...fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}), git: () => { throw new Error('not a git repo'); } });
const fifth = o => o.find(c => c.name.startsWith('已安装的网关二进制'));
const sixth = o => o.find(c => c.name.startsWith('工作区干净'));
return [
{ name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false },
{ name: '标准目录:.bak 里引用源码目录也必须判红', ok: badBak[0].ok === false },
{ name: '标准目录:干净样本必须判绿', ok: good[0].ok === true },
// 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。
{ name: '网关二进制:嵌了源码路径必须判红', ok: fifth(badBin)?.ok === false },
{ name: '网关二进制:trimpath 过的必须判绿', ok: fifth(good)?.ok === true },
// 编号:id 从 1 连续排到 N,不许跳号(原先只有 ⑥ 带编号,读者会去找不存在的 ⑤)。
{ name: '标准目录:id 连续编号,不跳号', ok: good.every((c, i) => c.id === String(i + 1)) },
// 「工作区干净」是"说出来但不改结论"的提示:脏 / 干净 / 读不到三种都被报出来,
// 且**都不判红**(做成红灯就是一条总在亮的判据,本文件头骂过这个病)。
{ name: '工作区干净:脏 → 说出来(且不判红)', ok: sixth(dirty)?.note.includes('脏 1 处') === true && sixth(dirty)?.ok === true },
{ name: '工作区干净:干净 → 明说干净', ok: sixth(clean)?.note.includes('干净') === true },
{ name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true }
];
}
function main() {
const json = process.argv.includes('--json');
const wantSelfCheck = process.argv.includes('--self-check');
if (wantSelfCheck) {
// 环境不足(临时目录写不进去 ⇒ 自检造不出样本树)必须报 2,不是崩栈、也不是 1。
// 1 会让人去查"是不是判据坏了",2 才说得清是机器的问题。
let checks;
try {
checks = selfCheck().concat(layoutSelfCheck());
} catch (e) {
// ★ 覆盖 **全部** 写点:上面 `mk()` 自己会翻译,但后面还有三处裸写
// (README.md / extra.mjs / test/t.mjs)。第一版只包了 `mk()`,那三处
// 撞上 ENOSPC 会打出原始英文 + 本文件堆栈 —— 退出码对、信号错。
const translated = describeEnvError(e, '判据自检要在临时目录里造样本树');
if (translated.code === 'ENOSPC') {
console.error(`\n${translated.message}\n`);
process.exit(2);
}
throw e;
}
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 layout = checkLayout();
const layoutBad = layout.filter(c => !c.ok);
const results = HOSTS.map(checkHost);
if (json) {
console.log(JSON.stringify({ hosts: results, layout }, 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 : ''}`);
}
console.log('\n 标准目录部署:');
for (const c of layout) console.log(` ${c.ok ? '通过' : '失败'} ${c.id} ${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}:bash deploy/redeploy-plugin.sh ${r.host}`);
}
process.exit(results.some(r => r.stale) || layoutBad.length ? 1 : 0);
}
// 仅在被直接执行时跑 main(被 import 时只导出,供测试调用)
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();