# 交接说明 — 2026-08-05 > **⚠️ 这不是最新的一份,而且文件名的日期是错的** —— 工作发生在 **2026-08-04**,写的是 > **v3.9.12**。因为 `08-05` 排在全部 `08-04-*` 之后,它在 `ls docs/` 里看起来最新,实际是 > 第五旧。文件名不改是因为别处引用了它,而重命名会同时断掉链接与 git 溯源。 > **当前一份是 [HANDOFF-2026-10-15.md](HANDOFF-2026-10-15.md)。** > 它的状态表与"仍未做"清单请一律以最新那份为准 —— 交接是 append-only 的。 > 索引在 [README.md](README.md);**`ls docs/` 的文件名排序不等于时间顺序**。 > 本文仍值得读的是 §6 的方法论,以及下面这条判断错误本身。 > **本文 §3/§4 已过期(同一会话内)。** queue-on-pin 当时记成"需作者拍板",作者一句 > "改成功能不就行了"点破了:做成**默认关的开关**,取舍就交给运维而不是代码。已实现并随 > **v3.9.12** 发布,见 §3′。§4"为什么没发版"因此不再适用 —— 已发版。 > > 留下原文不删,因为"把能做成开关的东西升级成拍板事项"本身是个值得记住的判断错误。 **上一份**:[HANDOFF-2026-08-04.md](HANDOFF-2026-08-04.md)。它的 §4.1 三条积压本轮全部 有了结论 —— **其中两条的结论是"不该按它写的方式做"**,见 §2。方法论沿用上一份 §6,本文 只补新增的。 --- ## 0. 当前状态 ``` 版本 3.9.12 最新 tag v3.9.12 master v3.9.12 之后累积一批未发版改动(见 §8) 工作树 干净 门禁 3378 pass / 0 fail(258 个文件,exit 0) secret-scan EXIT=0 ← 注意它只扫**被跟踪**文件且**跳过 test/**,而本轮 diff 大半在那 ``` 门禁数字承接上一份的权威表: | ref | pass | 备注 | |---|---|---| | `v3.9.11` | 3302 | | | `v3.9.12` | 3342 | +40:维度行为 9 + 抖动取舍 5 + queue-on-pin 26 | | `master` | **3378** | +36,**未发版**:strict-usage 15 + 分片 10 + 散射归因 7 + 折叠旁路 4 | --- ## 1. 本轮做完了什么 **没有任何行为变更。** 全部是守卫、文档、以及两条积压的结论。唯一写过的行为修复经实测 是净负面,已 revert(§2.2)。 | 项 | 产出 | |---|---| | 冷却维度守卫从源码切片改行为断言 | `connect-dimension-behaviour.test.js`(9 条,5 突变全 CAUGHT) | | `clearCallerBindings` | 结论"不接线",依据+实测写进注释 | | RPM 满即清绑定 | 显然的修法实现→实测净负面→revert,留 `sticky-transient-blip-tradeoff.test.js`(5 条)钉现状 | | 文档索引自相矛盾 | `docs/README.md` 第 1 条仍指 08-03 为"current",已修 | | 台账 | 第六轮 + 第七轮 | | 分支清理 | 7 个本地分支删除(6 个已合入 + 1 个内容已被 master 超集包含) | --- ## 2. 上一份 §4.1 三条的结论(接手时最要紧) ### 2.1 冷却维度守卫 —— 缺口是真的,但形状和台账记的不同 台账三处都记成"把冷却写到别处可逃"。逐个核实后,**更要紧的一条它没记**: 既有的行为断言(`connect-dimension-guard` 的行为半边、`connect-capacity-cooldown.test.js`) **全都自己用 `markRateLimited` 手写冷却**,再断言 `auth.js` 认这个维度。那证明的是 auth 侧 逻辑对;而 #224 / #230 / v3.8.0 三次错的都是 **chat.js 选哪个维度** —— 生产侧。 承重细节:既有测试给 `model` 与 `selector` **传同一个字符串**,所以两个维度看起来一模一样, 缺陷在它们眼里不可观测。 突变 2 是关键证据:把同一缺陷搬到切片外的 helper,旧源码守卫 **10/10 全绿**、旧 capacity 行为测试 **4/4 全绿**、新断言抓到 3 条。 旧守卫**保留**(它还守"切片内不得出现新的坏维度调用点"),但它头注释里"断言的是 INVARIANT 而不是具体那一行"这句被突变 2 证伪,已改成如实描述。 ### 2.2 ⚠️ RPM 满即清绑定 —— 显然的修法是**净负面**,不要再做一遍 **这是本轮最重要的一条,也是最容易被下一个人重犯的。** 我按显然的修法实现了:瞬时不可用(RPM / 冷却 / 维护)时保留绑定,`_stickyRotated` 让四个 写入点跳过替补轮的重钉。它通过了每一道自设关卡 —— 12 条断言、9 次突变双向覆盖(含"过度 修复"方向)、门禁 3323/0。 **然后测代价,方向是反的。** 6 轮受阻期间触达的不同账号数(每个 = 一次完整前缀写): | 做法 | 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,**而那恰好是我自己在修复注释里写的 "自我强化"场景**。 `sticky-transient-blip-tradeoff.test.js` 钉住现状并记下三方数字。承重断言是"后续受阻轮复用 同一个替补" —— 把 revert 掉的修复打回去,5 条里 3 条失败。 ### 2.3 `clearCallerBindings` —— 结论是"不接线",这条可以从积压里划掉 不是"该由哪个事件调用是设计决定",而是**没有正确的事件**: - **断连是主动错误的触发点。** 钩子确实存在(`server.js` 的 `res.on('close')`),所以这是 活着的诱惑。但 agent 客户端持续半途取消,而代码库其余部分刻意把取消当"什么都没发生": connect finalizer 对 abort 免除全部惩罚,`poolCheckin` 把签出的 cascade 条目**放回池里**。 在那里清绑定会亲手制造本模块要防止的上下文丢失 —— 放回的条目仍带账号 X 的 apiKey,下一轮 checkout 要 Y,在 `conversation-pool.js` 的 apiKey 比较上 MISS,整段历史重放 - **没有 session-reset 事件。** 唯一 caller 维度的 DELETE 是 `DELETE /v1/responses/{id}` - **账号维度的清理也不值得加。** 死绑定单调老化到 LRU 前端、**最先**被驱逐。实测 `STICKY_SESSION_MAX=10`:4 死 + 6 活,插 4 条 → 4 死全被驱逐、6 活全存活,与提前回收的 存活集**完全相同**。死绑定是免费驱逐缓冲。且 `status='error'` 无论如何是错的触发点 —— `maybeRecoverErrorAccount` 会半开回 `active` --- ## 3′. queue-on-pin —— 已实现(v3.9.12,默认关) **`WINDSURFAPI_STICKY_QUEUE_ON_PIN_MS=2000`**,或面板设置页的 `stickyQueueOnPinMs`(可热调)。 需 `STICKY_SESSION_ENABLED=1`。默认 `0` = 关。 下面 §3 原文把它记成"产品取舍,需作者拍板"。**那是判断错误**:做成默认关的开关,取舍就 交给运维而不是代码,现有部署字节级不变 —— 仓库对同类取舍本来就是这么处理的 (`STICKY_SESSION_ENABLED` / `stickyNoFallback` / `SINGLE_TENANT_CACHE` 全是 opt-in)。 **动作(记住这一条)**:把一件事标成"需要拍板"之前,先问"它能不能做成默认关的开关"。 能,就不是取舍,是一个功能。 方向已验证(第七轮"突变不检查方向"那条教训的第一次应用),含 2 轮 warmup: ``` 关 A,A,sub,sub,sub,sub,sub,sub 触达 2 个账号 = 2 次完整前缀写 开 A,A,A,A,A,A,A,A 触达 1 个账号 = 1 次 ``` 代价实测**每受阻轮 +1 秒**。下界不可选:`getAccountAvailability` 对 `retryAfterMs` 有 1000ms 地板,窗口实际 400ms 恢复也会等满 1 秒。想突破得自己从 `_rpmHistory` 重算窗口 —— 那是把 auth.js 的逻辑复制一份,正是"只覆盖部分路径"的成因,**不要做**。 实现细节与 13 次突变(3 次漏网及处置)见 `AUDIT-LEDGER.md` 第八轮。 --- ## 3. ~~queue-on-pin —— 设计与数字已备齐,等你拍板~~(已被 §3′ 取代,原文保留) 本轮推翻了台账"彻底修需 queue-on-pin(言下之意不可行)"的判断。**它可行**: - `getApiKey` 同步,但 `src/` 里只有 4 个调用点,改异步只需 1 个 `async` + 2 个 `await`。 真正的代价不是接线而是**原子性** —— 选择是池状态的纯函数,读写之间插入 `await` 会重新 引入 #37 要防的超订。另有 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):延迟换缓存 成本。这是产品取舍,不是工程判断。 --- ## 4′. 已发 v3.9.12 原文(下)判断"零行为变更所以不发版"。那个判断在当时是对的,但 queue-on-pin 落地后本轮 **有了一个真实的(opt-in)功能**,所以发了 v3.9.12,产物照常四个。 ## 4. ~~为什么没有发版~~(已被 §4′ 取代,原文保留) 本轮**零行为变更**(唯一那条已 revert)。tag 会触发四个产物构建,而这些产物在行为上与 v3.9.11 完全相同 —— 没有任何东西需要用户去装。所以: - 版本停在 **3.9.11**,master 累积 9 个 commit 未发版 - 下一次带真实行为变更的发布顺带带上这 14 条测试与文档更正 - 想单独发也可以,`package.json` + `package-lock.json`(两处)bump 到 3.9.12,照上一份的 发版顺序走即可 --- ## 5. 仍未做的 ### 5.1 需你拍板 - **`total_tokens` 含 `cache_write`**:上两轮遗留,仍未决。复核认为破坏 OpenAI 契约;但 Cascade 路径(#118)早已刻意如此并写明理由,改它会同时改两条路径并推翻既有决定 - ~~queue-on-pin~~ —— 已按"默认关的开关"实现,见 §3′。**它本来就不该在这一节** ### 5.2 可离线做 - **cascade 写入点的 `acquireAccountByKey` 路径**:该路径不经 `getApiKey`、因此不经 sticky 快路径,而成功路径的绑定写入仍会触发 —— 也就是绑定被覆盖而全程没有任何 clear。 **只读推断,未驱动复现**,是本轮唯一新增的未验证条目 - **散射的确切成因**:移除两个排序机制后仍散射,成因在候选**过滤**,未隔离 ### 5.3 卡在真实上游 - **billing tag 号校准**(#239 最后一环):需付费号跑 `DEVIN_CONNECT_DEBUG_META=1 npm run calibrate:devin` - **`rateTable` 真实响应**:`GetUserStatus` 403,从未见过 - **#236 GLM 工具调用停滞**:判据太模糊,需真实复现 --- ## 6. 本轮新增的方法论 ### 6.1 突变验证不检查方向 —— 这是本轮最贵的一课 我的修复通过了 12 条断言、9 次双向突变、全绿门禁,**而它在目标 regime 里让情况变坏了**。 突变验证只能证明"断言在测东西",对"这个方向是否正确"一个字都没说。 **动作**:改行为(而不是修明确的错误)之前,先构造目标 regime 并测出**修改前后两个数字**。 没有这两个数字,再密的测试也只是把一个未验证的方向钉牢。 ### 6.1b "需要拍板"之前先问"能不能做成默认关的开关" 本轮我把 queue-on-pin 升级成"产品取舍,需作者拍板",理由是它把 caller 吞吐钉在单账号 RPM 上。作者一句"改成功能不就行了"点破:**默认关的开关没有取舍** —— 不开的人行为字节级 不变,开的人自己选。仓库里三个同类开关都是这么做的。 **动作**:标"需要拍板"之前,先问"能不能做成默认关的开关"。能,就不是取舍,是一个功能。 真正需要拍板的是那些**无法做成开关**的(比如 `total_tokens` 含 `cache_write` —— 改它会 同时改两条路径并推翻既有决定,没有"默认关"这个选项)。 ### 6.1c 守卫要守在调用点,不是被调用的函数上 queue-on-pin 的 13 次突变里 3 次漏网,全是同一形态:断言测了**被调用的函数**,没测 **调用点用它没用**。 - 加了非破坏性的 `peekStickyBinding` 并为它写了三条断言 → 把等待循环换成 mutating 的 `getStickyBinding`,三条**全绿** - 为"按 selector 维度问可用性"写了单元断言 → 把调用点改成传 `modelKey`(连接路径恒 null), 26 条里 25 条**全绿** **动作**:为一个 helper 写了守卫之后,立刻问"谁该用它,以及如果那个人改用了别的东西, 哪条断言会红"。答不出就补一条驱动真实调用点的断言。 ### 6.1d 突变漏网不一定该补断言 —— 也可能是"它真的守不住" 第三条漏网(删掉 `WAITABLE` 白名单,26/26 全绿)查下去发现:每个不可等的 reason 都把 `retryAfterMs` 写死 60000,而旋钮 max 是 30000,预算那道闸已经全挡了。白名单是 belt-and-braces,**不是承重件**,任何行为测试都区分不出它在不在。 没有假装守住它,改成钉住让这份冗余安全的**前提**(每个不可等 reason 的窗口必须超过任何 允许的预算)。谁把某个 60000 调小,白名单会在那一刻静默变成唯一的拦阻,而那条断言会在 那一刻失败。 **动作**:突变漏网时先问"这个守卫该守哪一侧",再问"补断言能不能守住"。不能时,如实写下 它守不住、以及什么前提让它现在无害。 ### 6.2 既有测试可以当对抗者,不只是防回归 第一版把 `excludeKeys` 归成"瞬时"(它随请求过期)。`sticky-exclude-keys.test.js` 三条断言 立刻失败,而它的头注释正好写着原因:走到那里的失败类型是**死 token**,账号仍 `active` 且 无冷却,`excludeKeys` 是唯一挡住它的东西。保留绑定会让下一轮重新解析到已知坏账号。 **守卫的第二种价值:在设计阶段否掉一个错误的分类。** ### 6.3 harness 里"读起来像推导"的常量 `fillRpm` 原本写 `auth.rpmLimitFor ? auth.rpmLimitFor(acct) : 60`。`rpmLimitFor` **没有导出**,所以三元永远走 fallback,填的一直是字面量。当时仍然通过、且是因为正确的原因 (种子账号 `unknown` 档 = 20,填 60 确实超过),但那是运气。 **动作**:harness 里每个数字都要能回答"它是怎么来的";把"证明前置条件成立"写成断言,而不是 假设它成立。改后填过 `TIER_RPM` 上界并用探针实测证明账号已不可选 —— 把常量改成 3,12 条里 5 条立刻失败。 ### 6.4 并发 agent 会动共享工作树 本轮用 workflow 并发调查时,一个 agent 为了复现被测 commit 而把 `src/` 改回了我已经 revert 的版本,并留下 4 个 `zz-probe*.test.js`。它对自己的任务是对的,但那是**共享状态**。 **动作**:给并发 agent 的指令里写明"不得改 src/、必须 `git checkout --` 还原并验证 `git status --short` 干净";收尾前自己再核一遍工作树,别信指令被遵守了。另外 `git revert --no-commit` 之后 `git checkout -- ` 是从**索引**恢复(索引里正是那次 revert),要用 `git checkout HEAD -- `。 --- ## 7. issue 状态 与上一份相同,5 个 open、全部是维护者最后发言、没人在等回复。本轮无行为变更,因此没有需要 同步到 issue 的结论。 回复风格约束仍然有效:**结论 + 证据 + 下一步,十几行。** 工作笔记属于台账,不属于 issue。 --- ## 8. v3.9.12 之后:四件事 + 整体复核(未发版) 作者点了四件事,依次做完,最后整体 review。**未发版** —— 见 §8.5。 ### 8.1 `total_tokens` 含 `cache_write` → 做成默认关的开关 上一份 §5.1 把它列成"需拍板,且做不成开关"。**后半句是我的断言,而且错了。** `WINDSURFAPI_STRICT_USAGE_TOTAL=1` 恢复 OpenAI 的 `total == prompt + completion`。默认 0, 因为两种失效不对称:恒等式被破对绝大多数消费者只是观感;而把 cache_write 从 total 里拿掉 会**少报真实开销**,按 total_tokens 计费的中转会悄悄少收钱。 真正的难点不是加开关,是**成本统计读的是同一个对象**。天真地加开关会让运维一旦选择合规、 自己的账单统计当场少报 —— 为观感付真金白银且完全无声。所以拆开两个职责:`total_tokens` 只管线上契约,成本统计从 `cache_creation_input_tokens` 独立还原完整成本(它本来就在线上)。 有断言钉住"同一个请求在开关前后账单完全相同"。 三个 usage builder(Cascade / connect / ACP)全部覆盖 —— 第一版漏了 ACP 那个,复核抓到。 ### 8.2 远端分支已删 `copilot/explain-repository-structure`、`fix/handoff-followups` 均已完全合入、无关联 PR。 远端现在只有 `master`。恢复用 SHA:`db5c902`、`f17f435`。 ### 8.3 散射的确切成因 —— 台账记的结论是错的 台账几轮都写着"移除两个排序机制后仍散射,所以成因在候选**过滤**"。**两处都错**,而方法 是错的根源:它靠在两轮之间**清账号状态**来"关掉"一个机制,而那些字段**就是**机制本身。 改成逐个在源码里关掉一个比较项、状态一律不动: - **in-flight 对顺序散射毫无贡献** —— 请求在下一次取号前已释放,所有账号并列 0。它的注释 说的是"让并发突发散开(#37)",那是对的,但那是另一个场景。**两个现象被当成一个记了** - RPM 剩余比例与 LRU **各自独立充分**,必须同时关掉才会钉住 —— 压根不存在单一"成因" - RPM 剩余比例是持续驱动;LRU 只给一次交换然后收敛 ### 8.4 顺带挖出一个真缺陷:分片反噬 issue #37 追成因时发现的。caller 分片的门槛只判 `candidates[0]` 与 `[1]` 打平,然后按 `hash % candidates.length` 在**整个数组**上取下标 —— 于是它能把与谁都不平的账号提到队首, **包括在场最差的那个**。 并发突发下正是如此:正在服务的账号按 in-flight 排到最后,队首两个空闲账号打平、门槛放行, 交换把最忙的拉回 `[0]`。实测 pool=8 某个 callerKey 连续 8 次选中同一个账号,而七个同伴 in-flight 全是 0 —— 正是 #37 要防的堆积,被它自己的 tiebreaker 重新引入。 一直看不见,因为结果完全由 `sha256(callerKey) % pool` 决定(bucket 0→8/8,bucket 7→1/8), 而钉这条行为的测试用的 callerKey 恰好 hash 到最好的那一档。8 个真实形态的 callerKey 里 **只有 2 个真的散开全池**。 修法:置换限制在真正与 `candidates[0]` 打平的前缀内。修完 8/8 全部散开。 ### 8.5 为什么仍未发版 整体复核查出**六条实质缺陷,全部是这一轮自己造的**(详见台账第十轮),已全部修掉,但 门禁跑完到现在没有再走一遍完整发版流程。发版前该做的: ``` npm run test:release # 期望 3378 / 0 node scripts/secret-scan.mjs for s in test/mutations/*.json; do npm run mutate -- "$s"; done # 四个 spec 全绿 ``` 四个 spec 当前:collapse-bypass 3、queue-on-pin 13、strict-usage 11、shard 8。 ### 8.6 接手前必读的两条操作纪律 - **并发 agent 会吃掉未提交的改动。** 复核 agent 的 `git checkout HEAD -- src/x.js` 清理 会把你未提交的编辑一起还原。本轮真发生了:一个 commit 的 message 声称改了 `auth.js` 而 diff 里没有。让它们在 **worktree** 里跑,或者改完立刻 commit;commit 后核一次 `git show --stat HEAD -- ` - **`npm run mutate` 会因为工作树脏而拒绝运行**,这是设计如此(见台账第九轮)。它拒绝过 它自己的作者三次,每次都是对的