fix(pi-bridge): 按评审补三处 —— ENOSPC 只盖了一个写点、旧注释自相矛盾、兜底判据钉的是文本

pi 逐字读了上一版落地的代码,报了三个"还差一格"。都不是推翻,是同一根因
("环境不足伪装成别的")在这套守卫自己身上的残留。

## 一、翻译只覆盖了 5 个写点里的 1 个(最实质)

`selfCheck()` 要在临时目录造两棵样本树,写点有**五处**;上一版只把 `mk()` 里那两处
包了 try/catch,后面三处(`README.md` / `extra.mjs` / `test/t.mjs`)裸写。它们撞上
ENOSPC 时异常冒到 `main()` 的 catch:**退出码是对的(2),但打印的是原始英文
`ENOSPC: no space left on device, write` 加一段指向本文件的堆栈** —— 也就是上一版
要治的那个信号("看起来像检查器坏了")**恰恰在最需要它的路径上还在**。

改法:抽一个 `describeEnvError(e, what)`,在 `main()` 的 catch 里**统一**换成人话。
一处覆盖全部写点,以后再加写点也不用管。`mk()` 里那段裸判断一并换成调用它。

## 二、`lib/tmp-space.mjs` 的头注释在说谎(读者已误读一次)

原文写"`availBytes` 为 `null`(读不到 / 平台不支持 / **字段为 0**)" —— 而"字段为 0"
指的其实是 `statfs.bsize === 0`(测量层确实 `if (!s.bsize) return null`),读起来
却像是在说"可用 0 字节也算不知道" —— **正是我上一版刚踩、刚补判据的那个坑**。
pi 第一遍读就误读成了后者。已把两个 case 分开写死,并注明"这条注释写错过一次"。

## 三、兜底判据钉的是文本,不是机制

`env-guard.test.mjs` 原来对 `session-scan.test.mjs` 断言 /ENOSPC/ 与 /环境/,
而那段**解释性注释里本来就有这两个词** ⇒ 删掉整段翻译逻辑、只留注释,判据照样绿。
这正是 `permission-note.test.mjs` 自己警告过的"钉装饰不钉机制"。

改法(按仓库规矩,纯函数 + 反面样本 + 接线):
- 翻译逻辑提到 `lib/env-error.mjs` 的 `translateEnvError`(纯函数);
- 判据喂构造出来的错误验**行为**:ENOSPC 必须翻译且带药方、普通错误必须**原样返回
  同一个对象**("什么都翻译"比不翻译更坏 —— 真缺陷会被套上环境的外衣);
- `writeSession` 抽出 `write` 参数(**只为测试存在**,`pool.mjs` 的 `workerPath` 同一手法),
  于是"接线还在不在"是**行为**判据:喂一个必然 ENOSPC 的假写,翻译必须发生。
  抽它的理由写在注释里 —— 是"可被反面样本喂",不是复用(只有一个调用点)。
- 变异自检:删掉写点的翻译 ⇒ 第 28、29 两条立刻红(已实测)。

## 四、顺带三处小的一致性问题

- 端到端那条判据原靠"本机 /tmp 恰好是满的"来验 —— 那是把判据绑在**会变的环境**上,
  /tmp 一清空就自动跳过、无声失效。前置脚本加两个**只为测试存在**的开关:
  `--measure=<dir>`(只量并打印 JSON)与 `--inject-avail=<n>`(绕过测量直接判定),
  于是"不足⇒exit 2"与"充足⇒放行"在任何机器上都验得了(两个方向都验,缺一即假绿)。
- 判据 ⑥ 原先只有它自己带圈号前缀,读者会去找不存在的第 ⑤ 条。改成 `checkLayout`
  的每条都带**连续 id**(1..N),`name` 是纯展示串,并加一条"id 不许跳号"的自检。
- 两个实测数(`729_088` 字节 = 0.70 MiB、`712` 字节)是**不同时刻**量的,并列摆着像抄错,
  各标了来历;`lib/tmp-space.mjs` 里那条改用"一度真是 0"的说法。

验证:`npm test` **474/474**(上一版 453);`--self-check` **18 条全过**(新增 id 连续);
`npm test` 在临时目录不足时仍 exit 2 且一条用例都不跑。
This commit is contained in:
2026-09-14 19:38:31 +08:00
parent 0b548b8fcf
commit 5bc579f910
6 changed files with 291 additions and 99 deletions

View File

