Terminal turn errors survive same-turn retry history
Terminal turn errors survive same-turn retry history
The Web `turn-error` Definition suppressed its node permanently once the owning turn carried any `llm/retry` event. That rule encoded the retry model [bounded LLM request recovery](../architecture/2026-06-21-bounded-llm-request-recovery.md) originally shipped, where a retry closed the failed turn and opened the next numbered one: a turn with retry history could only be an intermediate failure whose facts already live
English
Problem
The Web turn-error Definition suppressed its node permanently once the owning turn carried any llm/retry event. That rule encoded the retry model bounded LLM request recovery originally shipped, where a retry closed the failed turn and opened the next numbered one: a turn with retry history could only be an intermediate failure whose facts already lived on the retry row, and the exhausted terminal failure landed in a later turn with no retry events.
The agent loop has since retried inside the failing turn and step — llm-retry's runtime invariant requires llm/retry inside an open turn and step, and its loop tests assert a recovered turn holds one step/start. Under that producer, "the turn owns retry history" and "this turn/end error is the exhausted terminal failure" always coincide, so the suppression hid exactly the failure it existed to defer to: exhausting every transient retry left the conversation with a neutral collapsed "Retried model request (N/N)" row, no error row, and a re-enabled composer. The live e2e scenarios missed the gap because they covered a non-retryable AUTH failure (no retry events, so the row rendered) and a transient failure that recovered (a completed turn derives no failure), never exhaustion.
Decision
Delete the suppression. The turn-error Definition matches only turn/start and error-reason turn/end, and renders whenever its turn recorded a terminal error; the settled retry chain renders beside it through the separate model-retry node. No hidden state, no retraction branch: with same-turn retries there is no event order in which a rendered terminal error is later superseded, because turn/end closes the turn.
Partial history windows behave identically by construction — a tail window containing only the error-reason turn/end derives the same node the full history does, where the old rule hid one and showed the other depending on which retry events the window happened to include.
Testing
The Definition suite drives the real assembler through a same-turn retry chain ending in an error-reason turn/end and asserts the turn-error node materializes with its message and code — in full history, in a tail-only window, and after prepending the earlier chain. A keyless Web composition scenario exhausts a scenario-owned two-retry policy against three injected SERVER throws and pins the terminal error row beside the settled retry row in the golden; the scaffold gained a replayRetryPolicy option so exhaustion runs in milliseconds instead of the shared default's five backed-off attempts.
Alternatives considered
Reset hidden when the terminal failure arrives. Rejected: it keeps a state machine whose only remaining transition is the one that caused the bug. Under same-turn retries no event sequence needs the suppression at all.
Distinguish intermediate from terminal turn/end errors. Rejected: the distinction does not exist in the log. A turn ends once; an error reason is always terminal for its turn.
Consequences
Exhausted recovery now leaves durable, replayable feedback: the red terminal row with the display-safe message and code, plus the collapsed retry chain as recovery context. Session logs recorded under the retired new-turn retry model would render one turn-error row per failed turn on replay; the pre-release format stance accepts that, and no shipped log producer has emitted that shape since same-turn retries landed.
中文
问题
Web 的 turn-error Definition 一旦发现所属轮次携带任何 llm/retry 事件,就永久抑制自身节点。这条规则编码的是有界 LLM 请求恢复最初交付的重试模型:当时重试会关闭失败轮次并开启下一个编号轮次——带重试历史的轮次只可能是中间失败,其事实已经落在重试行上,而耗尽后的终态失败落在一个没有重试事件的后续轮次里。
此后 agent loop 改为在失败的轮次与步骤内部重试——llm-retry 的运行时不变量要求 llm/retry 出现在打开的轮次与步骤内,其 loop 测试断言恢复后的轮次只有一条 step/start。在这一生产方行为下,"该轮次拥有重试历史"与"这条 turn/end 错误就是耗尽后的终态失败"总是同时成立,于是这条抑制规则恰好藏掉了它本想让位的那个失败:所有暂时性重试耗尽后,对话里只剩一条中性的折叠"已重试模型请求(N/N)"行,没有任何错误行,输入框恢复可用。live e2e 场景没有抓住这个缺口,因为它们只覆盖了不可重试的 AUTH 失败(没有重试事件,错误行照常渲染)和重试后恢复成功的暂时性失败(completed 轮次派生不出失败),从未覆盖耗尽。
决策
删除该抑制。turn-error Definition 只匹配 turn/start 和 error 原因的 turn/end,只要所属轮次记录了终态错误就渲染;定格的重试链通过独立的 model-retry 节点在其旁边渲染。没有 hidden 状态,也没有撤回分支:在同轮次重试下,不存在"已渲染的终态错误随后被取代"的事件顺序,因为 turn/end 即关闭轮次。
局部历史窗口在构造上就与完整历史一致——只含 error 原因 turn/end 的尾部窗口派生出与完整历史相同的节点;旧规则下窗口恰好包含哪些重试事件,决定了同一失败一处隐藏一处显示。
测试
Definition 套件驱动真实 assembler 走完同轮次重试链并以 error 原因 turn/end 收尾,断言 turn-error 节点连同消息与错误码物化——覆盖完整历史、仅尾部窗口,以及向前补齐早期链之后三种情况。一个无密钥 Web 组合场景用场景自有的两次重试策略对撞三个注入的 SERVER throw 直至耗尽,并在 golden 中钉住终态错误行与定格重试行并列的画面;scaffold 新增 replayRetryPolicy 选项,让耗尽在毫秒级完成,而不是走共享默认的五次退避尝试。
备选方案
终态失败到达时重置 hidden。 已否决:这会保留一台状态机,而它剩下的唯一迁移正是引发此 bug 的那一条。在同轮次重试下,没有任何事件序列需要这个抑制。
区分中间与终态的 turn/end 错误。 已否决:日志中不存在这一区分。一个轮次只结束一次;error 原因对其轮次而言永远是终态。
影响
恢复耗尽后现在留下持久、可回放的反馈:红色终态行带展示安全的消息与错误码,加上折叠的重试链作为恢复上下文。按已退役的"新轮次重试"模型录制的会话日志在回放时会为每个失败轮次各渲染一条 turn-error 行;预发布格式立场接受这一点,且自同轮次重试落地以来,没有任何已交付的日志生产方再发出过那种形态。