Ctrl K
ComunidadGuías…

docs/HANDOFF-2026-08-03.md

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

Archivo original ↗Preguntar a KiBot
# 交接说明 — 2026-08-03 会话

> **⚠️ 这不是最新的一份。** 本文写的是 #234 首次分析,当前一份是
> [HANDOFF-2026-10-15.md](HANDOFF-2026-10-15.md)。**它的状态表与"仍未做"清单请一律以最新那份为准** ——
> 交接是 append-only 的,旧的不删也不改结论。本文仍值得读的是:§5 的方法论。
>
> 另:`ls docs/` 的排序**不等于**时间顺序(`-B.md` 的 `-` 排在 `.md` 的 `.` 之前,而
> `HANDOFF-2026-08-05.md` 的文件名日期是错的)。顺序只看索引 [README.md](README.md)。


**上一份**:[HANDOFF-2026-07-27.md](HANDOFF-2026-07-27.md)(v3.9.0–v3.9.6 那一轮)。
那份仍然有效,本文只写它之后发生的事与仍然要做的事,不重复它的方法论。

**行号会漂移。** 本文的每处 `file:line` 都在 commit `2e3d765` 上核过,并尽量同时给出
一个符号锚点 —— 上一份交接文档就因为引用 `special-agent.js:676`(那里现在是无关代码)
而误导过读者。行号对不上时按锚点 grep,不要按行号读。

配套:[AUDIT-LEDGER.md](AUDIT-LEDGER.md)(哪些子系统扫过、结论、守卫在哪)、
[DEVIN-CONNECT-CUTOVER.md](DEVIN-CONNECT-CUTOVER.md)(生产切换与付费校准)。

---

## 1. 当前状态,一句话

分支 `fix/connect-drought-and-blockers`,**门禁 3245 pass / 0 fail,尚未合入 master、
尚未打 tag**。版本号已 bump 到 **3.9.8**(v3.9.7 未单独发,两轮合并成一个版本)。
发版那几步(`merge --ff-only` → 推 master → 打 annotated tag → 推 tag)**需要作者
拍板后执行**。

commit 数会随本文自身的修订而变(每改一次交接文档就 +1),所以这里不写死数字 ——
权威读数是 `git rev-list --count master..HEAD`。

