pi 逐处对文件后指出:我按"本平台不可达 ⇒ 搬去 test/lib/"把 `lib/user-question.js`
搬走,打红了 `deploy/check-shared-libs.sh` 两处(实测确认,脚本真退出码 1):
共用模块缺失:plugins/pi-mail-bridge/lib/user-question.js
共用测试已分叉:test/user-question.test.mjs(opencode vs pi)
根因不是取舍而是口径:**`lib/` 上挂着两条方向相反的不变量** ——
① 共用模块四方逐字节同源(`check-shared-libs.sh`,连相对路径一起钉);
② 本平台生产可达(我新加的规则)。而 `user-question.js` **是 dsh 桥的生产代码**
(`plugins/dsh-mail-bridge/src/index.ts` 引用它)⇒ 两条必然冲突。
**`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**;可达性只能当**报告**,
不能当搬家判据。教训的形状:**一条新判据上线时,先找它可能与哪些既有不变量冲突** ——
我只看⻅了自己那条。
改动:
- `user-question.js` 与它的测试回到 `lib/`、`test/`(路径也与 dsh 侧一致),
两边逐字节相同已复验;`check-shared-libs.sh` 退出码 0。
- `reach.mjs` 增加 `sharedLibNames()`:直接从 `check-shared-libs.sh` 的 `ALL_LIBS`
读共用清单做豁免(不手抄常量),并把"进快照但本平台不可达"降级为**报告**。
- `layout-boundaries.test.mjs` 增加回归判据:共用模块必须留在 `lib/`、
测试相对路径与 dsh 一致、两侧逐字节相同。
- 删掉 `reach.mjs` / `docs/DEV-TOOLING.md` 里那句**无据的机制说明**
("user-question 走前缀动态 import"):`localRefs` 的三条正则只认引号字面量,
对模板字面量形状是**盲的** ⇒ 那句若为真,搬走的就是生产代码而两条判据都会绿。
pi 读了 `src/` 下九个文件都找不到引用,我也确认是记忆偏差;理由改用 `addressing.js`
(传递可达、`src` 直接引用数为 0)—— 它已足够证明"直接引用数不是可达性"。
顺带按 pi 的第二条建议:`deploy/check-deploy-drift.mjs` 判据 ① 把
**非运行时差异**摘出来(`jsonTestOnlyChange`,只豁免 `scripts.test` 一类字段,
只对"两边都在、仅内容不同"的文件生效)。理由:一条**永远黄、没人打算为它动手**的判据
唯一的下场是被学会忽略,那时真正的运行时漂移会被一起忽略。
⚠️ 摘的条件很窄 —— **把运行时差异误判成非运行时比恒黄更坏(那是假绿)**,
所以 `main`/`start`/`dependencies` 变了、或解析不了,一律仍算运行时;
纯函数加了六个反/正样本的判据(含三个"必须算运行时"的)。
(该文件同时有另一条会话的改动,未提交、我未触碰;本次只加了我这一段。)
验证:`npm test` 463/463;`check-shared-libs.sh` 退出码 0;`--self-check` 18 条全过。
243 lines
16 KiB
Markdown
243 lines
16 KiB
Markdown
# 开发工具配置:为什么关掉 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` 杀自己同类:
|
||
**安全装置自己出错时,产出的是一份看着正常的报告**。
|
||
|
||
## 判据纪律:三种"看起来验过了"的失效形态(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`
|
||
汇总行才是可靠信号 —— 也**不要**为此引入手抄的"期望用例数"常量:手抄常量会过期。
|
||
|
||
### 两条同族的实现纪律
|
||
|
||
- **测试文件之间不许互相 `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 形状相同的现象时,先把"它是否已经由另一层按设计处理掉了"
|
||
查完再下结论 —— 否则会把一个正常状态写成缺陷,而这类误判会以"我发现了新问题"的
|
||
语气传播出去,比沉默更贵。
|