pi 的四条,逐条实测: **1. `install(1)` 不是 rename —— 而那句话是整节设计的理由** 头部原话:"install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动"。 按他给的命令实测 `strace … install -m 0755 /bin/true /tmp/t`: 目标不存在:`openat(t, O_WRONLY|O_CREAT|O_EXCL)` 目标已存在:`unlinkat(t, 0)` → `openat(… O_CREAT|O_EXCL)` → 写入 **全程没有 rename/renameat**。即复制路径,**旧文件在新文件写完整之前就没了**。 中途失败实证:`ulimit -f 1` ⇒ 退出码 **153**(SIGXFSZ),目标变成 **1024 字节截断 ELF**, 原 14 字节内容**已被销毁** —— 正是本段前半句写的风险,`install` 并不免疫。 (第一次测时我把退出码经管道取到了 `head` 的 0 —— 正是 docs 第 6 条那个坑,重测才拿到 153。) ★ 他补的第二个坑也确认:`/tmp` 与 `/opt/agentmail` **不同文件系统** (实测设备号 40 vs 2049)⇒ 就算换成 `mv` 也不原子(跨 fs 退化成 copy+unlink)。 已改成真原子三步:**目标同目录**暂存 → `install`(动的是"还没人用的名字")→ 一次 `mv -f`。 对照 `redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(同 fs)—— 同一仓库原先两套"原子切换",一套真、一套名义上的。 **2. 缺并发锁(环境前提表的第五列:同时性)** 原表(变量/命令/空间/身份)漏了这一类,而它不是假设:工作区是多 agent 共用的。 两个部署同时跑 ⇒ 各自 stop(一次失败、状态没人看)→ 两次写同一目标(配合上面那条 ⇒ 真能留半截)→ 两次后置验证互相把对方的"验证不过"当自己结论 → 谁回滚不确定。 三个脚本都加 `flock`(**不是**"检查锁文件存在",那本身有竞态)。 实测:同一把锁上第二个进程 `flock -n` 失败;脚本形态下 `install.sh` 的 `--check` 不建锁 (干跑只读、且刻意允许无写权限运行)。 **3. journalctl 抽成可喂函数 —— 并且他对我那次"复现"的更正成立** 他说我复现的是**"空输出"支**,不是"读不到"支。实测确认: `journalctl -u 不存在的-unit` 退出码 **0** ⇒ 我测到的是 else 分支。 已抽成 `am_scan_logs <unit> <since> <pattern> [命令]`,输出三态 `clean`/`hit`/`unreadable:<码>`(与既有 `describeEnvError`、`judgeRestart` 同一做法: 把能被样本喂的部分抽出来)。**用 `/bin/false`、`/bin/true`、假"输出含 panic"的脚本 三个样本喂过**(不碰生产):`unreadable:1` / `clean` / `hit` —— 三条支路现在都有覆盖。 判据也随之分开:**"命令不在"由 `AGENTMAIL_REQUIRE` 兜、"命令在但读不到"由这个函数兜**, 两列在代码里分开,而不只是注释里分开。 **4. 低优先项里 umask 那条是真问题(我原来以为可忽略)** 实测 `install -d` 权限位受 umask 影响;而本机生产 `/opt/agentmail/data` = **755**、 `agentmail.db` = **644**(全局可读),同一脚本里 `agent-config`/`pi-config` 却是显式 `-m 0700` —— 同一脚本两套口径,而那个库里是全部往来邮件。 已改:`install -d -m 0700 "$PREFIX/data"`、`-m 0700 "$ETC"`(密码与密钥)。 (现有生产权限不在本次改动范围,属部署后生效。) **顺带修一处我自己的口径不一致**:`install.sh` 的 root 检查用 `exit 1`(判据失败), 而另外两个脚本与 `env-defaults.sh` 的"环境不足"都用 **2** —— 调用者无法据此区分 "该重跑"还是"该修代码"。已统一为 2。 ★ 加锁过程中我自己连踩三次"复制粘贴的上下文假设"(都已修,并记进 docs 第 18 条): `install.sh` 没有 `bad()` ⇒ 127;`redeploy-plugin.sh` 没有 `$PREFIX`(用 `$DEST_ROOT`)⇒ `unbound variable`;`install.sh --check` 无写权限 ⇒ 建锁 `Permission denied` 又变 127。 **同一份代码搬到另一个脚本里,能引用的变量和函数是不一样的。** 验证:install.sh --check exit 0;npm test exit 0;prune 自检 22/22;drift 自检 35/0; check-shared-libs exit 0;全部 deploy 脚本 bash -n 通过。
27 KiB
开发工具配置:为什么关掉 pi-lens 的自动改写
本仓库明确关闭 pi-lens 的项目级自动格式化与 autofix。这份文档记录原因,
配置本身在 .pi-lens.json(根目录)。
一句话
pi-lens 编辑任一文件后会「安全格式化」它,而它的默认格式化器与本仓库的手工排版 不兼容 —— 每次编辑都会产生与内容无关的大面积 diff,把真正的改动埋掉。 已经造成过两次真实损失。
⚠️ .pi-lens.json 必须是严格 JSON
pi-lens 的配置加载是:
// pi-lens dist/index.js
PROJECT_CONFIG_BASENAMES = ['.pi-lens.json', 'pi-lens.json']; // 没有 .jsonc
function parseConfigFile(configPath) {
const text = fs.readFileSync(configPath, 'utf-8');
raw = JSON.parse(text); // ← 不做去注释处理
}
// 解析失败 → 打一行警告,然后**整份忽略**
所以带 // 注释会让这份配置完全失效,而失败方式是静默的:
进程照常跑,只有启动时一行 [pi-lens] ignoring invalid project config。
这个坑真的踩过:第一次写这份配置时把大段理由写成了 // 注释,
于是「已经关掉了」这个结论是假的 —— 防护从一开始就没生效。
现在理由放在本文件里,JSON 里只留一个 $comment 指针。
为什么必须关
pi-lens 在编辑文件后会走 smart-default 回退选择格式化器(见它的
FORMATTER_POLICY_BY_EXTENSION):
| 扩展名 | 默认格式化器 |
|---|---|
.ts .tsx .js |
biome(默认 tab 缩进 + 双引号) |
.html |
prettier(双引号、<!DOCTYPE html> 变小写、按 80 列折行) |
两套默认值都与本仓库的排版冲突。两次已发生的损失:
19a3161:git add -A把约 7000 行 biome 重排扫进了功能提交, 那次提交无法审查(还掩盖了一处 Go 文件的删行)。a404cba:prettier 改写了client/electron/index.html—— 单引号变双引号、 DOCTYPE 变小写,直接打破test/theme.test.mjs的两条断言(该测试要求 同步内联脚本里是classList.add('dark'),单引号)。
为什么不是「把格式化器配成本仓库风格」
试过:把缩进、引号、lineWidth 全部对齐之后,biome format --write 仍然改动
17 个文件 —— 本仓库的注释按语义换行、数组与调用按可读性手工折行,
这些格式化器还原不了。
谁在守着这个仓库的格式
不是格式化器,是这些:
tsc --noEmit(前端类型)go vet/gofmt -l(Go)- tree-sitter / ast-grep(pi-lens 的结构规则与安全规则,只读、不改写)
- 各包的测试套件(
npm test、go test ./...、node --test)
相关
biome.jsonc:只挡得住 biome,挡不住 prettier —— 两者是并列的候选格式化器, 各有各的配置。所以真正的开关是.pi-lens.json,它两条改写路径一起关。
部署残留清理(deploy/prune-deploy-artifacts.sh)
部署会留下两类持续增长的东西:网关旧二进制(每份 ~24MB)与插件快照(opencode/dsh 的
快照含 node_modules,一份几十 MB)。2026-09-14 实测 /opt/agentmail 累计到 1.3GB,
其中旧二进制 ~790MB、快照 ~540MB。
bash deploy/prune-deploy-artifacts.sh # 干跑,只报告
bash deploy/prune-deploy-artifacts.sh --apply # 真删(默认:网关留 3 份、每插件留 3 份快照)
bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项,两侧都验)
三条硬规矩:保留回滚窗口(发布纪律要求有回滚目标,所以不是全清);绝不删正在使用的 快照(扫 /proc 的 cmdline 与 cwd,命中就跳过 —— 删掉它进程一重启就找不到自己的代码); 在线数据库永不入列。
注意那个"在用"判断必须排除本进程及其祖先链:调用方常把路径写在命令行里,
不排除就会出现"永远判为在用"(与 pkill -f 杀掉自己那条命令同一个坑,已写进脚本注释)。
四类残留与各自的窗口
| 类别 | 来源 | 窗口 |
|---|---|---|
网关旧二进制 agentmail-gateway.bak-<ts> |
redeploy-gateway.sh |
保留最新 3 份 |
插件快照 <plug>/<ts> |
redeploy-plugin.sh |
每插件保留最新 3 份,current 永远保留 |
数据库/附件备份集 backups/agentmail-<ts>.db + attachments-<ts>.tar.gz、data/agentmail.db.bak-<ts> |
reset-demo.sh 与早期手工留档 |
保留最新 1 集 |
/tmp/agentmail-pre-deploy-*.db、/tmp/agentmail-pre-prune-*.db |
redeploy-gateway.sh、prune-test-sessions.sh |
各保留最新 2 份 |
同一个 <ts> 的库与附件包算一个备份集,一起进出窗口 —— 拆开留没有意义。排序按
文件名里的时间戳而不按 mtime:09-02 的两份备份被 09-08 的一次"打开看一眼"改了 mtime,
按 mtime 排会把最老的判成最新的。没有时间戳的文件一律不碰(判定不了就不删)。
pre-prune 那份是会话归档(不可逆操作)唯一的回滚点,所以它单独一条窗口、报告里也写明身份。
三条判据,以及它们各自防的那个错
- 在线库不入删除清单(
del()里的readlink -f比对):备份删错能重建,在线库删错回不来。 - 每个插件至少留一份"上一版":只有
current时脚本报⚠ 无回滚目标。它只报告、 不造快照 —— 造不出来的东西不该假装有。(2026-09-14 那次事故就是这么暴露的: 四个插件各只剩current,回滚目标没了。) --self-check两侧都验:干净样本该删的删、该留的留、在线库不动必须是绿的; 把窗口外的备份换成指向在线库的符号链接,脚本必须拒跑(退出码 1), 不是"删了才发现"。最后一条是"自检没有碰生产根":比对前后/opt/agentmail的清单指纹。
事故记录:自检把生产当成了沙箱(2026-09-14)
--self-check 第一版用 ROOT="$t" … bash "$0" 传假根。脚本读的是 AGENTMAIL_ROOT,
于是这个前缀赋值被静默忽略,自检的 --apply 打在了生产根上,删掉一批回滚备份
(3 个旧网关二进制、8 个插件快照、09-02 的两组备份集)。
为什么没被发现:PRUNE_TMP_DIR 那一路的变量名是对的,所以输出的 /tmp 段看着"确实是假根",
干跑那一轮的报告也像模像样 —— 半对的状态比全错更难认。是 bash -x 里那行
ls -1t /opt/agentmail/… 露的马脚。
改法不是"下次小心":① 传对变量名;② 自检的根目录必须在临时区,否则拒跑;
③ "自检不碰生产"进判据(前后指纹比对)。这条与 pkill -f 杀自己同类:
安全装置自己出错时,产出的是一份看着正常的报告。
判据纪律:三种"看起来验过了"的失效形态(2026-09-14)
同一天里,三条自己写的判据各以一种方式失效 —— 它们都绿着,但都不再判别任何东西。 记在这里,因为这是可迁移的那部分;具体实证留在各自的头注释里(下面有索引)。
一、钉装饰:断言落在注释上,不落在机制上
env-guard.test.mjs 曾对源码文本断言 /ENOSPC/ 与 /环境/,而那段解释性注释里
本来就有这两个词 ⇒ 把整段翻译逻辑删掉、只留注释,判据照样绿。
改法:能被反面样本喂的抽成纯函数(translateEnvError),断言行为
(ENOSPC 要翻译;普通错误必须原样返回同一个对象 —— "什么都翻译"比不翻译更坏)。
二、分支退化:判据绑在一个会变的环境上
"空间不足 ⇒ exit 2"那条原本靠"本机 /tmp 恰好是满的"来验。机器一恢复健康
(/tmp 被清空),那条就自动跳过、无声失效。
改法:给被测物开一个只为测试存在的开关(--inject-avail),让两种机器状态
都验得了;两个方向都要验(只验"不足⇒2",一个恒报不足的坏守卫也能绿)。
三、跑不到的分支:断言在,区分力不在
按"真实测量"分叉的那版写成了
realAvail < MIN ? (不足分支) : (充足分支),而本机真实可用就是 0 ⇒ 永远走
不足分支。于是把开关整个忽略掉,断言照样绿。
比"分支退化"更狠:退化至少留了一行自我声明("此条退化为弱检查"),短路是无声的 ——
代码看起来两个方向都验了,实际只跑了一个。
改法:不与真实测量比,让两个探针互为反面(注入 1 字节必须 exit 2,
注入 128 MiB 必须放行),并断言输出里的判定词而不只是退出码 ——
退出码可能与真实状态巧合相同。论证对真实值任意取值都成立。
变异纪律(做"删掉机制看判据红不红"时)
- 先证明你能撤回来,再注入变异。
- 变异只对"已在 HEAD 里干净提交"的文件做;还原只走
git checkout HEAD -- <file>。 - 还原路径不得依赖被测资源。 一次把备份写进
/tmp—— 正是当时被占满的那个资源, 备份没写成而变异已覆盖源文件;是同一次"手写||兜底把失败吞掉"才让恢复变得不确定。 - 判据不得用被测物证明自己(第 3 条是它在"还原"上的投影)。
- 别只看过滤后的输出。
grep '^not ok'会丢掉"整份文件没跑起来"这个信息 (语法错时只报一条not ok 1 - test/xxx.test.mjs)。退出码 +# pass/# fail汇总行才是可靠信号 —— 也不要为此引入手抄的"期望用例数"常量:手抄常量会过期。 - 判据的退出码不许经管道取值。 反例(2026-09-14,我自己踩的):
bash deploy/check-shared-libs.sh 2>&1 | tail -25; echo "exit=$?"——$?拿到的是tail的退出码,于是那条脚本的红(真值 1)被我报成了 0; 两个失败信息之所以还看得见,只是因为它们走 stderr 没进管道。 用$PIPESTATUS[0],或先落文件再读 —— 管道会改写量纲,与上一条同族。 - "注入点"会把该抓的 bug 藏起来。 2026-09-14 实例:
checkLayout的每个自检样本都 显式注入repoUnits,于是那条判据的真实默认值从没被任何样本走过 —— 而它当时恰好是错的(解析到不存在的/home/program/agentmail/systemd), 判据因此一个文件都没比过却报"一致",自检还 100% 绿。 ⇒ 默认值本身要有判据(导出常量 + 断言存在)、样本要留至少一条不注入的; ⇒ 同类还有位置选择器(bad[0]/badBak[0]):在函数前面插一条新检查就改变了 既有断言的语义 —— 断言要按名字锚定。 - "看起来在比、其实没比"要设成一条自查。 同一轮里它出现了三次:
目录路径错(
../systemd)、空目录被当成"一致"、依赖是符号链接而选目录时 只挑isDirectory()⇒ 三次都产出"通过"。 ⇒ 判据绿的时候也要留下覆盖范围的证据("比了 22 个文件"/"165 个包"): 空 note 无法区分"一致"和"没比过",而那正是这三次的样子。 ⇒ 一条判据如果只能靠真文件系统喂,它就没法被自检 —— 读写都要走可注入面。 - 变异之前先提交。 我在未提交状态下变异,然后用
git checkout HEAD -- <file>还原,把自己的改动一起冲掉了(这条纪律我写过、还是踩了)。 顺序必须是:提交 → 变异 → 确认红 →cp还原 →cmp校验。 - 改"布局/文件集"时,先在脑子里跑一遍"如果这一步被回滚,部署判据会怎么变"。
(pi 2026-09-14 提的,很准。)实例:把
lib/user-question.js搬到test/lib/, 收益是"快照更干净";但回滚它会让快照立刻多出一处运行时漂移 —— 因为快照是"搬家后"的树,而仓库回到了"搬家前"。这类改动的收益账里没有这一项, 于是它不体现在任何判据里,只体现在回滚之后的红灯上。 - 判据的退出码不许经管道取值(与第 6 条同源,写清取法):临时命令里
cmd | tail -25; echo $?拿到的是tail的退出码。要取就读${PIPESTATUS[0]}。 脚本侧四个 deploy 脚本都有set -o pipefail(install.sh还带-e), 所以脚本内的管道判定是对的 —— 但那是判据的一部分,不是风格:redeploy-plugin.sh的if ! node … | sed …; then与install.sh里同形状那处, 依赖pipefail才测的是被检程序的状态;谁重构时把set -o pipefail删了或挪了位置, 判定会静默变成"sed成功即成功"。动那几行要连着 pipefail 一起想。 - 判据的输出必须能自证"它比完了全部对象"。 实例:
check-shared-libs.sh的show_diff里cmp … | head -3在set -euo pipefail下返回 1 ⇒ 独立调用触发set -e⇒ 脚本当场中止:只报第一个分叉文件,后续对象与收尾汇总都不打印。 退出码恰好还是 1(判定是对的),所以光量退出码看不见它 —— 这正是"判定对、证据被截断"。修法|| true,并在注释里写明它不是风格而是判据。 - 自检样本必须"独立":每条样本都要说清它锚在哪一条检查、用的是哪一棵树。 这一族我 2026-09-14 一天里踩了三次,合起来记比拆成三条好,因为修法是同一个 (显式命名 + 显式复位)—— 三者都是"断言的语义被当前状态悄悄改掉":
| 形态 | 实例 |
|---|---|
| 位置选择器 | 自检里 bad[0] / badBak[0]:前面插一条新检查之后,锚点指的就不是原来那条了 |
| 共享夹具状态泄漏 | 新补的四条豁免样本第一版全红:它们继承了两棵树里既有的改动(extra.mjs、只存在于 a 的 test/),红的理由根本不是要测的那件事 |
| 探针自身没有分辨力 | E 那次的探针按文件名判两侧,而两条路径 basename 相同(deploy/service-failure-notify.mjs vs /opt/agentmail/bin/…)⇒ 两侧读到同一个串、样本"通过"得毫无意义。与 grep -c 用例名 那个假数、"名字出现在 # Subtest: 头"同族不同形:锚在"名字"而没有锚在"哪一侧" |
配套一条(同一天踩到):自检的夹具形状必须与生产形状一致,否则夹具会把真 bug 藏住 ——
prune-deploy-artifacts.sh 的构建暂存自检用 : > 造普通文件,而生产是
有内容的目录;正是这个差异让"ls -1t 对多个目录打 路径: 头 ⇒ 那段清理一直空转"
这个 bug 藏了很久(判据自己在用文件名判"删了没有",夹具认了错形状,于是它也认了)。
14. 退出码也有量纲。 node deploy/check-deploy-drift.mjs --self-check 的退出码
不是"自检的结论":自检本体 28/28 全过,但同一个进程接着跑了宿主判据、
于是整体 exit 1。报"自检失败"就是把两个量纲混成一个。
⇒ 报结论时分开说:"自检本体 N/M 通过;整体退出码还包含 X"。
15. "命令不在" ≠ "命令在但输出为空"。 把两者压成同一个字符串就会产出假绿 ——
实例(pi 评审 2026-09-14,实测复现):
fc="$(journalctl … 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)",
journalctl 失败(无权限读日志 / unit 不存在 / dbus 不通)⇒ 错误被 2>/dev/null 吞掉
⇒ grep 读空输入 ⇒ 输出 0、退出 1 ⇒ || true ⇒ fc="0" ⇒ 打印"近 2 分钟无 panic/fatal"。
下面那条 sse 同形,后果更坏:把"工具缺失/读不到"归因成"插件没连上",
提示人去查密钥,而问题在日志读不到 —— 一条把人引向错误方向的假绿。
⚠️ 顺带实测:PIPESTATUS 分不开这两种情况(命令不存在与"存在但无匹配"都给 1),
所以不能靠管道状态区分,必须先把输出取出来、成功后再过滤;
"命令是否存在"另用 command -v 做前提检查(AGENTMAIL_REQUIRE)。
⇒ 规矩:"命令不在"走 2/红 + 人话;"命令在但输出为空"才是判定结果。
同族放宽:环境自足不能只覆盖变量,也要覆盖命令(journalctl/curl/systemctl/
git/go/npm 都曾是被假设存在的那一类)。
16. "原子替换"要指名哪一步原子 —— 而且要验 install/mv 到底做了什么。
实例(pi 评审 2026-09-14,已用 strace + ulimit -f 实测):
redeploy-gateway.sh 头部断言"install(1) 本质是 rename,是原子的 ——
要么完整换掉,要么原样不动",而那是整节设计的理由(为什么优先 install、
为什么失败即回滚)。实测 strace … install -m 0755 /bin/true /tmp/t:
unlinkat(t) → openat(t, O_CREAT|O_EXCL) → 写入,全程没有 rename。
中途失败实证:ulimit -f 1 下安装 ⇒ 退出码 153(SIGXFSZ),
目标变成 1024 字节的截断 ELF,原 14 字节内容已被销毁 ——
头部后半句自己写的风险("中途失败留下半截二进制")install 并不免疫。
⇒ 真原子 = 临时文件放在目标同目录(同 fs)+ 最后一次 mv。
还要注意 /tmp 与 /opt 常是不同文件系统(实测设备号 40 vs 2049),
所以"改成 mv 就原子了"同样不成立 —— 跨 fs 的 mv 退化成 copy+unlink。
17. 环境前提表有第五列:同时性(并发)。 变量/命令/空间/身份之外,
还有"同一时刻只能有一个"。prune/redeploy/install 都会写同一批路径,
而工作区是多 agent 共用的 ⇒ 需要 flock(不是"检查锁文件是否存在"——那本身有竞态)。
⇒ 判据:"两个同时启动 ⇒ 第二个 exit 2 并点名"。
18. 同一份代码搬到另一个脚本里,"能引用的变量和函数"是不一样的。
我给三个脚本加同一段锁,第一版照抄:install.sh 没有 bad()(用裸 echo)⇒
bad: command not found(127);redeploy-plugin.sh 没有 $PREFIX(它用 $DEST_ROOT)⇒
PREFIX: unbound variable(set -u);而 install.sh 的 --check 刻意允许无写权限运行
⇒ 在那里建锁又变成 Permission denied。三处都是"复制粘贴的上下文假设"。
19. 注释里的数字无法被判据守住。 —— 而不是只落到你想到的那一个。**
实例(pi 评审 2026-09-14):我在 env-defaults.sh 的 HOME 上写了两条规则
("mkdir -p 对已存在的不可写目录会返回成功 ⇒ 必须单独判 -w"、"判据落在能不能写、
不落在路径像不像"),同一条规则没落到紧邻的 TMPDIR 上 —— 而 ENOSPC 正是这条链的
元老问题。两种失败形状(不可写 / 写满)都在中间炸,报错看起来像工程问题。
⇒ 同族的"变量"与"命令"是同一张表的两列,写规则时要把表列全:
设了要判"可写"、判了可写还要判"有空间"(0 是真的没有,读不到才是"不知道")。
17. 注释里的数字无法被判据守住。 同一文件里曾同时写"写点五处"和"共 6 处"
(且它的式子 2+2+3 加起来是 7,实际 10 处)——三处说法三个数。
与"不要手抄期望用例数常量"同源:两组矛盾的数字比没有数字更糟,
因为它让读者以为有人数过。要判覆盖完整只能靠机制(整段 try/catch),不靠数数。
11. 一条新判据上线时,先找它可能与哪些既有不变量冲突。 2026-09-14:
新加的"本平台不可达 ⇒ 搬去 test/lib/"与既有的"共用模块四方逐字节同源、
连相对路径一起钉"(deploy/check-shared-libs.sh)方向相反 ——
我只看⻅了自己那条,于是"按规则推断出的正确动作"把共用判据打红两处,
还差点让下次部署静默删掉一个 dsh 桥的生产模块(lib/user-question.js)。
⇒ 可达性只能当报告,不能当搬家判据;判据里读共用清单做豁免(不手抄)。
两条同族的实现纪律
- 测试文件之间不许互相
import(启用node --test时每个文件一个进程、 模块导入是进程内的)⇒ 被引的那个文件的用例会在引用者那个进程里再注册一遍。 实测:从测试文件取夹具,让巨行用例(单条往临时目录写 ~12 MiB)跑了两次 (测试总数 475;修完 459)。夹具放非测试模块(lib/session-fixtures.mjs), 判据也进套件(env-guard.test.mjs里那条扫描)。 - 提交前先看
git status里有没有"不是我的"文件。 这个工作区是多 agent 共用的 —— 2026-09-14 20:02~20:04 就有另一条会话在改deploy/install.sh、deploy/prune-deploy-artifacts.sh、deploy/redeploy-gateway.sh(-trimpath那组加固), 而当时我正在同一个仓库里连续提交。git add -A会把别人没写完、没审过的改动 一起做进我的提交里,而且从git log上看不出来是谁的。 规矩:显式列出要提交的路径,别用-A/-u图省事;提交信息里也不要把 别人的改动算作自己的成果。(这次三次提交都是显式路径,事后git show --stat核对过。) - 写点要"一处覆盖全部",且覆盖范围不取决于入口。 同一条"ENOSPC 被翻译成环境问题"
在这套代码里被漏过三次,而且是三个不同的入口假设(不是同一处错了三次):
①
test/lib/session-fixtures.mjs的writeSession(第一版只包writeFileSync,mkdirSync在 try 之外); ②deploy/check-deploy-drift.mjs的mk()(mkdtempSync也是一个写点,却在 try 之外); ③ 同一个文件的main()—— 兜底层放在调用方,而被兜的selfCheck()是导出的 ⇒ 任何绕过main()的调用者拿不到翻译。 三次都可以概括成一句话:"我以为的入口/哪一行"决定了覆盖范围。 - 判据过宽和过窄都是坏的。 "测试文件不许互相 import"那条判据自己同时踩过两边:
过窄(只匹配静态 from,漏掉动态
import())、过宽("文件里出现别的测试文件名" 把注释里的散文引用也算违规,还被自己注释里的示例字面量点亮)。 最终形状只能是"解析真引用"。 - 因果特定的判据 + 因果无关的判据要配对。 "测试文件互相 import"只能发现已知成因;
同族的另一种成因它看不见 —— 实测跨文件同名用例不会被 runner 拦
(两个文件各写一个同名用例 ⇒
# tests 2 / # pass 2 / # fail 0,零警告)。 所以补一条不挑成因的运行期判据:test/lib/run-suite.mjs从同一次运行的 TAP 里数结果行,重名即红。 - 判据的锚点必须与结论一一对应。 同一条重名检查,锚
^(ok|not ok) <n> - <名字>(结果行)是对的;锚"名字出现过"是错的 —— TAP 里名字既出现在# Subtest:头、 又出现在结果行,效应 2 倍、噪声也 2 倍且恰好同值,于是"4"看起来还能解释; 若行种类是 3,就会把"两次"读成"三次"。别让噪声与效应同阶。 lib/与test/lib/的边界(2026-09-14):部署脚本是cp -a "$SRC/." "$STAGING/"加一条rm -rf "$STAGING/test"(没有EXCLUDE_DIRS这种变量)⇒lib/整份进快照。 于是规则必须是可判定的:lib/= 从生产入口可达的模块;只被测试引用的放test/lib/。 判据在test/lib/reach.mjs(真走 import 闭包,含按路径 fork 的子进程入口) 与test/layout-boundaries.test.mjs。 ★ 别写成"被src/直接 import":实测 22 个lib/模块里 4 个src直接引用数为 0 (addressing.js被lib/inbox-format.js传递引用、mail-session-id.js、crash-notify.mjs、user-question.js)—— 直接引用数不是可达性,所以判据真走图。 ★★ 但这条例外更要紧:lib/首先是四桥共用命名空间,其次才是"本平台可达"。user-question.js在 pi 侧只被测试引用、却在四桥共用清单上 (deploy/check-shared-libs.sh的ALL_LIBS,它是 dsh 桥的生产代码)。 按"不可达就搬走"处理它,会同时打红两处(共用模块缺失 + 共用测试已分叉), 而且下一次部署会静默把它从生产快照里删掉。 所以可达性只能当报告,不能当搬家判据 —— 判据里读共用清单做豁免。 (教训的完整形状:一条新判据上线时,先找它可能与哪些既有不变量冲突 —— 这里两条不变量方向相反,而我只看见了自己那条。)
索引(实证在各自文件头注释里,此处不复述)
plugins/pi-mail-bridge/test/lib/tmp-space.mjs—— 测量层:bavail × bsize、0是"真的没有"而不是"不知道"(只有null才是不知道)。plugins/pi-mail-bridge/test/lib/env-error.mjs—— ENOSPC ⇒ 人话;为什么它是纯函数、 为什么不与deploy/的实现合并(deploy/的独立性比去重值钱)。plugins/pi-mail-bridge/test/lib/reach.mjs——lib/与test/lib/的边界判据。deploy/check-deploy-drift.mjs—— 现场比树(判据自己算,不靠手抄常量)+ 判据自检的两侧验证 + 为什么它自带兜底层。deploy/check-deploy-drift.mjs的运行输出本身就是部署状态的判据来源; 不要把它抄成一份哈希清单往外发 —— 抄出来的那一刻就开始过期 (2026-09-14 发给jianf的清单在两次提交后就作废了)。
附:一条被自己的结论"半路纠正"的观察(留作提醒)
2026-09-14 排查时看到 permission_requests 里有一条 result IS NULL 的挂起请求
(11:56 创建,已挂 3h54m),当时的推断是"worker 被重启/超时杀掉 ⇒ 停在授权上没人解除
⇒ 这是那个 bug 的同族缺口"。
实际是正常状态:网关的等待窗口是 PermissionWaitWindow = 10 * time.Minute
(server/internal/models/permission_mode.go),请求 12:06 就失效了,
界面靠 AttachPermissionDeadline 推导出的时刻(不是布尔快照)自己判断过期。
教训:看到与已知 bug 形状相同的现象时,先把"它是否已经由另一层按设计处理掉了"
查完再下结论 —— 否则会把一个正常状态写成缺陷,而这类误判会以"我发现了新问题"的
语气传播出去,比沉默更贵。