**2026-08-03 第二轮更新**:下面 §3.1–§3.5(#234 / #235 / #239 主体)**已经做完并
带突变验证**,详见 [RELEASE_NOTES_3.9.8.md](releases/RELEASE_NOTES_3.9.8.md)。仍未
修的是 §3.6 起那几条,以及 §3.9 / §3.10。保留 §3.1–§3.5 的原文不删 —— 它们记录的
是缺陷的推导过程和实测数字,重做同类判断时仍然是最好的参照。

`package.json` 已经是 3.9.7,所以**如果决定不发这一版,版本号要退回去**,否则
`src/version.js`(单点读 package.json)会报一个不存在的版本。

---

## 2. 这一轮做了什么(v3.9.7 待发内容)

release notes 在 `docs/releases/RELEASE_NOTES_3.9.7.md`,不在这里重复。只记结构:

| 类别 | 条数 | 说明 |
|---|---|---|
| 生产缺陷修复 | 6 | 断连吞 429 冷却窗口、上游 aborted 被当断连、socket/gRPC 码驱逐健康账号、空/畸形目录擦掉 last-known-good、缩水目录楔住、检索 query 只认下划线 |
| 外部 PR 合并 | 1 | #238 warelik(swe-1-7 reasoning-only 停滞),台账已记 S |
| 合并后自查修复 | 2 | reasoning 提升重复、rescue 输出被拼接 |
| 对抗复核修复 | 6 | **全部是本轮修复自己引入的**,详见 release notes 该节 |
| 假测试 / 失效守卫 | 5 类 | 4 条源码守卫经突变验证放过自己要抓的缺陷 + 1 条墙钟竞态 |
| 文档失真 | 6 | 含 `.env.example` 一条**活跃**的错 `DEFAULT_MODEL` |

### 最该记住的一件事

**六条对抗复核发现的缺陷全部是本轮修复自己引入的**,其中一条是 blocker(我把"空目录
响应"当成"最大幅度的缩水"走同一套确认,而接受一个空快照会**删除**过滤器 → fail open
→ 正是 #231 的症状;我只是把 fail-open 延迟了一个重试间隔)。

这是本项目 v3.9.0–v3.9.6 的主导失效模式第七次出现。**对抗复核是唯一稳定发现它们的
手段,而且必须在合并前跑,不是发版后跑。**

另有三次"我自己写的断言编码了我刚修掉的缺陷",以及一次"我写的守卫被自己的注释满足"
(正则匹配到注释里引用的 `applyCloudModels([])`)。**所有结构守卫在读结构前必须先剥
注释。**

---

## 3. 仍未修(有 file:line,可直接接手)

按价值排序。**§3.1–§3.5 已随 v3.9.8 做完**(标了 ✅),原文保留作为推导过程与实测
数字的参照。§3.6 起仍未修。

### 3.1 ✅ 已修(v3.9.8) 干旱判定会被单个账号触发

`auth.js:148` 的 `isDroughtMode()` 把没有配额数据的账号从**分母**里剔掉,
`droughtCount === knownCount` 于是变成"所有**有数据的**账号都干旱"。而
`refreshCredits` 失败时写的是 `credits = { lastError, fetchedAt }`(无 `weeklyPercent`),
正是上一份交接记过的"GetUserStatus 报 subscription has been canceled 而 chat 正常"
那个形态。

实测 40 账号池:

```
1 dry + 39 refresh-failed              drought=true   ← 1/40 就把整池限制到免费模型
1 dry + 1 healthy + 38 refresh-failed  drought=false
40 refresh-failed (none known)         drought=false
40 dry (真池级干旱)                     drought=true
```

触发条件精确:**≥1 个已知干旱 + 0 个已知健康**。一个刷新成功的健康账号就能压住。

另有观测口径不一致:`getDroughtSummary` 报 `knownAccounts=40`(Dashboard 横幅显示这个),
`isDroughtMode` 内部数的是 `1`。同一个词两个含义。

**这是潜伏缺陷** —— 干旱门禁在 connect 上从没跑过(见 3.2),所以它一直被掩盖;把门禁
接上的那一刻它变成用户可见故障。**修 #234 必须连它一起修,否则修复本身制造事故。**

修法方向:干旱判定要求最低已知覆盖率(而不是拿 1 个样本断言 40 个账号的状态),
`getDroughtSummary` 把两个 known 口径分开报。

### 3.2 ✅ 已修(v3.9.8) #234:干旱门禁在生产默认后端结构性不可达

`chat.js:2590–3366` 的 connect 短路块(锚点:`selectBackend({ modelInfo }).flow === 'devin_connect'`)**每条出口都 return**(逐字符核过大括号:流式
分支返回流式响应对象、非流式失败转移循环每个分支都 return、循环内无 depth-1 的
`break`),而干旱门禁在 `chat.js:3683`(锚点:`isModelBlockedByDrought(routingModelKey)`)。生产默认 `DEVIN_CONNECT=1`,所以它一次都没执行过。

**同一个结构原因还漏了 special-agent**:`special_agent` 分支在 `chat.js:3397`(锚点:`backendSel.flow === 'special_agent'`),也在门禁之上。
但 F1/F2 是**配置互斥**的 —— `DEVIN_CONNECT=1` 时 special_agent 分支本身不可达
(connect 优先级最高,5 个 special_agent 模型全解析成 `devin_connect`)。

**第三个逃逸点**:ACP vision 重路由(`chat.js:2597`,锚点:`acpVisionEnabled() && hasMultimodalContent`)在 strict-model 校验、entitlement
403、以及门禁的计划落点**全部之前**就 return 了。它不是 `selectBackend` 的 flow,是
`devin_connect` **内部**逃到 `special_agent` —— 所以按 flow 枚举的元守卫抓不到它。
**元守卫必须按逃逸点枚举,不能按 flow。**

### 3.3 ✅ 已修(v3.9.8) 干旱谓词跨了命名空间

`isModelBlockedByDrought` 用 `getTierModels('free')`(MODELS 空间)判 connect selector。
实测(当前 HEAD 仍如此):

```
isModelBlockedByDrought('swe-1-6-slow')     = true   ← connect 唯一免费可达
isModelBlockedByDrought('gemini-2.5-flash') = false  ← connect 根本路由不到
isModelBlockedByDrought('TOTAL-GARBAGE')    = true   ← 它是"不在免费表里"而非"是付费"
```

两个空间不相交(20 个 selector 是 MODELS key,但没有一个在免费表里,所以不存在意外正确)。
把门禁接上并复用这个谓词的结果是:**干旱时唯一还能跑的模型被屏蔽,而放行的那个跑不了。**

修法:新增 `isConnectSelectorBlockedByDrought(selector)` 走 `FREE_REACHABLE_SELECTORS`
(`auth.js:34` 已导入)。**不要复用也不要给旧谓词加参数** —— 两个空间就该是两个谓词。

**注意**:给它写"空集 fail-open"是**死代码** —— `FREE_REACHABLE_SELECTORS` 是模块级
冻结字面量,全仓无 `.add/.delete/.clear`。为它写测试会得到一条假测试。

`getDroughtSummary().freeTierModels` 要按后端解析,且**必须走 `getBackendSwitch` 而不是
`process.env.DEVIN_CONNECT`** —— Dashboard 可以在 env 未设时热切换 devinConnect(实测
env 删掉后 rows 130→56)。另注意 `getDroughtSummary` 是 arity 0 而 `handleModels` 收
per-request env,所以"四个面一致"不是结构性保证的,要显式钉。

### 3.4 ✅ 已修(v3.9.8) #234:发现视图必须**重建**,只过滤会更糟

两个独立 agent 发现同一件事,并**推翻了我原先的判断**:`swe-1-6-slow` 根本不在
`/v1/models` 那 56 行里(它连 105 个目录快照里都没有)。所以:

```
free-only 池:  现状 56 行宣传 / 0 行可达
               仅按 entitlement 过滤 → 0 行   ← Codex/Cline 零模型直接拒绝启动
unprobed 池:   过滤后 56 行(unknown tier 乐观放行)
               后台探测落地后几分钟自己掉到 0 行
```

还有三处:

- **`handleModels` 有两个行产出器** —— `listModels` 过滤(`handlers/models.js:21` 附近)与 live_catalog 合成循环(`:33` 起,live 目录填充后 86 行)。只过滤第一个,free-only 池仍宣传 86 个付费 selector
- **connect live 目录只从一个账号拉,永久 latch**(`auth.js:774`
  `if (_connectCatalogSynced || _connectCatalogSyncPromise) return;`)。加付费账号也不刷新,
  所以"跨活跃账号的 entitlement 并集"**没法从 live 目录取** —— 这条是 3.4 的前置
- **52 个目录 selector 在 chat 能跑却从不宣传**,全部 `mapped:true`

`hasConnectEntitledAccount` 在**空池**上返回 false,所以 fail-open 必须手写,且条件要
镜像 `chat.js:2801`(锚点:`hasEnvToken && !hasConnectEntitledAccount`)(`hasEnvToken || 空池`)—— chat 只要 env token **被设置**就豁免,
不看池大小,否则 token+free-account 混合部署会宣传 0 行而 chat 正常服务付费 selector。

**干旱状态不应影响 `/v1/models` 的行**:drought 是 15 分钟刷新 / 周重置的瞬态,让目录随
配额翻转会打死缓存模型表的客户端;而 #234 的验收原文是四处 "agree on the Connect model
namespace" —— 要的是命名空间一致,不是集合一致。

### 3.5 ✅ 已修(v3.9.8) #235/#239:credit 费率表与逐请求计费接线

`devin-connect-catalog.js:284-307` 已经解码 `#1.13.1.21`(逐模型 credit 费率表,
fixed32 f32),传入 catalog 时返回 `rateTable: { selector → creditFloat }`。

消费者是**零个**:`auth.js:2860` 调 `fetchUserStatus({ token })` 没传 catalog,只取了
`balance` / `periodStart` / `periodEnd`。

而免费/付费是硬编码的:MODELS 里 163 个模型 `credit == 0` 的有 **0 个**(`glm-5.2` 写死
`credit: 1.5`),`FREE_TIER_BASE = ['gemini-2.5-flash']` 静态,目录快照连 credit 字段都
没有。所以 #235 报的"我以为 GLM-5.2 免费,结果周配额用完了"**不是用户没找到设置,是代理
确实不知道** —— GLM-5.2 免费是动态促销(andya1lan 说到 7.5,报告者说 pro 促销到 8/16)。

接上就能得到 connect 命名空间的真实免费白名单,替代手维护的 `FREE_REACHABLE_SELECTORS`。

**一条纪律先写在这:字段缺失绝不能静默放宽免费列表。** 免费账号可能压根不带这个字段
(它在 plan 子消息里),那种情况必须回落到保守白名单,否则会烧掉用户的付费配额 ——
正是 #235 那个坑的放大版。校准需要付费 token,但接线与兜底可以先离线做完并测好。

### 3.6 `WINDSURFAPI_STRICT_MODEL=0` 做的正是作者说最不能做的事

它静默把付费请求改写成 `swe-1-6-slow`,**而且响应会撒谎** —— `connectMeta.displayModel
= reqModelName`(`chat.js:2933`(锚点:`connectMeta = { id: ccId`)),客户端看到的 `model` 是它请求的付费名:

```
STRICT=0  model=claude-opus-4-9-medium → 200,上游实际跑 swe-1-6-slow
          SSE 里 model 字段 = 客户端请求的那个名字
```

该 env var 全仓只在 `chat.js` 和它自己的测试里出现,`.env.example` 本轮已补(标注为陷阱)。
更要紧的是 **`test/devin-connect-strict-model.test.js:62-77` 把这个行为断言成期望行为** ——
套件在强制执行 AC5 的违反。改的时候要连那条测试一起改成"记录观察"而非契约。

### 3.7 #236:GLM 工具调用停滞 —— 诊断有结论,修法待定

两条猜测都被推翻:

- **不是"GLM 不支持工具"**:模型输出带 markup 时 `finish=tool_calls` 正常,链路是通的
- **不是 selector 没登记**:`glm-5-2-none` / `-max` / `-1m` / `-none-1m` 全部
  `mapped=true`(走归一化后的目录存在性检查),不需要补别名

真实机制:模型用**纯文本**声明工具意图却不发 markup → `finish=stop` + `tool_calls=0`
→ 客户端读成"选择用文字回答" → 循环停滞。三种形态实测:

```
A 文本声明意图、无 markup  -> finish=stop        tool_calls=0   (停滞)
B 文本带 markup            -> finish=tool_calls  tool_calls=1   (正常)
C reasoning-only           -> rescue 触发并愈合                (#238 已覆盖)
```

与 #238 同族,但 rescue 的条件是 `sawReasoning && !sawText`,**覆盖不到 A**。要不要也救
这一支需要真实上游复现后再定 —— A 的判据比 #238 模糊得多,容易把"模型真的只想用文字
回答"误判成失败。已在 issue 上请报告者用 OpenCode 直连复现并给两个具体信息。

### 3.8 #232 的保护在生产后端上完全不存在

`filterModelKeysByCloudCatalog` 与 `isModelAllowedByCloudCatalog` 在 `devinConnect` 时
都直接 early-return 未过滤输入(`models.js:757` `filterModelKeysByCloudCatalog` /
`:739` `isModelAllowedByCloudCatalog` —— 注意这两个的行号顺序与函数名是**反**的,
按符号 grep 不要按行号猜):

```
DEVIN_CONNECT=0  filterKeys=6/163   /v1/models=1 行
DEVIN_CONNECT=1  filterKeys=163/163 /v1/models=56 行(仅来自 selector 存在性,无 entitlement)
```

#231 报的症状在**生产默认后端上 100% 未修**。作为命名空间边界这个 exemption 是对的
(MODELS-space UID 过滤确实管不了 connect selector),所以结论是:那份保护要在 connect
命名空间里**重新做一遍** —— 也就是 3.4。两件事其实是一件事。

顺带一条窄的既存残留:已采纳的快照会永久往模块级 `MODELS` 表注入 key,且没有任何东西
移除它们,所以一个已删除账号的模型仍然被宣传(实测 `removeAccount` 后 key 仍在、
`listModels` 仍列、池 total=0)。

### 3.9 三条守卫覆盖缺口(记录,非交付缺陷)

都需要静态分析级改造,不适合塞进发版:

- `dashboard-escape-behaviour` 验证 helper 但不验证 **207 个调用点**(拆掉某个 sink 的
  转义调用,两个守卫仍全绿)
- `connect-dimension-guard` 只检查 `finalizeConnectAccount` 那一段源码(把冷却写到别处可逃)
- `handler-route-parity-guard` 的"绑定名被读到"可被一行 debug log 满足

### 3.10 更早积压(sticky 线,本轮逐条核实仍然成立)

1. 首轮 thundering-herd:bind-after-success + inflight-first 排序把同 caller 并发首轮散射
2. RPM 满(60s 窗口)即清绑定 → 换号重写缓存。彻底修需 queue-on-pin
3. `stickyBindByUserOnly=1` 时 cascade(真 modelKey)与 connect(null)写入折叠同槽;
   叠加 `stickyNoFallback` 可成自持硬失败楔子(需两个实验 flag 全开)
4. **绑定槽无 selector 维度**:一个 caller 的全部 connect 会话共享 `caller\0*`,按模型
   分区的池上会逐轮互相驱逐。本轮已把模块头那条错误的"模型维度防止跨模型冲突"注释改对,
   但**行为未修**
5. `clearCallerBindings` 生产无调用点(本轮重新核实仍如此)。注释已改为明确标注未接线
6. GPT 系 cache tag 校准存疑:注释说 GPT 系不带 tag4,而 #230 在 gpt-5-6-sol-max 实测到
   tag4。需付费 token 重跑 `npm run calibrate:devin`
7. response store:多个小租户可挤掉受害者会话(**既存局限非回归**,基线同样如此)。
   彻底修需按租户配额而非纯 LRU 的驱逐策略

---

## 4. 本机环境状态

- **账号池是空的**(`data/accounts.json` = `[]`),`.env` 里没有
  `DEVIN_CONNECT_TOKEN` / `WINDSURF_API_KEY`。**本轮全部验证都是进程内的,零上游调用。**
- `npm run login:devin` 默认走离线自检,真登录要 `LOGIN_REAL=1` + 邮箱密码。作者本轮
  尝试过但登不上
- 因此这两条**进程内不可判定**:
  1. connect 部署上 `refreshCredits` 到底能不能成功(影响 3.1 的现实频率,但**不影响
     修法** —— 修法对成功与失败两种情况都要正确)
  2. StopReason 3/5 的真截断捕获(旧账,当前实现已不依赖它)
- 本机推理网关会间歇 502/429,对抗复核有 agent 因此挂掉过(本轮 12 个 agent 挂 1 个,
  是证书错误)

---

## 5. 这一轮新增的方法论

上一份交接的 §5 仍然有效。这里只补它没有的。

### 5.1 对抗复核必须在**合并前**跑

上一轮是发版后跑,于是 v3.9.2–v3.9.6 每个版本都在修上一个版本。本轮改成合并前跑,
结果在**自己这一轮的修复上**找出 6 条(含 1 blocker)。同样的成本,换掉了一整个补丁版本。

### 5.2 "把 bug 固化成契约"的作者最可能是几小时前的自己

本轮出现 **4 次**:两条我写的断言编码了我刚修掉的缺陷,两条既有断言编码了别人的缺陷。
判别动作:**修完一个缺陷后,去看有没有测试在断言那个缺陷的现象。** 如果有,它的注释里
八成写着"the documented contract"之类的话。

### 5.3 结构守卫必须先剥注释

我自己写的目录守卫第一版**被自己的注释满足** —— 正则匹配到注释里引用的
`applyCloudModels([])`。与复核在同族守卫上查出的"注释满足断言"是同一个反模式。

### 5.4 守卫的词汇表会漏掉整个家族,而且会漏第二次

`connect-error-blame` 的演进史:UPPER_SNAKE 字面量 → 加 socket 码 → 加透传的小写 gRPC
status → 放宽字符类到含数字与连字符。**每一次都以为已经完整了。** 判别动作:问"这个
词汇表是从哪里推导的?那个来源会不会本身就不完整?" —— 透传(`code: code || 'X'`)
按构造就是开放的,任何从源码字面量推导的词汇表都看不见它。

### 5.5 注释里的论据也要验

我写"RETRYABLE_CODES 已把这些当可重试",复核实测 `isRetryable` 对那三个 gRPC status
全是 `false` —— 论据只对 socket 码成立。**"注释不是事实"这条,对自己刚写的注释同样适用。**

### 5.6 一条断言可能同时耦合两个维度

`rate-limit.test.js:120` 既断言精确毫秒、又隐含断言"走的是哪条分支"。注入 4.5 秒延迟后
另一条分支回答约 1622996ms,而原断言把它当成"大约 1632000"照样通过 —— **测试在走错代码
路径时仍然全绿**。修的时候两个维度都要显式钉。

### 5.7 操作纪律补两条

- **批处理命令容易让 shell 卡住**,拆细逐条跑更稳。本轮卡过两次,第一次我误判成"CPU
  被忙等进程占满"(实际 `pgrep` 无残留、磁盘 70Gi 空闲)—— **不要猜原因**
- **终端渲染会截断**:`git log --oneline -3` 只印一行。权威读数用 `.git/logs/HEAD`
- **本机 shell 是 zsh,不是 bash。** 两个 bash 习惯会静默失效,而且**失败长得和"守卫是
  瞎的"一模一样**:
  - `TESTS="a.js b.js"; node --test $TESTS` —— zsh 不对未加引号的变量做词分裂,两个路径
    作为**一个**参数传进去,node 报 `Could not find 'a.js b.js'`。用 `${=TESTS}` 或数组
  - `read A B < <(cmd)` —— 进程替换配 `read` 读不到值,变量留空
  
  接手时的判别动作:**突变验证必须先看 baseline 的 pass 数**。我这轮连拿 4 次
  "SURVIVED — 守卫是瞎的",全部是 `pass=0` —— harness 一个测试都没跑。
  `pass=0 fail=0` 不是"守卫没抓到",是"没测量"。**先证明 baseline 非零,再相信任何
  SURVIVED 结论。**(与台账里"confirmed:0 常是没跑成"同一条)

---

## 6. 需要作者拍板的

1. **v3.9.7 发不发**。代码就绪(3195 pass / 0 fail),三步未执行:`merge --ff-only`
   → 推 master → 打 annotated tag → 推 tag。**不打 tag 就没有二进制产物。**
   若决定不发,`package.json` / `package-lock.json` 的版本号要退回 3.9.6
2. **`total_tokens` 含 `cache_write`,因此不等于 `prompt + completion`**(上一份交接的
   遗留问题,仍未决)。复核认为破坏 OpenAI 契约;但 Cascade 路径(#118)早已刻意如此并
   写明理由。改它会同时改两条路径并推翻既有决定 —— 这是产品取舍
3. **#236 的 A 支要不要救**(3.7)。判据模糊,需要真实复现才能定