Ctrl K
ComunidadGuías…

docs/HANDOFF-2026-07-27.md

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

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

> **⚠️ 这不是最新的一份。** 本文写的是 v3.9.0–v3.9.6,当前一份是
> [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)。


v3.9.0 已发布,之后 master 累了 **10 个未发版 commit**。这份文档是给下一个接手的人
(或下一个会话)的:未发版的内容是什么、还剩什么没做、以及这一轮学到的方法论。

配套文档:[AUDIT-LEDGER.md](AUDIT-LEDGER.md)(哪些子系统扫过、结论、守卫在哪)、
[DEVIN-CONNECT-CUTOVER.md](DEVIN-CONNECT-CUTOVER.md) §8.0.1(首次付费 wire 校准结论)。

---

> **2026-07-28 更新**:§3 原清单的 1–7 已全部修完(见该节表格),未发版 commit
> 增至 15 个。发 v3.9.1 的理由不变且更充分。剩余未修项只有 sticky 线的旧积压。

## 1. 最要紧的一件事:v3.9.0 是坏的,应尽快发 v3.9.1

**已发布的 v3.9.0 里,它自己的头号功能对最常见的客户端形态完全不可用。**

`previous_response_id` 参与了 callerKey 派生,而它每轮都变 → turn 1(没有它)和
turn 2(有)派生出不同 callerKey → response store 的按调用方隔离拒绝查询 →
**不发 `user` / `prompt_cache_key` 的标准 OpenAI SDK 客户端每轮链式必 404**。

已在 `30485c3` 修复。这是 v3.9.1 的主要理由。

---

## 2. 未发版内容(v3.9.0..HEAD,10 个 commit)

按用户可感知程度排序。

### 用户可感知的缺陷修复

| 缺陷 | commit | 影响 |
|---|---|---|
| `previous_response_id` 破坏 callerKey → 链式必 404 | `30485c3` | **blocker**,见上 |
| `StopReason 4` 被猜成 `length` | `30485c3` | 付费账号上**每个完整响应**都被报成截断:chat 的 `finish_reason='length'`、responses 的 `status='incomplete'`。按 length 自动续写的客户端会对完整答案无限续写 |
| usage 破坏子集不变量 | `30485c3` | 缓存命中轮出现 `cached_tokens(1765) > prompt_tokens(3)`,`total` 少报约 91%。前置计费中继按这些数字计量 |
| 流式丢失截断信号 | `30485c3` | 同一请求非流式报 `incomplete`、流式报 `completed` → agent 把半截答案当完整收下 |
| 不可应答对话直达上游 | `8334cea` | 空 input / 只 system / 只 assistant → `UPSTREAM_INTERNAL` → 连续 2 次隔离账号 120s。可把池**降级到单账号**(`lastAccountExempt` 保证每 tier 留一个存活,不能完全打空) |
| system prompt 每轮重复 | `09a7796` + `2ced8a2` | 第 N 轮送 N 份相同 instructions;更糟的是 toggle-back(X→Y→X)会保留**已撤销**的指令 |
| **跨租户泄漏** | `e838341` | store 用完整 callerKey 隔离,而 `:client:<ip+ua>` 是猜测身份:反代后两个用户派生出**逐字节相同**的 callerKey → B 能读 A 的整段对话 |

### 内存与健壮性

| 项 | commit |
|---|---|
| response store 加字节预算(`RESPONSE_STORE_MAX_BYTES`,默认 128m) | `dc0a2ad` |
| 字节驱逐按**租户字节份额**公平化 + 单条上限 + 内容级兜底裁剪 | `2ced8a2` |
| `truncateMessages` 负数 slice bug(501 条进 → 901 条存) | `30485c3` + `4f29f23` |
| `approxBytes` 计入 base64 图片 + 非 Latin1 按 2 字节/字符 | `2ced8a2` |
| 协议层地基补 20 条硬化测试 | `0179306` |

### 新增配置(README 中英 + .env.example 均已记录)

- `RESPONSE_STORE_ENABLED`(默认开)/ `_TTL_MS` / `_MAX` / `_MAX_MESSAGES` / `_MAX_BYTES`
- 链式需要 per-caller 身份:`user` / `safety_identifier` / `prompt_cache_key`;
  单用户自部署用 `WINDSURFAPI_SINGLE_TENANT_CACHE=1`

