原创

一次 OpenClaw 消息投递故障复盘:delivery-recovery 为何不断重试?

最近在运行 OpenClaw 时,后台日志里不断出现类似内容:

[delivery-recovery] Delivery entry ... delivery state is send_attempt_started;
refusing blind replay without adapter reconciliation

[delivery-recovery] Outbound delivery retry: retry failed ...

而且同一批记录会隔几秒重复出现,日志量非常大。

一开始看起来像是 OpenClaw 自己的 recovery 机制出了问题,但继续排查后发现,真正的原因并不是日志系统,而是 消息投递失败后留下了未正确收尾的 delivery 状态。

一、问题现象

日志中持续出现:

delivery state is send_attempt_started
refusing blind replay without adapter reconciliation

同时还有:

Outbound delivery retry: retry failed

更关键的是,同一个 cron delivery ID 会不断重复。

例如:

cron-direct-delivery:v1:cron:water-reminder-every-10min:...

以及其他 cron task ID。

这意味着 OpenClaw 的 delivery recovery 线程正在持续扫描这些历史记录,但每次都无法完成恢复。

最开始以为只是旧日志没有清理,但即使清空 Docker 日志,新日志仍然会马上重新出现。

这说明问题不是:

Docker 日志太多

而是:

OpenClaw 仍在持续产生这些日志

二、先定位 delivery 数据存在哪里

OpenClaw 的数据目录为:

/openclaw-data/.openclaw32

对应的 SQLite 主状态库:

/openclaw-data/.openclaw32/state/openclaw.sqlite

最开始使用系统自带的 SQLite:

sqlite3 --version

发现版本只有:

3.7.17

而 OpenClaw 数据库已经使用了较新的 SQLite STRICT table,因此会出现:

malformed database schema (...) - near "STRICT": syntax error

将 sqlite3 升级到 3.45.3 后,可以正常查看数据库:

sqlite3 /openclaw-data/.openclaw32/state/openclaw.sqlite ".tables"

其中有一个非常关键的表:

delivery_queue_entries

查看表结构:

sqlite3 /openclaw-data/.openclaw32/state/openclaw.sqlite \
"PRAGMA table_info(delivery_queue_entries);"

可以看到这些字段:

queue_name
id
status
entry_kind
session_key
channel
target
account_id
retry_count
last_attempt_at
last_error
recovery_state
platform_send_started_at
entry_json
enqueued_at
updated_at
failed_at

其中最关键的是:

status
recovery_state
last_error
retry_count

三、真正原因:企微发送失败

继续查询卡在 send_attempt_started 的记录:

sqlite3 -header -column \
/openclaw-data/.openclaw32/state/openclaw.sqlite "
SELECT
  queue_name,
  id,
  status,
  recovery_state,
  retry_count,
  channel,
  target,
  account_id,
  last_error
FROM delivery_queue_entries
WHERE status = 'send_attempt_started'
   OR recovery_state = 'send_attempt_started'
ORDER BY updated_at DESC
LIMIT 100;
"

最终发现真正的错误是:

errcode: 93006
errmsg: invalid chatid

也就是说,OpenClaw 调用企业微信发送消息时,传入的目标 ID 不正确。

数据库中的 target 包含:

HeYangYang
user:HeYangYang
channel:HeYangYang

这些值被发送到企业微信后,被接口判定为:

invalid chatid

所以整个链路实际上是:

Cron 定时任务触发
        ↓
OpenClaw 创建 delivery
        ↓
开始发送企业微信
        ↓
状态进入 send_attempt_started
        ↓
企业微信返回 93006 invalid chatid
        ↓
发送失败
        ↓
delivery 的 recovery_state 仍然停留在
send_attempt_started
        ↓
delivery-recovery 再次扫描
        ↓
无法确认消息是否已经真正发送
        ↓
拒绝 blind replay
        ↓
打印日志
        ↓
下一轮继续扫描

日志中的:

refusing blind replay without adapter reconciliation

其实是一种保护机制。

因为对于 OpenClaw 来说:

send_attempt_started

意味着:

我已经开始尝试发送消息,但我现在不能百分百确定外部平台到底有没有收到。

如果此时直接重发,就可能产生重复消息。

所以 OpenClaw 宁愿拒绝自动重放,也不会贸然再次发送。

问题在于,这些记录没有被正确转成最终状态,于是 recovery 会一直扫。

实际查询中可以看到,一部分记录已经是:

status = failed
recovery_state = send_attempt_started

另一部分 cron delivery 则是:

status = pending
recovery_state = send_attempt_started

并且 last_error 都是企业微信的 93006 invalid chatid。

四、为什么重启 Docker 没用

因为这些状态不是保存在 Docker 临时文件里,而是持久化在 SQLite 中。

