Skip to content

feat: 流中断分层恢复与历史污染修复 - #82

Merged
sunerpy merged 21 commits into
mainfrom
feat/stream-recovery-live-mode
Jul 31, 2026
Merged

feat: 流中断分层恢复与历史污染修复#82
sunerpy merged 21 commits into
mainfrom
feat/stream-recovery-live-mode

Conversation

@sunerpy

@sunerpy sunerpy commented Jul 31, 2026

Copy link
Copy Markdown
Owner

背景

针对反复出现的 Kiro upstream event stream failed unexpectedly(上游 HTTP 200 后事件流在完成前中断),
本 PR 引入分层恢复机制,并修复调查中发现的三条历史污染通道。

硬约束:不牺牲实时流。stream_buffer_until_complete 因牺牲打字机效果不作为方案依赖。

变更概览(21 提交,1185 tests / 0 fail)

流恢复(默认关闭)

新增 stream_recovery_modeoff | reasoning_restart | exact_replay,默认 off
env KIRO_STREAM_RECOVERY_MODE):

  • Tier A reasoning_restart — 仅当零可见字符、零工具调用、无工具意图时,重新发起一次
    attempt 并把 reasoning 续写进同一条 SSE。覆盖本地 73 条历史失败中的 37 条。
  • Tier B exact_replay — 已交付可见正文时,重发并对 reasoning / 正文 / 工具三通道做
    逐字节前缀匹配,追平前零交付,任何分歧立即中止该次重放。
  • off 档与恢复前字节等价(有专门测试守护),单开关回滚。

核心模块:StreamRecoveryCoordinator(跨 attempt 持有单一出站 SSE,单终止、单 completion、
onTerminal 每条退出路径恰好一次)、RecoveryAttemptFactoryExactReplayMatcher

语义截断判定

截断由未闭合工具意图(observer.hasOpenToolIntent)决定,绝不依据缺失
completion metadata —— 真实流量实测本端点 100% 的流都是 metadata-less clean EOF(22/22,
跨账号跨模型),旧判据会让恢复档误判每一个成功响应。

dialect 工具意图闭合改为三态(none | complete | incomplete)并与 tool-call parser 共用
代码区规则;恢复档下 incomplete 同时抑制 remainderText 与整轮工具调用(零泄漏)。

签名安全分档

  • Tier A 恢复(recovered: true不发布 reasoning 签名信封 —— 已交付 reasoning 是
    旧半段+新全段,发布即 FALSE HIT,下一轮必然 THINKING_SIGNATURE_INVALID
  • Tier B 追平成功后可发布 —— 逐字节匹配后整条回复等价于该次重放自身的完整输出。
  • 陈旧闸门为请求作用域owningAttemptId vs 请求最新 attempt id),不得
    per-account attempt epoch(会被无关同账号请求推进,误杀健康并发流的信封)。

传输层缓解

sdk_http_keep_alive(默认 false)仅禁止复用已完成请求的 socket,maxSockets 保持 50,
实时流/多进程/多账号并发不变,每请求多一次 TCP/TLS 握手。本机 Bun 1.3.14 实测生效
(keepAlive=true → 3 请求复用 1 端口;false → 3 端口)。

可观测性

  • Kiro stream request started —— 无条件每入站流式请求一条,作为发生率指标的分母
    (不受 enable_log_api_request 影响)。
  • 失败日志新增 emittedReasoningChars / emittedVisibleChars / emittedToolCount /
    sawToolIntent / sdkHttpKeepAlive / processId / bunVersion / streamElapsedMs /
    upstreamEventCount(仅长度与计数,绝不含正文)。
  • Kiro stream ended without completion metadata —— WARN,本端点常态(~100% 命中),
    已与失败判定完全解耦。

历史污染修复

调查发现三条通道会让模型把协议叙述当成自己该写的内容并逐字回吐,导致 assistant 轮输出
scratch 散文而不发工具调用:

  1. collapseAgenticLoops[system: tool calling continues] 写进 assistant content
    (实测 944/2370 条)→ 改为 content: '',对齐官方 Kiro IDE 形状;
  2. 客户端会把污染输出存成历史再回灌 → 新增 stripPollutionMarkers 入站清洗
    否则旧会话永久自我复制;
  3. <thinking> 原始 CoT 无界注入 → 收敛为活跃工具环内最近 1 轮
    findThinkingTextReplayIndex);
  4. 三处合成 assistant 分隔轮 → 改为合并相邻同角色轮次,从根上消除合成需求。

生态先例:Quorinex/Kiro-Go 同域踩过同一坑并修复带回归测试;LiteLLM #24498 有模型复读协议
字符串的铁证;官方 aws/language-servers 从不使用占位符。