**测试 2856 → 3001**,门禁 226 文件,全绿。

---

## 3. 已确认但未修的(有 file:line,可直接接手)

### 2026-07-28 已修(接手会话,分支 `fix/handoff-followups`)

原清单 1–7 全部修完,每条都有回归守卫且做过突变验证。全量
`npm run test:release`:**3052 pass / 0 fail**(基线 3001)。

| 原编号 | 结论 | commit |
|---|---|---|
| 1 `StopReason` 3/5 | 两个猜测值退役为 `stop`;截断改由 `resolveFinishReason` 按 `completion_tokens == 上限` 判定(不依赖未校准整数)。运维方的 `STOP_REASON_MAP` 覆盖优先级最高 | `92946c5` |
| 2 流式异常中断报 `completed` | **根因比原记录更深**:devin-connect 把"缺 end-of-stream 帧"当正常抽干 → 抛 `STREAM_TRUNCATED`(可重试)。translator 另行关成 `incomplete`(理由 `upstream_incomplete`)且不再进 store | `2c85edb` |
| 3 `'content'` 子串匹配 | 改整词匹配明确词表;`content_complete` / `no_content` / `contents_delivered` 等不再被误判成拒答 | `855fe7c` |
| 4 字节预算失效 | 公平份额加下限 `MIN_TENANT_BYTES`。实测 600 租户:5.88 倍零驱逐 → 1.0 倍 498 次驱逐 | `9378b5b` |
| 5 `normalizeConnectUsage` 调用点无覆盖 | 新增 `test/devin-connect-finish-callsite.test.js` 咬住 finish 事件本身 | `92946c5` |
| 6 CUTOVER §8.7 过期 | 重写,并写明"截断由 usage 判定"这条新机制 | `92946c5` |
| 7 无检索端点 | `GET`/`DELETE /v1/responses/{id}`,作用域与续接一致;另有一套走真实 HTTP 的路由层测试 | `f943e49` |

**下一个接手的人注意**:`max_tokens` 在付费层也不强制(同 prompt 在 16 和 4096
上限下输出逐字节相同),所以真截断可能极少出现 —— 想彻底钉死 3/5 仍需刻意制造
一次真截断并捕获。当前实现不再依赖那次捕获,但拿到了应该把它写进
`DEVIN_CONNECT_STOP_REASON_MAP` 的文档示例里。

### 自查补修(同一轮,2026-07-28)

对抗复核的 agent 全部因本机网关 502 挂掉、**未产出任何结论**,所以改为手工逐面探测。
查出并修掉两条**我自己在上面那批修复里引入的**缺陷:

| 缺陷 | commit |
|---|---|
| 任意一条**无关**的 `DEVIN_CONNECT_STOP_REASON_MAP` 覆盖(如 `7=length`)会把 usage 截断推断**整体**关掉 —— 判定用 `Object.hasOwn(合并表, n)`,而默认表已含 0..6 | `1bfb3b8` |
| `STREAM_TRUNCATED` 落进 `finalizeConnectAccount` 末尾的 `reportError`,把**传输层**故障记进账号错误预算 → 网络一抖就三次一批把健康账号打成 `status='error'`(持久化退避)。正是 CONTENT_BLOCKED / UPSTREAM_ERROR 豁免当初要消除的失效模式 | `f9cd3ac` |

第二条尤其值得记:**加一个新错误码时必须同时问"它落进 finalizeConnectAccount 的哪个
分支"**。末尾的 `else reportError` 是个静默的兜底,新码默认被当成账号故障;而单账号池
有 `lastAccountExempt` 掩盖,只有存在健康 peer 时才显形。

### 发版后对抗复核(v3.9.1 已发布后,2026-07-28)

网关恢复后跑通复核,**查出 3 条缺陷,全部是 v3.9.1 自己的修复引入的**,已在 v3.9.2 修掉:

