OpenClaw 微信早报定时任务排障复盘

这次排障表面上看像是“Cron 正常、日志正常、系统也显示 delivered”,但真正的问题根本不在任务有没有执行,而在于:执行完成后,消息是否真的稳定送达当前微信会话。


一、问题现象:后台看起来正常,用户侧却不稳定可见

这次排查的对象主要是两条早报任务:

  • GitHub 早报
  • 油价早报

从 OpenClaw 后台状态来看,它们一开始都不像“坏掉了”的任务:

  • Cron 在正常运行
  • 日志里多次出现 status=ok
  • 甚至还能看到 deliveryStatus=delivered

如果只看这些信息,很容易下结论:

任务已经成功执行,问题不大。

但用户侧的实际体验完全不是这样。消息并没有稳定出现在当前微信会话里,或者即使有“送达”,内容也不一定是用户真正需要的最终早报。

这一步首先说明了一个很重要的问题:

在 OpenClaw 里,status=okdeliveryStatus=delivered 只能说明“系统认为这次运行完成并进行了投递”,但不能直接等同于“用户已经在当前会话里看到了正确结果”。

所以,这类用户可见的定时任务,最终验证标准必须从“后台状态正确”切换到“当前微信会话真实可见”。


二、尝试过的几条链路,为什么都不够稳

排查过程中,先后尝试了几种不同的任务链路:

  • main + systemEvent
  • current / sessionKey 绑定
  • delivery.mode = none
  • isolated + announce

这些方案看起来都“像是能发消息”,但实际语义并不完全一样。

1)main + systemEvent

这条路更像是把事件投递回主会话,适合需要主会话上下文接力处理的场景。

但它的关键问题是:

它不天然等于“把最终内容稳定发回当前微信会话”。

在定时任务场景里,尤其是要求“到点就给用户发一条最终可读消息”,这条链路并不够直接。

2)current / sessionKey 绑定

这种方式更偏向:

  • 绑定上下文
  • 绑定会话归属
  • 绑定后续处理位置

但它解决的是“任务归谁处理”的问题,不是“最终一定如何投递给用户”的问题。

也就是说:

current/sessionKey 更像上下文路由,而不是稳定送达保证。

3)delivery.mode = none

这次排障里,delivery.mode=none 非常容易被误解。

很多时候看到 none,会下意识理解为:

  • 先内部跑完
  • 再自动回到当前会话

但它的真实语义其实更接近:

只内部运行,不做对外投递。

也就是说,它不是“静默执行后自动回微信”,而是“执行归执行,但不负责外发”。

所以对于“定时给用户发消息”这种任务,none 并不能解决最终送达问题。


三、最后确认:真正符合这类场景的还是 isolated + announce

排到后面,最终确认下来,真正符合 OpenClaw 官方设计思路的路径,仍然是:

  • isolated + announce
  • 并显式指定:
    • channel
    • to
    • accountId

原因很简单:

  • isolated 让任务在独立运行环境里完成
  • announce 让 cron runner 自己负责最终投递
  • channel/to/accountId 把目标会话明确到不能再模糊

这条链路的核心优势在于:

它不是“任务跑完了再看看要不要发”,而是“这个任务从设计上就是为了产出一个可直接投递的最终结果”。

但这还不是全部。

真正最容易踩坑的地方,其实在下面这一步。


四、最大的坑不在配置,而在“输出协议”

这次最关键的坑,不是哪个字段写错了,而是:

模型输出了过程性话术,系统却把过程性文本当成最终 summary 投递了。

isolated + announce 模式下,如果 prompt 约束不够强,模型非常容易输出类似这种内容:

  • “我先查一下今天的数据”
  • “我再整理一下结果”
  • “拿到更多信息后我再判断是否发送”
  • “我先验证一下来源是否可靠”

从对话角度看,这些话没问题;但从定时任务角度看,它们是致命的。

因为在 announce 链路里,OpenClaw 并不会替你判断“这句话到底是不是最终要发给用户的内容”。它只会拿到当前轮次产出的文本,并把它当成最终 summary 去投递。

结果就是:

  • 系统记录为“已送达”
  • 但用户收到的可能只是过程说明
  • 甚至收到一条看起来不像早报的废话
  • 最差情况下,用户会觉得“根本没发对内容”

