pi 复核后提出的收尾:跨文件的教训现在没有容器(实证散在三个文件的头注释里)。 先 `ls docs/` 看过:`docs/DEV-TOOLING.md` 已经有"三条判据,以及它们各自防的那个错" 那一节,主题完全相同 —— 所以**并进去而不是新开一篇**(这是 pi 明确要求的形状)。 写的是**可迁移**的那部分,实证不复述、只给索引: - 三种失效形态:**钉装饰**(断言落在注释上)、**分支退化**(绑在会变的环境上)、 **跑不到的分支**(断言在、区分力不在 —— 短路是无声的,比退化更狠); - 变异纪律 5 条:先证明能还原再注入、只对已干净提交的文件做、还原路径不得依赖被测资源、 判据不得用被测物证明自己、别只看过滤后的输出(也**不要**为此引入手抄的期望数常量); - 两条实现纪律:测试文件之间不许互相 import(会二次注册整套用例,实测 475→459); 写点覆盖范围不得取决于入口(这条在这套代码里被漏过三次); 以及"判据过宽和过窄都是坏的"—— 那条"测试文件互相引用"判据自己同时踩过两边。 - 索引指出:`check-deploy-drift.mjs` 的运行输出**就是**部署状态的判据来源, **不要**抄成哈希清单往外发(发给 jianf 的那份在两次提交后就作废了)。
12 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汇总行才是可靠信号 —— 也不要为此引入手抄的"期望用例数"常量:手抄常量会过期。
两条同族的实现纪律
- 测试文件之间不许互相
import(启用node --test时每个文件一个进程、 模块导入是进程内的)⇒ 被引的那个文件的用例会在引用者那个进程里再注册一遍。 实测:从测试文件取夹具,让巨行用例(单条往临时目录写 ~12 MiB)跑了两次 (测试总数 475;修完 459)。夹具放非测试模块(lib/session-fixtures.mjs), 判据也进套件(env-guard.test.mjs里那条扫描)。 - 写点要"一处覆盖全部",且覆盖范围不取决于入口。 同一个"ENOSPC 被翻译成环境问题"
在这套代码里被漏掉过三次:只包了 5 个写点里的 1 个;
mkdtempSync在 try 之外; 兜底层放在main()里而被兜的函数是导出的。判据过宽过窄都是坏的 —— 上面"测试文件互相引用"那条判据自己就同时踩了过窄(漏动态导入)和过宽 (把注释里的散文引用也算违规),最后只能收成"解析真引用"。
索引(实证在各自文件头注释里,此处不复述)
plugins/pi-mail-bridge/lib/tmp-space.mjs—— 测量层:bavail × bsize、0是"真的没有"而不是"不知道"(只有null才是不知道)。plugins/pi-mail-bridge/lib/env-error.mjs—— ENOSPC ⇒ 人话;为什么它是纯函数、 为什么不与deploy/的实现合并(deploy/的独立性比去重值钱)。deploy/check-deploy-drift.mjs—— 现场比树(判据自己算,不靠手抄常量)+ 判据自检的两侧验证 + 为什么它自带兜底层。deploy/check-deploy-drift.mjs的运行输出本身就是部署状态的判据来源; 不要把它抄成一份哈希清单往外发 —— 抄出来的那一刻就开始过期 (2026-09-14 发给jianf的清单在两次提交后就作废了)。