「聊天已过期」:桌面端曾经把每一次发送都拦下来


你在桌面端同一个会话里连着干了几小时,工具调用一条接一条。某次回车之后,界面上弹出一句“Chat out of date”,说明文字大意是:这个窗口落后于同一会话的另一个视图,最新消息已经加载,如果还想发,请再发一次。你照做了,它还是这句。重启应用、重开窗口,依然如此。你的数据其实一条没丢,但这个会话从此写不进新消息。

2026-09-27 合并的 PR #125523 修掉了这个循环,issue #123909 里有完整的复现时间线。

症状:一个不收敛的拒绝循环

  • 直接触发是一类看起来很无辜的操作:把一个会话移动进某个 project。移动之后,窗口的内存 store 冻结在迁移边界上,于是之后每一次发送都被客户端拦下。
  • 这不是孤例:#123033 和 #123856 报的是同一个守卫在没有明确触发条件时的一般性出现。
  • 还有一个更安静的连带伤害:对受影响的会话做 fork,会以这份冻结的内存 store 作为种子,静默丢掉迁移之后的所有历史——它不报错,只是新会话里没有那段内容。

根因:两个消费者对同一份 store 的假设不一致

问题出在两个都读同一份会话 store 的组件,它们对“什么叫做最新”有不同理解:

  • 保留策略(transcript-retention.ts,最初为 #77311 引入)会主动释放 store 的头部,只留下实时窗口加一个权重页的余量。每个 ChatMessage.parts 里都带着渲染后的工具输出、文件预览、diff 和图片,长期窗口的体积就是这么长起来的。对工具调用密集的协调者型转录来说,这份预算很快就被烧掉,于是 trim 之后的 store 里渲染消息条数远少于后端真实条数。
  • 旧守卫(stale-transcript-guard.ts)比较的是条数:把最新 120 行那一页(includeCompacted: true)折叠成聊天消息,比如 85 条,而 trim 后的 store 比这更少,于是判定“本地视图落后”,拒绝发送并装入刷新页;而下一次窗口锚点移动又会 trim 一次,下一次发送又被拒。结构上就不可能收敛,而且它能扛过重载与重启,因为 hydration 一旦建立窗口切分就会再 trim 一次。

短对话永远不会超出那份预算——这就是为什么只有又长又重的对话会中招,包括设计上“永久存在”的 Bot Chat。

修复:比较尾部,而不是总数

新逻辑换了判断依据:

  • 新增 lastDurableRowId:从视图末尾往前找,第一个带持久化 rowId 的消息就是这条视图的“尾部”。保留策略只会释放头部,所以最后一个持久行永远是活着的尾巴;未持久化的行(乐观显示的输入、正在流式输出的回复)排在它之后。
  • 只要本地视图的最后一个持久 rowId 等于后端最新页自己的最后一个持久 rowId,这个视图就是当前的,无论头部被释放了多少。对端窗口确实有更新的行时,尾部会变,所以真正落后的情况照样会被拦下。
  • 顺手还修了一个误判来源:后端写入的 systemNotice 渲染在时间线上但不属于任何视图,按条数统计会凭空多出一个“不存在的窗口”——仅仅因为就地切换了模型,就会导致每次发送都被拒。

你能做什么

  • 升级是最直接的答案:这个修复进入你的安装后,长对话的发送不再被这个循环拦住(桌面端随 hermes update 更新)。
  • 在还没升级的机器上遇到这个提示时,最实用的绕过是新开一个会话继续,或者从命令行接着聊(hermes sessions browse 可以直接挑一条历史会话继续),不要反复点重发。
  • 如果你已经因为这个问题 fork 过某条会话,请把它当成不可靠的副本:原会话还在,里面的历史是完整的,别用 fork 出来的那份做备份。
  • 同一天还有另一条跟“长对话被切开”有关的修复,值得连着读:回合中途压缩为什么不能改写历史;桌面端本身的用法与形态见界面模式那篇和生态页里的 Hermes Desktop。

发布状态

补丁合并于 2026-09-27,晚于 v0.21.5 的发行标签(v2026.9.24),目前只在 main 上。

小结

这条 bug 的教训不在某一行的写法,而在于两个模块各自都“没写错”:一个按权重释放历史以控制内存,另一个按条数判断新旧——两者一相遇,判断就永远不成立。修复没有去调预算数值,而是换了一个两边都同意的事实:尾部那一条持久行。只要它一致,视图就是新的;只要不一致,就确实落后。内存该省的继续省,发送该通的路才通。