Skip to content

Latest commit

 

History

History
1773 lines (1293 loc) · 117 KB

AUDIT-LEDGER.md

File metadata and controls

1773 lines (1293 loc) · 117 KB

审计台账 / 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 §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 和我的工作副本差在哪",并在那个条件下跑一次。可复用的复验方式:

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), 四进程测试无同步起跑屏障 —— 抗争用证明由确定性交错测试承担。