因此:

docker restart

或者:

docker compose down
docker compose up -d

都不会解决。

甚至重新创建容器以后,这些记录仍然存在。

OpenClaw 启动之后会重新读取:

delivery_queue_entries

然后 recovery 继续工作。

所以现象就是:

重启
↓
日志暂时消失
↓
OpenClaw 启动完成
↓
delivery-recovery 开始扫描
↓
继续刷屏

五、解决方案

解决这个问题分两部分:

第一部分:清理已经卡死的历史 delivery。

第二部分:修复企业微信 target/chatid,避免继续产生新记录。

方案一:清理历史失败 delivery

操作数据库之前,建议先停止 OpenClaw:

docker stop openclaw32-openclaw-gateway-1

然后备份:

cp /openclaw-data/.openclaw32/state/openclaw.sqlite \
/openclaw-data/.openclaw32/state/openclaw.sqlite.bak.$(date +%Y%m%d%H%M%S)

先统计:

sqlite3 -header -column \
/openclaw-data/.openclaw32/state/openclaw.sqlite "
SELECT
  status,
  recovery_state,
  COUNT(*) AS cnt
FROM delivery_queue_entries
WHERE channel='wecom'
  AND last_error LIKE '%invalid chatid%'
GROUP BY status,recovery_state;
"

确认这些都是历史失败消息后,可以清理:

sqlite3 /openclaw-data/.openclaw32/state/openclaw.sqlite "
DELETE FROM delivery_queue_entries
WHERE channel='wecom'
  AND recovery_state='send_attempt_started'
  AND last_error LIKE '%invalid chatid%';
"

然后确认:

sqlite3 /openclaw-data/.openclaw32/state/openclaw.sqlite "
SELECT COUNT(*)
FROM delivery_queue_entries
WHERE channel='wecom'
  AND recovery_state='send_attempt_started'
  AND last_error LIKE '%invalid chatid%';
"

如果返回:

0

说明已经清理完成。

然后启动:

docker start openclaw32-openclaw-gateway-1

观察:

docker logs -f --tail 100 openclaw32-openclaw-gateway-1

正常情况下,原来那批 delivery-recovery 日志应该消失。

六、真正需要修复的是 WeCom target

仅删除数据库记录,只能解决历史问题。

如果代码继续把:

HeYangYang
user:HeYangYang
channel:HeYangYang

当成企业微信 chatid 发送,那么新的任务依然会:

发送
↓
93006 invalid chatid
↓
send_attempt_started
↓
delivery-recovery
↓
继续刷日志

因此真正的根治方案是检查 WeCom Adapter 的 target 路由。

需要明确区分:

userid
chatid
external_userid
群聊 chatid
单聊 userid

不能把 OpenClaw 内部的:

user:xxx
channel:xxx

直接当成企业微信 API 的 chatid。

这也是此次问题最核心的地方。

从实际失败数据看,所有这些记录的 last_error 都明确指向 93006 invalid chatid,说明企业微信目标解析就是当前主要故障点。

七、如何避免日志文件继续无限增长

即使修复了业务问题,也建议给 Docker 开启日志轮转。

在 docker-compose.yml 中加入:

logging:
  driver: "json-file"
  options:
    max-size: "50m"
    max-file: "3"

例如:

services:
  openclaw-gateway:
    image: openclaw-openclaw32:0821

    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "3"

这样最多保存约:

50MB × 3

避免异常情况下日志占满磁盘。

如果只是临时清空当前 Docker 日志,可以:

truncate -s 0 "$(docker inspect \
--format='{{.LogPath}}' \
openclaw32-openclaw-gateway-1)"

但要注意:

清日志并不能解决 delivery-recovery 本身。

只要数据库里的异常 delivery 还在,它马上还会继续写。

八、总结

这次问题表面上是:

OpenClaw delivery-recovery 疯狂刷日志

实际上真正原因是:

WeCom target/chatid 错误
        ↓
企业微信返回 93006
        ↓
delivery 发送失败
        ↓
recovery_state 卡在 send_attempt_started
        ↓
OpenClaw 为避免重复发送拒绝 blind replay
        ↓
recovery 不断扫描同一批记录
        ↓
日志持续刷屏

所以完整解决方法应该是:

升级 SQLite 工具
↓
定位 delivery_queue_entries
↓
确认 last_error
↓
备份数据库
↓
清理历史 invalid chatid delivery
↓
修复 WeCom target/chatid 映射
↓
增加 Docker 日志轮转

这次排查也说明一点:

看到 recovery 日志一直重复时,不要第一时间关日志。

日志只是结果。

真正应该追的是:

delivery 为什么一直无法进入最终状态?

找到这一层,问题才算真正解决。

正文到此结束
Loading...