docs/AUDIT-LEDGER.md
Rama master · réplica de lectura del repositorio original.
# 审计台账 / Audit Ledger
哪些子系统被**实际探测过**、结论是什么、以及在哪里被守卫住。
写这份台账的动机:2026-07 那一轮把鉴权面探了 30+ 次、协议层 fuzz 了 190+ 次,
**零缺陷** —— 因为它们早被审过多轮。这种"扫过且是干净的"结论本身就是资产:
没有记录,下一个人(或下一个我)会把同样的时间再烧一遍。
台账只记 **exhaustive 扫描过的老代码**。新代码的缺陷走 commit / release notes。
---
## 怎么读这份文件
十七轮追加。**它是按时间追加的,不是按主题组织的** —— 同一个面会在多轮里出现,
后面的取代前面的。所以:
- **想知道当前哪些面没扫过** → `Ctrl-F` 搜 `仍未 exhaustive 扫描`,**取最后一个命中**
(当前是第十七轮那张)。**后面的取代前面的**。
*轮次名和行号一样会过时 —— 括号里这个只是对照用,**判据是"最后一个命中"而不是轮次号**。
上一版写"第十一轮那张",在写下时是对的,第十六轮追加后就不是了。每次追加新表都要改这里,
所以别依赖它。*
*这里原本写"2026-08-05 实测:第 1150 行"。**那个行号在写下的瞬间就是错的**:我量的是编辑前
的文件,而把它推到 1154 的正是同一个 commit 的这处编辑本身。已删掉行号 —— 一个会被下一次
追加推走的行号本来就不该写进导航,`Ctrl-F` 才是稳定的指路方式。*
*上一版这段写"第五、十、十一、十二轮各一次" —— 逐个核过,那是错的:实际六个命中分布在
第二/三/四/五轮与第七轮内(那张标着"第十轮更新",就地改过但没搬位置),而第十二、十三轮
压根没有这张表。**"取最后一个命中"仍然对**,错的只是那句括号里的分布。*
*轮次数**不能**用 `grep -c "^## 20"`:它总比实际轮数少 1 —— 第一轮是文件开头那几节
无日期的分面小节,不带 `## 20` 前缀。上一版把这条 grep 写成了权威口径,它一直少数一轮。*
- **想知道某个子系统的结论** → 先看下面的分面小节(协议层 / 鉴权 / 图片解码),那三节从
2026-07-27 至今没有被推翻;再用 `Ctrl-F` 搜模块名看后续轮次有没有补充
- **想知道方法论** → 每一轮的"判别动作"小节。最常复发的四条:
修复只覆盖部分路径(**实际发生 6 次**;第十二轮那次是"抄一份就会是第七次"的假设,已避开,
别把避开的算进发生数)、守卫守在了错误的一侧、探针坏了却当成被测对象没那个性质、
以及**声明"某处已有守卫/没有守卫"时没有真的打开文件读**(第十二轮那条最贵)
| 轮次 | 主题 |
|---|---|
| 分面小节 | 协议层 / 鉴权与暴露面 / 图片解码 / v3.9.0 新代码(均 2026-07-27) |
| 第二轮 | 错误归属、目录退化、以及守卫本身 |
| 第三轮 | #234 / #235 / #239 主体;突变验证成为默认手段 |
| 第四轮 | v3.9.8 后的三条积压 |
| 第五轮 | 并发对抗 8 条积压 —— **4 条被反驳**(拦住不该做的工作) |
| 第六轮 | 冷却维度守卫改成行为断言 |
| 第七轮 | sticky 两条积压:一条不该修,一条修错了并已 revert |
| 第八轮 | queue-on-pin 落地 —— "需拍板"是升错了级 |
| 第九轮 | 把违反两次的纪律做成拒绝启动的工具 |
| 第十轮 | 整体对抗复核:六个 lens 查出的全是本轮自己造的 |
| 第十一轮 | 接手核实、#240、`FORCE_COLOR` 把工具关掉、四个坏探针 |
| 第十二轮 | #234 收尾、Cascade 流式记账、**以及一条我写进台账的"更正"本身是假的** |
| 第十三轮 | 接手核实、合并 #241、**一个碰巧正确的未测量数字**、guard 2 一天抓我三次 |
| 第十四轮 | 出口 parity —— **"mirrors X" 是一份没人当清单用的清单**;一条测试把缺陷钉成了契约;**一个只在 CI 的浅克隆下才坏的探针** |
| 第十五轮 | 突变存活的三种成因,处置互相矛盾 |
| 第十六轮 | 审计工具与被审对象共享同一个盲点 |
| 第十七轮 | 六条线战役 + 一次独立审查 —— 我审自己的东西时看不见的三类 |
---
## 图例
| 状态 | 含义 |
|---|---|
| ✅ CLEAN | 实际探测过,未发现缺陷。列出探测方式,便于复核或加码 |
| 🛡 GUARDED | 关键不变式已有测试守卫,且**做过突变验证**(注入退化确认守卫会失败) |
| ⚠️ FINDINGS | 发现过缺陷,已修;列出 commit |
| ⬜ UNAUDITED | 尚未 exhaustive 扫描 |
「突变验证」是这份台账的信任基础:一条从未失败过的测试等于没有测试。下表标注
🛡 的都做过 —— 把历史缺陷或退化重新注入源码,确认守卫以具体条数失败。
---
## 协议层 (2026-07-27)
| 模块 | 行数 | 状态 | 依据 |
|---|---|---|---|
| `src/proto.js` | 178 | ✅🛡 | 18 类畸形输入 fuzz 全部可控抛出、无慢路径 |
| `src/connect.js` | 170 | ✅🛡 | 帧层 10 类畸形输入;gzip 炸弹(80MB→7ms 抛错,RSS 受控) |
| `src/proto-trace.js` | 907 | ✅ | 2000 层深度炸弹 1ms 内被深度上限截住,零栈溢出 |
| `src/windsurf.js` | 1540 | ✅ | 170 次畸形帧调用(见下),零栈溢出、零非受控抛出 |
| `src/cascade-native-bridge.js` | 1525 | ✅ | 同上;另核实 30+ 处 `parseFields` 消费者**无一自递归** |
| `src/grpc.js` | 457 | ✅ | trailer 解码已有测试;HTTP/2 会话池按 port 复用、error/close 即摘除 |
**探测方式**:对每个模块的每个导出函数喂 5 类恶意载荷(1500 层嵌套炸弹、8KB
随机字节、全 0、全 0xFF、空 buffer),共 170 次调用,断言无栈溢出、无非 `Error`
抛出、RSS 受控(67MB)。
**关键设计发现(值得保留的知识)**:
- `parseFields` 是**扁平**的 —— 嵌套消息以 `subarray` 交回调用方,由调用方决定是否
下钻。这是深度炸弹打不穿协议层的根本原因:递归深度由调用方的业务逻辑决定,而
那些消费者经核实无一自递归。**任何把 `parseFields` 改成自动递归的重构都会引入
栈溢出面。**
- `decodeVarint` 走双路径:≤28 位用整数快路(覆盖全部字段 tag 和多数小整数),
超出转 BigInt。这不是优化洁癖 —— `>>>` 会把 >2^31 的值静默截断成 uint32,
request_id / 时间戳 / 计费计数器都会算错。
- 未知/废弃 wire type(3/4/6/7)**拒绝而非跳过**。跳过会让解析器失步,后续所有
字段错位 —— 比直接失败危险得多。
- `connect.js` 的 gzip 上限加在**解压后**(`maxOutputLength`),不只是线长。通过
16MB 线长检查的高压缩比帧仍可膨胀到 GB 级;没有这层,低内存主机上 OOM-killer
会在 throw 被 catch 之前先杀进程(audit CONN-1)。
**守卫**:`test/proto-codec-hardening.test.js`(20 条)。突变验证 4 种退化:
移除 len-delim 越界检查(抓 2)、未知 wire type 改静默跳过(抓 1)、移除 varint
64 位溢出守卫(抓 1)、负数走 int32 截断(抓 2)。
`test/connect.test.js` 已覆盖帧层炸弹(8 条,先前既有)。
---
## 鉴权与暴露面 (2026-07-27)
| 面 | 状态 | 依据 |
|---|---|---|
| 运维端点鉴权 | ✅ | 无凭据 / 用 chat API key 冒充 dashboard 密码 → 全 401 |
| 路径归一化 | ✅🛡 | 前缀混淆、`../`、`%2e%2e`、`//`、大小写、`/v1/v1/` 重写链 → 全 401 |
| 静态文件穿越 | ✅🛡 | locale 白名单;`../../.env`、`%2e%2e%2f`、`....//` → 401/400 |
| SSRF | ✅ | `169.254.169.254` / `127.0.0.1:22` / 私网段 / `metadata.google.internal` → 全 `ERR_PROXY_PRIVATE_IP` |
| 暴力破解 | ✅ | 5–8 次后 429;**伪造 XFF 无法绕过桶**;锁定期内正确密码同样被挡 |
| 请求体 / 畸形 JSON | ✅ | 12MB → 413(`data` 事件即时中断);2000 层嵌套 → 400;截断 JSON → 400 |
| 密钥泄漏 | ✅ | 6 端点 + 日志文件对上游 token / dashboard 密码 / API key **零命中** |
共 30+ 次绕过尝试,全部被拦。
**关键设计发现**:
- **SSRF 防护是"先解析再判定"**:`metadata.google.internal` 的 `latencyMs=4` 说明
真做了 DNS 解析,再按解析出的私网 IP 拒绝 —— 不是字符串黑名单。所以
**DNS rebinding 也挡得住**。改成字符串匹配会静默退化。
- **`safeEqualString` 连长度泄漏都堵了**:先 sha256 再 `timingSafeEqual`,两边永远
等长;`verifyPassword` 的明文路径在长度不等时还刻意烧等量周期。
- 路由层用 `===` 精确匹配 + `path` **从不 URL 解码**。这是路径穿越打不进来的原因:
`%2e%2e` 保持字面,匹配不上任何精确路由。**任何"顺手"加归一化/解码的重构都会
打开经典双解码绕过。**
- `/dashboard/api/` 前缀**带尾斜杠**。少了它,`/dashboard/apiaccounts` 会被
`slice` 成伪 subpath。
**守卫**:`test/router-path-security.test.js`(11 条,突变验证 4 种绕过:加 URL
解码抓 2、前缀去尾斜杠抓 1、`===` 放宽成 `startsWith` 抓 1、locale 白名单放宽抓 1)。
另有既有的 10 个安全测试文件(`dashboard-auth-fail-closed` / `-hardening` /
`brute-force` / `lockout-map-bound` / `server-auth` / `auth-safe-equal-hash` /
`dashboard-proxy-validate` / `caller-key-xff-spoof` / `xff-client-ip-consistency` /
`local-windsurf-security`)。
**审计缺口的教训**:上述既有 10 个套件全部直接 `import handleDashboardApi` ——
它们证明了**鉴权逻辑**正确,却从未走过 `server.js` 里决定"哪个 URL 进哪个 handler"
的**路由层**。这类"测了逻辑、没测装配"的缺口值得主动找。
---
## 图片 / 二进制解码 (2026-07-27)
| 模块 | 状态 | 依据 |
|---|---|---|
| `src/vendor/png.js` | ✅ | 13 类畸形 PNG 全部可控抛出(签名、截断 chunk、尺寸/像素上限、inflate 上限) |
| `src/vendor/jpeg-js/decoder.js` | ✅ | 16 类畸形 JPEG 全部可控抛出;0×0 退化图被 `shrinkPixels` 正确拦下 |
| `src/image.js` 入口链 | ✅ | 200 张畸形图 6ms,无 DoS |
---
## v3.9.0 新代码 (2026-07-27)
台账方法论的直接验证:老代码扫了 190+ 次 fuzz 零缺陷,而同期新代码一压就出真 bug。
| 模块 | 状态 | 依据 |
|---|---|---|
| `src/response-store.js` | ⚠️🛡 | 真实 agent 循环 + 9 个对抗 agent;发现 1 blocker + 5 major,全部已修 |
| `src/handlers/responses.js` v3.9.0 改动 | ⚠️🛡 | 同上;含一个让核心功能对规范客户端全失效的 blocker |
| `src/handlers/chat.js` 可应答守卫 | ⚠️🛡 | 我第一版守卫位置错误(可绕过 + 过度拒绝),已改到共享层 |
### 2026-07-28 补扫:流终止与 finish_reason 语义
接手会话把交接文档 §3 的 1–7 全部修完。这一批的共同主题值得单独记一条:
| 面 | 状态 | 依据 |
|---|---|---|
| 连接层流终止 | ⚠️🛡 | 缺 end-of-stream 帧被当作正常抽干 → 截断回复以"完整一轮"到达四条协议路由。现抛 `STREAM_TRUNCATED`;守卫 `test/responses-stream-truncation.test.js`(12 条,突变验证 3 种) |
| `finish_reason` 词汇映射 | ⚠️🛡 | 三处独立缺陷同源:未校准枚举猜测(connect 3/5)、子串匹配(special-agent `'content'`)、缺失即良性(responses translator)。守卫见各 commit |
| 响应检索端点作用域 | ✅🛡 | `GET`/`DELETE /v1/responses/{id}` 与续接同一套作用域;外来 id 一律 404 不泄漏存在性;路由层另有真实 HTTP 测试 |
**新的规律(值得记住)**:这一批里有三个缺陷是同一个错误模式的不同外衣 ——
**把"不知道"当成"没事"**。缺失的终止帧当成正常结束、未校准的枚举值当成已知语义、
缺失的 finish_reason 当成良性完成。三处都在"信息不足"时选了乐观解释,而代价都是
把坏结果报成好结果。遇到任何"默认值/兜底"分支时值得专门问一句:这个默认是在
断言事实,还是在承认无知?
**方法论补充(与 §5.4 突变教训同类)**:修复过程中发现三条既有测试写死了错误
前提 —— `connect-usage-finish-calibration` 断言 `3 → length`(照名字顺序的猜测)、
`responses.test.js` 一条 mock 不发终止 chunk、`request-id-body` 写死站点计数 4。
前两条是**把猜测固化成契约**:一旦写进断言,后来的人会把它当既有结论而不是待验证
假设。写这类测试时应当在注释里标明"这是猜测,证据是 X",否则它会伪装成事实。
**关键教训**:这批缺陷有一半是**我修复上一批时新引入的**。详见
[HANDOFF-2026-07-27.md](HANDOFF-2026-07-27.md) §5.3–5.4 —— "修好了"和"没修坏"是两个
独立命题;以及三条突变测试的硬教训(假测试、突变覆盖不到的路径、突变本身写错)。
## 未 exhaustive 扫描 ⬜(2026-07-27 原表 —— **已被后续五轮取代**)
> **别拿这张表当当前状态。** 它是第一轮写下的,此后第三、四、五、七、十一轮各更新过一次,
> 每次都在自己那一节里。**当前的一份在文末**(第十一轮),或见目录里的"当前未扫描面"。
> 这一张保留是因为它记着模块的相对风险排序与理由,那部分仍然成立。
按当前架构下的实际风险排序。注意 `tool-emulation` 与协议层大文件主要服务
**Cascade 路径**,而生产部署走 `DEVIN_CONNECT` —— 投入产出比低于新代码审计。
行数为 2026-08-04 复核值(原表写的 `models.js` 697 与 `auth.js` ~3000 已经漂了,这两个模块
在此期间长了不少;其余九处经核对仍然准确)。
| 模块 | 行数 | 备注 |
|---|---|---|
| `src/handlers/tool-emulation.js` | 1784 | 工具方言/提示注入面 |
| `src/langserver.js` | 1721 | LS 进程生命周期、内存护栏 |
| `src/conversation-pool.js` | 768 | 跨租户复用边界(与 sticky 有交互) |
| `src/models.js` | 1128 | 目录合并/别名解析 |
| `src/auth.js` 其余部分 | 4260(整文件) | `getApiKey` 已审;凭据加密、持久化竞态未审 |
---
## 方法论笔记
这轮最有价值的规律:**被审过的地方是硬的,没审过的地方才有货。**
- 鉴权面 30+ 次探测、协议层 190+ 次 fuzz → **零缺陷**(都被审过多轮)
- 同期新写的 response store,一用真实 agent 循环压测 → **立刻出真 bug**
推论:审计预算应优先投向**新代码**和**从未被真实客户端走过的路径**,而不是继续
扫老代码。老代码的价值在于把它的不变式**守卫住**(上表 🛡),而不是反复重扫。
第二条:**用真实客户端跑真实任务**比读代码有效。response store 的
tool_calls 重复缺陷靠单元测试发现不了 —— 它只在一个真实 agent 多轮工具循环里
出现,且症状是一个与"账号已死"无法区分的不透明 503。
### 2026-07-30 补扫:测试自身的质量
复核唯一跑成功的那一面审的是**测试**而非代码,5 条全部经独立突变验证成立。这一节记的
不是某个缺陷,而是三类**测试反模式** —— 它们让全绿的套件失去意义:
| 反模式 | 识别方法 | 实例 |
|---|---|---|
| **源码 grep 冒充行为守卫** | 断言里只有 `assert.match(src, /.../)` 或 `src.includes(...)`,而被守的是**行为** | 破坏 header 名派生 → 6 个信号里 4 个 404,35 个测试全绿 |
| **条件断言** | `if (X) { assert A } else { assert B }` —— 只能证明恰好走到的那一支 | DELETE 端点整个失效,套件 11/11 全绿 |
| **恒真断言** | 断言的字面量在 src 里根本不存在 | `doesNotMatch(html, /pageSize=1000/)`,而实际写法是 `pageSize: '1000'` |
**源码 grep 的正当用途是守结构性不变量**(如"新增内部路由必须消费 `__synthetic_finish`"、
"每个上游错误码必须有明确的归属分支")—— 那类命题本来就无法用行为测试表达,因为要守的
代码还没被写出来。但它不能替代行为验证。
**判别标准**:问"如果这个功能完全坏掉,这条断言会失败吗?"如果答案依赖"字符串还在
文件里",它就不是守卫。
### 2026-07-30 方法论:追问同类 + 报告者必须被独立验证
**一、修掉一个误落进兜底的东西之后,必须枚举还有什么在下面。**
v3.9.2 修了 `STREAM_TRUNCATED` 落进 `finalizeConnectAccount` 的裸 `else reportError`,
但只修了那一个码。这轮枚举全部 12 个上游错误码,又找出三个(`TIMEOUT` /
`DEADLINE_EXCEEDED` / `NO_TOKEN`),各 3 次调用即把健康账号打成 `status='error'`。
**"它是误落进去的"本身就说明那个位置会接住东西 —— 所以要问接住了几个。**
已做成源码级元守卫,新码不归类即构建失败。
**二、报告者必须被独立验证,包括报告者是自己的时候。**
本轮我曾报告"#232 逐账号隔离失效"并给出实测输出 —— 是误报,漏了两轮确认机制
(首次同步基线取静态目录数,小目录需第二轮相同快照才采纳,而探测脚本只调了一次)。
推翻自己的假警比发现它花的时间更长。**实测输出摆在眼前也可能因前提错误而无效。**
---
## 2026-08-03 补扫:错误归属、目录退化、以及守卫本身(第二轮)
这一轮扫的是**上一轮修复留下的面**,以及**守卫自己**。合并前跑对抗复核(6 面 ×
攻击→独立确认),在本轮修复上找出 6 条 —— 全部是自引入的。
| 面 | 状态 | 依据 |
|---|---|---|
| 错误归属:socket 码与透传的 gRPC status | ⚠️🛡 | v3.9.6 枚举了 `classifyUpstreamError` 能产出的 12 个码,但**枚举本身有盲区**:socket 码带 `.code` 从 Node 直接过来、无字面量;`aborted`/`cancelled`/`deadline_exceeded` 经 `code: code \|\| 'UPSTREAM_ERROR'` 原样透传、也无字面量。各 3 次驱逐健康账号(seed 健康 peer 排除 `lastAccountExempt` 掩盖)。守卫加固后又揪出 7 个未分类 status,逐个定;`unauthenticated` 是真账号故障却只是恰好落进兜底、没走 re-login,已显式归类 |
| 断连 vs 上游冷却 | ⚠️🛡 | #225 的 abort 分支排在 else-if 链首,吞掉 #224 的 429 reset window(实测 3h → 0,5 次断连吸收 5 个 429 零冷却)。修法把"冷却"与"惩罚"分开:上游声明的冷却是关于账号/模型的事实,与客户端是否还在听无关。守卫 `test/connect-abort-cooldown.test.js`(13 条,3 种突变) |
| 目录快照退化响应 | ⚠️🛡 | 空/畸形响应此前零确认擦掉 last-known-good(实测 106/163 → 263/263);缩水且**内容变化**的目录永久楔住。第一版修法**本身是错的**(见下)。守卫 `test/cloud-catalog-degenerate-response.test.js`(20 条,含池级维度) |
| Dashboard 转义 | ⚠️🛡 | 178 处 `esc(` + 29 处 `escJsAttr(` 调用,而 234 个测试文件里**没有一条执行过这两个函数**。把两者改成恒等函数(等于删掉全仓 XSS 防护)后 `dashboard-syntax` 仍 12/12 全绿。改为从 HTML 抽出真身执行;同一突变下新守卫抓 11 条 |
| 干旱横幅 fail-open 状态 | ⚠️🛡 | 反转分支条件会让两个被断言的字符串**逐字不变**,于是运维在未 fail-open 时看到 fail-open 警告、真 fail-open 时看到普通文案,套件全绿。改为抽出三元表达式对两种状态各跑一次 |
### 四条源码守卫经突变验证放过了自己要抓的缺陷
这是本轮最值得记的一节:**守卫看不见的东西守不住**,而"看不见"有四种独立成因。
| 守卫 | 逃逸口 | 同一突变下 |
|---|---|---|
| `connect-dimension-guard` | `dimension.includes('selector')` 接受**反序**的 `model \|\| selector`(connect 上 `model` 恒真,正是 v3.8.0 CAPACITY 的形状);`mark...\([^)]*\)` 遇嵌套调用截断 —— **未突变源码上就已**只在检查 4 个调用点里的 3 个 | 旧守卫 9/9 全绿 |
| `connect-error-blame` | 正则只读自构造的 `code: 'UPPER'` 字面量,对 Node 透传码与小写 gRPC status 结构性盲视 | 旧守卫全绿,报 `emitted.size=12` |
| `handler-route-parity-guard` | `includes('connectErrorToHttp(')` 被一个**丢弃返回值**的死调用满足 | 旧守卫 10/10 全绿 |
| `dashboard-syntax` 的转义检查 | 只断言四个声明字符串存在 | 旧守卫 12/12 全绿 |
修法统一改成"解析一个集合、与另一个集合求差、差非空即失败";能抽出来跑的就真的跑。
**新的判别动作(比"如果功能坏掉这条断言会失败吗"更细一层)**:问"这个守卫的**词汇表**
是从哪里推导的?那个来源会不会本身就不完整?"—— 透传(`code: code || 'X'`)按构造就是
开放的,任何从源码字面量推导的词汇表都看不见它。`connect-error-blame` 的演进史是
UPPER_SNAKE → 加 socket 码 → 加透传 status → 放宽字符类含数字连字符,**每一次都以为
已经完整了。**
### 合并前对抗复核:6 条自引入缺陷
上一轮是**发版后**跑复核,于是 v3.9.2–v3.9.6 每个版本都在修上一个版本。本轮改成**合并前**
跑,同样的成本换掉了一整个补丁版本。
最该记的一条:**我修"空目录响应"的方法本身是错的。** 我把它当成"最大幅度的缩水"走同一套
确认,但后果不对称 —— 接受小的**非空**快照是过度限制(fail closed);接受**空**快照会
`applyCloudModels([])`,那会**删除**过滤器 → fail open → 正是 #231 的症状,且经池级并集
连带掏掉其他账号。实测两次连续空响应 148/163 → 163/163。**我只是把 fail-open 延迟了一个
重试间隔。** 而确认对空响应本就是错的工具:被限流的上游会连续返回空。
第二条:那个**收敛上限在解决一个已经被解掉的问题**。计数器按账号而非按候选,两个不相关
的轮次替第三个候选付账,它第一次被看到就被采纳;采纳后 auth.js 记为已同步便不再重取,
90 秒抖动永久钉住错的模型表(实测 6/163)。楔住的真正成因是"只在首轮 arm 复查",那一半
已修 —— 真实降级是**稳定的**,会自我重复并在两轮内正常确认。
### 三类此前未记录的反模式
1. **"把 bug 固化成契约"的作者最可能是几小时前的自己。** 本轮出现 4 次。判别动作:
**修完一个缺陷后,去看有没有测试在断言那个缺陷的现象。**
2. **结构守卫必须先剥注释。** 我自己写的目录守卫第一版被自己的注释满足(正则匹配到
注释里引用的 `applyCloudModels([])`)。
3. **注释里的论据也要验。** 我写"RETRYABLE_CODES 已把这些当可重试",实测 `isRetryable`
对那三个 gRPC status 全是 `false` —— 论据只对 socket 码成立。**"注释不是事实"对自己
刚写的注释同样适用。**
### 一条断言可能同时耦合两个维度
`rate-limit.test.js:120` 既断言精确毫秒、又**隐含**断言"走的是哪条分支"。注入 4.5 秒延迟
后另一条分支回答约 1622996ms,而原断言把它当成"大约 1632000"照样通过 —— **测试在走错代码
路径时仍然全绿**。它还是全套件里唯一一条对墙钟派生输出做精确相等的断言(已 grep 核对)。
### 仍未 exhaustive 扫描 ⬜(更新)
上一版那张表仍然有效。本轮新增两个已知覆盖缺口(记录,非交付缺陷):
| 面 | 缺口 |
|---|---|
| Dashboard 调用点 | 行为守卫验证了 `esc`/`escJsAttr` 两个 helper,但不验证 **207 个调用点** —— 拆掉某个 sink 的转义调用,两个守卫仍全绿 |
| 冷却维度的写入位置 | `connect-dimension-guard` 只检查 `finalizeConnectAccount` 那一段源码,把冷却写到别处可逃 |
---
## 2026-08-03 第三轮:#234 / #235 / #239 主体 + 突变验证作为默认手段
这一轮的做法变了:**每条修复都把缺陷重新引入一次**,确认断言真的会失败,而不是只看绿灯。
成本不高,收获比对抗复核更直接 —— 它查的是"我的测试有没有在测东西",而对抗复核查的是
"我的修复有没有引入新缺陷"。两者不重叠,都要做。
| 子系统 | 结论 | 守卫 |
|---|---|---|
| 干旱判定覆盖率 | 1 个账号即可宣布全池干旱(实测 1 dry + 39 refresh-failed → true) | `drought-coverage-floor.test.js`(8 条,4 突变全 CAUGHT) |
| 干旱谓词命名空间 | MODELS 空间谓词判 connect selector,两集合零重叠 | `drought-connect-namespace.test.js`(10 条,3 突变全 CAUGHT) |
| 干旱门禁可达性 | 生产默认后端上结构性不可达,从未执行 | `drought-gate-reachability.test.js`(9 条,行为+结构双层,3 突变全 CAUGHT) |
| 发现视图 | 只测存在性;只加 entitlement 过滤会打到 0 行 | `connect-discovery-rebuild.test.js`(7 条) |
| connect 目录 latch | 模块级布尔永不清除;去 latch 后 clear-then-fill 会缩集合 | `connect-catalog-delatch.test.js`(5 条) |
| credit 费率表 / 逐请求计费 | 解码器就绪、消费者零个,4 段断链 | `connect-rate-table-wiring.test.js`(12 条,6 突变全 CAUGHT) |
### 新判别动作:突变验证前必须先证明 baseline 非零
本轮连拿 **4 次**"SURVIVED —— 守卫是瞎的",全部是 harness 一个测试都没跑:
- 本机 shell 是 zsh,`node --test $TESTS` 不对未加引号的变量做词分裂,两个路径当**一个**
参数传进去
- `read A B < <(cmd)` 进程替换配 `read` 读不到值
- `grep --include=*.js` 未加引号被 zsh 当 glob 吃掉
`pass=0 fail=0` **不是**"守卫没抓到",是"没测量"。与台账里"confirmed:0 常是没跑成"同一条,
但这次的伪装更好:它出现在一个看起来正常运行的突变循环里。**先证明 baseline 非零,再相信
任何 SURVIVED 结论。**
### 突变验证查出四条我自己写的空转测试
1. **"两个行产出器都过滤"** —— live 目录在新进程里是空的,producer 2 压根不产行,断言无论
如何都过。去掉 producer 2 的过滤器不会让它失败
2. **"空响应不缩小并集"** —— 带空贡献者的并集在算术上本来就不变,所以即使空响应被接受这条
也过。**断言错了后果**:真正的伤害是账号被记为已同步而无贡献,后来真的有 selector 时永不
重取
3. **`sawTable ? free : null` 的区分没有任何断言钉住** —— 在保守回落下谓词对 null 与空集合
的回答相同,所以那条断言钉的是没有行为后果的实现细节。改成在
`getCurrentlyFreeConnectSelectors` 上钉(那里可见)
4. **一条恒真断言** `assert.ok(x.length >= 0)`
判别标准补一条:**问"这条断言在什么输入下会失败?"如果答不出具体输入,它就没在测东西。**
比"功能坏掉这条断言会失败吗"更早生效 —— 后者需要先想象一个缺陷,前者只需要看断言本身。
### 结构缺陷:去掉一个 latch 不够,外面还有一个
`trySyncConnectCatalog` 只能从 `fetchAndMergeModelCatalog` 里被调到,而那个函数被 **Cascade**
的 per-key 门禁挡着。一旦某账号的 Cascade 目录被记为已同步,connect 同步对它永久不可达 ——
它自己的 per-key 资格压根没被查过。
这是"只覆盖部分路径"的第五次,形态是新的:**我修的那一层之上还有一层同类门禁**。追问方式:
修完一个 latch/gate 之后,问"到达这个 latch 的路径上还有几个 latch?"
### 两条本轮自己引入的缺陷
- `forgetConnectCatalogForAccount` 用动态 `import().then()` 写入:落在调用方返回之后(删号后
立刻读仍看得到 departed 账号的 selector),且绕过注入的 dep seam
- rescue 重试时上一次尝试的计费被算进这一次(`attempt_reset` 未清 `billing`)
### 元守卫必须按逃逸点枚举,不按 flow
ACP vision 重路由不是 `selectBackend` 的 flow,是 `devin_connect` 逃进 `special_agent`,
按 flow 枚举的守卫看不见它。`drought-gate-reachability` 因此枚举**块内门禁之上的每个
`return`**,并要求每个都登记豁免理由;带"守卫的守卫"(枚举结果为 0 即失败),因为一个
不再匹配任何真实 `return` 的枚举会永远通过。
### 仍未 exhaustive 扫描 ⬜(第三轮更新)
| 面 | 缺口 |
|---|---|
| `STRICT_MODEL=0` 的静默改写 | 仍在;且 `devin-connect-strict-model.test.js` 把该行为断言成期望行为,套件在强制执行 #234 验收标准的违反 |
| 模块级 `MODELS` 表注入 | 已采纳快照永久注入 key 且无人移除,已删除账号的模型仍被宣传 |
| 逐请求 billing tag 号 | `credit_cost` / `committed_acu_cost` 只有声明顺序推测,需付费 token 校准(已在 #239 请社区协助) |
---
## 2026-08-03 第四轮:v3.9.8 之后的三条积压 + 突变验证继续收割
| 子系统 | 结论 | 守卫 |
|---|---|---|
| MODELS 表注入 | 已删除账号的模型永久被宣传(实测 removeAccount 后 key 仍在、listModels 仍列、池 total=0) | `models-injection-eviction.test.js`(8 条,5 突变全 CAUGHT) |
| sticky 绑定维度 | connect 每个 selector 共享单槽 `caller\0*`,按 entitlement 分区的池上零亲和 | `sticky-selector-dimension.test.js`(7 条,突变 CAUGHT) |
| dashboard 转义调用点 | 208 个调用点零覆盖;审计结论:当前无未转义的 innerHTML sink | `dashboard-escape-callsites.test.js`(3 条,3 突变全 CAUGHT) |
| sticky TTL 断言 | 70ms 对 120ms TTL 仅 50ms 余量,满套件负载下 3 次红 1 次 | 加余量至 400/600,突变确认未削弱 |
### 新判别动作:先问"这条顺序/这个记账维度在哪条路径上承重"
两次踩到同一形态:
1. **注入记账维度选错**。按"谁注入的"记账,而 applyCloudModels 跳过已存在的 key,所以
只有第一个报告该 UID 的账号有记录 —— 移除最后一个真实持有者时什么都不驱逐。正确的
问题不是"谁注入的"而是"还有谁能到达"
2. **调用顺序只在一条路径上承重**。`forgetCloudModelCatalog` 内部的"先丢目录再驱逐"
顺序,经由 removeAccount 时无关(上游已经丢过了),只在直接调用时承重。我的注释声称
它承重,而突变验证显示零测试失败 —— 补了一条直接调用的测试才让它真的承重
**动作**:写下"顺序/维度很重要"的注释时,立刻问"经由哪个调用者时它才重要?"然后给那条
路径写测试。这是本轮第五、六次"我的注释声称某处承重、实际没有断言覆盖"。
### 复现失败也要归因,不能当成"缺陷不存在"
第一次复现 MODELS 泄漏得到 delta +0,看起来像"缺陷不存在"。真实原因是单条目快照触发了
缩水确认守卫被隔离,`applyCloudModels` 压根没执行。**一次没能复现的尝试,和一次证明缺陷
不存在的实验,不是同一件事。** 归因之前不要下结论 —— 我在对话里一度声称复现成功而当时
工具输出是空的,那句话没有依据。
### 守卫的守卫抓到了我凭印象写的字段名
dashboard 守卫第一版的字段表里有 `labelHash`,它在整个 dashboard 里根本不存在。没有"每个
字段必须至少匹配一个已转义 sink"这条反向断言,那个守卫会永远通过且什么都不police。
同一条守卫的计数逻辑还有个 `break`:`keyPrefix` 与 `apiKey_masked` 共享同一表达式,break
在第一个匹配上使得 keyPrefix 永远记不到。**一个表达式可以同时命中多个维度,计数时不要
break。**
### 仍未 exhaustive 扫描 ⬜(第四轮更新)
| 面 | 缺口 |
|---|---|
| `STRICT_MODEL=0` 静默改写 | 仍在。**但上一份交接对它的描述不准确**:那条测试只断言"opt-out 不得 400",没有断言响应里的 model 字段 —— 谎言是没有断言覆盖的,不是被固化成契约的,修它不需要跟测试打架 |
| `connect-dimension-guard` | 只检查 `finalizeConnectAccount` 那一段源码,把冷却写到别处可逃 |
| `handler-route-parity-guard` | "绑定名被读到"可被一行 debug log 满足 |
| sticky 线余 6 条 | thundering-herd / RPM 清绑定 / 双 flag 楔子 / clearCallerBindings 未接线 / GPT tag 校准 / response store 租户配额 |
| 逐请求 billing tag 号 | `credit_cost` / `committed_acu_cost` 需付费 token 校准(已在 #239 请社区协助) |
---
## 2026-08-04 第五轮:并发对抗 8 条积压 + v3.9.9–v3.9.11
这一轮最有价值的产出**不是修了什么,是拦住了不该做的工作**。
用 16 个 agent 并发调查上一份交接的 8 条积压(每条先复现,通过的再过一轮对抗反驳),
**4 条被反驳**:
| 项 | 反驳理由 |
|---|---|
| `clear-caller-bindings` | **抓到伪造的复现数字**,且是已登记旧账 |
| `sticky-rpm-clear` | 机制真但归因错,严重性远低于所报;一个复现基于不可达路径 |
| `response-store-tenant-quota` | 复现真实,但那是**有文档有测试的既定策略**,不是缺陷 |
| `connect-dimension-guard` | 台账已 publish 这条限制,"不是发现" |
照 8 条全做,会有 4 条在改不该改的东西。**对抗验证的价值是拦住工作,不只是找缺陷。**
### 子系统结论
| 子系统 | 结论 | 守卫 |
|---|---|---|
| 订阅取消信号 | 明确信号被丢进 `credits.lastError`,无人读;账号 100% chat 失败却显示 active/错误 0 | `subscription-cancelled-signal.test.js`(7 条,4 突变全 CAUGHT) |
| 降级后回显名 | 三条协议路由都谎报;`messages.js`/`gemini.js` 对 `displayModel` 引用数为 0 | `connect-degrade-honesty.test.js`(9 条)+ messages/gemini 各补流式与非流式 |
| 路由错误分类守卫 | 源码形状判据可被"一个被 log 读到的死绑定"满足 | `route-error-parity-behaviour.test.js`(7 条,行为断言) |
| `stickyNoFallback` | 返回 null 却不清绑定 → 永久楔死 | `sticky-no-fallback-wedge.test.js`(6 条,含"过度修复"方向) |
| 首轮并发散射 | **不是缺陷** —— issue #37 的明确意图 | `sticky-concurrency-tension.test.js`(4 条,钉当前选择) |
### 新判别动作:"经由哪个调用者才承重"
本轮两次出现"注释声称某处承重、实际零测试失败":
1. `forgetCloudModelCatalog` 内部顺序只在**直接调用**时承重,经由 `removeAccount` 时上游
已先丢目录 → 顺序无关
2. `messages.js` 的 `!messageStarted` 条件**不承重**(`startMessage()` 自带 early-return,
`this.model` 只被读一次)。承重的等价物在 `gemini.js`,那里每帧重读
**动作**:写下"顺序/条件很重要"时,立刻问"**经由哪个调用者时它才重要**",然后给那条路径
写测试。否则注释就是一句没有守卫的断言。
### harness 静默失效的三种新形态
除了已记的 zsh 词分裂,本轮又踩三种:
1. **`import(...?query)` 创建独立模块实例** —— 我写绑定的实例与 `auth.js` 读的不是同一个,
统计不涨、绑定读不到。是"守卫的守卫"(misses 必须 >0)暴露的
2. **`setExperimental('key', true)` 是 no-op** —— 它收补丁对象。四种 flag 组合的复现结果
因此完全一样,**差点据此否掉一条真缺陷**
3. **测错了路径** —— 路由测试第一版驱动"抛出错误",而真实调用点在流式 translator 的
`error()` 里处理 error chunk。12 条全红,但红的原因与契约无关
**共同判别动作**:每个复现/突变结果都要能回答"**这个数字是怎么产生的**"。答不出就是
harness 在骗你。
### "先提交,再突变" —— 本轮违反三次
突变循环里的 `git checkout -- <file>` 会把同一文件里**尚未提交的真修复**一起还原。之后的
突变测的是"修复缺失"而不是"突变生效",而那个失败**看起来像突变被抓到**。三次都是自己踩的。
### 长任务必须后台跑
工具层反复返回**空输出**,全部出现在多分钟阻塞命令之后。改成 `nohup ... &` + 轮询标记文件
后再没出现。十项机制逐个测过都正常 —— 问题是用法,不是工具坏了。
### 交接文档的四处修正
见 `HANDOFF-2026-08-04.md` §3。最要紧两条:`stickyNoFallback` **一个 flag 就够**楔住
(记的是两个);首轮散射**不是缺陷**而是 issue #37 的明确意图。
### 仍未 exhaustive 扫描 ⬜(第五轮更新)
| 面 | 缺口 |
|---|---|
| RPM 满即清绑定 | 彻底修需 queue-on-pin,且与"首轮散射张力"相关 |
| `clearCallerBindings` | 事实成立,但"该由哪个事件调用"是设计决定 |
| 散射的确切成因 | 移除两个排序机制后仍散射 —— 成因在候选**过滤**,未隔离 |
| billing tag 号 | 需付费 token 校准(#239) |
| `total_tokens` 含 `cache_write` | 产品取舍,需作者拍板 |
---
## 2026-08-04 第六轮:冷却维度守卫改成行为断言
第五轮把 `connect-dimension-guard` 判成"台账已 publish 的限制,不是发现"。**那个判定
对,缺口也仍然是真的** —— 已 publish 只说明它不算新发现,不说明它被守住了。本轮把它
关掉。
### 缺口的真实形状与台账记的不同
台账三处都记成"把冷却写到别处可逃"。逐个调用点核实后,更要紧的是第二条:
1. **词法切片可逃**(如记)。切片是 `finalizeConnectAccount` → `waitForAccount` 之间的
源文本
2. **既有行为断言压根没覆盖"chat.js 选哪个维度"** —— 这条台账没记。
`connect-dimension-guard` 的行为半边、以及 `connect-capacity-cooldown.test.js`,
**全都自己用 `markRateLimited` 手写冷却**再断言 `auth.js` 认这个维度。证明的是 auth
侧逻辑对;而 #224 / #230 / v3.8.0 三次错的都是 **chat.js 写哪个维度**
3. 承重细节:既有测试 `model` 与 `selector` **传同一个字符串**,所以写在哪个维度下看起来
一模一样,缺陷在那些测试里不可观测
`src/` 里 4 个切片外的 `markRateLimited` 调用点核实为 Cascade 路径(合法传 `modelKey`),
不是逃逸点。
### 守卫
`connect-dimension-behaviour.test.js`(9 条)驱动**真实的** `finalizeConnectAccount`,
按生产形状传 `{ model: 'gpt-5.5', selector: 'gpt-5-5-sol' }`,断言池子结果。
5 次突变全部 CAUGHT,其中第 2 条是关键:
| 突变 | 旧源码守卫 | 旧 capacity 行为测试 | 新行为断言 |
|---|---|---|---|
| 1. 切片内反序 `model \|\| selector` | CAUGHT 1 条 | — | **CAUGHT 3 条** |
| 2. **同一缺陷搬到切片外 helper** | **SURVIVED 10/10** | **SURVIVED 4/4** | **CAUGHT 3 条** |
| 3. `#224` 形状:RATE_LIMITED 两条改写 `model` | CAUGHT 1 条 | — | **CAUGHT 3 条** |
| 4. QUOTA 冷却整条删除 | — | — | **CAUGHT 1 条** |
| 5. MODEL_BLOCKED 误加冷却(反向) | — | — | **CAUGHT 1 条** |
9 条断言每条都在至少一次突变下失败过 —— 无空转。
**两处防"因错误原因通过"**:断言账号仍 `status !== 'error'`(被驱逐的账号同样选不出来,
只断言"没被选中"会在冷却压根没写时也通过);以及 MODEL_BLOCKED 这条**不该**冷却的码必须
仍可正常选出。
旧守卫**保留** —— 它另外守着"切片内不得出现新的坏维度调用点",是另一条真属性。它的头注释
原本声称"断言的是 INVARIANT 而不是具体那一行",突变 2 证伪了这句,已改成如实描述。
### 判别动作:"手写状态再断言"只测了消费侧
一条形如"写入状态 → 断言消费者认它"的测试,守的是**消费侧**。如果历史缺陷出在**生产侧**
(谁写、写哪个维度),它一条都拦不住,而它读起来非常像在守那个缺陷。
**动作**:看到测试自己构造被守的状态时,问"历史缺陷是发生在构造这一侧,还是消费这一侧"。
同一侧才算守住。
---
## 2026-08-04 第七轮:sticky 两条积压 —— 一条不该修,一条修错了
本轮结论:**积压里剩的两条 sticky 项,一条正确答案是"不动",另一条我按显然的修法做完、
实测是净负面、已 revert。** 两条都从"待办"变成了"有依据的结论"。
### `clearCallerBindings` 无生产调用点 → 结论是"不接线"
台账此前记的是"事实成立,但该由哪个事件调用是设计决定"。查完是:**没有正确的事件**,
而且找它本身是错的问法。
| 候选触发点 | 结论 |
|---|---|
| 客户端断连 | **主动错误**。钩子确实存在(`server.js` 的 `res.on('close')` → abort),所以这是活着的诱惑。但 agent 客户端持续半途取消,而代码库其余部分刻意把取消当"什么都没发生":connect finalizer 对 abort 免除全部惩罚,`poolCheckin` 还把签出的 cascade 条目**放回池里**。在那里清绑定会亲手制造本模块存在意义所要防止的上下文丢失 —— 放回的条目仍带账号 X 的 apiKey,下一轮 checkout 要 Y,在 `conversation-pool.js` 的 apiKey 比较上 MISS,整段历史重放 |
| session reset | **不存在这个事件**。唯一 caller 维度的 DELETE 是 `DELETE /v1/responses/{id}`,删一条记录,无"会话结束"语义 |
| 账号离池 | 是真的清理,但它是**账号维度**的,需要另一个导出;而且**不值得加**(下面) |
**账号维度的清理也不值得加。** 绑定所指账号消失后它再也不会被刷新(`auth.js` 在解析它的
同一次调用里就清掉),于是单调老化到 LRU 前端、**最先**被驱逐。实测 `STICKY_SESSION_MAX=10`:
容量满时 4 死 + 6 活,再插 4 条 → 4 条死的全被驱逐、6 条活的全存活,与"提前回收"的存活集
**完全相同**。死绑定是免费的驱逐缓冲;提前回收反而会去驱逐活的。另外 `status='error'`
无论如何都是错的触发点 —— `maybeRecoverErrorAccount` 会把它半开回 `active`。
依据已写进 `sticky-session.js` 的注释(含实测数字),这条从积压里划掉。
### RPM 满即清绑定 → 显然的修法是净负面,已 revert
我先按显然的修法实现了:瞬时不可用(RPM / 冷却 / 维护)时保留绑定,并让返回对象带
`_stickyRotated`、四个写入点据此跳过替补轮的重钉(不加第二半,`setStickyBinding` 会无条件
覆盖,第一半等于没做)。12 条断言、9 次突变全 CAUGHT、门禁 3323/0。
**然后测了代价,方向是反的。** 6 轮受阻期间触达的不同账号数(每个不同账号 = 一次完整前缀
写,约为读的 5.6 倍 —— 依据是 `devin-connect.js` 的 "hit cost measured at 17.8% of
miss"(1/0.178),**不是**同处引用的 tag4 token 计数,那组数只确定哪个 tag 承载
cache-write):
| 做法 | pool=4 | pool=8 |
|---|---|---|
| 保留绑定(我的修复) | **4** | **3** |
| 清绑定(现状) | 2 | 2 |
| queue-on-pin(短等) | 1 | 1 |
机制是结构性的,不是偶然:清绑定**收敛** —— 替补成为新绑定,后续每轮读它的缓存;保留绑定
**散射** —— 每轮受阻请求重新进常规选择,而候选排序末位是 `lastUsed` **升序**,刚服务过的
替补被排到最后,下一轮必然换一个冷账号。实测 pool=4 序列 d→a→b→d→a→b。
只有抖动**短于相邻两轮间隔**时保留才划算。RPM 窗口 60 秒而 agentic 循环相邻轮隔几秒,所以
受阻 caller 通常连续多轮受阻 —— 正是保留会输的 regime。**而那恰好就是我自己在修复注释里
写的"自我强化"场景。**
已 revert,并留下 `sticky-transient-blip-tradeoff.test.js` 钉住现状 + 记下三方数字。承重
断言是"后续受阻轮复用同一个替补":把 revert 掉的修复打回去,5 条里 3 条失败。
### queue-on-pin 是正确答案,但它是产品取舍
对抗调查推翻了台账"彻底修需 queue-on-pin(言下之意不可行)"的判断 —— **它可行**:
- `getApiKey` 是同步的,但 `src/` 里只有 4 个调用点,改异步只需 1 个 `async` + 2 个
`await`。真正的代价不是接线,是**原子性**:选择是池状态的纯函数,读与写之间插入任何
`await` 都会重新引入 #37 要防的超订(实测把可见突变移除后,8 次并发取号对 5 个 callerKey
全部塌成 1 个账号)。另有 19 个测试文件 55 处同步消费
- 所以钩子应该加在**异步调用方**(`waitForAccount` 头部,在第一次 `getApiKey` 之前 ——
一旦它返回,绑定已经被清了)。**同样的模式在 cascade reuse 上已经上线两处**:
`acquireAccountByKey` 返回 null 时 `for (w<10) { await sleep(500) }` 再降级
- 等多久不用猜:`getAccountAvailability` 已经按原因给出 `retryAfterMs` —— `rpm_full`
1500ms、`rate_limited` 45s、`quota_exhausted` 10.8M ms。所以"等还是换"可以逐原因精确决定
**未做,因为它把 caller 的吞吐钉在单账号 RPM 上(pro 60/min、未探明 20/min),是延迟换缓存
成本。** 这是产品取舍,不是工程判断,列进"需作者拍板"。
### 判别动作:先问"这个修法在目标 regime 里的代价是多少",再写它
本轮最贵的一课。我的修复通过了每一道自设关卡 —— 12 条断言、9 次突变双向覆盖、门禁全绿 ——
**而它在目标 regime 里让情况变坏了**。突变验证只能证明"断言在测东西",它对"这个方向是否
正确"一个字都没说。
**动作**:改行为(而不是修明确的错误)之前,先构造目标 regime 并测出**修改前后两个数字**。
没有这两个数字,再密的测试也只是把一个未验证的方向钉牢。
### 一条被既有测试拦住的分类错误
第一版把 `excludeKeys` 归成"瞬时"(理由:它随请求过期)。`sticky-exclude-keys.test.js`
三条断言立刻失败,而它的头注释正好写着原因:走到那里的失败类型是**死 token** ——
`reportDeadToken` 只记一次健康事件,账号仍 `active` 且无冷却,`excludeKeys` 是唯一挡住它
的东西。保留那个绑定会让下一轮重新解析到已知坏账号。
**既有测试当了对抗者。** 这条记下来是因为它说明了守卫的第二种价值:不只防回归,还能在设计
阶段否掉一个错误的分类。
### 仍未 exhaustive 扫描 ⬜(第十轮更新 —— 上一版此表有三行已过期)
| 面 | 缺口 |
|---|---|
| ~~queue-on-pin~~ | **已实现**(v3.9.12,默认关)。见第八轮 |
| ~~散射的确切成因~~ | **已隔离**(第十轮)。此前"成因在候选过滤"是**错的**,而错在方法:靠清账号状态来"关掉"机制,而那些字段就是机制。逐项在源码里关掉后:in-flight 对顺序散射毫无贡献,RPM 比例与 LRU 各自独立充分 |
| ~~cascade 的 `acquireAccountByKey` 路径~~ | **已测**(第九轮)。范围比记录的窄:默认配置下两条路径不共享槽位,只有 `stickyBindByUserOnly` 折叠时才覆盖,且那是该 flag 的直接推论、不修 |
| ~~`total_tokens` 含 `cache_write`~~ | **已做成默认关的开关**(v3.9.13)。此前记成"需拍板",而它做得成开关 —— 默认关的开关没有取舍 |
| ~~Cascade **流式**路径不记 per-account 消费~~ | **已修**(第十二轮)。这一行的"已加测试钉住"**是对的** —— 守卫在 `strict-usage-total.test.js` 里(源码扫描形态)。第十二轮我一度把它判成假话,那个判断本身是错的,详见第十二轮 |
| billing tag 号 | 需付费 token 校准(#239) |
| #240 预算耦合 | warelik 的外部报告,66 天生产数据。`streamChatWithEmptyRetry` 里 rescue 迭代会推进共享的 `attempt`,单向饿死 empty-retry 预算。报告者明确把决定权留给维护者 |
---
## 2026-08-04 第八轮:queue-on-pin 落地 —— "产品取舍"是我升错了级
第七轮把 queue-on-pin 记成"产品取舍,需作者拍板"。**那是升错了级**:做成默认关的开关,
取舍就交给运维而不是代码,现有部署字节级不变。仓库对同类取舍本来就是这么处理的
(`STICKY_SESSION_ENABLED` / `stickyNoFallback` / `SINGLE_TENANT_CACHE` 全是 opt-in)。
作者一句"改成功能不就行了"点破的就是这一条。
**动作**:把一件事标成"需要拍板"之前,先问"它能不能做成默认关的开关"。能,就不是取舍,
是一个功能。
### 方向验证(第七轮那条教训的第一次应用)
第七轮的教训是"突变验证不检查方向"。这一轮先测方向再定稿。同一场景,旋钮关 vs 开,
含两轮 warmup 让被绑账号真正持有前缀:
| | 序列 | 触达账号数 = 完整前缀写次数 |
|---|---|---|
| 关(现状) | `A,A,sub,sub,sub,sub,sub,sub` | **2** |
| 开 | `A,A,A,A,A,A,A,A` | **1** |
pool=4 与 pool=8 结果相同。对照第七轮被 revert 的"保留绑定"是 4 和 3 —— 同一个场景、
同一把尺子,方向相反。
**代价也测了**:每个受阻轮 **约 1 秒**。下界不是我们能选的 ——
`getAccountAvailability` 对 `retryAfterMs` 有 `Math.max(1000, ...)` 地板,所以一个
实际 400ms 就恢复的窗口也会报 1000ms。想突破得自己从 `_rpmHistory` 重算窗口,那是
把 auth.js 的逻辑复制一份,正是"只覆盖部分路径"的成因,不做。
一个被自己规则否掉的改动:我按 `retryAfterMs` 给首次 sleep 定长(而不是固定 1 秒
tick),测下来**墙钟没有变化**,只是少轮询几次。按"改行为前先测两个数字"的规矩,
没测出收益的优化不上车,已丢弃。
### 三个实现要点
1. **挂钩点是 `waitForAccount` 头部** —— 唯一的异步调用方,连接路径经
`acquireConnectAccount`、两条 cascade 路径直接走它。必须在它第一次调 `getApiKey`
**之前**,因为那次调用就是清绑定的地方
2. **`getApiKey(..., { pinOnly: true })` 复用真实的可用性合取判断**,而不是在等待循环
旁边重写。它是绝对的:pinOnly 永不返回非绑定账号,连"压根没有绑定"时也不返回 ——
否则循环会把替补当成自己的绑定并重钉上去,把等待悄悄变成它本要避免的换号
3. **只在账号自己声明的恢复时间放得进预算时才等**。reason 码本身不够用:`rpm_full`
对窗口即将滚过的 caller 是 ~1.5s,对刚把配额一次性打满的是 ~59s
### 同一个突变形态本轮出现四次:守卫守在了错误的一侧
13 次突变里有 3 次漏网,全是同一形态 —— 断言测的是**被调用的函数**,不是**调用点**:
| 突变 | 漏网时的状态 | 补法 |
|---|---|---|
| 轮询改用 mutating 的 `getStickyBinding` | 三条 peek 测试全绿 | 驱动真实等待循环,断言一个排队轮恰好 +1 hit |
| 可用性探测丢掉 selector 维度 | 25/26 全绿 | 端到端只打 selector 维度冷却,断言仍等待 |
| `WAITABLE` 白名单整条删除 | 26/26 全绿 | **不补断言** —— 见下 |
前两条补成了端到端断言。第三条查下去发现**它今天真的守不住**:每个不可等的 reason 都把
`retryAfterMs` 写死 60000,而旋钮 max 是 30000,预算那道闸已经全挡了。所以白名单是
belt-and-braces,不是承重件。
**没有假装守住它。** 改成钉住让这份冗余安全的**前提**:每个不可等 reason 报的窗口必须
超过任何允许的预算。谁把某个 60000 调小(比如给 `tier_expired` 报一个真实重探延迟),
白名单会在那一刻静默变成唯一的拦阻,而这条断言会在那一刻失败。实测把 `missing` 的
60000 改成 2000,它立刻红。
**判别动作**:一条突变漏网时,先问"这个守卫应该守哪一侧",再问"补断言能不能守住"。
答案是不能时,如实写下它守不住、以及什么前提让它现在无害 —— 那比一条读起来像守卫的
断言有用。
### 又一次踩了"先提交再突变"
修 stats 那条时改完没提交就跑突变循环,`git checkout HEAD -- src/` 把修复一起还原了。
台账第四轮记过这条,这是第二次。**纪律没变:修复先 commit,再跑突变。**
---
## 2026-08-04 第九轮:把记过两次仍然违反的纪律做成工具
台账把"修复先 commit,再跑突变"记了两次(第四轮、第八轮),**我两次都违反了** ——
第七轮丢了一个 revert,第八轮丢了 stats 修复。写第三遍不会有用。
**判别动作**:同一条纪律违反两次之后,不要再记第三遍。问"它能不能变成一个拒绝启动的
前置检查"。这个仓库对"善意不可靠"的答案本来就是守卫,纪律不该例外。
### `npm run mutate` —— 三条前置检查,每条对应一次真实事故
`scripts/mutate-verify.mjs` + `test/mutations/*.json`。
| 拒绝条件 | 对应事故 | 为什么必须拒绝而不是警告 |
|---|---|---|
| 工作树脏 | 第七、八轮 | 循环自己的 `git checkout HEAD --` 会连未提交的修复一起还原,之后每次突变测的是"修复缺失"而不是"突变生效" —— 而那个失败**看起来正是突变被抓到**。它伪造的是好消息,所以警告没用 |
| baseline 不绿 / 条数不符 | 第三轮四次假 SURVIVED | SURVIVED 只在套件本来通过时才有意义。那四次全是套件压根没跑起来(路径错 / 模块级 env / import 期常量) |
| anchor 匹配次数 ≠ 1 | 反复 | 匹配不上的 `replace` 是静默 no-op:测试全绿,报告写 SURVIVED,什么都没突变过 |
另外用 `git checkout HEAD --` 而非 `git checkout --`:后者从**索引**恢复,中途暂存过的
内容会被重新装回去。第七轮的 revert 就是这么丢的。
**自证**:提交它之前它拒绝了我自己的运行(`package.json` 与 `test/mutations/` 未提交)。
### spec 入库:让"5 次突变全 CAUGHT"这句话可复核
此前每次突变结果只活在跑它的人的终端里。release notes 里的突变数字**下一个人无法复核**,
而一条重构后悄悄失效的守卫会继续保有旧声誉。现在两个 spec 入库,重放本轮全部结果:
```
sticky-collapse-bypass.json 3 mutations, 全 CAUGHT
sticky-queue-on-pin.json 13 mutations, 12 CAUGHT + 1 有据可查的漏网
```
`expectCaught: false` 是**一等公民**,不是待修项标记。queue-on-pin 的 `WAITABLE` 白名单
就是这么记的 —— "这条守不住"本身是结论,理由写在 spec 的 name 里和第八轮。
### 直接在 master 上提交:做成可选 hook,而不是第三条纪律
本轮我把 6 个 commit 直接写在 master 上(`95f35cd`、`cc9a03e`、`557b9f7`、`6aa25a1`、
`0719a9d`、`b910c7e` —— 点出 SHA 是因为 **git 不记录 commit 是在哪个分支上写的**,所以
这句话本身无法从提交图核实,只能靠作者点名)。为什么难自查:直接提交产生的历史与
`git merge --ff-only` **字节级相同**,所以事后看不出异常。只能在发生的那一刻挡。
`.githooks/pre-commit`,照 `.gitmessage` 的惯例**本地 opt-in**
(`git config core.hooksPath .githooks`)。只挡 `git commit`,不挡 master 前进 ——
`--ff-only` 不产生 commit,发版流程不受影响(两条都实测过)。
**没有强制 `core.hooksPath`。** 那会改动每个贡献者的本地配置,而这条规则来自全局约定、
不是本仓库的既有约定 —— 拿别人的工作流去修我自己的失误不成比例。
### 顺带收尾一条只读推断:范围比交接写的窄
交接 §5.2 记的是"cascade 的 `acquireAccountByKey` 路径不经 sticky 快路径,绑定被覆盖而
全程无 clear",标为未复现。测完:
| `stickyBindByUserOnly` | 结果 | 槽位 |
|---|---|---|
| 关(默认) | **不覆盖** | 2 个独立绑定 |
| 开 | 覆盖,`fallbacks` 增量 **0** | 折叠成 1 个 |
`bindingKey` 的注释是对的,两条路径默认不共享槽位。所以这不是 cascade 路径的普遍缺陷,
是那个 flag 折叠槽位的**第三个**后果(前两个:跨后端零亲和、叠加 `stickyNoFallback`
的楔子)。
**不修** —— flag 要的就是"一个 caller 一个槽",最后写入者赢是该语义的直接推论;要修就得
取消折叠,等于关掉这个 flag。钉住的是范围与不可见性:覆盖是 `setStickyBinding` 干的,而
`clearStickyBinding` / `noteStickyFallback` 只能从 `getApiKey` 的 sticky 分支到达,这条
路径压根不调 `getApiKey`,所以 `fallbacks` 不动、`creates` 不动、一行日志都没有。
**动作**:交接里标"只读推断"的条目,下一轮优先测掉。它们不会自己变准,而**写宽了的范围
比没写更糟** —— 它会让下一个人去修一个默认不存在的缺陷。
---
## 2026-08-04 第十轮:整体对抗复核 —— 六个 lens 查出的都是本轮自己造的
作者要求对本轮全部改动做整体 review。六个独立 lens 攻 v3.9.12..HEAD 的 13 个 commit,
**没有一条重大发现指向老代码 —— 全是这一轮新引入的**。这本身是最有信息量的结果:新代码
的缺陷密度远高于被审过多轮的老代码,而我在写它们的时候每一步都跑了突变验证。
### 六条实质缺陷,全部已修
| 严重度 | 缺陷 | 谁造的 |
|---|---|---|
| high | 突变工具把**被截断的运行**报成干净的 SURVIVED | 第九轮的工具本身 |
| high | tie 判据漏了排序的 **trouble 项**(5 项只比 4 项),分片仍能提拔被降级的账号,且扫描会提前停止**分片不足** | 第十轮的分片修复 |
| high | `strictPin` 少 `isStickyEnabled()`:两个**面板可设**的 flag 就能豁免分片边界,而 sticky 是 **env-only const** | 老代码,但被我的 span 改动放大成可触发 |
| high | `WINDSURFAPI_STRICT_USAGE_TOTAL` 只在 2/3 个 usage builder 生效(special-agent 前端漏掉)—— **第 6 次 partial-path** | 第十轮的 flag |
| medium | `fullBillableTokens` **零个生产调用者**,而 `recordAccountSpend` 内联重写了同一套算术;被导出那份的突变守卫在守到不了线上的代码 | 同上 |
| medium | `strictUsageTotal` 有两份实现,"只有字面量 1 生效"只约束 chat.js | 同上 |
另修:`sha256` 被我移出条件、变成每请求白算一次;`|| (prompt + completion)` 尾巴是唯一能
让累计消费**变负**的路径;tie 判据的 quota 项完全没覆盖;台账第八/九轮日期早了一天。
### 最贵的一条:恒真断言,写在"为这个反模式审别人"的同一轮里
`shard-tied-prefix.test.js` 的"最忙的账号不会被提上来"取快照取在 acquire **之后**,于是
被选中账号自己那次 +1 已经在 `maxInflight` 里,`chosen._inflight !== maxInflight + 1` 对
任何输入都成立。它读起来完全像一条守卫。
实测差别:把原始分片缺陷打回去,改之前 **0** 条失败,改之后 **3** 条失败。
**动作**:断言里出现"从被测对象自己算出来的期望值"时,先问"这个期望值是不是已经包含了
我要检测的那次变更"。
### 同一形态在本轮出现 **六次**:守卫覆盖了谓词,没覆盖它单独承重的项
`tiedWithFirst` 有 4 个判据项(后来 5 个)。spec 一开始只隔离了 2 个。逐个查:
| 项 | 初测 | 真相 |
|---|---|---|
| in-flight | 漏网 | **承重** —— 同毫秒突发时它是唯一能分开忙/闲的东西 |
| RPM 比例 | 漏网 | **承重** —— 被其他 caller 压满的账号(50/60)会被提拔 |
| quota 桶 | 静默未覆盖 | **承重** —— 种子账号没有 credits,`quotaScore` 恒返回 100,**没有 fixture 能让它说话** |
| trouble 桶 | 压根没写进判据 | **承重**,而且是排序第二优先键 |
| lastUsed | 漏网 | 有据可查的漏网,理由记在 spec 里 |
**动作**:一个谓词由 N 项合取而成时,spec 必须逐项各有一条突变。"整体漏网"和"某一项
不承重"是两个不同结论,而只测整体分不出来。附带教训:**一项在所有 fixture 里都恒等,
不等于它不承重** —— 那只说明 fixture 不够。
### 并发 agent 会吃掉未提交的改动
`d0b9c9f` 的 message 声称改了 `auth.js`,而它的 diff **不含 auth.js** —— 我 Edit 之后、
`git add` 之前,一个并发复核 agent 跑了 `git checkout HEAD -- src/auth.js` 清理,把我未
提交的改动一起还原,`git add` 因此暂存了空。下一个 commit 补上了。
**这正是第九轮那个工具 guard 1 防的失效模式,来自另一个方向** —— 不是我自己的突变循环,
是别人在同一棵工作树上动手。工具挡得住我,挡不住并发。
**动作**:让复核 agent 在 **worktree** 里跑(`isolation: 'worktree'`),或者改完立刻
commit 再交给它们。并且:commit 之后核一次 `git show --stat HEAD -- <file>`,确认声称改
了的文件真在 diff 里。
### 复核者也要被复核
那条 high"截断误判"标着 `reproduced=true`,给的两种机制(语法错 / 模块抛异常)**都不
成立** —— node 在这两种情况下都会报一条 fail,所以都会被抓到。我按他说的复现两次都得到
CAUGHT,再自己找到真正能触发的路径(`process.exit(0)` 截断)。**结论对,证据错。**
另一个 lens 如实报告它拿不到可信测量,因为其他 agent 在并发改同一棵树 —— 那份自陈比它
的发现更有价值。
**动作**:复核发现照样要独立复现,`reproduced=true` 不是免检标记。
### 第十轮补记:judge 阶段失败过一次,以及它暴露的两件事
六个 lens 都返回了,**judge 阶段 6 次尝试全部停滞**(每次 180 秒无进展)。所以那一轮的
修复是照**未经裁决的**原始发现做的 —— 我逐条独立复现后才动手,但缺了"判 REAL/NOT_REAL、
分回归与既有、裁定范围外"这一层。
**根因是我的脚本**:judge 的输入是六份完整报告序列化后 ~25k tokens,而它只需要 findings。
裁掉 `how_measured` / `what_i_could_not_establish` 之后 resume 成功(六个 lens 结果有缓存,
只重跑 judge,代价很低)。
**动作**:给 judge 传**结论**而不是整份报告。它要复核就让它自己回仓库里查,那比把证据原文
塞进 prompt 更可靠也更省。
### 判别动作:引用必须支持结论,不只是"本身为真"
第六个 lens(claims-audit)专审本轮的数字。最要紧一条:"cache_write 约为读的 5.6 倍"被我
引用了**六处**,每处都拿 tag4 的 A/B token 计数(14361 / 14356+5)当依据。那组数是**token
计数**,只确定了哪个 tag 承载 cache-write,对成本一个字都没说。比例本身站得住 ——
`devin-connect.js` 的 "hit cost measured at 17.8% of miss",1/0.178 ≈ 5.6 —— 但我引的不是
它。
**两句话各自都真,而推不出彼此,引用仍然是错的 —— 而且比不给引用更危险,因为它看起来
可核。**
**动作**:写"实测 X"时,问的不是"我引的那句话是不是真的",而是"**从那句话能不能推出
X**"。
同批还有:同一个量在仓库里有两个数(`~10x` 无任何推导、`~5.6x` 可推),而两者都被当成一个
运维旋钮的依据;一段注释先说门槛已删、三段后又基于它推理;"8 个 callerKey"的分母对不上入库
的 7 个。全部已改,详见 `dcc8cee`。
### hook 的作用范围我在注释里夸大了
同一轮里我写的 pre-commit,文档字符串声称"拒绝在 master 上产生 commit"。实测两条例外:
- **`cherry-pick` / `revert` 完全不经过 pre-commit** —— git 不在那条路径上调它,所以两者
都能在 master 上静默产生新 commit。补了 `post-commit` 事后警告(它不假装能拦,恒退出 0)
- **冲突合并时它给的补救命令是 git 自己会拒绝的** —— 那种状态下 `git switch -c` 报
"cannot switch branch while merging"。**在唯一需要那条建议的时刻它不可用。** 改成状态
感知
五条路径现在逐个实测过。**动作**:写下 hook 的作用范围时逐条试,别按"pre-commit 会在提交
前运行"这类直觉推 —— git 有多条产生 commit 的路径不经过它。
### 又一次自己踩"持有未提交改动时不要 reset --hard"
改完 hook 之后,我的测试清理里那句 `git reset --hard <sha>` 把它一起还原了,于是我对着旧
文件调试了两轮才发现。与并发 agent 吃掉改动同一类,只是这次是我自己。第九轮那个工具挡得
住突变循环,挡不住我手敲的 reset。
### 第十轮再补:judge 裁决了那批修复,六条里三条是修复自己引入的
judge 阶段重跑成功后,我把它的任务改窄成"**审那六个修复本身**" —— 那是本轮唯一没被任何人
复核过的东西。裁决 `safe_to_keep=true`、**无需 revert**,但给了六条具体改动,其中三条正中:
| 缺陷 | 我在哪一步造的 |
|---|---|
| **guard 5 会在突变被真正抓到时中止整轮** —— `r.total !== base.total` 少了 `&& r.fail === 0`。计数变化只在**什么都没失败**时才是问题;有失败就说明抓到了 | 修"截断误报 SURVIVED"时 |
| **负数 clamp 只护住三个计数器里的一个** —— clamp 写在 `fullBillableTokens` 里,而 `recordAccountSpend` 用未 clamp 的值累加 `promptTokens` / `completionTokens`,实测两者**永久为负** | 修"不可达尾巴"时 |
| **"25 个场景"算错**(5×5×2 = 50),且 "forced true" 与"门槛不存在"是同一个条件不是第三个 | 写等价性论证时 |
**动作**:修复本身要过一遍复核。一批修复是这一轮**唯一没人看过**的代码,而它们是在已经
发现问题的紧张状态下写的 —— 缺陷密度不会更低。
### 判别动作:没看到信号,先问探针坏没坏
本轮最难看的一条。judge 指出 `git revert` 没被 post-commit 覆盖(REVERT_HEAD 在
post-commit 之前已被清掉,所以 ref 判断落空)。我照它改了,然后"实测"验证 —— 给 post-commit
加一句无条件 `echo`,跑 revert,**什么都没打印**,于是我下结论:git 对 revert 根本不跑
post-commit,任何 hook 都盖不住。并把这个结论写进了 commit message 和三处注释。
**结论是错的。** 那次 revert **压根没产生 commit**(目标 commit 早被 reset 掉了),所以没有
任何钩子有机会运行。干净的端到端测试:revert 一个真实 commit → post-commit 运行、警告打印、
正确点出新 commit。judge 原本的判断从头到尾是对的,`^Revert "` 那条匹配确实是承重的。
台账第五轮就记过"复现失败要归因,不能当成缺陷不存在"。**我记下了那条规则,然后在同一轮里
对着一个坏探针违反了它**,并且比原来更糟 —— 因为我把错误结论写成了注释里的"实测"。
**动作**:探针**没有**产出预期信号时,第一个假设是探针坏了,不是被测对象没有那个性质。
具体到这里:验证"某操作是否触发钩子"之前,先确认那个操作**真的产生了 commit**。
### 同一形态本轮第三次:凭直觉判断一个机制的"作用范围"
三次都是范围比直觉更窄或更宽:
1. `tiedWithFirst` 漏了排序的 trouble 项(以为镜像了全部判据,实际 5 项只比 4 项)
2. pre-commit 的作用范围(以为拦住"在 master 上产生 commit",实际只拦 `git commit`)
3. post-commit 对 revert 的覆盖(先以为盖了,再以为盖不了,实际盖了但靠的是另一个信号)
**动作**:写下"X 会/不会发生"之前,把 X 的每条触发路径逐个跑一遍。git 有多条产生 commit 的
路径,每条调用的钩子集合都不一样。
### 第十轮收尾:judge 要的五条守卫 + 七条如实记录
judge 的 `new_tests_wanted` 五条全部落地(`67ce2a0`、`5f1403f`),另记它 `ledger_items` 里
值得留档的七条:
1. **guard 5 在同一次编辑里修掉一个假 SURVIVED、又引入一个假 ABORT。** 形状:**一个守卫按
一次复现写出来,然后被无条件套用。** 复核者给的机制(语法错 / 模块抛异常)在 commit
message 里被正确地驳回为"这两种本来就会被抓到",而新守卫随后恰好打破的就是那一类
2. **`scripts/secret-scan.mjs` 跳过 `test/`**(`IGNORED_PREFIXES`),且只读被跟踪文件 ——
本轮约 2333 行新增里有约 1100 行**从未被扫过**。"secret-scan 干净"这句话必须带上这个
范围说。已手工扫过本轮 `test/` 增量:只有自己写的 `cafebabe`/`deadbeef` 占位十六进制
3. **Cascade 流式路径没有 `recordAccountSpend` 调用点**(只有 `nonStreamResponse` 里那一
个)。既有、此前无记录,而它**限定了 strict-usage-total 那个功能的记账承诺范围**。已加
测试钉住,未修
4. **`d0b9c9f` 的 commit message 描述了一处它自己 diff 里没有的 `auth.js` 改动**(代码由
`8449002` 补上)。代码现在是对的,而**message 与 diff 不符会永久留在历史里**
5. **`scatter-attribution.test.js` 有一条断言 `distinct > 2`,而 POOL=4 时实际产出 4** ——
容忍了设计不会产生的状态。宽容断言不是错,但它比精确断言少守住一段
6. **`sticky-collapse-bypass.test.js` 手敲生产者写入**。今天忠于 `chat.js`,但没有被绑住。
已加形状守卫(第 4 条守卫)
7. **`RELEASE_NOTES_3.9.12.md` 里那处 5.6x 错引**用**刻意不改**:`docs/README` 声明 release
notes 是 append-only 历史,而 v3.9.12 的 GitHub Release body 已由它发布。记在这里,免得
下一轮把它当成新发现
### 判别动作:一个守卫按一次复现写出来,就会只对那一次复现正确
guard 5 是最清楚的例子:我按"截断"这一种复现写了"测试条数必须一致",然后无条件套用,于是
在"真的被抓到但条数也变了"的场景里把整轮杀掉。修法是把条件收窄成
`条数变了 && 什么都没失败`。
**动作**:写下一个守卫之后,问"**除了我复现的那一种,还有哪些情况会命中这个条件**",并对
每一种给出一句话的处置。答不出就说明这个守卫还没写完。
---
## 2026-08-04 第十一轮:接手核实 + #240 + 一个把工具自己关掉的环境变量
这一轮是**接手会话**:先逐条核实上一份交接的声明,再收掉它漏记的东西。
### 交接的声明全部核实成立
| 声明 | 实测 |
|---|---|
| 门禁 3401 / 0 | 260 文件、pass 求和 3401、EXIT=0 |
| 突变四个 spec 35 条全符合预期 | 33 CAUGHT + 2 条 spec 里声明的 `expectCaught: false` |
| `secret-scan` 跳过 `test/` 且只读被跟踪文件 | `IGNORED_PREFIXES` 含 `'test/'`;来源是 `git ls-files` |
| strict-usage-total 覆盖三个 usage builder | special-agent.js:252 / devin-connect.js:1810 / chat.js:1754 |
| Cascade 流式不记 per-account 消费 | chat.js:4894 是唯一调用点,注释自陈 non-stream |
**没有虚报。** 记下来是因为"核过且是准的"本身是资产 —— 下一个人不必重烧这段时间。
### 交接漏了一个开放 PR
**#241(warelik,+439/−26)在交接与台账里都没有记录。** 它比 #240 早 22 分钟开,而 #240 的
正文引用了它;交接只写"#240 是本轮唯一没被碰过的外部实质报告"。
**动作**:交接的"未做"清单要按 `gh pr list --state open` 与 `gh issue list` 各核一遍,而不是
凭本轮碰过什么来回忆。漏记一个开着的 PR 比漏记一条 issue 更贵 —— 那边有人在等。
### #240:从代码本身找"这是不是刻意语义"的证据
报告者把决定权留给维护者,并明确问了一句:共享 `attempt` 会不会本来就是"任何投机重试都从
同一个池里喝水"的账号保护语义。
判据不必猜:**rescue 臂一直有自己的 `rescueAttempt`/`rescueMax` 一对**。共享一个池的设计不
需要第二个计数器。而 `attempt` 只在 empty 那一侧被读(判据 + backoff/日志),rescue 侧从不读
它、只是经 `continue` 顺带推进 —— 那是在 `for (…; ; attempt++)` 头里计数的产物。
**动作**:遇到"这个耦合是不是故意的"这类问题,先找**冗余**:如果按"刻意"的解释某个既有结构
是多余的,那个解释大概是错的。
### 拆预算不抬天花板,而这件事必须两个方向都测
| 顺序 | 拆分前 | 拆分后 |
|---|---|---|
| 全空回复 | 3 | 3 |
| 全 reasoning-only | 3 | 3 |
| 先空、后 reasoning | 5 | 5 |
| **先 reasoning、后空** | **3** | **5** |
上限 `1 + max + rescueMax` 拆分前**就已经够得到**,只在"先空"那个顺序上。所以饿死不是"上限
太低",是"上限的花法依赖顺序"。
要紧的是:**一个把饿死"修"成抬高上限的改动,在只测饿死的测试里长得一模一样。** 所以两个
方向各有断言,而"上限被抬高 3"这条突变现在抓 6 条。
### 12 条断言逐条查空转,查出一条真缺陷
按第六轮的标准逐条核 —— 四条从未在任何突变下失败过,其中一条不是冗余而是**坏的**:
"主开关覆盖两条臂"那条,**把主开关从 empty 臂删掉它照样通过**。第一轮传的是
`REASONING_ONLY`,而 rescue 预算此时已是 0,那一轮既不可 rescue 也不算空,请求在第 1 次调用
就结束 —— empty 臂的判据压根没被走到。它对自己命名的那件事零断言。
工具当时报的是 `CAUGHT 1`,而那 1 条失败来自**另一个文件**。逐条统计才看得见。
**动作**:突变的 CAUGHT 计数是**跨文件求和**的,不能用来推断"我这个文件的哪条断言咬住了"。
要按叶子测试名逐条收。另两条的处置是补突变(rescue 自己的预算差一、rescue 日志报合并计数),
第四条如实保留为**对照** —— 它在任何预算突变下都不会失败,因为它第一次重试就愈合、够不到
任何上限,作用是给"4 次调用"一个可比的数。
### 有据可查的漏网:backoff 倍数
backoff 键在 `emptyAttempt` 上这件事**测不到**:所有测试都设 `RETRY_ON_EMPTY_MS=0`,两个版本
都睡 0ms。
墙钟断言按本仓库自己的先例否掉 —— 负载会同时抬高正确与突变两条路径,350ms 与 700ms 之间没有
稳定余量(第四轮那条 sticky TTL 断言就是这么连红三次的)。为一个延迟属性给生产代码加 sleep
缝不成比例。危害有界且是观感级:rescue 之后那次空重试多等 350ms,不影响调用次数与上限。
**已如实记成 `expectCaught: false`,并钉住那个可观测的替代物**:日志行报的计数(那条突变
CAUGHT)。同时修掉测试文件里一句过度声明 —— 它原写"日志行钉住了这个属性",实际钉的是计数。
### `FORCE_COLOR` 把突变工具整个关掉,而它伪装成"你的套件坏了"
`scripts/mutate-verify.mjs` 只从子进程环境里删了 `NODE_TEST_CONTEXT`。带 `FORCE_COLOR` 时
node 给汇总行套 SGR 码(`\x1b[34mℹ pass 62\x1b[39m`),而计数正则锚在行首行尾,于是**恒得
`pass=0`**,guard 2 以"baseline is not green"拒绝启动。
- **失败在安全侧** —— 0 计数永远造不出假 SURVIVED。但报错把责任推给套件,与第三轮那四次假
SURVIVED 同一种伪装:`pass=0` 是"没测量",不是"没抓到"
- **不是假设**:导出 `FORCE_COLOR=3` 时 `test/mutate-verify-harness.test.js` 自己 **6/10 红**。
门禁此前是绿的,只因环境恰好没设这个变量
- 实测:除 `'0'` 外任何值都上色,**包括空字符串**;`TERM`/`COLORTERM` 单独不会。所以是
`delete` 而非覆写
- 影响面已实测封口:带 `FORCE_COLOR=3` 跑全量门禁,修好后 **3405 / 0、EXIT=0**,没有别的文件
对它敏感
**两半各自单独就够**(5/9 → 14/0),注释已如实写明它是冗余而非合取 —— 保留是因为一半治病因
(子进程不上色)、一半治形状(解析不依赖颜色从哪来)。
**动作**:工具从子进程输出里解析数字时,问一句"什么环境变量能改变这个输出的形状"。这类缺陷
只在设了那个变量的机器上出现,所以本机绿不代表 CI 绿。
### 本轮四个坏探针
同一轮里我自己写坏了四个探针,全部表现为**空输出或零结果**:
1. POSIX awk 不支持 `\s`,`/connectTools\s*=/` 一条都没匹配 —— 差点据此断言"没有重新赋值"
2. 拿一个在 v3.9.10 基线上不存在的测试文件当探针,node 什么都没跑
3. `parseFields` 是返回数组、不收回调,我传了回调读返回值为空 —— 差点据此写"解码侧静默丢掉
字段 0"(实际它原样产出)
4. 门禁计数第一次用 `tail -40` 截,把汇总丢了
第五轮就记过"没看到信号先问探针坏没坏"。**四次里有三次我的第一反应仍是"被测对象没有那个
性质"。** 唯一让我快速识破的是第 3 次 —— 因为结论听起来太好(正好支持我想写的那句)。
**动作**:探针给出"零/空"时,先构造一个**已知非零**的输入喂给同一个探针。这比复查逻辑快。
### 仍未 exhaustive 扫描 ⬜(第十一轮更新)
| 面 | 缺口 |
|---|---|
| ~~#240 预算耦合~~ | **已修**(第十一轮)。11 条突变 CAUGHT + 1 条有据可查的漏网 |
| ~~PR #241~~ | **已合并并随 v3.9.18 发布**(第十三轮,merge `1e2aaea`)。三条评审意见贡献者全部改到位。原文保留:已审完,三条回给贡献者:第 2 条理由在 connect 路径上不成立(`emulateTools` ⟺ `params.tools` 非空,两处字面同条件)、nudge 累积被 digest 放大 43 倍(242 → 10457 字符)、字段号 0 不可达但守卫在调用方。六条审查突变全 CAUGHT,测试质量高 |
| ~~#234~~ | **六条判据已逐条核,五条满足、第六条是真缺陷、已修**(第十二轮) |
| ~~Cascade 流式路径不记 per-account 消费~~ | **已修**(第十二轮) |
| billing tag 号 | 需付费 token 校准(#239) |
| backoff 倍数 | 有据可查的漏网,见上 |
| `known()` existence 项 | 有据可查的漏网(第十二轮):没有 fixture 能造出 mapped-but-unknown。前提已被断言钉住 |
| ~~`release.yml` 的 action 升级~~ | **已被真实运行验证**(2026-08-06 复核)。原文写"要等下一次打 tag" —— 此后 `v3.9.15`…`v3.9.19` 五个 tag 都跑过 release.yml,**最近一次六个 job 全 success**,含 #233 修的 `macOS x64 (non-blocking)`。原文保留:九个 action 升到 node24(第十二轮),ci.yml 那部分合并即验证 |
| #236 | 判据太模糊。正文只有"反代给 chatgpt、遇到浏览器操作就报错",无请求体、无错误码、无模型名。**该做的是向报告者要能区分候选机制的信息,不是从代码倒推一份猜测清单** |
| ~~#235~~ | **已解**(第十二轮,2026-08-06 复核确认):`getCurrentlyFreeConnectSelectors` 现在有第二个调用者 `dashboard/api.js:1782`(`GET /models`),而 `dashboard/index.html:7417` 渲染 `m.currentlyFree`。**这一行本轮之前一直停在"仍未暴露"** —— 第十二轮在正文里写了"标注顺带解掉 #235"却没回来改这张表,而交接 §3.3 指路让人"取最后一个命中",第十二、十三轮都没有自己的表,所以读者恰好落在这张过期的上。原文保留:技术主体 v3.9.8 已发(费率表判免费),但当时 `auth.js:322`(干旱判定内部)是唯一调用者,报告者(pro 号、设了 GLM-5-2 一下烧完周限)看不见哪些模型吃配额 |
---
## 2026-08-04 第十二轮:#234 收尾 + Cascade 流式记账 + 台账自己有一句假话
### 我判定台账"说了假话",而那个判定本身是假的
第十一轮那张表写着 `Cascade 流式路径不记 per-account 消费 | 既有。…已加测试钉住,未修`。
本轮我搜了一遍,得出"**没有那个测试**"的结论,把它写成一条更正落进台账,还在 commit
message 里说这是"注释声称有覆盖而实际零断言"的又一次。
**那条更正是错的。守卫一直在** —— `strict-usage-total.test.js:247`,一个源码扫描形态的
断言,它的失败消息里甚至预先写好了"哪天流式路径真的记账了,就把这条删掉"。
**怎么错的**:我 grep 了提到 `recordAccountSpend` 的测试文件(这个文件命中了),再对命中
的文件 grep `stream|cascade`(它又命中了),然后**看着命中行判断那是 usage 算术的测试,
没有打开读**。两次 grep 都指向了正确的文件,而我在最后一步用眼扫过去就下了结论。
**是全量门禁抓到的,而且是在修复已经提交之后** —— 那条测试断言"流式路径**没有**调用点",
修完自然变红。它的消息直接告诉我该怎么做。
值得记的三条:
1. **我做的是"更正",而更正比新增声明更危险。** 新增声明会被下一轮审;而一条"我核过了,
上一轮那句是假的"会让下一个人**停止怀疑**这个位置。台账第五轮记过"报告者必须被独立
验证,包括报告者是自己的时候" —— 那条规则我引用过、也写进过记忆文件,然后在同一轮里
违反了它。
2. **grep 命中不等于读过。** 判定"某个断言不存在"的唯一可靠方式是打开命中的文件读那段
代码。搜索能证明"存在",不能证明"不存在" —— 后者需要穷尽,而穷尽必须包括读。
3. **源码扫描形态的守卫在按行为搜索时是隐形的。** 我找的是"驱动流式路径并断言 tally
变化"的测试,而实际守卫是"扫 chat.js 源文本、断言 streamResponse 段落里没有那个调用"。
台账自己说过源码 grep 守卫的正当用途是守结构性不变量 —— 这正是一个,而它的形态让我的
搜索策略看不见它。
**动作**:要断言"这个面没有守卫"之前,除了 grep 行为关键字,还要 grep **被守对象的名字**
在测试目录里的全部命中,并**逐个打开读**。命中数少的时候这个成本是几十秒。
### #234 六条判据逐条核完:第六条是真缺陷
前五条满足,各有专属测试文件通过。第六条("Dashboard、`/v1/models`、干旱摘要、chat 前置
检查对 connect 模型命名空间达成一致")**不满足**,实测:
```
DEVIN_CONNECT=1、免费池
/v1/models 1 行(swe-1-6-slow)
dashboard /models 163 行,与前者零重叠 —— 全是账号调不了的,唯一调得了的不在里面
```
成因是两个视图从两个命名空间各自推导同一条规则。修法是一个导出的谓词
`buildConnectReachability`,两边都调它。**没有在 dashboard 侧再抄一份** —— 抄一份就是
"只覆盖部分路径"的第七次。
**dashboard 侧标注而非删行。** 这一点是判断的关键:allow/deny 面板只渲染这个列表,narrow
掉会让已在名单里的条目既看不见又**点不掉**。标注顺带解掉 #235(报告者是 pro 号、设了
GLM-5-2 一下烧完周限,他要的正是"看得见哪些吃配额"),而删行给不了他这个。
### 一个修复在第一版就把它要修的缺陷重新引入了
谓词第一版在 `resolveConnectSelector` **之后**又比对一次 `FREE_REACHABLE_SELECTORS`。
`STRICT_MODEL=0` 时 `claude-4-sonnet` 降级成 `swe-1-6-slow`,于是它把付费模型判成免费池
可达 —— **正好是 #234 要修的那个谎**。`connect-discovery-rebuild` 第一次运行就红。
**既有测试当了对抗者**(第七轮记过同一形态)。值得记的是这条缺陷的形状:一个"兜底放行"
的分支放在归一化**之后**,于是归一化本身成了绕过它的路径。
### 前置断言挡住了一条会说谎的测试
流式记账那个测试里,"重试只计费一次"这条最初在 `calls=1` 的运行上通过 —— 压根没有重试
发生。挡住它的是 `assert.ok(ctx.upstreamCalls >= 2, 'precondition: …')`。
连踩四个 harness 错误才让它真的重试:`buildUsageBody` 读 cascade 字段名而非 OpenAI 那套
(用错会静默回落 chars/4,累计值照样动、只有金额不对);非流式 fake 要返回数组形态否则
502;流式重试的闸是 `err.isModelError`;抄来的 fixture 的 `waitForAccount` 第二次返回
null,把整轮卡在一次上游调用。
**动作**:任何"只发生一次"的断言,都要配一条"那件事确实发生了 ≥2 次"的前置断言。否则它
在"什么都没发生"的运行上恒真 —— 与第三轮那四次假 SURVIVED 同源。
### 两条 SURVIVED 是两种不同的东西,不能一起处理
突变跑完 11 条里 2 条 SURVIVED。逐个查完,它们的正确处置完全相反:
| 漏网 | 真相 | 处置 |
|---|---|---|
| FREE_REACHABLE 兜底整条删除 | **它压根不执行**。`swe-1-6-slow` 既不是任何 MODELS key 也不是任何 `_windsurf_id`(163 个全查),而两个调用方传的都是 MODELS 派生 id | **删掉** —— 是我自己上一个 commit 写下的死代码 |
| existence 项 `known()` 删除 | 不是死代码。163 个条目里 58 个 mapped 且全部 known、105 个 unmapped,**没有 fixture 能造出 mapped-but-unknown** | 记 `expectCaught: false`,并把让它无害的**前提**钉住 |
第二条的关键在于:`mapped` **不结构性蕴含** `known`。`resolveConnectSelector` 第一个分支
(`devin-connect-models.js:257`)直接从 `SELECTOR_MAP` 返回 `mapped: true` 而**不查目录**,
所以一个别名指向被快照轮换删掉的 selector 时就是 mapped-but-unknown,而 `known()` 是唯一
拦住它的东西。要复现得手动制造别名表与目录的漂移。
于是新增一条断言:**没有任何 MODELS 条目是 mapped-but-unknown**。哪天它红了,就是该给那条
突变写 fixture 的信号 —— 断言消息里直接写了怎么做。这是第八轮 WAITABLE 白名单那条先例的
同一套处理。
**判别动作**:一条 SURVIVED 先问"这段代码今天会被执行吗"。答案是"不会"时它是死代码(删),
是"会,但没有输入能让它说话"时它是有据可查的漏网(记下来 + 钉前提)。**两者读起来一样,
处置相反** —— 而把死代码当漏网留着,正是 v3.9.13 那条 medium 缺陷的形状(被导出的 helper
零生产调用者,而它的突变守卫在守到不了线上的代码)。
### guard 6 第一次真的抓到东西
突变跑到第二条时中止:`mutation … left the tree dirty after its restore: M src/handlers/chat.js`。
那是我**未提交**的流式记账改动。工具拒绝在 guard 1 禁止的条件下继续测量。
本轮之前 guard 6 只是理论上的。它在本轮抓了我**三次**:未提交的 `chat.js`、未提交的
`.env.example`、以及在突变**运行中途**对 `.github/workflows/` 下手。
前两次是"先提交,再突变"(第四、七、八、九轮各记过一次)。**第三次是一条新的**:
> **突变循环运行期间,整棵工作树属于它。** 不只是"开始前要干净" —— 循环每一步都在
> `写入 → 跑测试 → git checkout HEAD -- 还原`,在此期间任何**别的**编辑都会被它的
> `git status --porcelain` 检查看成脏,而且真有被它的还原一起吃掉的风险。
我犯这个是因为想"趁跑的时候把别的活干掉"。那个直觉在这个仓库里是错的:突变循环不是后台
任务,它是一个**排他持有工作树**的任务。
**动作**:突变跑起来之后就别碰仓库 —— 要并行干活,去 worktree(第十轮记过同一条,当时是
给并发 agent 用的,现在这条对我自己也成立)。
---
## 2026-08-05 第十三轮:接手核实 + 合并 #241 + guard 2 在一天里抓了我三次
接手核实全部跑过:门禁 3433/0(263 文件)、secret-scan EXIT=0、191 个 md 文件零链接漂移、
`master == origin/master`。**但突变循环只有五个 spec 跑得起来。**
### 一个碰巧正确的未测量数字,比一个错的更难发现
`strict-usage-total.json` 的 `expectBaselinePass` 从 `71127dc`(删掉一条断言,18 → 17)起
就过期,guard 2 在第一条突变之前中止整个 spec。而 `HANDOFF-2026-08-04-E` §0 写着"六个 spec
共 60 条:56 CAUGHT + 4 声明漏网",本文首发在漂移之后 **73 分钟** —— 那 60 条里最多跑了 49。
修完重跑,**60 条的分布恰好就是 56 + 4**。数字是对的。
**这才是要记的地方。** 一个错的数字下一轮会被算出来;一个碰巧正确的未测量数字不会 —— 接手
的人按 §3 跑循环看到红,第一反应是怀疑自己的环境,而不是怀疑那行字。而它旁边那些数字
(门禁 3433、文件数 263)全都真实,所以它借到了它们的可信度。
**判别动作**:交接/台账里每个数字要能回答"这一次是谁跑出来的"。删或加断言之后,核一遍有没有
spec 钉着旧计数 —— 但**别用静态数 `it(` 的办法**(见下)。
### 探针又坏了一个,而且我这次是拿它去核别人的数字
核其余五个 spec 的 baseline 时,我写了个静态数 `it(` 的脚本,它在
`retry-rescue-budget-split` 上报了 **MISMATCH(spec=71 / 数出来 63)**。那是假警报:该 spec
跨两个文件、且有循环生成的测试,静态数数不到。真值用运行时计数核出来是 13 + 58 = 71,一致。
第五轮起这条记过四次,这是第五次。唯一让我这次快速识破的是**它刚刚 EXIT=0 过** —— 一个
EXIT=0 的 spec 不可能 baseline 不匹配,两个信号直接矛盾。
**可靠口径**:运行时计数(逐文件取 `ℹ pass N` 求和),或者干脆直接跑 guard 2 —— 它就是干
这件事的,而且比任何静态探针都准。
### 合并 #241:一个 PR 能静默打断的东西不止源码
三条评审意见他都真改了,门禁 3444/0 与声明一致,第 2 条等价性我独立核实成立(`chat.js:2844`
与 `3088` 字面同表达式、同 `const`、中间未重新赋值;整个 `src/` 只有 `chat.js` 构造
`connectParams`,三处重新 spread 都保留 `.tools`)。测试质量按突变标准审过,承重。
**但合并打断了 `retry-rescue-budget-split` 的两条突变 anchor** —— PR 往 rescue 判据里插了
`hasTools`,而那两条 anchor 正是原来那行。guard 3 在匹配次数 != 1 时 die,下次跑循环会红。
同一原因它的 baseline 也在合并那一刻过期(71 → 应为 76)。
**测试套件不跑 spec。** 所以他的 3444/0、我在 worktree 里的复测、以及合并后的门禁,三个绿灯
都看不见这一条。
**判别动作**:改了 `src/` 之后,除了门禁,还要核 `test/mutations/*.json` 有没有 spec 钉着被
改的那几行。零成本做法是跑一遍突变循环。**门禁绿不代表 spec 还成立** —— 这两件事测的是不同
的东西,而只有前者在 CI 里。
### 合并后复核查出的一条真缺陷:钳漏在同一个函数里
`DEVIN_CONNECT_RESCUE_REASONING_MAX_CHARS` 用 `Number.isFinite(x) && x >= 0`,没有钳。
**而这个函数上面十二行的 `RESCUE_MAX` 已经因为同一个洞加过 `RESCUE_MAX_CEILING = 5`**,
它的注释就写着"`1e9` 能过 Number.isFinite,然后请求挂几个小时"。
驱动复现:50000 字符 reasoning + `1e9` → nudge **50089 字节**。已加
`DIGEST_MAX_CEILING = 32000`,并在注释里写明这个数是判断而非实测的上游上限。
**判别动作**:新增一个旋钮时,把同一个文件里同类旋钮的清洗逐个读一遍 —— 已经吃过的教训会
写在它们的注释里,而新旋钮不会自动继承。这条比"复核新代码"更具体:**去读邻居的伤口**。
### 说"这个 PR 引入了 X"之前,先看邻居有没有同一个 X
我一度把"空串静默关掉 digest"也算成这个 PR 的缺陷,写进了待办。驱动核实后**不是**:
`Number('') === 0` 过 `>= 0` 是这个文件里**四个旋钮共有的既存约定**。新旋钮照抄邻居。
处置因此完全不同:不是改代码,而是改两处说谎的文字(源码注释原写"非数值会回落到默认值而不是
静默关掉",空串两边都不是它描述的行为)。如果按"PR 缺陷"处理,我会去改四个旋钮的行为 ——
那会动到既存部署,为一个不存在的缺陷。
**这是第五轮"4 条被反驳"那类的同一价值**:拦住不该做的工作。
### guard 2 一天抓我三次,三次都对
1. spec 6 的 18(别人留下的漂移)
2. 我把 baseline 从 71 加 3 算成 **74**,真值 **79** —— 71 是合并前的数,我拿一个**已知会
过期的存量**当基数
3. (同上,重跑确认)
第 2 条最值得记:我今早刚写下"可靠口径是运行时计数或直接跑 guard 2",然后用了比静态计数更糟
的办法 —— 静态计数至少是去数当前的文件,"存量 + 增量"连当前状态都没看。
**动作**:**存量数字不能当基数。** 要一个计数就去测它,不要从记录里的数字推。
### 本轮产出
| 项 | commit |
|---|---|
| spec 6 baseline 18 → 17 + 交接 §0.1 | `47a1d12` |
| 索引"master == v3.9.17"不成立 | `628dbd9` |
| 合并 PR #241 | `1e2aaea` |
| digest 钳 + 3 条断言 | `eedf274` |
| 两条断掉的 anchor + 6 条新突变 | `9830571` |
| baseline 74 → 实测 79 | `87bd1e9` |
终态:门禁 **3447 / 0(264 文件)**、六个 spec **66 条 = 62 CAUGHT + 4 声明漏网**、
secret-scan EXIT=0、`git diff --check` 干净。
**#241 是行为变更且默认开,已随 v3.9.18 发布**(`0e16817`,tag `v3.9.18`)。这一行原本写
"未发版" —— 写下时是对的,发版发生在同一个会话稍后。**同一会话内会过期的句子,发版后要回来
改**,而我当时只改了交接 §6、漏了这里。
### 第十三轮的补充:发版后的对抗复核(15 个 lens + 逐条独立验证)
发版之后又跑了一轮 fan-out 复核,审这一轮自己的 11 个 commit 与全部 192 个 md 文件。
40 条唯一发现里 **35 CONFIRMED / 5 REFUTED**,而**密度最高的三个文件正是本轮改得最多的**
(交接 E 10 条、`devin-connect-openai.js` 8 条、本文件 8 条)。三条值得记的:
1. **我那条 digest 上限只堵了一半。** `1e9` 堵住了,`0.5` 没有 —— `Math.min(0.5, 32000)`
正确地得到 0.5,而消费者 `slice(-n)` 对 0<n<1 朝零截断,`slice(-0.5)` === `slice(0)` ===
整个字符串。实测 `MAX_CHARS=0.5` 送出全部 50000 字符。**clamp 抓不到它,正因为 clamp 是
对的。** 已加 `Math.floor` + 2 条断言 + 1 条突变(`2451ec8`)。
*判别动作*:给一个数值旋钮加钳之后,问"这个数最终被谁消费,那个消费者对非整数/边界值的
行为是什么"。钳住范围不等于钳住语义。
2. **一个专门用来修正不实说明的更正,自己引入了第三条不实说明。** 本文导航那句
"2026-08-05 实测:第 1150 行"在写下的瞬间就是错的 —— 我量的是编辑前的文件,而把它推到
1154 的正是同一个 commit 的那处编辑。*判别动作*:文档里写行号 = 写一个会被下一次追加推走
的引用;要么别写,要么在**最终状态**上重测。
3. **"共 N 处"里的 N 也是一个需要测量的数字。** 交接与 merge message 都写了"三处重新 spread
(3295/3437/3454)",实际四处,而 `3437` 这行不存在。结论没错(漏掉的 3229 同样保留
`.tools`),但枚举错了。`grep -c` 一次就能避免。
## 2026-08-06 第十四轮:出口 parity —— "mirrors X" 是一份没人当清单用的清单
问的是"同一条不变式在四条出口协议上是否都成立"。挑这个面的理由就写在本文
`## 方法论笔记` 里:被审过的地方是硬的,没审过的地方才有货。
### 一条真缺陷:Gemini 静默丢掉全部工具参数(第七次"只覆盖部分路径")
`src/handlers/gemini.js` 第 9 行自己写着 "Mirrors src/handlers/messages.js"。而
messages.js 的 **B4** 没镜像过来 —— 两处 `catch {}` 把畸形 `arguments` 塌成 `{}`。
同一份上游 payload 实测:Anthropic 出口保住 `__raw_arguments` 并记一行日志,Gemini
出口 `args = {}` 且零日志。已修 `aeb18dd`。
**判别动作**:一个文件声称"mirrors X",就把 X 里带编号的修复逐条对过去。**那句声明
本身就是最好的检查清单**,而它在这里躺了一个月没被当清单用。
### 一条既有测试把缺陷钉成了契约
`it('survives malformed tool-call argument JSON (args -> {})')` 断言
`deepEqual(args, {})`。它真正测的是"不抛异常",但把当时的返回值写进期望之后,数据丢失
在后来每个读者眼里都像刻意设计。
**判别动作**:"survives / 不崩"这类测试,断言只钉不崩那一条。**测试名里用括号解释返回值
是这个毛病的显式征兆。**
### 突变本身的两条
1. **第一次跑出的漏网是我的锚错了,不是守卫有洞。** 锚在 `error()` 的早退卫兵上,而真正的
latch 是它末尾的 `this.finished = true` —— 改卫兵不改 latch,`finish()` 照样早退,
**突变压根没注入它声称的缺陷**。理由写进了 spec 的 `name`,免得下一轮把锚改回去。
2. **我把"日志行被删"记成声明漏网,理由"断言日志需要 harness" —— 假的。**
`test/retry-rescue-budget-split.test.js:198` 从第十一轮起就在替换 `log.warn`。
**这与 PR #242 那条 settle-flush survivor 同一个毛病:"工具还没建"而工具在隔壁文件里。**
*判别动作*:声明一条漏网之前,grep 一遍要用的那个手法在 test/ 里有没有先例。
### 查证但未修
- `mapFinishReason` 的 `|| 'STOP'` 对 `finish_reason:'error'` 会翻成正常结束(special-agent
"H2" 要防的后果),但**不可达** —— error 帧先到并 latch `finished`。按 §4.7 不给到不了的
分支加映射,改成把前提写进注释 + 测试钉住。*我一度加了 `error: 'OTHER'` 又撤掉。*
*判别动作*:确认"可达"要走完整条链。我第一次的探针直接调 `openAIToGemini`,而生产上那个
值到不了它 —— 探针证明的是"函数会这样",不是"系统会这样"。
- `buildGetChatMessageRequest` 四处 `env` 穿线缺口。生产行为不受影响,但**零测试传假 env**,
所以是陷阱而非活缺陷:第一条传假 env 的断言会静默拿到真环境。坏探针形状,这次在源码侧。
### 顺带修掉的两处文档过期
- **第十一轮那张"仍未 exhaustive 扫描"表有两行过期**,而交接 §3.3 指路让人"取最后一个命中"
—— 第十二、十三轮都没有自己的表,读者恰好落在这张上。`#235` 第十二轮就解了(正文写了、
表没改),`release.yml` 那行写"要等下一次打 tag"而此后五个 tag 都跑过、六个 job 全 success。
**导航规则是对的,数据过期了 —— 而规则的正确性掩盖了数据的过期。**
- `docs/README.md` 首行把 master 声明成一个**不存在的 tag**(比 v3.9.19 高一位),而 master
实际领先 v3.9.19 五个 commit —— 两个方向同时错。文档守卫补了第 14 条(版本声明必须对上
真实 tag),它先红后绿。
*这里刻意不复述那句原话。* 第一版写法把错误的字符串照抄了进来,于是新守卫立刻抓到**本文**
—— 与 §7.4 那条同形:扫原始 prose 分不清"claim"与"对 claim 的引用"。守卫这一侧不再加特例
(剥引用块已经试过一次,而这次不是引用块);**记录一条已修的错误声明时,描述它、不要重贴它。**
### 本轮产出
| 项 | commit |
|---|---|
| gemini 出口 parity 修复 + 7 条突变 spec | `aeb18dd` |
| 突变锚改到真正的 latch | (见下) |
| 声明漏网撤销 + 日志断言 | (见下) |
| 两处台账过期行 + 索引版本声明 + 守卫第 14 条 | (见下) |
### 发版事故:一个只在 CI 条件下才坏的探针(本轮第五条,也是最贵的)
**v3.9.20 第一次打的 tag 让 Test job 红,六个 job 零产出。** 红的是本轮新增的那条版本守卫,
而本地三道门禁跑过三遍全绿。
两个原因叠加,各自单独都不够:
1. **它读 `.git/refs/tags` + `packed-refs`。** GitHub 的 checkout 是浅克隆,**只带正在构建
的那一个 tag**,于是每个更早的 tag 在 CI 里都"不存在"。我为这种情况写了探针检查
(`known.size > 0`)—— **它没救场**,因为正在构建的那个 tag 在场。
**一个只在"完全读不到"时才跳过的探针,挡不住"读到一半"。** 这是本文记过四次的坏探针形状
的第五次,而这次的新意在于:**它在我的工作副本里是好的,只在 CI 的 checkout 下坏。**
2. **它扫全部 md 文件,而本文件是 append-only。** 抓到的是本文里 `master == v3.9.17` ——
那句话**写下时为真**。在一份只追加的历史记录里,过期的旧声明正是它该有的样子。
第 2 条使这成为"守卫分不清声明与看起来像声明的东西"的**第三次**:第一次是交接 §7.4 那条
(扫原始 prose,匹配到勘误块里引述的旧指令),第二次是本轮我自己在这份台账里重贴了错误字符串,
第三次是这个。
修法去掉两个依赖:比对 `package.json`(每个 checkout 都有,且是 `src/version.js` 实际吐出的
值),并**只查会做当前声明的两个文件**(索引 + 活头交接)。
**判别动作**:一条新断言只要读了**仓库元数据**(git 状态、文件系统布局、环境变量),就必须问
"CI 的 checkout 和我的工作副本差在哪",并**在那个条件下跑一次**。可复用的复验方式:
```bash
git clone -q --depth 1 --no-tags file://$PWD /tmp/shallow && cd /tmp/shallow
node --import ./test/setup-env.mjs --test <the new test>
```
零 tag 的浅克隆下新版 15/15 通过,旧版在那里必红。**本地跑三遍不等于跑过。**
附带一条小的:核镜像时我用 `windsurfapi` 查 manifest 拿到 403,而真名是 `windsurf-api`。
**GHCR 上 403 和 404 都可能只是名字写错** —— 差一步就把"推上去了"读成"没推上去"。
删 tag 重打前核过三样(零 GitHub Release、零成功 job、GHCR 无该包),所以无人可能下载到东西;
理由写进了新 tag 的 annotation。
终态:**已发 v3.9.20**(tag → `4f05a71`,六个 job 全 success,四个产物 + 三个镜像 tag 已核)。
门禁 **3473 / 0(266 文件)**、七个 spec **74 条全部如声明且零声明漏网**、secret-scan EXIT=0、
`git diff --check` 干净。
## 2026-08-07 第十五轮:突变存活的三种成因,处置互相矛盾
这一轮的清单来自一个审计 agent 的 16 条假设(它自陈没有复核任何一条,而同一轮里已有 4 条被
证否、1 条是它编造的文件名)。判定完 16 条之外,方法论上得到两条,都是**在做的过程中被自己
的工具打脸**才发现的。
### 一、一条 SURVIVED 的突变有三种成因,而处置**相反**
本轮实测撞到全部三种,每种的正确反应不同 —— 读成同一回事,要么留着真洞,要么把计数凑饱满。
**(a) 更早的门先把 fixture 拦掉了。** 把 `COMMAND_CHAINING_RE` 收窄成 `/[&|]/` 的突变
SURVIVED。原因不是护栏有洞,是抓它的那条 fixture(`run cat /etc/passwd > /tmp/leak`)**无
反引号无句末标点**,被新加的 `bounded` 门在链式检查**之前**就拒了 —— 于是 `; > < $` 这几个
字符从来没承重过。处置:**补能通过前一道门的 fixture**(加了三条带反引号的:重定向、分号、
命令替换),之后它 CAUGHT fail=2。
**(b) 没有 fixture 同时满足突变需要的合取条件。** 把 `rule()` 两个分支合并的突变 SURVIVED,
因为没有输入**同时**做到「删掉 >512 字节」和「命中 >1 次」。处置:**造那条输入**(同一行
catalogue 重复两遍)。
**(c) 那行代码活着,但没有任何合法输入能让它说话。** 去掉增长分支的 `re.global` 判据什么都
不变:`String.match(非全局正则)` 返回 `[fullMatch, ...捕获组]`,所以计数只可能在**带捕获组的
非全局规则**上超过 1 —— 而那个文件里没有一条这样的规则。处置:`expectCaught: false`
**外加一条钉住前提的测试**(扫源码断言没有非 global 规则带捕获组)。一个前提未被断言的漏网,
与一个没解释的洞**读起来一模一样**。
**判别动作**:看到 SURVIVED,按这个顺序问:①有没有更早的门拦掉了 fixture?②有没有哪个合取
条件没被同时满足?③这行今天有没有任何合法输入能驱动?**①②的答案是"补测试",③的答案是
"不要补"。** 前两种记成 `expectCaught: false` 就是留着真洞;第三种去补 fixture 则是在给
到不了的代码造入口。
### 二、未被断言的日志行与不存在的日志行无法区分
两个修复的**交付物本身就是可观测性**(一条 `log.warn`),而两处都没有任何测试驱动那行日志。
形态:把「拒绝时记日志」的判据**反转**(`!stored && wantsPersistence` → `!stored &&
!wantsPersistence`)的突变 SURVIVED —— 因为没有任何东西在观察日志,反转与不反转对测试完全
等价。
补法:`log.warn` 是可替换的,存原值 → 换成收集器 → `finally` 里还原。补了三条之后那条突变
CAUGHT fail=2;同一个洞在另一个修复上存在,同样补掉(删掉日志行的突变现在 CAUGHT fail=1)。
**这条对整个仓库成立,不只那两处。** 凡是「这个修复的价值在于让下次踩到时可见」,就必须有一条
测试捕获那行输出,否则下一个人删掉它、改窄它、或把判据写反,门禁都是绿的。
**判别动作**:一个修复如果没有改变任何返回值、只增加了日志,问「删掉这行日志,哪条测试会红?」
答不出来就等于没做。
### 三、`--test-force-exit` 在并发下静默截断计数,而 exit 仍是 0
同一棵树(shasum 逐字节相同)连跑门禁三次:**3685 → 3687 → 3687**。第一次少两条,而
`fail=0`、`EXIT=0`。逐文件核对:某个文件的 `ℹ suites` 行在中途被切断,整个 suite 没登记。
不是 harness 缺陷:`run-test-shard.mjs` 的 `ok: !timedOut && code === 0` 会把真超时变成
exit 1。机制是 `--test-force-exit` 在 stdout flush 完成前切断子进程,而子进程仍退 0。多个
worktree 并发跑门禁时(评审多个 PR 就会这样)最容易出现。
**判别动作**:`pass=N fail=0` 里的 N 会静默变小。**引用门禁数字时,`EXIT=0` 加上跨次稳定
才是证据,单次读数不是。** 算术不闭合(基线 + 新增 ≠ 现值)时,先跑第二遍再怀疑代码。
### 四、探针错三次,其中一次生产了一条假缺陷
同一轮里写坏三个探针:env 变量名写错(`WINDSURFAPI_` 而非 `DEVIN_CONNECT_`)、方法名写错
(`push` 而非 `feed`)、以及**把返回字符串的 `flush()` 当对象读 `.text`**。第三个报出
「未闭合 span 不按 text 放行」—— 一条**不存在的缺陷**,而且它与被审 spec 的声明矛盾。
识破靠的正是那个矛盾:一个 EXIT=0 的 spec 声明「flush 释放未闭合 span 为文本」,而我的探针说
它不释放,**两个信号只能有一个是真的**。台账第五轮记过同款(拿已知非零输入喂同一探针),这次
起作用的是另一个更便宜的动作。
**判别动作**:探针结论与被审对象自己的声明/测试矛盾时,**先怀疑探针**。尤其当结论「恰好支持
你想写的那句话」时 —— 那是最需要停下来的信号。
### 五、跨会话引用的数字会腐坏,而它读起来和刚测的一样
本轮我连续三次向用户报告「致谢页 / 多语言」的问题清单,四个数字里**三个是错的**:
| 我说的 | 实际(本轮 `shasum` / `node -p` 实测) |
|---|---|
| 三个 README 内容完全相同,SHA 一致 | **两个**文件,SHA 不同,大小 40874 / 38040,**是真正的双语** |
| `README.md.bak` 也被 git 跟踪 | 该文件**不存在** |
| 四个徽章里三个链接指错 | **两个**徽章,链接都正确 |
| 台账 47 条 | **53 条**(README:521 自己就写着 53) |
| 致谢页应该从台账生成 | **已经是了** —— Dashboard 致谢面板早就从 `contributors.json` 渲染 |
这些数字来自更早的会话。上下文压缩之后它们作为"我已核实过的事实"活了下来,而我在三次报告里
**逐次引用、从未重测**。最后一条最能说明问题:我把一件**已经做完的事**提议成待办,还给它写了
理由("手写会造出第二个真相源")—— 那个理由本身是对的,只是它描述的问题不存在。
和第十一轮那条(交接里一个碰巧正确的未测量数字)是同一类,但方向相反:那次数字对而证据假,这次
**数字假而叙述自洽**。三个错数字混在正确数字(门禁 3687、207 个 anchor)中间,借到了它们的可信度。
**判别动作**:任何跨会话存活的数字,在**用它做决策之前**重测一次 —— 尤其当它支撑的是"还有一件
大事没做"这类结论。一句 `shasum README*.md` 就能推翻本轮三条。压缩后的记忆里,**"我核实过"这个
标记不会跟着数字一起保存**,所以旧数字和新数字看起来一模一样。
---
## 2026-08-10 第十六轮:审计工具与被审对象共享同一个盲点
第十五轮的教训是"跨会话的数字要重测"。这一轮把它推进一步:**重测用的工具,可能和产生错误的
工具是同一个** —— 那样重测只会确认错误。三个发现全是这一类,加上两个合并 PR 时抓到的。
### 一、一个计数和它的守卫出自同一条正则,于是互相印证而不是印证代码
交接文档记着"86 个环境开关,86 个全部有文档",并且把用来数它的那条 grep 标成"对的方法"。
**两半都错:真实 156 个,47 个在四份面向读者的文档里都搜不到** —— 其中包括上一轮新增的
**全部 5 个 wire 坐标开关**(`#13` prompt 缓存、`#12`/`#11` tool_choice、三个 signature tag、
两条路径的 user_jwt)。恰恰是最需要写清"tag 号来自 `.proto` 声明顺序而非抓包,所以默认关是
刻意的"那一批,而这段说明当时只存在于源码注释里。
成因是那条 grep 只认 `env.FOO`,而漏掉的 70 个恰好写成 `positiveIntEnv('FOO', 默认)` 和
`runtime-config.js` 里 `{env:'FOO', kind:'bool', def:true}` 的表驱动声明。**数字和覆盖率
出自同一个正则,所以"数一遍确认"这个动作本身没有独立性。**
同一个盲点在默认开台账里是**第四次**出现(`a98a572` `3af3f63` `c374ec7` 是前三次):它四个
发现模式都要求字面 `env.NAME` 紧邻 `'0'`/`'1'`,于是六个默认开开关带着绿灯上线 ——
`WINDSURFAPI_BREAKER`(关掉=坏账号永不摘除)和 `_SPEND_ON_DEMAND`(真实花钱)在内。
修:`93b38f9`(47 个补文档 + 正确数法写进 `docs/ENV-SWITCHES.md` 头部)、`c445c9d`(台账补
表驱动模式)、`d367a50`(新增 src→文档 方向的守卫)。原守卫只做"散文提到 → 必须在
`.env.example`",反方向注释里承认是盲区,理由是"正则会有几百个误报" —— 限定三前缀 + 只匹配
本仓库实际用的读取形式 + 先剥注释后,**实测零误报**。
### 二、我犯了同一个错误的**反面**,并据此写了一整个重复的测试文件
搜**环境变量名**得出"degraded serve 没有关闭路径测试",是错的:表驱动开关的关闭路径测试走的是
**运行时字段名**(`setBreakerTunables({degradedServe:false})`,那才是热改 API)。我照着这个
结论把测试写完了,才发现 `devin-connect-breaker.test.js` 早就测了,已删除。
一个实体两个合法名字,搜一个然后从沉默里下结论 —— 和上面那条是同一个错误指向相反方向。
`hasOffPathTest` 现在两个名字都认,别名映射**从表里读**而不是手写,并断言解析出 ≥20 条:
映射变空不会让任何测试变红,只会让真实测试重新隐形,是往"安全"方向的静默失效。
顺带纠正一处源码注释:`src/auth.js` 写着 degraded serve "Default OFF … until an operator
opts in",而 `ea1332e` 早就刻意把它翻成默认开,注释漏改(`0d2f1dc`)。排查 429 死循环时
这是第一个要确认的事实。
### 三、合并 PR 暴露的两个新变体
**(a) PR 的两处改动互相抵消。** `#252` 加了 `conflictsWithKimiPreamble()` 在 Kimi 前导下跳过
XML 强化段,又把 `toolReinforcement` 默认值换成一句泛泛的话。问题是**第一件的检测条件就是
"强化段里含 `<tool_call>`",而第二件把这个 marker 删了** —— 实测合并后隔离函数在默认配置下
永远返回 false,代码永远不执行。PR 真正生效的机制是"把冲突文本从全局默认值里删干净",不是它
声称的按方言跳过;而 `toolReinforcement` 是**无条件**拼给所有带工具的 Cascade 请求的,代价是
所有非 Kimi 方言一起失去格式示范。已还原(`0523910`),隔离函数开始真正触发,`#252` 涉及的
137 个测试仍全过。
原有测试用 `setSystemPrompts` **注入**含 marker 的文本,证明抑制**能**工作但没证明它**会被
触发** —— 默认值不带 marker 时抑制静默变 no-op,那条测试照样绿。**一个开不了火的守卫,和一个
通过了的守卫,看起来一模一样。**
**(b) 守卫的第五种读取形式,`#249` 一进来就漏掉了。** 它写
`export const LEAK_TRACE_ENV = 'WINDSURFAPI_LEAK_TRACE'` 然后 `env[LEAK_TRACE_ENV]` ——
读取点是**常量名**,四种模式全看不见。它自己文档写全了,于是守卫绿着、统计却少算一个,
正是这道守卫要防的失效从它不看的那扇门进来(`1fbefd0`)。开关数 156 → 157。
### 四、一条测试里两个随机失败,合起来约 7%
`test/getuserjwt.test.js` 的 `buildMetadata` 断言。`3c1a173` 引入时就带着,一直概率性通过,
合并后的全量恰好掀到 —— **第一反应是"合并破坏了什么",错的**。`buildMetadata` 有个随机字段,
两次同参调用**内容和长度都会变**(实测长度在 73/74/75 之间)。
- 扫裸 `0xaa` 字节判定"字段 21 存在":2000 次实测 **4.3%** 假失败。tag 字节只有落在字段
边界上才是 tag,任意字段 *值* 的任意一字节都可能是 `0xaa`。
- `without.length === plain.length` 判定"显式 null 不加字段":5000 次实测 **3.3%**。
台账第十一轮记过"断言 wire 不变要用字段不存在,不能用字节相等",**但比长度、扫单字节是同一个
坑的变体,规则那样写没拦住**。都改成断言解析出的字段结构(`f91d54b` `62dec04`),修后连跑
60 次 0 失败。
突变验证走了两轮才对:先试 `if (userJwt)` → `if (userJwt !== undefined)`,**SURVIVED** ——
但那是第十五轮 (c) 类的**无效突变**,传 null 时 `writeStringField` 本来就产不出字段,突变体
与原文行为等价。换成真会写出字段 21 的突变才变红。
### 判别动作
- **在信任一次扫描之前,先证明它能看见。** 往真实文件里追加一个已知坏样本,每种本仓库实际
用的形态各一个,要求守卫**逐个变红**;再检查一个近似但合法的样本(前缀碰撞、纯注释提及)
**保持绿**。这一轮五个探针几分钟就跑完,抓出两个真缺陷。
- **一个计数和执行它的守卫不要共用一条正则。** 共用时绿灯只说明它们彼此一致。
- **从派生查找表得出的结论,要给表本身加计数断言。** 表变空是往"安全"方向静默失效。
- **扫描说某样东西不存在时,先问它可能在另一个名字下面。** 同一个开关有 env 名和字段名两种
合法写法。
- **加了条件行为,要检查出厂默认值仍然满足那个条件**,并用**真实默认值**跑端到端断言,不要用
注入的 fixture。
- **间歇性失败在合并后出现,先用几千次直接调用量化**,再怀疑合并。
### 仍未 exhaustive 扫描 ⬜(第十六轮更新 —— 上一版四行中三行状态未变)
| 面 | 缺口 |
|---|---|
| billing tag 号(`#239` 相关) | 仍需**一次付费校准**。tag 来自第三方 `.proto` 声明顺序,不是测量。默认关 |
| backoff 倍数 | 同上,需要生产观测 |
| `#236` | 等报告者补可复现信息 |
| `#240` 预算耦合 | 报告者明确说没验证过。已按潜伏守卫处理 |
| ~~`known()` existence 项~~ | **前提已被断言钉住**(第十二轮)。不是死代码 |
| 五个 wire 坐标开关的 tag 号 | `#13` / `#12` / `#11` / `credit_cost ^14` / `Metadata #21`。**全部默认关**,一次付费抓包可一起解锁。**不要因为"看起来该开"就改默认** |
这一轮没有新增未扫描面。上表除最后一行外都需要**外部输入**(付费校准、生产观测、报告者补
信息),不是"还没做",是"做不了"。
## 2026-09-17 第十七轮:六条线战役 + 一次独立审查 —— 我审自己的东西时看不见的三类
背景:这一轮先做了 PR #271 的验收与六条独立审计线(请求路径 CPU、编码器与分帧器复制放大、可靠性、
安全、门禁工具、图像),33 个提交、发布 v3.9.36;随后由**另一主脑**(Codex,配三个分面 subagent)
对这批交付做对抗审查,派出一个独立 worker 修复,再由**写这批代码的会话**逐条复跑验收,发布 v3.9.37。
审查抓到 **1 个 P1 + 4 个 P2,全部在这一批自己的代码里**。
### 抓到的五条(以及为什么我自己的验证没抓到)
| # | 缺陷 | 我为什么漏了 |
|---|---|---|
| P1 | 锁回收按**固定路径**进行:两个回收者都读到同一个死 owner,慢的那个删掉的是**新持有者**刚建立的锁 → 两个写者并发、凭据可丢 | 我的验证全是"单线程正确性 + 字节不变";没有**确定性交错**这一类 |
| P2 | `owner.json` 半写(ENOSPC)留下非空目录,`rmdir` 必然失败 → 永久 BUSY | 我只测了"写失败要报错",没测"报错之后还能不能恢复" |
| P2 | `credentialStored` 默认 true → 存储未启用也报"已保存" | 新增字段只写了成功路径的用例 |
| P2 | 淘汰修复漏了 resolve 重关联分支 → 残留 29 条索引 | 我修的是"我想到的那条路径",不是"所有写入点" |
| P2 | **CI 的 wire 门禁在已打 tag 的提交上与自己比较**(`git describe --tags` 返回 HEAD 自己的 tag),门禁空转 | 我审了门禁的**逻辑**,没审门禁的**输入** |
最后一条最值得记:上一轮刚因为"怕 PR 抓出 bug"而加的门禁,**自己就是坏的**,而且恰好在**发布提交**
上坏 —— 也就是说它在最需要它的那一刻不设防。
### 判别动作
- **并发原语:身份不能是路径。** 只要"谁持有"靠**第二次写**(`owner.json`)或固定路径上的重读决定,
回收就不可能安全:总有一个窗口让两个参与者对同一个名字得出不同结论。身份写进**不可变实例名**
(host/pid/nonce),回收只删"那一个被证明已死"的实例,发布前再确认自己仍持有它。
- **给每一层安全网做"删除性突变":删掉它,必须至少有一条测试变红。** 这一轮的复核正是这么做的:
删掉发布围栏、删掉索引归属登记,测试都变红(好);但**删掉"guard 后二次读取旧身份"和"缺失身份
永不算死亡"两层,全套 4683 条测试依然全绿** —— 两层都被更早的另一道检查挡着。这不是缺陷,但意味着
未来一次重构可以在无人察觉的情况下把洞放回来。两条测试已补,各自在对应突变下变红。
- **每一个"与上一个 release 比较"的门禁,都要断言基线 SHA ≠ HEAD**,并对**选择器本身**写测试:
已打 tag 的 HEAD、非祖先标签、只有 lightweight 标签、没有合格基线(失败关闭)。
- **故障注入要打在真实的边界调用上**(`writeFileSync`/`readdirSync` 返回旧列表),不要只构造"函数返回
失败";"旧观察作用于新对象"这类交错只能靠"返回陈旧快照"来复现。
- **修复候选要能被拒。** 这一次 worker 交了两版不安全候选(只修普通竞争的、以及迁移时把原目录搬走的),
都被拒并要求给出"与未升级旧版并行"的交错证明。**"能过测试"不等于"协议成立"**。
### 仍未 exhaustive 扫描 ⬜
与第十六轮同表,无新增面:外部输入项(billing tag 号、backoff 倍数、#236 复现信息、#272/#273 的
上游 A/B 与逐帧抓包)仍在等数据。另记:锁的**动态突变 sweep** 在 Windows 上不可用(需受信 POSIX git),
四进程测试无同步起跑屏障 —— 抗争用证明由确定性交错测试承担。