@ -327,31 +327,57 @@ function checkHost(spec) {
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;
}
/** 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
* 一个永远说「一致」的比较器看起来同样令人放心。 */
export function selfCheck() {
const a = mkdtempSync(join(tmpdir(), 'drift-a-'));
const b = mkdtempSync(join(tmpdir(), 'drift-b-'));
// ★ `mkdtempSync` 也在 try 里 —— 它本身就是一个写点:临时目录满的时候
// (2026-09-14 实测 `bavail` 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外,
// 于是那条路径照样崩出一段指向本文件的堆栈(退出码还不是 2,是未捕获异常)。
let a;
let b;
try {
a = mkdtempSync(join(tmpdir(), 'drift-a-'));
b = mkdtempSync(join(tmpdir(), 'drift-b-'));
} catch (e) {
throw describeEnvError(e, '判据自检要在临时目录里建两棵样本树');
}
const mk = (root, content) => {
mkdirSync(join(root, 'lib'), { recursive: true });
// ★ 自检要在临时目录里造两棵小树。临时目录满了(2026-09-14:`/tmp` 是 tmpfs,
// `bavail` 只剩 0.70 MiB)时这里会抛 ENOSPC —— 而它是一个**未捕获的异常**,
// 堆栈指向本文件的 `mk()`,看起来像检查器自己坏了。真相是环境不足。
// 翻译成说得清的错,交给 main() 报 2(环境问题)而不是崩栈。
// ★ 自检要在临时目录里造两棵小树。临时目录满了时这里会抛 ENOSPC ——
// 而它是一个**未捕获的异常**,堆栈指向本文件的 `mk()`,看起来像检查器自己坏了。
// 真相是环境不足。翻译成说得清的错,交给 main() 报 2(环境问题)而不是崩栈。
try {
writeFileSync(join(root, 'lib', 'x.mjs'), content);
writeFileSync(join(root, 'README.md'), 'doc');
} catch (e) {
if (e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message)))) {
const err = new Error(
`环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— 判据自检要在里面造两棵小树。` +
'这是环境问题,不是检查器的问题。药方:TMPDIR=<有空间的目录> 后再跑。'
);
err.code = 'ENOSPC';
err.cause = e;
throw err;
}
throw e;
// 统一交给 describeEnvError 翻译(见上方):这里只负责 `mk()` 自己能看到的两处写,
// 后面还有三处写在别处(README.md / extra.mjs / test/t.mjs),由 main() 的
// catch 统一兜住 —— 那是"一处覆盖全部写点"的那一层。
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
}
};
const out = [];
@ -444,7 +470,11 @@ export function checkLayout(inject = {}) {
const exists = inject.exists ?? existsSync;
const stat = inject.stat ?? statSync;
const out = [];
const push = (name, ok, note = '') => out.push({ name, ok, note });
// 每条检查带一个**显式 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';
@ -543,11 +573,11 @@ export function checkLayout(inject = {}) {
} catch { return null; } // 不是 git 仓库/没有 git:不判
})();
if (gitOut === null) {
push('⑥ 工作区干净(仅提示,不影响结论)', true, '读不到 git 状态,不据此判定');
push('工作区干净(仅提示,不影响结论)', true, '读不到 git 状态,不据此判定');
} else {
const dirty = gitOut.split('\n').map(l => l.trim()).filter(Boolean);
push(
'⑥ 工作区干净(仅提示,不影响结论)',
'工作区干净(仅提示,不影响结论)',
true,
dirty.length === 0
? '干净 —— 快照就是 HEAD 的内容'
@ -599,14 +629,17 @@ export function layoutSelfCheck() {
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x'
}), git: () => { throw new Error('not a git repo'); } });
const sixth = o => o.find(c => c.name.startsWith('⑥'));
const sixth = o => o.find(c => c.name.startsWith('工作区干净'));
return [
{ name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false },
{ name: '标准目录:干净样本必须判绿', ok: good[0].ok === true },
// ⑥ 是"说出来但不改结论"的提示:脏/干净/读不到三种都被报出来,且**都不判红**。
{ 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 }
// 编号: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 }
];
}
@ -621,8 +654,12 @@ function main() {
try {
checks = selfCheck().concat(layoutSelfCheck());
} catch (e) {
if (e && e.code === 'ENOSPC') {
console.error(`\n${e.message}\n`);
// ★ 覆盖 **全部** 写点:上面 `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;
@ -652,7 +689,7 @@ function main() {
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.name}${c.note ? ' — ' + c.note : ''}`);
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}`);