第10期是10月10日凌晨发的。当天上午,两个新证据先后到位,把结论又往前推了一步。
10月10日早上,凌晨刚完成双轨改造的 chatreview 跑完了首次自动同步。打开蟹蟹轨,那位客户的对话赫然在列:总计58条消息,其中就包括那22轮"在吗",一条不少,回复话术和推理时间戳俱全。
这实锤了第10期里的一个推断:chatreview 记录的是"系统实际处理了什么",不是"客户看到了什么"。在页面上,这22条回复和正常送达的对话没有任何视觉区别。
这意味着一个反直觉的事实:如果没有客户"连问22次"这种极端行为留下的数据异常,一次完整的出站失败事故,在复盘数据里看起来就是一场再正常不过的对话。
22次"在吗"之前,还有36条正常对话记录。他不是路过随便问问的新客,是聊过天的熟人。
对一个老客户静默,比对一个新客静默,损失要重得多。
第10期引用了那行唯一的报错,但没有逐层拆解。本期补上:
[wecom-kf] [WARN] dialogue outbound transition failed (non-blocking):
params.runtime.agent.session.loadSessionStore is not a function
wecom-kf 插件的"对话状态流转"逻辑。它在投递回复前,需要先加载会话存储。
loadSessionStore 这个函数。注意报错原文是 is not a function——对象在,方法不在。它不是"连不上""超时了",而是"我调用的那个接口,此刻根本不存在"。
不是 wecom-kf 插件自己——是 OpenClaw 核心运行时通过插件 SDK 提供给插件的 API。
到这里,责任链开始分岔了:报错的是插件,但缺的东西该由核心提供。
第10期用了"启动竞态(race condition)"这个词,但没展开。这个词值得专门讲清楚,因为它是这类故障最难缠的地方。
系统里多个组件同时启动,谁先就绪不固定,而系统正确运行依赖特定的就绪顺序。一旦实际顺序错了,就出故障。之所以叫"竞态"——各组件像在赛跑,结果取决于谁先到终点,而每次发令枪响,名次都可能不一样。
餐厅早上开门营业:
更糟的是这家餐厅的管理制度:前台没告诉客人"做不出",也没记一笔"这单漏了",菜单直接进了碎纸机。客人坐在那儿干等,越等越疑惑:营业吗?营业吗?营业吗……
00:30:09 Gateway 被 OOM 杀掉(打烊)
00:31:39 Gateway 重启完成,http 端口开始监听(开门)
00:32:32 wecom-kf 通道恢复上线(前台就位,开始接单)
↓ 但:核心的 session store 还没把 loadSessionStore
↓ 挂到运行时对象上(后厨还没生火)
00:34~36 客户 22 条消息进来 → 蟹蟹正常推理生成回复(菜单写好)
↓ → 投递时调用 loadSessionStore → is not a function
↓ → (non-blocking) 静默丢弃(菜单进碎纸机)
00:39:18 一行 WARN 日志(碎纸机里唯一一张残页)
正常重启时,核心可能 1 秒内就把 API 挂好,插件永远遇不到这个空窗。但那晚的重启很慢(90 秒加载、一堆插件报错),空窗被拉长到好几分钟,客户正好踩了进去。
换个时间重启,可能永远不发作。
第10期把故障定位到"出站投递环节",这个定界仍然成立。但责任划分需要修正为:
| 层 | 角色 | 责任 |
|---|---|---|
| wecom-kf 插件 | 案发地 | ① 调用时机太早,没等核心 API 就绪;② 失败后静默吞掉——(non-blocking) 意味着不抛错、不重试、不告警 |
| OpenClaw 核心运行时 | 病根嫌疑 | 启动竞态——会话存储 API 的就绪晚于通道插件的上线 |
用一句话概括分工事故:不是快递员把包裹弄丢了,而是他去仓库取件时仓库还没开门,他两手空空回来,还没跟任何人说。
第10期有处表述需要修正。原文在定性环节暗示故障属于"wecom-kf 出站通道的问题"。更准确的表述应该是:
案发地在 wecom-kf 出站环节,根因嫌疑在核心运行时的启动竞态。两者合谋:核心让插件在错误的时间调用了一个不存在的 API,插件则用静默失败把这个错误藏了起来。
这个区分直接影响修复方向:
这篇勘误到此为止——它负责把"锅是谁的"说清楚。但"怎么修"还没完。以下问题,等解决方案验证后,会出一篇完整的方案文章:
Restart=always 只能保证活过来,保不住那几分钟的空窗)