验证

  • bun run typecheck / bun test1185 pass / 0 fail,90 文件)/ bun run build / make ci 全绿
  • Final Verification Wave 四轮:F1 签名安全评审、F2 代码质量评审、F3 真实流量 e2e QA、
    F4 回归 —— 共发现并修复 5 个 Blocker,最终四路全 APPROVE
  • F3 真实流量:三档模式 + thinking 模型 + 并发全通;含 fenced/inline 代码区内 <invoke name=
    的健康回答在两恢复档下逐字节相同(验证代码区规则不会误判健康响应)
  • 故障注入矩阵覆盖 reasoning / 正文 / raw tool intent / metadata 前后每个边界 + abort +
    attempt epoch 并发

兼容性

默认行为不变(stream_recovery_mode: 'off')。新配置键经 additive-only 回填,
现有 kiro.json 无需修改即可升级。

sunerpy added 21 commits July 31, 2026 12:11
- 新增 sdk_http_keep_alive 配置项,默认 false
- 仅在请求结束后关闭 socket 复用,maxSockets 保持 50
- 补充 bunVersion / PID / streamElapsedMs / upstreamEventCount 观测字段
streamLogDetails 追加 emittedReasoningChars / emittedVisibleChars /
emittedToolCount / sawToolIntent,数值来自每个 attempt 自有的
EmittedOutputAccumulator 与 StreamObserver,因此流失败后仍可读。
只记长度与计数,redaction 口径与 API 日志 sink 一致。

新增两个导出的日志事件名常量:

- STREAM_REQUEST_STARTED_LOG:每个入站流式请求首次发送时无条件写一条
  轻量记录,不受 enable_log_api_request 影响,作为流失败发生率指标的分母
  (此前没有任何无条件的发送记录,指标不可测)。守卫用请求作用域布尔而非
  仅 streamAttempt === 1 —— HTTP 错误换号不动 streamFailureCount,
  单条件会重复打点并高估分母。
- STREAM_MISSING_COMPLETION_LOG:SDK 迭代器干净 done 但从未收到 completion
  metadata 时告警。该形态此前完全静默(合成 finish_reason:"stop" 并落地全部
  成功副作用),零日志点,发生率不可测。标记放在 handleSdkStreaming 共享的
  complete() 入口,一处覆盖 buffered / 输出前 done / live pull 三条路径。

纯观测,零行为改动:不 throw、不改控制流、不改任何下发的 SSE 分块。

Refs: .omo/plans/stream-recovery-live-mode.md T0.4
按计划 §9 逐行核对 Phase 1(Tier A)矩阵,补齐 T1.1–T1.5 未覆盖的 10 例,
全部走 RequestHandler.handle 集成路径(不重复 coordinator 单测):

- row 1:reasoning_restart 下输出前失败仍由既有 legacy 重试处理,判别式用
  legacy `retrying` 记录独有的 `error` 字段(coordinator 退避记录不带)
- row 3:reasoning 已结束、text 未开始(内联 <thinking> 关块)仍 Tier A 命中
- row 7:completion metadata 后断在恢复模式下仍走 ignored 路径、零额外发送
- row 8:恢复 attempt 迭代中 caller abort 即时终止、无后续发送、队列可用
- row 9:耗尽时恰好 stream_max_attempts 次发送 + 2 条 retrying + 1 条终端日志
- row 10:并发同账号在恢复模式下两条健康流签名均正常 publish
- 空上游流(零事件)在恢复模式下保持成功,不被判为语义截断
- 恢复流 SSE 字节形状:分帧完整、通道顺序正确、唯一终端帧且位于末尾
- 45% 指标分母在发生恢复时仍恰一条
- 恢复路径 retry/终端日志脱敏(reasoning-log-redaction 的 wireHandler 追加
  可选 configOverrides 参数,既有调用点零改动)

反向证伪四轮(legacy error 字段、decideRecoveryTier、语义截断谓词的两个
条件)均按预期变红。生产代码零改动:矩阵未暴露 bug。
按 T2.5 补齐 Phase 2 Tier B 精确影子重放的验收覆盖,生产代码零改动。

- replay-matcher:新增 chunk 切分无关性(逐字符、单巨块、CJK、代理对跨块、
  多字节中途失配)与 tool 通道差异(id 变、name 变、args 流中暂扣至 terminal、
  args 分块重组追平、前缀未完成时多出工具、前缀完成后新工具释放)两组用例。
- coordinator:多字节重切分追平、tool 身份差异连续消耗预算、args 差异暂扣、
  预算耗尽只 map 一次终端错误、暂扣态 abort 无遥测且 onTerminal 恰一次。
- request-handler:新增 §9 Tier B 故障注入矩阵 9 例,覆盖 text 中途断的 SSE
  字节级零泄漏、预算耗尽保持 UpstreamUnexpectedError + emittedOutput、
  语义截断经 Tier B 追平、reasoning-only 走 Tier A 零 matcher 介入、
  stream_max_attempts 与 max_request_iterations 双预算、每次 replay 一条遥测,
  并钉住 tool-intent-only 与完整工具调用中断的保守 'none' 路由。
