# 技术破壁专栏 · 第11期（勘误篇）

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

> **勘误对象**：第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）"这个词，但没展开。这个词值得专门讲清楚，因为它是这类故障**最难缠**的地方。

### 定义

系统里多个组件同时启动，谁先就绪**不固定**，而系统正确运行依赖特定的就绪顺序。一旦实际顺序错了，就出故障。之所以叫"竞态"——各组件像在赛跑，结果取决于谁先到终点，而每次发令枪响，名次都可能不一样。

### 类比

餐厅早上开门营业：

- **9:00** 前台（wecom-kf 插件）准时上岗，开始接单
- **9:08** 后厨（核心运行时的会话存储）才生好火
- **9:03** 客人来了，前台热情接单、写好菜单，转身递给后厨——**后厨没人**

更糟的是这家餐厅的管理制度：前台没告诉客人"做不出"，也没记一笔"这单漏了"，菜单直接进了碎纸机。客人坐在那儿干等，越等越疑惑：营业吗？营业吗？营业吗……

### 这次的实际时序

```
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，插件则用静默失败把这个错误藏了起来。**

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

- 只盯着 wecom-kf 改（比如加消息重试），重启竞态下次会从**别的插件**冒出来——病根没除
- 正确姿势是**两头都管**：插件侧把出站失败提级告警（防御），核心侧等上游修复启动竞态的新版本（治本）

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

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

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

---

**专栏信息**
- 期号：第11期（勘误篇）
- 关联阅读：第10期《消息进来了，回复去哪了？——「在吗」连问22次的故障定界平反记》、第09期《OpenClaw升级踩坑实录：从"升级即翻车"到双层防护体系》
- 关键词：启动竞态、race condition、静默失败、责任划分、wecom-kf、可观测性
- 写作日期：2026-10-10
