Files
MailUI4Agents/docs/DEV-TOOLING.md
JianFeeeee 92a51c7edf docs(tooling): 判据口径收紧后同步本节 —— "可定位的非自身引用",并修正 reset-demo.sh 那行**指错了来源**
`b16c38a` 把判据从"这个名字出现过吗"收紧成"引用必须带路径"。本节当时写的还是旧口径
("至少一处非自身引用")⇒ 文档与判据不同步,读的人会以为裸名也算。

★ 并修正一行**本来就错的**引用:
```
reset-demo.sh 原写: 发现路径 = `client/electron/README` 与演示脚本族
实测(收紧后逐工具列来源):
  reset-demo.sh 的路径限定来源 = docs/DEV-TOOLING.md(本节表格)+ deploy/prune-deploy-artifacts.sh 注释
  ⇒ `client/electron/README` **根本不在判据的 SCAN 范围内**(SCAN_DIRS 无 client/electron)
     所以它作为"发现路径"是**无效引用** —— 原文把它写成了依据,而判据从不看那里。
```
⇒ 改成实测的两个来源。

验证: 收紧后 5/5 非门禁工具**全部**仍有路径限定来源,且**没有一个**依赖我的分析日志
(`docs/API.md` 对 5 个工具的路径限定命中 = 0/5);criteria-hygiene 9/9。
2026-09-25 07:47:13 +08:00

411 lines
32 KiB
Markdown
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.

