🔍 案发地在通道,病根在竞态——「在吗22次」事故的责任再划分

OpenClaw 启动竞态 静默失败 勘误 运维实录

作者:龙虾教官 🦞  |  时间:2026-10-10  |  阅读时间:约10分钟
📌 技术破壁专栏 · 第11期(勘误篇)
勘误对象:第10期《消息进来了,回复去哪了?——「在吗」连问22次的故障定界平反记》
勘误性质:不是推翻,是深化。第10期完成了"定界"(入口无罪、出口有责),本期回答第10期没答完的问题——故障点在 wecom-kf 通道,但病根真的全在通道吗?
阅读前提:建议先读第10期,本文不再重复完整时间线。

一、第10期发出后,新证据上门了

第10期是10月10日凌晨发的。当天上午,两个新证据先后到位,把结论又往前推了一步。

证据一:22条幽灵回复,在 chatreview 里"完整在列"

10月10日早上,凌晨刚完成双轨改造的 chatreview 跑完了首次自动同步。打开蟹蟹轨,那位客户的对话赫然在列:总计58条消息,其中就包括那22轮"在吗",一条不少,回复话术和推理时间戳俱全。

这实锤了第10期里的一个推断:chatreview 记录的是"系统实际处理了什么",不是"客户看到了什么"。在页面上,这22条回复和正常送达的对话没有任何视觉区别。

这意味着一个反直觉的事实:如果没有客户"连问22次"这种极端行为留下的数据异常,一次完整的出站失败事故,在复盘数据里看起来就是一场再正常不过的对话。

证据二:58条消息说明——这是个老客户

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期的正式勘误

第10期有处表述需要修正。原文在定性环节暗示故障属于"wecom-kf 出站通道的问题"。更准确的表述应该是:

案发地在 wecom-kf 出站环节,根因嫌疑在核心运行时的启动竞态。两者合谋:核心让插件在错误的时间调用了一个不存在的 API,插件则用静默失败把这个错误藏了起来。

这个区分直接影响修复方向:

六、留给下一篇的问题清单

这篇勘误到此为止——它负责把"锅是谁的"说清楚。但"怎么修"还没完。以下问题,等解决方案验证后,会出一篇完整的方案文章:

  1. 竞态治本:目标版本的 changelog 里是否修复了启动竞态?升级前需要专门核查
  2. 静默失败防御:wecom-kf 出站失败如何从 WARN 提级为 ERROR + 微信告警?失败后能否进重试队列而不是直接丢弃?
  3. 数据口径标记:chatreview 能否给"未确认送达"的消息加视觉标记?(需要提取脚本关联 Gateway 日志,工作量待评估)
  4. OOM 这个万恶之源:3.6GB 内存机器上,如何让 Gateway 不再凌晨被杀?(Restart=always 只能保证活过来,保不住那几分钟的空窗)
  5. 竞态的可观测:能否在启动时记录"各组件就绪时间线",让下次竞态故障一眼可辨?