- redaction:新增 exact replay 遥测只含匹配量、不含 reasoning/正文/影子文本。
- 跟踪 raw 与 dialect 工具意图的闭合状态\n- 保持 metadata-less clean EOF 在全部档位成功\n- 修正非流式输入 token 字段并补齐 F1/F3 回归
- 让初始 open 与 coordinator 共用幂等终结函数\n- 覆盖同步抛错、异步拒绝与初始 abort
初始 openAttempt(1) 失败会被外层循环在同一入站请求内重试,但它原先复用
coordinator 的 finishTerminal(),进而触发 cleanupRequest() 摘除入站 abort
listener 并 latch requestCleanupDone,导致第 2 次 attempt 收不到 caller
abort(实测 retrySignalAborted=false),启用 SDK deadline 时还可能残留定时器。

按 DECISION 3 把终止所有权分层:onTerminal 仅在 Response 真正交付时触发
(coordinator 语义不变,仍恰一次),初始 open 失败改走新增的
onInitialOpenFailure,只做 attempt 级释放(endUpstreamWait),请求级清理
仍由外层 finally 的幂等 cleanupRequest() 持有。

新增回归用例覆盖「初始 open 失败 → 外层重试 → 第 2 次 send 阻塞 → caller
abort」,断言重试 attempt 的 signal 以同一 reason 对象中断且队列槽只释放
一次;补齐 delivered-response 的请求级 terminal 恰一次用例。
collapseAgenticLoops 把折叠环内非首条 assistant 轮的 content 改写为
`[system: tool calling continues]`,仅改写带 toolUses 的条目,因此该串与工具调用
共现、构成大量 in-context 示例(实测一次真实会话 944/2370 条),模型把它当成
"要调工具时 content 就该是这种终止式短句"并逐字回吐,进而只输出散文、不发工具调用。

改为 `content: ''`,与官方 aws/language-servers 的 `content: msg.body`(工具专用轮
即 `""`,无占位符)一致;toolUses 与 reasoningContent 原样保留。不恢复原文本,
避免重新引入折叠本就要省掉的 token 成本。

上游作者已因同一原因换掉过 'Continue'(680fc10),随后 ec828d4 为折叠重复开场白
又引入了这个更像指令的标记。

同步更新三处钉死该标记的既有测试。
只停止产出污染并不自愈:模型已把标记写进自己的可见输出,OpenCode 把该输出存为
assistant 历史,之后每次请求都回放它。旧会话会永久自我复制这个污染。

在 parseAssistantMessage(入站 assistant 解析的唯一入口,同时覆盖折叠与非折叠
路径)剥除两个标记字面量:`[system: tool calling continues]` 与
`[system: conversation continues]`。字节安全:不含标记的文本按原引用返回;
剥除时把标记两侧的空白重新发射为两侧原本就有的最小分隔符,不动周围真实内容。

先例:Quorinex/Kiro-Go `stripPollutedToolCallText` 及其回归测试
TestScrubsClientReplayedToolCallText —— 同域同机制,入站清洗是其修复四步中的第二步。

新增回归覆盖:字节安全、入站清洗(含 reasoning_content)、客户端回放污染不上线、
>=3 对折叠工具链全文序列化零标记,以及两项相邻 wire 约束(不发空 toolUses 数组、
currentMessage.content 仅在带 toolResults 时可空)。
@sunerpy
sunerpy merged commit 17f5352 into main Jul 31, 2026
2 checks passed
@sunerpy
sunerpy deleted the feat/stream-recovery-live-mode branch July 31, 2026 13:12
@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.35681% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/request/stream-recovery.ts 95.36% 12 Missing ⚠️
src/plugin/request.ts 60.00% 4 Missing ⚠️
src/core/request/request-handler.ts 98.60% 2 Missing ⚠️
src/core/request/response-handler.ts 98.26% 2 Missing ⚠️
src/__tests__/stream-recovery.fixture.ts 98.96% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/core/request/recovery-attempt.ts 100.00% <100.00%> (ø)
src/core/request/recovery-integration.ts 100.00% <100.00%> (ø)
src/core/request/replay-matcher.ts 100.00% <100.00%> (ø)
src/core/request/stream-error.ts 100.00% <100.00%> (ø)
src/core/request/stream-log-events.ts 100.00% <100.00%> (ø)
src/infrastructure/transformers/history-builder.ts 96.19% <100.00%> (+5.42%) ⬆️
...infrastructure/transformers/message-transformer.ts 100.00% <100.00%> (ø)
...rc/infrastructure/transformers/tool-call-parser.ts 99.21% <100.00%> (+0.18%) ⬆️
src/plugin/config/loader.ts 95.76% <100.00%> (+0.07%) ⬆️
src/plugin/config/schema.ts 100.00% <100.00%> (ø)
... and 11 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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