# 开发工具配置:为什么关掉 pi-lens 的自动改写
本仓库**明确关闭** pi-lens 的项目级自动格式化与 autofix。这份文档记录原因,
配置本身在 `.pi-lens.json`(根目录)。
## 一句话
pi-lens 编辑任一文件后会「安全格式化」它,而它的默认格式化器与本仓库的手工排版
不兼容 —— 每次编辑都会产生与内容无关的大面积 diff,把真正的改动埋掉。
已经造成过两次**真实损失**。
## ⚠️ `.pi-lens.json` 必须是严格 JSON
pi-lens 的配置加载是:
```js
// 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 列折行) |
两套默认值都与本仓库的排版冲突。两次已发生的损失:
1. **`19a3161`**:`git add -A` 把约 7000 行 biome 重排扫进了功能提交,
那次提交无法审查(还掩盖了一处 Go 文件的删行)。
2. **`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
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` 那份是会话归档(不可逆操作)唯一的回滚点,所以它单独一条窗口、报告里也写明身份。
### 三条判据,以及它们各自防的那个错
1. **在线库不入删除清单**(`del()` 里的 `readlink -f` 比对):备份删错能重建,在线库删错回不来。
2. **每个插件至少留一份"上一版"**:只有 `current` 时脚本报 `⚠ 无回滚目标`。它只报告、
**不造快照** —— 造不出来的东西不该假装有。(2026-09-14 那次事故就是这么暴露的:
四个插件各只剩 `current`,回滚目标没了。)
3. **`--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` 杀自己同类:
**安全装置自己出错时,产出的是一份看着正常的报告**。
## 按需工具(`deploy/` 下非门禁的那些)
`deploy/check-*.sh` 是**门禁族**:它们靠**命名约定**被 `criteria-hygiene.test.mjs`
用 `readdirSync` 强制接线,凡该族必须被入口脚本调用(判据在、不许走不到)。
★ 而这条约定的**代价**是:**为了躲开它而改名之后,就没有任何判据管"改名后还找不找得到"**。
为躲开 `check-*` 而改名的工具(`recount-*`/`prune-*`/`archive-*`/`reset-*`)属于**按需工具**:
不强制接线,但必须**有发现路径** —— 否则它存在,而没人会知道它存在。
判据在 `criteria-hygiene` 的「非门禁工具必须有发现路径」:每个非门禁 `deploy/*.sh`
至少要有**一处可定位的非自身引用**(文档 / 入口 / 别的工具)。实测它建起来时抓到的第一个孤儿
就是 `archive-stale-sessions.sh`(全仓零引用)。
★ **"可定位"= 引用必须带路径(`deploy/<工具名>`),裸名不算** ——
判据从"这个名字出现过吗"收紧成"读者能照着走到它吗"(`c155560`)。
理由是一个实测的**假绿**:只在分析日志里提一句裸名,就能让真孤儿判绿 ——
判据分不清「我在讨论里提到它」与「有人会照着这条信息找到它」,而本仓的分析日志全是前者。
⚠️ 它**不是**按文件名豁免(那是给逃逸指路);判据落在**引用的形态**上:
任何文件里的路径限定引用都算数,任何文件里的裸名都不算。
⇒ 所以下表每一行的发现路径,写法上都是**路径限定**的(标题/表格/注释里的全路径)。
| 按需工具 | 发现路径 |
|---|---|
| `deploy/prune-deploy-artifacts.sh` | 本节上方专节 |
| `deploy/recount-relay-counts.sh` | 本节(口径复算;被 `docs/DEBTS.json` 引用) |
| `deploy/archive-stale-sessions.sh` | 本节 —— 归档陈旧会话(`--dry-run` 先看) |
| `deploy/prune-test-sessions.sh` | `docs/DEV-TOOLING.md` 的清理表、`redeploy-gateway.sh` 注释 |
| `deploy/reset-demo.sh` | `docs/DEV-TOOLING.md` 的清理表、`deploy/prune-deploy-artifacts.sh` 注释 |
⚠️ 同名判别器的**两个不同机制**(别混):
```
check-* 族 : 「判据在但**走不到**」(**执行**路径)—— readdirSync 强制
按需工具族 : 「工具在但**没人知道它存在**」(**发现**路径)—— 本表就是那个发现路径
改名正好绕开前者 ⇒ 因此后者必须独立存在,不能指望前者兜住
```
## 判据纪律:三种"看起来验过了"的失效形态(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 必须放行),并断言**输出里的判定词**而不只是退出码 ——
退出码可能与真实状态巧合相同。论证对真实值**任意取值**都成立。
### 变异纪律(做"删掉机制看判据红不红"时)
1. **先证明你能撤回来,再注入变异。**
2. **变异只对"已在 HEAD 里干净提交"的文件做**;还原只走 `git checkout HEAD -- <file>`。
3. **还原路径不得依赖被测资源。** 一次把备份写进 `/tmp` —— 正是当时被占满的那个资源,
备份没写成而变异已覆盖源文件;是同一次"手写 `||` 兜底把失败吞掉"才让恢复变得不确定。
4. **判据不得用被测物证明自己**(第 3 条是它在"还原"上的投影)。
5. **别只看过滤后的输出。** `grep '^not ok'` 会丢掉"整份文件没跑起来"这个信息
(语法错时只报一条 `not ok 1 - test/xxx.test.mjs`)。退出码 + `# pass`/`# fail`
汇总行才是可靠信号 —— 也**不要**为此引入手抄的"期望用例数"常量:手抄常量会过期。
6. **判据的退出码不许经管道取值。** 反例(2026-09-14,我自己踩的):
`bash deploy/check-shared-libs.sh 2>&1 | tail -25; echo "exit=$?"` ——
`$?` 拿到的是 **`tail` 的**退出码,于是那条脚本的红(真值 1)被我报成了 0;
两个失败信息之所以还看得见,只是因为它们走 stderr 没进管道。
**用 `$PIPESTATUS[0]`,或先落文件再读** —— 管道会改写量纲,与上一条同族。
7. **"注入点"会把该抓的 bug 藏起来。** 2026-09-14 实例:`checkLayout` 的每个自检样本都
显式注入 `repoUnits`,于是那条判据的**真实默认值从没被任何样本走过** ——
而它当时恰好是错的(解析到不存在的 `/home/program/agentmail/systemd`),
判据因此**一个文件都没比过却报"一致"**,自检还 100% 绿。
⇒ 默认值本身要有判据(导出常量 + 断言存在)、样本要留至少一条**不注入**的;
⇒ 同类还有**位置选择器**(`bad[0]`/`badBak[0]`):在函数前面插一条新检查就改变了
既有断言的语义 —— 断言要按**名字**锚定。
8. **"看起来在比、其实没比"要设成一条自查。** 同一轮里它出现了三次:
目录路径错(`../systemd`)、空目录被当成"一致"、依赖是**符号链接**而选目录时
只挑 `isDirectory()` ⇒ 三次都产出"通过"。
⇒ 判据绿的时候**也要留下覆盖范围的证据**("比了 22 个文件"/"165 个包"):
空 note 无法区分"一致"和"没比过",而那正是这三次的样子。
⇒ 一条判据如果**只能靠真文件系统喂**,它就没法被自检 —— 读写都要走可注入面。
9. **变异之前先提交。** 我在**未提交**状态下变异,然后用 `git checkout HEAD -- <file>`
还原,把自己的改动一起冲掉了(这条纪律我写过、还是踩了)。
顺序必须是:提交 → 变异 → 确认红 → `cp` 还原 → `cmp` 校验。
10. **改"布局/文件集"时,先在脑子里跑一遍"如果这一步被回滚,部署判据会怎么变"。**
(pi 2026-09-14 提的,很准。)实例:把 `lib/user-question.js` 搬到 `test/lib/`,
收益是"快照更干净";但**回滚它会让快照立刻多出一处运行时漂移** ——
因为快照是"搬家后"的树,而仓库回到了"搬家前"。这类改动的收益账里没有这一项,
于是它不体现在任何判据里,只体现在回滚之后的红灯上。
11. **判据的退出码不许经管道取值**(与第 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 一起想。**
12. **判据的输出必须能自证"它比完了全部对象"。** 实例:`check-shared-libs.sh` 的
`show_diff` 里 `cmp … | head -3` 在 `set -euo pipefail` 下返回 1 ⇒ 独立调用触发
`set -e` ⇒ **脚本当场中止**:只报第一个分叉文件,后续对象与收尾汇总都不打印。
退出码**恰好还是 1**(判定是对的),所以光量退出码看不见它 ——
这正是"判定对、证据被截断"。修法 `|| true`,并在注释里写明它不是风格而是判据。
13. **自检样本必须"独立":每条样本都要说清它锚在哪一条检查、用的是哪一棵树。**
这一族我 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):我刚给部署加了"同时性"那一列(`flock` 部署锁),
**却忘了把 `flock` 登记进三个脚本的 `AGENTMAIL_REQUIRE`**。后果(已实测):
`flock` 不在机器上 ⇒ `command not found`(127)⇒ `! flock` 为真 ⇒
打印"**另一个部署正在跑(锁被占用)**" —— 退出码事后是对的(2),
但**诊断是错的**,而照着它做的是"等另一个部署结束":**永远等不到**。
⇒ 每次给部署路径加一个新命令,都要回去在 `AGENTMAIL_REQUIRE` 里加一个词。
20. **"服务停着而脚本死了"是独立于"文件半截"的一类风险 —— 环境前提的第六列候选(中断)。**
`redeploy-gateway.sh` 在 `systemctl stop` 与 `systemctl start` 之间有窗口,
外部信号(Ctrl-C、宿主杀进程、会话被回收、OOM)会让脚本直接退出 ⇒
**服务留在停止状态而脚本什么都不说** ⇒ 后果是"邮件全停 + 无人告知",
比半截二进制更难发现。原子 `mv` 只修了后者。
修法:进窗口前 `trap … INT TERM HUP`、出窗口摘掉,trap 只在"确实还停着"时动手
(否则会多起一次服务)。判据可喂:用 stub `systemctl` + 探针脚本,给自己发 `SIGINT`,
断言"停着 ⇒ 调了 start、退出码 130"与"没停 ⇒ 不调 start"。
21. **夹具把生产形状简化掉的那一角,恰好就是出问题的那一角。**
同一个形状在**一天之内出现两次**(都是 pi 用读代码找出来、而我这边全绿):
· `sandbox-launch.test.mjs` 手写 `fsWith([…, '.pi/agent/sessions', …])`,
**只列 sessions、不列 agentDir** ⇒ "agentDir 在不在 rw 里"永远测不出来;
生产里 hub 的凭据存储把 `auth.json.lock` 写在 `agentDir` **直下**,
后果是那个 agent **整个不工作**(不是某次工具调用失败)。
· `fsRealShape()` 里 cwd 总是存在的 ⇒ "兜底目录由 worker 自己建"这条测不出来;
生产里无 path 新会话的首回合因此**不套沙箱**,`guardDecision` 退化成 `ask`。
⇒ 写夹具时问一句:**"生产里这个值是本来就有的,还是被谁建出来的?"**
凡"由被测代码自己建出来的东西",夹具都不能预先给好,否则测的是另一半。
22. **注释里的数字无法被判据守住。** —— 而不是只落到你想到的那一个。**
实例(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 形状相同的现象时,先把"它是否已经由另一层按设计处理掉了"
查完再下结论 —— 否则会把一个正常状态写成缺陷,而这类误判会以"我发现了新问题"的
语气传播出去,比沉默更贵。