| 缺陷 | 严重度 | commit |
|---|---|---|
| Cascade 中途死掉时补发的**合成** `finish_reason:'stop'` 满足了 `sawTerminalChunk` → v3.9.1 的截断守卫在**默认后端**上完全失效 | **blocker** | `8fa5e97` |
| store 提交门用 `truncated` → 合法的 `length`/`content_filter` 轮次被踢出 store,与非流式行为分叉 | major | `8fa5e97` |
| `capEntryBytes` 静默删除超额消息(后面有更小消息时),标记路径不触发 | major | `9fb5302` |

**最值得记的教训**:这三条都不是"漏改了什么",而是**修复本身建立在一个没验证的前提上**
—— "每条真实成功路径都会发 `finish_reason`"。发版前我逐条读过 8 条流终止路径并认为前提
成立,但漏掉第 9 种:**代理自己合成的**终止帧。读代码能找到"谁发终止帧",找不到
"谁**伪造**终止帧"。守卫因此改为直接咬合成帧这个形态,而不是只咬"缺终止帧"。

第二条则是"修复只覆盖部分路径"陷阱的又一次复发(本仓库第 3 次)。**改动跨层行为时,
除了逐条核对四条协议路由,还要核对流式 vs 非流式这一维。**

### v3.9.2 之后又一轮(v3.9.3)

补跑复核期间自查发现:**v3.9.2 的 blocker 修复只覆盖了 3 条内部路由里的 1 条。**
`messages.js` / `gemini.js` 各自都有为同一场景写的守卫,都被同一个合成终止帧骗过
(实测 `stop_reason:end_turn` / `finishReason:STOP`,均无 error 帧)。已在 `161c88d` 修掉。

**根因比标记更深一层**:`finishPartialStreamAfterError` 除了合成 `finish_reason`,
**还会写 `[DONE]`**,而这两条路由都把裸 `[DONE]` 当权威终止信号 —— 所以有**两个**入口
被骗,只堵一个无效。我第一次尝试只堵了 finish 帧,验证"看起来没生效"因而一度误判回滚,
直到把两个入口一起折价才生效。(`responses.js` 对 `[DONE]` 是 `continue`,本就没这问题。)

**这是"修复只覆盖部分路由"的第 4 次,而且不完整修复就在上一个修复里。** 因此新守卫
加了一条**源码级元守卫**:任何内部 translator 不消费 `__synthetic_finish` 直接失败。

**给下一个接手的人的操作性结论**:改动"流终止/完成语义"时,必须逐条核对
(a) 四条协议路由,(b) 流式 vs 非流式,(c) **每个终止信号的所有入口**
(finish_reason 帧 / 裸 `[DONE]` / 错误帧)。只核对 (a) 已经不够了 —— 这一轮的两次
返工都是漏了 (c)。

### v3.9.4:检索端点自发布起不可用(把 bug 写成了契约)

补跑复核期间自查发现:v3.9.1 加的 `GET`/`DELETE /v1/responses/{id}`
**从发布起对所有客户端都必然 404**。无 body 的请求用
`callerKeyFromRequest(req, token, null)` 派生身份,而能链式的客户端 POST 时带
`:user:<hash>`、bodyless GET 派生 `:client:<ip+ua>`,键不同;不带身份信号的客户端
两边键相同却过不了 `hasPerUserScope`。`SINGLE_TENANT_CACHE=1` 也救不了。已在
`4a4653d` 修掉:身份信号改走 query(`?user=` / `?prompt_cache_key=` /
`?safety_identifier=`),与 POST body 同一套词汇、同一条提取路径。

**最该记的一条:它带着全绿的测试发布,是因为我把 bug 固化成了契约。** 那条路由层
测试断言"bodyless GET 必须 404",注释里还写着 "the documented contract"。测到的
现象是真的,对现象的解释是错的 —— 功能死了、测试却绿,而且后来读到它的人会以为
这是设计意图。

**规律**:写测试时必须区分"我在断言**期望**行为"和"我在记录**当前**行为"。
后者必须在注释里标明是观察而非契约,否则它会伪装成结论,并为 bug 背书。
(这与本文档 §5.4 的"假测试"是同类但不同种:那些是**测不到**,这条是**测错了方向**。)

### v3.9.5:合并两个外部 PR + 检索端点契约修复

复核 16 条候选里自验成立 5 条(验证阶段全部因网关 429 挂掉,所以逐条自验),
**其中 2 条是 v3.9.4 自己引入的**:

