Skip to content

fix(bridge): 重启后不再把历史 provider 错误重放成「本轮执行失败」 - #1170

Merged
deepcoldy merged 2 commits into
masterfrom
fix/phantom-turn-failed-card
Sep 1, 2026
Merged

fix(bridge): 重启后不再把历史 provider 错误重放成「本轮执行失败」#1170
deepcoldy merged 2 commits into
masterfrom
fix/phantom-turn-failed-card

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

用户报障:一个没有任何异常的会话,在两次 daemon 重启时各收到一张
⚠️ Claude 本轮执行失败 provider_server_error」卡。

卡上三处都对不上:失败时刻是发卡瞬间、任务是当时残留的另一条消息、
而那条真错误发生在 10 小时前

不是误判逻辑写错,是同一条历史错误在每次重启时被重放

卡片显示 实际
2026/8/31 09:44:35 重启瞬间(UTC 16:44:35,按 daemon 宿主 PDT 渲染)
2026/8/31 21:32:42 下一次重启(UTC 04:32:42
真错误在 UTC 09:17:09

根因(两个独立缺陷叠加)

1. 合成的 local turn 会发出用户可见的失败终态

local-* / local-headless-* 是归因队列给「匹配不上任何 pending mark 的
transcript 活动」造的 id —— 终端手敲的输入,或重启后被重放的历史。它的
fallback 早已被无条件抑制(shouldSuppressBridgeEmit 对 isLocal 恒 true),
终态直通 daemon,而 daemon 会把任何非 completed 终态渲染成失败卡,
并盖上 new Date() 与会话当前的 lastUserPrompt。

线上日志是直接证据:重启那一刻 33 个 local turn 打了 suppressed 日志,
而出事那个 local-f1938d86 一条都没有 —— 它在抑制门之前就被 continue 掉了。

2. head-of-line drop 不回收 journal ⟹ 每次重启重放同一段

handleTurnStart 把「没产出任何文本」的 collecting turn 从队列摘除时,它就
再也到不了 drainEmittable —— 而那是 worker 唯一清 journal 的地方。entry 残留,
之后每次重启都重新 mark 并从记录的 offset 重新 drain。那条 entry 在 6 次重启里
被反复 restore,到今天仍躺在 journal 文件里

修法

  • 终态循环里只对失败一侧设门。completed 终态保留(负责 dedupe claim /
    durable release / CoT finalize,对用户不可见),真实 Lark turn 完全不受影响。
  • 队列是纯函数(不碰 fs),所以由它上报、worker 回收 —— 与
    pruneExpired 的返回值同一套契约。drain 放在 ready.length === 0 早返回
    之前:HOL-drop 往往正好落在一个什么都不 emit 的 tick 上。

刻意不改的一处(请复审重点看这里)

bridge-turn-queue.ts 里 api-error 用 = 覆盖已有终态,看着像 bug(同文件
另外两处都是 ??=),但它是对的,我一开始判错了,靠证据纠回来:

  • 那条 end_turnoutput_tokens = 1,而文本有 147 字符 —— 不可能;
  • 在 60 个 transcript 里搜这个形态(end_turn + output_tokens=1 + 长文本):
    出现 2 次,两次都紧跟 api-error,脱离 api-error 出现 0 次
  • token 数正常的那几例,尾巴全是 Let me verify... / Let me report...
    —— 宣告了下一步却从未执行;
  • claude 自己标了 truncatedAfterOutput: true,文案也写着 "may be incomplete"。

end_turn流被切断时补写的,不是真完成。改成 ??= 会把真正的中途
中断判成成功 —— 方向反了,会制造漏报。

影响面

仅 claude-code 的 Lark fallback bridge。adapters/cli/ 未改动,其余 20+ CLI
走 codex/structured bridge,不经过这两处。PtyBackend / TmuxBackend 共用同一条
worker 归因路径,行为一致;adopt 会话不受影响(local turn 在 adopt 下本就照常
投递,改动只在失败终态一侧)。

验证

  • bun run build 通过
  • 两个测试文件 84/84 通过(新增 9 条)
  • 差分变异 3/3 全部打红
    变异 结果
    移除 isLocal 终态门 1 红
    HOL-drop 停止上报 2 红
    把 journal drain 移到早返回之后(真移动,非新增 1 红
  • 用线上真实 transcript + 真实 journal 跑生产代码复现:修复前 36 个 turn 中
    恰好 1 个 isLocal 且 failed,两次重启逐位一致;修复后 0 张失败卡
  • 阳性对照已打:真正的 mid-flight 中断(前面没有 end_turn)仍正确判 failed,
    失败卡照常发出 —— 修法不是「一律抑制」
  • 全量 bun run test:本分支 6 failed / 同一 commit 的干净 base 同样 6 failed
    双向差集各 1 条(本侧 listen-with-probe 单跑 6/6 绿且只 import node:http
    够不到改动文件;base 侧 v3-daemon-run),负载 flake 特征
    ⚠️ 注意:base 未 build 时 plugin-mcp-sandbox 是 skip 而非 pass
    describe.skipIf(!existsSync(builtCli))),build 后同样 2 红,为 pre-existing

未做

未 live 部署验证 —— 触发条件是 daemon 重启且 journal 里有残留 entry,
不便在生产上刻意制造。判据用的是「线上真实 transcript 重放 + 变异」。

🤖 Generated with Claude Code

会话无任何异常,却在两次 daemon 重启时各收到一张「⚠️ Claude 本轮执行失败
`provider_server_error`」卡。卡上的失败时刻是发卡瞬间、任务是当时残留的
另一条消息,都对不上那条真错误(它发生在 10 小时前)。

两个独立缺陷叠加:

1. 合成的 local turn 会发出用户可见的失败终态。
   `local-*` / `local-headless-*` 是归因队列给「匹配不上任何 pending mark
   的 transcript 活动」造的 id —— 终端手敲的输入,或重启后被重放的历史。
   它的 fallback 早已被无条件抑制,但**终态**直通 daemon,而 daemon 会把任何
   非 completed 终态渲染成失败卡,并盖上 `new Date()` 与会话**当前**的
   lastUserPrompt —— 于是时间和任务都是错的。
   现只对失败一侧设门;completed 终态保留(它负责 dedupe claim / durable
   release / CoT finalize,且对用户不可见)。真实 Lark turn 完全不受影响。

2. head-of-line drop 不回收 journal,导致每次重启重放同一段。
   `handleTurnStart` 把「没产出任何文本」的 collecting turn 从队列摘除时,
   它就再也到不了 `drainEmittable` —— 而那是 worker 唯一清 journal 的地方。
   entry 残留,之后每次重启都重新 mark 并从记录的 offset 重新 drain。
   队列是纯函数(不碰 fs),所以由它上报、worker 回收,与 `pruneExpired`
   的返回值同一套契约。

顺带说明一处**刻意不改**的地方:`bridge-turn-queue.ts` 里 api-error 用 `=`
覆盖已有终态,看着像 bug,实际是对的。那条 `end_turn` 是流被切断时补写的,
不是真完成:`output_tokens=1` 而文本 147 字符,且该形态在 60 个 transcript
里从不脱离 api-error 出现(near=2 / away=0),claude 自己也标了
`truncatedAfterOutput:true`。改成 `??=` 会把真中断误判成成功。

影响面:仅 claude-code 的 Lark fallback bridge(`adapters/cli/` 未改动,其余
20+ CLI 走 codex/structured bridge,不经过这两处)。PtyBackend / TmuxBackend
共用同一条 worker 归因路径,两者行为一致;adopt 会话不受影响(本地 turn 在
adopt 下本就照常投递,改动只在失败终态一侧)。

验证:
- `bun run build` 通过
- `test/bridge-turn-queue.test.ts` + `test/claude-turn-terminal-contract.test.ts`
  84/84 通过(新增 9 条:HOL-drop 上报契约含 drain-once 与 local 不上报、
  失败终态门含真实 Lark turn 仍报卡的阳性对照、completed 仍发的反向用例、
  以及 worker 接线断言)
- 差分变异 3/3 打红:移除 isLocal 终态门 → 1 红;HOL-drop 停止上报 → 2 红;
  把 journal drain 移到 `ready.length === 0` 早返回之后(真移动,非新增)→ 1 红
- 用线上真实 transcript + 真实 journal 跑生产代码复现:修复前 36 个 turn 中
  恰好 1 个 `isLocal` 且 failed,两次重启逐位一致;修复后 0 张失败卡
- 全量 `bun run test`:本分支 6 failed / 同一 commit 的干净 base 同样 6 failed,
  双向差集各 1 条(本侧 listen-with-probe 单跑 6/6 绿且只 import node:http,
  够不到改动文件;base 侧 v3-daemon-run),负载 flake 特征。注意 base 未 build
  时 plugin-mcp-sandbox 是 skip 而非 pass,build 后同样 2 红,为 pre-existing

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审结论:APPROVE

独立验证了作者点名的三个重点,结论一致。

① api-error 用 = 覆盖终态 —— 正确,不应改 ??=

独立扫描 742 个 transcript:

  • 同一 turn 内同时出现「终态 assistant 事件 + api-error」共 38 例,其中 37 例 end_turn 在 api-error 之前
  • 铁证(session 41197eb8):end_turn 事件 output_tokens=1 而文本 147 字符("Let me rewrite the whole file cleanly..."),下一条即为 server_error「Connection lost mid-response」。1 个 token 不可能承载 147 字符,该 end_turn 是流被切断时补写,非真完成。
  • 可疑形态(end_turn + output_tokens≤1 + 文本≥80)出现 4 次,全部同 turn 带 api-error,脱离 api-error 出现 0 次。
  • 正常 token 的 end_turn 位于 api-error 之前的,结尾均为 "Let me..." / "Now let me..." 宣告(未执行)。

= 让 api-error 覆盖假 completed → 判 failed;改 ??= 会让假 completed 挡住 api-error → 把真中断判成成功,制造漏报

② isLocal 失败终态门 —— 不会吞真卡

  • isLocal 仅在两个合成路径赋值(handleTurnStartlocal-*、ingest 的 local-headless-*);mark() 永不赋值,journal 恢复的 turn 走 mark() 也不带。真实 Lark turn(含恢复的)永不 isLocal,失败终态照常发出。
  • daemon 侧(worker-pool.ts turn_terminal)失败卡由 buildSessionTurnFailedCard(ds, ...) 从会话当前状态渲染(Date.now() + 当前 lastUserPrompt),local turn 无对应 Lark 消息,卡必然错误,抑制正确。
  • local-headless-* 路径逻辑已核:headless 合成后若来 api-error 会置 terminalOutcome=failed,门正确兜住。
  • 跳过 emitTurnTerminal 对 local turn 安全:其 bookkeeping(submit-failure-chain / managed-turn-origin / durable release)均依赖 dispatchAttempt,local turn 没有。

③ drain 顺序 + clearPending

  • drain 在 if (ready.length === 0) return; 之前:变异(真移到早返回后)打红,顺序正确。
  • clearPending() 不清 journal:结论(本 PR 不动)同意,但定性需补正——stopBridgeWatcher() 也在 in-worker CLI 重启时调用(adopt 流结束、spawnCli 重启),不止 worker 退出。live journal 目录实测有 4 条 17-28h 的 stale entry(其中 2 条对应 transcript 含 provider error 的会话)。影响面确实不同(有 restore gate / jsonlPath 作用域 / 年龄门 / InflightInputTracker 重投兜底,且 fix ① 已从症状侧兜住),最坏是重投 stale partial 而非幻影失败卡。建议作为 follow-up:在 clearPending 里清掉被丢 plain-IM turn 的 journal entry(看起来安全)。

测试

  • 84/84 通过,新增 9 条。
  • 差分变异独立复现 3/3 打红(移 isLocal 门 / HOL 停止上报 / drain 移到早返回后)。
  • 一个非阻塞弱点:worker 门的行为测试在 ContractHarness 里复制了一份 gate,变异 1 仅被源码断言测试抓住(能抓红但脆)。建议把 gate 抽成共享纯函数做行为覆盖。

其他

  • bun run build 通过。
  • 全量 bun run test(root 环境)4 failed:mojo-launcher-env-quarantine(root 下 chmod 0o500 不限制 root,环境问题)+ plugin-mcp-sandbox 2 条(pre-existing),均不碰本 PR 路径。
  • CI test:bun smoke 失败均为无关环境 flake(dashboard/desktop/npm/mojo/tmux-discovery),master 最近一次 CI 绿,建议 re-run 确认。
  • 落后 master 1 个 commit(无关),rebase 更稳。

总结:三点独立验证全过,测试与变异已复现。可以合并。 两个 follow-up(clearPending journal 残留、gate 抽共享函数)不阻塞。

原断言只校验 worker.ts 里存在该守卫的字符串与正则,对**位置**不敏感:
把 isLocal 失败终态门的每个字节原样保留、仅移到 `emitTurnTerminal(...)`
之后,幻影失败卡的缺陷即完全复活(失败终态先发出,门再 log + continue
进虚空),而两个测试文件 84/84 仍全绿——即该缺陷回归时门禁不会有任何声音。

补一条函数内相对位置断言:切片限定在 `emitReadyTurns` 到 `drainPathInto`
之间,断言门的下标小于 `emitTurnTerminal(` 的下标。两个下标都先钉
`toBeGreaterThan(-1)`——`toBeLessThan` 对 -1 会静默放行,切片若没命中目标
函数就会伪装成通过。

仅改测试,不动生产代码。双向验证:干净树 84/84 通过;将门移到发卡之后
后恰好 1 条转红(`expected 11235 to be less than 10815`)。
相邻三套件 127/127、`bun run build`、`tsc --noEmit` 均通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy
deepcoldy merged commit 242d386 into master Sep 1, 2026
8 of 9 checks passed
@deepcoldy
deepcoldy deleted the fix/phantom-turn-failed-card branch September 1, 2026 13:41
deepcoldy added a commit that referenced this pull request Sep 1, 2026
两处 #1170 复审时提出、但没赶上合并的收尾。仅注释 + 测试,不动生产逻辑
(`emitReadyTurns` 的可执行代码与 master 逐字节相同,已用「剥注释后 diff」核对)。

1. 注释没写清「为什么终态是唯一泄漏口」,容易让人误以为要按 adoptMode 分情况。
   实际两个 mode 都一样:fallback 循环的**第一道**检查
   (`terminalOutcome.status !== 'completed' → continue`)与 mode 无关,且排在
   `shouldSuppressBridgeEmit` **之前**,所以失败的 local turn 根本到不了那道
   gate —— 无论 adopt 与否。也就是说在本门之前,**失败终态是唯一的泄漏口**,
   两个 mode 同理,因此这个门刻意不按 adoptMode 分支。
   (另注:fallback 即便放行,携带的是 `final_output` 答案文本,不是卡;
   「本轮执行失败」卡只来自终态侧。)
   顺带把「真实 Lark turn 不受影响」的理由写实:`isLocal` 只由两条合成路径
   赋值,`mark()` 从不赋值,journal 恢复的 turn 也走 `mark()`。

2. 补 `local-headless-*` 用例。它与 `local-*` 共用同一个门,复审时判定「逻辑上
   已覆盖」——但「走同一分支」是论证,不是测试。补一条真样本:assistant 边界在
   没有任何 collecting 上下文时到达(重启切断流、user 事件已被 absorb),队列
   合成 headless turn,随后 api-error 让它拿到 failed 终态。

验证:
- `bun run build` 通过;两个测试文件 85/85 通过(新增 1 条)
- 差分变异:移除 harness 的门 → **2 条**转红(`local-*` 与 `local-headless-*`
  各一条),证明新用例有区分力、不是装饰
- 我的注释改动会移动字节下标,而 `09d6388b8` 的位置断言按下标比较,所以专门
  重验它仍有判别力:把门**原样移到** `emitTurnTerminal(...)` 之后(真移动,
  非新增)→ 恰好 1 条转红(`expected 11924 to be less than 11504`)
deepcoldy added a commit that referenced this pull request Sep 1, 2026
两处 #1170 复审时提出、但没赶上合并的收尾。仅注释 + 测试,不动生产逻辑
(`emitReadyTurns` 的可执行代码与 master 逐字节相同,已用「剥注释后 diff」核对)。

1. 注释没写清「为什么终态是唯一泄漏口」,容易让人误以为要按 adoptMode 分情况。
   实际两个 mode 都一样:fallback 循环的**第一道**检查
   (`terminalOutcome.status !== 'completed' → continue`)与 mode 无关,且排在
   `shouldSuppressBridgeEmit` **之前**,所以失败的 local turn 根本到不了那道
   gate —— 无论 adopt 与否。也就是说在本门之前,**失败终态是唯一的泄漏口**,
   两个 mode 同理,因此这个门刻意不按 adoptMode 分支。
   (另注:fallback 即便放行,携带的是 `final_output` 答案文本,不是卡;
   「本轮执行失败」卡只来自终态侧。)
   顺带把「真实 Lark turn 不受影响」的理由写实:`isLocal` 只由两条合成路径
   赋值,`mark()` 从不赋值,journal 恢复的 turn 也走 `mark()`。

2. 补 `local-headless-*` 用例。它与 `local-*` 共用同一个门,复审时判定「逻辑上
   已覆盖」——但「走同一分支」是论证,不是测试。补一条真样本:assistant 边界在
   没有任何 collecting 上下文时到达(重启切断流、user 事件已被 absorb),队列
   合成 headless turn,随后 api-error 让它拿到 failed 终态。

验证:
- `bun run build` 通过;两个测试文件 85/85 通过(新增 1 条)
- 差分变异:移除 harness 的门 → **2 条**转红(`local-*` 与 `local-headless-*`
  各一条),证明新用例有区分力、不是装饰
- 我的注释改动会移动字节下标,而 `09d6388b8` 的位置断言按下标比较,所以专门
  重验它仍有判别力:把门**原样移到** `emitTurnTerminal(...)` 之后(真移动,
  非新增)→ 恰好 1 条转红(`expected 11924 to be less than 11504`)
deepcoldy added a commit that referenced this pull request Sep 1, 2026
两处 #1170 复审时提出、但没赶上合并的收尾。仅注释 + 测试,不动生产逻辑
(`emitReadyTurns` 的可执行代码与 master 逐字节相同,已用「剥注释后 diff」核对)。

1. 注释没写清「为什么终态是唯一泄漏口」,容易让人误以为要按 adoptMode 分情况。
   实际两个 mode 都一样:fallback 循环的**第一道**检查
   (`terminalOutcome.status !== 'completed' → continue`)与 mode 无关,且排在
   `shouldSuppressBridgeEmit` **之前**,所以失败的 local turn 根本到不了那道
   gate —— 无论 adopt 与否。也就是说在本门之前,**失败终态是唯一的泄漏口**,
   两个 mode 同理,因此这个门刻意不按 adoptMode 分支。
   (另注:fallback 即便放行,携带的是 `final_output` 答案文本,不是卡;
   「本轮执行失败」卡只来自终态侧。)
   顺带把「真实 Lark turn 不受影响」的理由写实:`isLocal` 只由两条合成路径
   赋值,`mark()` 从不赋值,journal 恢复的 turn 也走 `mark()`。

2. 补 `local-headless-*` 用例。它与 `local-*` 共用同一个门,复审时判定「逻辑上
   已覆盖」——但「走同一分支」是论证,不是测试。补一条真样本:assistant 边界在
   没有任何 collecting 上下文时到达(重启切断流、user 事件已被 absorb),队列
   合成 headless turn,随后 api-error 让它拿到 failed 终态。

验证:
- `bun run build` 通过;两个测试文件 85/85 通过(新增 1 条)
- 差分变异:移除 harness 的门 → **2 条**转红(`local-*` 与 `local-headless-*`
  各一条),证明新用例有区分力、不是装饰
- 我的注释改动会移动字节下标,而 `09d6388b8` 的位置断言按下标比较,所以专门
  重验它仍有判别力:把门**原样移到** `emitTurnTerminal(...)` 之后(真移动,
  非新增)→ 恰好 1 条转红(`expected 11924 to be less than 11504`)
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🚀 Released in v3.18.12

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant