zai-org / zai-org/feedback

[Bug] 飞书消息去重仅保存在内存 2 分钟,重启/延迟重放会重复执行已完成任务

Open
#203 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

priority: P2
Dominant language
No language data
Stars
22
Forks
1
PR merge metrics
No merged PRs in 30d

Description

提交前确认 · Pre-submission checklist
  • 我已搜索现有 issues,未发现同类问题。
  • 我已阅读 CONTRIBUTING.md。
问题类别 · Category

对话 / Agent 交互 · Agent chat

涉及的 Agent 框架 · Agent framework

ZCode Agent(自研)

严重程度 · Severity

影响体验 · Major

该问题可能重复执行带外部副作用的任务,例如写文件、发布内容或调用第三方工具。

复现频率 · Reproducibility

偶现 · Sometimes(取决于飞书是否重放旧事件);但去重失效条件是确定的:进程重启后必定丢失记录,或消息重放间隔超过 2 分钟后记录被清除。

问题描述 · Description

ZCode 通过飞书 WebSocket 接收消息时,已经使用 providerMessageId 做重复消息检测,但当前实现只使用进程内 Map,且 TTL 只有 2 分钟。

因此发生以下任一情况后,飞书重放同一个 message_id 时会被 ZCode 当作新输入再次执行:

  1. ZCode/host 进程重启,内存 Map 被清空;
  2. 同一消息在 2 分钟之后被再次投递。

如果用户刚执行过 /新建,旧消息重放还会被当作新任务的第一条 prompt,表现为“ZCode 突然自己重新执行已经完成的任务”。

复现步骤 · Steps to reproduce
  1. 在 ZCode 中配置飞书机器人(WebSocket 模式)。
  2. 通过飞书发送一条任务消息并等待任务完成。
  3. 重启 ZCode,或者等待超过 2 分钟。
  4. 让飞书重新投递相同的 im.message.receive_v1 事件(相同 message_id,例如 WebSocket 重连后的重放)。
  5. 观察 ZCode 再次调用 sendPrompt,甚至创建新 session。
期望表现 · Expected behavior

同一个飞书 message_id 在合理的幂等窗口内只能执行一次;去重状态应跨 ZCode/host 重启保留。处理失败时可以释放该 ID 以允许飞书重试。

实际表现 · Actual behavior

同一条旧消息在重启后或超过 2 分钟后再次进入 sendPrompt,导致已完成任务被重复执行。

本机日志中的脱敏时间线:

21:33:41 provider callback ... text=<消息 A>
21:33:41 provider callback duplicated ... messageId=<同一 message_id>
21:33:42 sendPrompt ... taskId=<旧 session>

21:40:48 [重启后] provider callback ... text=<消息 A>
21:40:50 sendPrompt ... taskId=<旧 session>

21:07:37 provider callback ... text=<消息 B>
22:49:39 provider callback ... text=/新建
22:52:10 provider callback ... text=<消息 B>
22:52:12 sendPrompt ... taskId=<新建 session>

本地数据库中旧 session 的输入均为 promoted,没有待处理的旧队列项;22:52 的执行来自一次新的 Feishu provider callback,并创建了新 session,因此不是本地任务队列恢复。

根因定位

ZCode 3.5.3 打包代码 out/host/index.js 中:

  • 去重容器:B = new Map(),仅存在于 host 进程内存;
  • TTL:hue = 2 * 6e4,即 2 分钟;
  • markInboundDelivery() 只检查该 Map;
  • releaseInboundDelivery() 在处理失败时删除 Map 项。

现有逻辑能挡住同一进程内的即时重复投递,但无法覆盖重启或延迟重放。

建议修复
  1. 以 (botId, provider, chatId/providerUserId, providerMessageId) 为 key,持久化已处理消息 ID;
  2. 使用至少数天的 TTL,并设置最大条数防止无限增长;
  3. 写入采用原子替换;
  4. 只有在消息处理/回复失败时才释放该 key,保留当前允许重试的语义;
  5. 增加“同一 message_id 在 host 重启后仍被拒绝”的回归测试。

我已在本机做了实验性补丁:将去重记录持久化到 ~/.zcode/v2/bot-inbound-dedupe.v1.json,TTL 7 天、上限 10,000 条,并保留处理失败时释放 ID 的逻辑。回归测试覆盖:

  • 同一消息 ID 在两个独立 Node 进程之间仍会被拒绝;
  • 处理失败释放后允许重试;
  • host 已接入持久化去重实现。

测试 3/3 通过,补丁版 ZCode host 启动及重启正常。

ZCode 版本 · ZCode version

3.5.3(Build 3.5.3.3911)

设备 / 系统 / 浏览器 · Device / OS / Browser

Apple Silicon / macOS 26.6 (25G72) / arm64

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in the bundled host implementation at out/host/index.js, focusing on markInboundDelivery() and releaseInboundDelivery(). Review how the in-memory Map and its two-minute TTL are used, then examine the described regression cases for separate Node processes and failed processing. Done means duplicate message IDs remain rejected after a host restart or delayed redelivery, while failed processing still permits retry.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.