Ctrl K
ComunidadGuías…

docs/HANDOFF-2026-08-05.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 交接说明 — 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 -- <file>` 是从**索引**恢复(索引里正是那次
revert),要用 `git checkout HEAD -- <file>`。

---

## 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 -- <file>`
- **`npm run mutate` 会因为工作树脏而拒绝运行**,这是设计如此(见台账第九轮)。它拒绝过
  它自己的作者三次,每次都是对的