这就是为什么后台明明显示 delivered,但用户感知依然是“这任务不稳定”——因为真正送到用户眼前的内容,不是正确的最终早报,而是模型中间态输出。


五、修复关键:不要再换链路,要先锁死“最终摘要协议”

排到这里,真正的修复方向就清楚了:

不是继续无止境地换链路,而是先把输出协议收紧。

这次修复的关键约束有三条:

1)如果输出,第一行必须直接是最终标题

不能先寒暄,不能先铺垫,不能先解释自己在做什么。

也就是说,任务一旦决定输出,开头就必须像这样:

【GitHub 早报】

或者:

【今日油价早报】

用户第一眼看到的就必须是可读结果,而不是过程说明。

2)禁止任何过程性话术

例如:

  • 我先查一下
  • 我再确认一下
  • 稍后发送
  • 还需要判断
  • 我先整理一下

这些在普通对话里没问题,但在定时早报里都不应该出现。

因为一旦出现,它们就很可能被系统当成“最终可发送文本”。

3)如果没有足够信息,就直接不输出

这是一个很重要但经常被忽略的原则。

与其发出一条半成品、解释性、过程性的内容,不如在信息不足时直接保持静默。

因为定时摘要的目标不是“展示模型正在努力”,而是:

只在准备好最终结果时,输出最终结果。


六、最终跑通方式:一条保留,一条重建

最终处理结果不是两条任务完全一样地硬改,而是区别对待:

GitHub 早报

  • 保留原本可用链路
  • 重点放在输出协议收紧
  • 避免过程性文本被误投递

油价早报

因为原有链路问题更多,最终采用了更干净的重建方式:

  • 新建 clean job
  • 固定来源白名单
  • 使用 announce 投递
  • 增加“必须直接输出最终摘要”的强约束协议

这样处理的好处是:

  • 不把旧任务历史包袱继续带下去
  • 新链路语义更清晰
  • 验证标准也更明确

七、这次复盘真正说明了什么

这次排障最后得到的结论,其实不只适用于“微信早报”,而适用于所有 用户可见的定时任务

1)不要只看后台日志

后台 okdelivered 只能作为参考,不能作为最终验收标准。

2)定时任务的终点是“用户真实可见”

真正的成功标准不是:

  • run 成功
  • 日志正常
  • 系统没报错

而是:

当前会话里,用户能否真实看到一条格式正确、内容正确、时机正确的消息。

3)任务输出必须有“最终结果协议”

对于 announce 场景,最怕的不是没输出,而是输出了过程性文本却被系统当作最终结果发送。

所以这类任务必须把“输出格式”和“中间态禁止输出”写进 prompt 本身,而不是依赖模型自行理解。


八、这次排障沉淀下来的 4 条经验

1)delivered=true 不等于用户已经收到有效消息

它只能证明系统完成了一次投递动作,不能证明用户看到了正确结果。

2)delivery.mode = none 不是“自动回当前会话”

它的语义更接近“内部运行,不负责投递”。

3)current/sessionKey 解决的是上下文绑定,不是稳定送达

不要把会话绑定能力误当成最终消息送达保证。

4)isolated + announce 的真正风险在输出协议

链路本身并没有错,错的是如果不限制模型输出,它就可能把中间话术送给用户。


九、当前可复用的做法

如果以后再做这类“用户可见的定时摘要任务”,建议默认按下面这套思路设计:

  1. 优先使用 isolated + announce
  2. 显式指定:
    • channel
    • to
    • accountId
  3. 输出协议必须强约束:
    • 第一行就是最终标题
    • 禁止过程说明
    • 信息不足就不输出
  4. 验证标准只看一条:
    • 用户当前会话里是否真实可见

做到这四点,类似“系统说发了,但用户感觉没收到”的问题,基本就能少掉一大半。


结语

这次排障最值得记住的一句话是:

用户可见的定时任务,最终要对“用户看到什么”负责,而不是只对“后台跑了什么”负责。

一旦把这个标准立住,很多判断都会变得更直接:

  • 不是看内部有没有执行
  • 不是看字段有没有报错
  • 而是看结果是否真正出现在用户眼前

这才是这次 OpenClaw 微信早报排障复盘的真正价值。