| 缺陷 | 归属 |
|---|---|
| 身份信号走 query → 把 PII 写进 URL(`https-proxy.js` 原样打印整个 URL,而 `caller-key.js:107` 明写 `user` 属 PII 不得落日志) | v3.9.4 |
| query 白名单只覆盖 6 种作用域词汇里的 3 种 | v3.9.4 |
| 检索硬编码 `status:'completed'` → 把截断洗成完成 | v3.9.1 起 |
| drop 标记经检索变成"假装是模型输出"的内容 | v3.9.2 |
| 数组形态 content → 产出结构非法的响应体 | v3.9.1 起 |

**最该记的一条**:那条 PII 缺陷里,我在两小时前才引用过 `caller-key.js` 的那段注释
(用它论证跨租户隔离),然后亲手违反了它。**读过某条约束不等于会遵守它** ——
改动涉及"值放到哪里"时,要专门问一句这个值有没有既定的存放/记录约束。

**另一件事:我连续四个版本判断错了。** `macOS x64` job 的终态是 `cancelled` 而非
排队(`macos-13` runner 已于 2025-12-04 下线),我每次看到 UI 上的 `queued` 就照
workflow 注释解释,没查终态,四次都跟作者报"非阻塞、不用管"。是外部贡献者(#233)
发现的。**教训:注释解释的是设计意图,不是当前事实;判断"某个 job 没问题"必须看终态。**

### 仍未修

1. **#234(外部报告,已验证成立)**:`DEVIN_CONNECT` 侧模型发现与干旱门禁仍用 Cascade 假设。
   实测 `FREE_REACHABLE_SELECTORS = ['swe-1-6-slow']` 而 drought 的
   `freeTierModels = ['gemini-2.5-flash']` —— 生产默认走 connect,干旱在 connect 部署上
   用错了白名单。另含 connect discovery 从共享 MODELS registry 出发、connect chat 路径
   在 Cascade 干旱门禁之前返回等。

2. 更早积压(sticky 线):thundering-herd、RPM 满清绑定、双实验 flag 折叠楔子、
   sticky 绑定槽无 selector 维度、`special-agent.js:676` 只查绑定不写、
   `clearCallerBindings` 生产无调用点。

2. **response store:多个小租户可挤掉受害者会话**(非本轮引入)。字节份额下限让
   小租户回落到全局 LRU 扫描,攻击者用大量不同 API key 前缀(每个都在下限之下)写入
   可驱逐受害者的会话。实测 400 个小租户 → 受害者 5/5 会话全丢。**基线同样如此**
   (`9586593` 实测也是 5/5 全丢,且预算还超 3.92 倍),所以这是既存局限而非回归 ——
   本轮只是让预算真正生效了。彻底修需要按租户配额而非纯 LRU 的驱逐策略。

### 需要你(项目作者)拍板,我没擅自改

**`total_tokens` 含 `cache_write`,因此不等于 `prompt + completion`。** 复核认为这
破坏了 OpenAI 契约;但 Cascade 路径(#118)早已刻意如此并写明理由("grand total 要
反映完整成本")。改它会同时改动两条路径并推翻既有决定 —— 这是产品取舍。

---

## 4. 本机环境状态

- 账号池:1 个真付费 **teams** 账号(13/13 selector 可达)。**免费 `swe-1-6-slow`
  在该账号上被上游墙(`UPSTREAM_INTERNAL`),是已知现象,不是 bug** —— 不要用它测试,
  也不要把它的失败读成"账号已死"
- 期间观察到 `refreshCredits` 报 `subscription has been canceled`(GetUserStatus
  端点),但 chat 路径正常 —— 两者是独立端点
- agent 循环压测 harness 在 `/tmp/agent-harness.mjs`(真实 `/v1/responses` + 3 个
  工具 + 链式)。它不在仓库里,重启机器会丢;真要长期用应该收进 `scripts/`
- 本机 `codex` 0.146-alpha **对自定义 provider 是坏的**(打一个纯 echo 服务器也失败,
  `models_manager` 子进程超时)。与本项目无关,别浪费时间排查

---

## 5. 方法论(这一轮最值钱的产出)

### 5.1 被审过的地方是硬的,没审过的地方才有货

- 鉴权面 30+ 次绕过尝试、协议层 190+ 次 fuzz → **零缺陷**(都被审过多轮)
- 同期新写的 response store,一用真实 agent 循环压测 → **立刻出真 bug**

审计预算应优先投向**新代码**和**从未被真实客户端走过的路径**。老代码的价值在于把
不变式**守卫住**(见台账 🛡 标记),而不是反复重扫。

### 5.2 用真实客户端跑真实任务,比读代码有效

response store 的 tool_calls 重复缺陷靠单元测试发现不了 —— 它只在真实多轮工具循环
里出现,症状还是个与"账号已死"无法区分的不透明 503。

### 5.3 自己的修复必须被对抗验证

这一轮派 9 个 agent 证伪我自己的修复,结果:**每个原始缺陷都是真的(无幻觉),但
我的修法有一半不对,并且引入了 1 个 blocker + 4 个 major。**

具体我引入的:字节驱逐冲掉其他租户的会话、驱逐吃掉刚写入的条目、`approxBytes` 对
base64 图片完全盲视、CJK 低估 2 倍、instructions 去重保留已撤销的指令、空输入守卫
可被绕过且过度拒绝、legacy `role:'function'` 被误拒。

**"修好了"和"没修坏"是两个独立命题,后者必须单独验证。**

### 5.4 突变测试的三条硬教训

1. **一条从未失败过的测试等于没有测试。** 本轮发现自己写了三条假测试:一条恒真
   断言(检查的字符串在整个仓库都不存在)、两条镜像实现(在测试里复刻生产算术,
   生产退化不会失败)。修法:把逻辑抽成**导出的纯函数**,生产与测试共用同一份 ——
   不要在测试里复刻。
2. **突变覆盖不到的路径,它也证明不了那是死代码。** 我曾把一个自我驱逐守卫当死代码
   删掉,依据是突变验证"移除后行为不变"—— 但那次突变只走到无租户扫描路径,
   `oldestId(tenant)` 那条从未被覆盖,而它恰恰是守卫真正生效的地方。
3. **突变本身会写错。** 有两次我用了恒真条件(如 `>= 1`)做突变,那反而比原 bug
   更安全,于是误判"测试弱"。必须用**真实的原始错误形态**做突变。

### 5.5 注释里的夸大和代码里的 bug 一样有害

我曾在代码注释里写"可逐个打空账号池",而 `lastAccountExempt` 默认保证每 tier 留一个
存活 —— 实际是"降级到单账号"。下一个读者会照着错误的威胁模型做判断。**改代码时
一并检查它引用的注释是否还成立**(本轮修掉两处过期注释:一处声称流式总是发
`response.completed`,一处声称用了已被弃用的 Symbol 方案)。

### 5.6 反复出现的两个结构陷阱(已做成 CI 守卫)

- **`modelKey=null` 陷阱**(已 3 次):connect 选号恒传 `modelKey=null`,任何写在
  model 维度的状态对它结构性不可见。守卫:`test/connect-dimension-guard.test.js`
- **修复只覆盖部分协议路由**(已 2 次):四条 handler 各有翻译层。守卫:
  `test/handler-route-parity-guard.test.js`,含一条元守卫 —— 新增流式路由不登记即失败

### 5.7 会话操作纪律(踩过的坑)

- **严禁遍历删账号**。我曾在测试清理里写 `getAccountList().map(removeAccount)`,
  把用户的真账号一起删了,且**无法恢复**(项目刻意不记录 token,这是正确的安全设计)。
  清理只按自己创建的 id。
- **不要 `--amend` 已推送的 commit**(需要强推,项目规则要求先确认)。改成追加一个
  新 commit,历史只增不改。
- 全量 `npm test` 的计数会因 `--test-force-exit` 输出竞态波动(同一棵树见过
  2924/2952)。**权威口径是 `npm run test:release`**(逐文件进程隔离)。见到总数
  波动先重跑再怀疑代码。
- 测试文件里别写字面控制字符 —— 一个 NUL 曾让 git 把整个测试文件归类为 binary
  (diff 不可读)。用 `\0` 转义。