feat: 流中断分层恢复与历史污染修复 - #82
Merged
Merged
Conversation
- 新增 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 时可空)。
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
针对反复出现的
Kiro upstream event stream failed unexpectedly(上游 HTTP 200 后事件流在完成前中断),本 PR 引入分层恢复机制,并修复调查中发现的三条历史污染通道。
硬约束:不牺牲实时流。
stream_buffer_until_complete因牺牲打字机效果不作为方案依赖。变更概览(21 提交,1185 tests / 0 fail)
流恢复(默认关闭)
新增
stream_recovery_mode(off|reasoning_restart|exact_replay,默认off,env
KIRO_STREAM_RECOVERY_MODE):reasoning_restart— 仅当零可见字符、零工具调用、无工具意图时,重新发起一次attempt 并把 reasoning 续写进同一条 SSE。覆盖本地 73 条历史失败中的 37 条。
exact_replay— 已交付可见正文时,重发并对 reasoning / 正文 / 工具三通道做逐字节前缀匹配,追平前零交付,任何分歧立即中止该次重放。
off档与恢复前字节等价(有专门测试守护),单开关回滚。核心模块:
StreamRecoveryCoordinator(跨 attempt 持有单一出站 SSE,单终止、单 completion、onTerminal每条退出路径恰好一次)、RecoveryAttemptFactory、ExactReplayMatcher。语义截断判定
截断只由未闭合工具意图(
observer.hasOpenToolIntent)决定,绝不依据缺失completion metadata —— 真实流量实测本端点 100% 的流都是 metadata-less clean EOF(22/22,
跨账号跨模型),旧判据会让恢复档误判每一个成功响应。
dialect 工具意图闭合改为三态(
none|complete|incomplete)并与 tool-call parser 共用代码区规则;恢复档下
incomplete同时抑制 remainderText 与整轮工具调用(零泄漏)。签名安全分档
recovered: true)不发布 reasoning 签名信封 —— 已交付 reasoning 是旧半段+新全段,发布即 FALSE HIT,下一轮必然
THINKING_SIGNATURE_INVALID。owningAttemptIdvs 请求最新 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 散文而不发工具调用:
collapseAgenticLoops把[system: tool calling continues]写进 assistant content(实测 944/2370 条)→ 改为
content: '',对齐官方 Kiro IDE 形状;stripPollutionMarkers入站清洗,否则旧会话永久自我复制;
<thinking>原始 CoT 无界注入 → 收敛为活跃工具环内最近 1 轮(
findThinkingTextReplayIndex);生态先例:Quorinex/Kiro-Go 同域踩过同一坑并修复带回归测试;LiteLLM #24498 有模型复读协议
字符串的铁证;官方 aws/language-servers 从不使用占位符。
验证
bun run typecheck/bun test(1185 pass / 0 fail,90 文件)/bun run build/make ci全绿F4 回归 —— 共发现并修复 5 个 Blocker,最终四路全 APPROVE
<invoke name=的健康回答在两恢复档下逐字节相同(验证代码区规则不会误判健康响应)
attempt epoch 并发
兼容性
默认行为不变(
stream_recovery_mode: 'off')。新配置键经 additive-only 回填,现有
kiro.json无需修改即可升级。