docs/plan/2026-08-28-feishu-inbound-queue-technical-design.md
Rama main · réplica de lectura del repositorio original.
# 飞书入站消息队列(FIFO + 排队卡片)技术方案
## 1. 需求背景
当前飞书通道的行为是「到达即打断」:Agent 正在执行任务时收到用户的新消息,会立即取消正在执行的任务(置位取消标志、关闭会话 shell),然后以新消息为起点重新执行(见 `backend-ts/src/feishu/agent-inbound-handler.ts` 的代际取消机制)。
这套行为有两个问题:
- 用户只想补充信息(例如「顺便加个单测」),旧任务却被整单作废,已产生的中间成果丢弃;
- 打断是无感知的,用户不清楚旧任务是「被打断了」还是「已完成」,也没有选择权。
期望改为显式可控:执行中的任务不被打扰,新消息进入队列排队;用户如果想立刻纠偏,通过卡片按钮主动触发打断。
## 2. 需求描述
### 2.1 要做的
1. Agent 执行任务期间,同一会话再次收到用户消息时,不再取消当前任务;新消息进入该会话的 FIFO 队列尾部等待。
2. 入队的消息立即向用户发出一张飞书交互卡片,内容包括:排队提示(当前任务执行中,该消息将在当前任务完成后自动执行)、消息摘要、两个按钮:
- **立即发送**:取消当前正在执行的任务,该消息跳到队首立即执行(纠偏语义),其余排队消息保持原有相对顺序;
- **取消本次任务**:将该消息从队列移除,不影响当前任务和其他排队消息。
3. 当前任务执行完成后,自动按顺序消费队首消息并完整执行;每条排队消息开始执行时,把它的排队卡片就地 PATCH 升级为进度卡片,此后沿用现有进度卡生命周期(执行中 → 完成/失败/取消)。
4. 「立即发送」触发的打断沿用现有取消链路:置位 AgentLoop 取消标志、关闭该会话 shell 子进程、清理不完整的消息尾部;被打断任务的进度卡片显示「已被下一条指令中断」。
5. 私聊和群聊统一生效;群聊中按钮仅允许原消息发送者操作(卡片回调携带点击者身份,与服务端记录的发送者比对),他人点击返回 toast 提示「仅消息发送者可操作」。
6. 队列按会话维度隔离,**不设容量上限、不做排队超时**;每个 Bot 应用独立。
7. 队列持久化到 MySQL,后端进程重启后自动恢复队列继续串行消费,排队消息不丢失;重启时正在执行的任务仍由现有 CrashRecoveryRunner 续跑,续跑完成后自动接力消费队列。
### 2.2 明确不做的
| 不做项 | 说明 |
|--------|------|
| 微信通道同类改造 | 微信通道维持现状(到达即打断),本期不动 |
| 桌面/Web 端消息队列 | desktop 已有独立的 WS 通道队列(`message_queue` 表 + QueuePanel),语义与本需求不同,互不影响、不复用代码 |
| 队列容量上限 / 满员拒绝 | 用户明确选择无限 FIFO |
| 排队超时自动取消 | 排队消息永久有效直到被执行、取消或队列清空 |
| Bot 级「打断模式」开关 | 彻底移除自动打断,不保留双模 |
| 卡片按钮的管理端统计 | 不做埋点和报表 |
| 队列可视化管理界面 | admin 不新增页面 |
## 3. 已确认的决策记录
| # | 决策点 | 结论 |
|---|--------|------|
| 1 | 多条消息排队策略 | **无限 FIFO**:A 执行中收到 B、C,A 完成后依次执行 B、C,每条都有独立卡片和按钮 |
| 2 | 「立即发送」作用于非队首消息 | **跳到队首**:被点击的消息 rank 调整为当前最小 rank − 1,打断当前任务后立即执行它,其他消息保持原序 |
| 3 | 生效范围 | **私聊 + 群聊统一**(同一入站链路) |
| 4 | 按钮权限 | **仅原发送者可点**(open_id 比对) |
| 5 | 卡片形态 | **一卡到底**:排队卡 → PATCH 为进度卡 → 终态,全生命周期一张卡 |
| 6 | 队列存储 | **MySQL 新表持久化 + 启动恢复**;CrashRecoveryRunner 续跑被打断任务后接力消费 |
| 7 | 自动打断行为 | **彻底移除**,「取消旧任务」只能通过「立即发送」按钮触发 |
## 4. 技术选型
| 选型点 | 选择 | 依据 |
|--------|------|------|
| 卡片按钮回调接收 | SDK 长连接 `EventDispatcher` 注册 `card.action.trigger`(`@larksuiteoapi/node-sdk@^1.73.0`,`EventDispatcher.register<T>` 泛型扩展支持) | 与现有 `im.message.receive_v1` 共用同一 WSClient,不需要暴露公网 HTTP 回调端点,不引入新部署面 |
| 回调解析 | SDK 类型 `RawCardActionEvent`(`context.open_message_id` / `operator.open_id` / `action.value`) | 官方类型定义齐备 |
| 队列存储 | MySQL 新表 `feishu_inbound_queue`(Flyway `V088__feishu_inbound_queue.sql`) | 桌面端的 `message_queue` 表面向 WS 协议的 UI 自助队列(排序/编辑/折叠),生命周期与字段模型均不同;独立建表做领域隔离,互不牵连 |
| 双实例安全 | 行级 CAS 认领(`UPDATE ... SET status='RUNNING' WHERE id=? AND status='QUEUED'`,affectedRows=1 才消费) | 与 `claimInboundMessage` 同款模式,蓝绿部署窗口期防重复消费 |
| 执行编排 | 复用 `agent-inbound-handler` 的会话锁 + `agentLoop.registerCancelFlag`,新增队列消费钩子 | 改动收敛在 feishu 域 |
## 5. 现状分析(相关代码索引)
| 模块 | 位置 | 与本需求的关联 |
|------|------|----------------|
| 长连接事件入口 | `backend-ts/src/feishu/monitor.service.ts` `createFeishuBotHandle` | 需增加 `card.action.trigger` 注册 |
| 入站处理 | `backend-ts/src/feishu/inbound-processor.ts` | 上层已完成 claim 去重/授权/上下文拼装,onMessage 返回后发文本回复;排队场景返回 null 即可不产生文本回复 |
| 执行编排(待改造核心) | `backend-ts/src/feishu/agent-inbound-handler.ts` | 现有 locks/cancelFlags/generations 三件套实现「到达即打断」;保留 locks 与 cancelFlag 注册,删除代际打断 |
| 进度卡片 | `backend-ts/src/create-app.ts` `buildFeishuProgressCard` / `createFeishuProgressCard`(支持 `existingMessageId` PATCH 分支)、`backend-ts/src/feishu/card-progress-listener.ts` | 排队卡→进度卡的升级复用 `existingMessageId` PATCH 路径;终态文案需区分「打断」与「自然取消」 |
| 崩溃恢复 | `backend-ts/src/harness/core/crash-recovery-runner.ts` | 已有 `onExecutionFinished` 收尾钩子(`recoverSession` 的 finally 中调用),用于崩溃路径的消费接力 |
| Shell 管理 | `backend-ts/src/create-app.ts` `onGenerationCancelled` → `shellManager.closeByConversation` | 打断回调从「代际取消」迁到「立即发送」路径复用 |
| 入站去重 | `feishu/message.service.ts` `claimInboundMessage` | 事件级去重已在队列上游完成,队列表的 `(bot_id, message_id)` 唯一键做二次保险 |
## 6. 总体设计
### 6.1 会话级状态机
```
┌────────────────────────────────────────────┐
│ SESSION EXECUTION SLOT │
消息到达 │ │
│ │ IDLE ──claim队首──► RUNNING ──终结──┐ │
├─ 会话IDLE ──┤ ▲ │ │
│ │ └────── drainNext() ◄──────────────┘ │
│ └────────────────────────────────────────────┘
│ ▲
└─ 会话BUSY ─► 入队(QUEUE) ─┘
│ 「立即发送」→ 调整rank + 打断RUNNING → 本条抢下一次drain
│ 「取消本次任务」→ status=CANCELLED → 出局
└─ 轮到自己 → status=RUNNING(CAS)→ PATCH卡片为进度卡 → 执行
```
每条队列行的状态流转:`QUEUED → RUNNING → (行删除)`;`QUEUED → CANCELLED → (行删除)`。
### 6.2 主流程时序(忙时入队)
```
用户消息 D 到达
→ InboundProcessor.process(授权/群上下文/引用解析,不变)
→ AgentFeishuInboundHandler.onMessage
├─ 会话空闲?──是──► 原直发路径:buildMessage → saveUserMessage → execute → 回复
└─ 会话繁忙
├─ buildMessage(context)(媒体下载此刻完成,产物固化)
├─ INSERT INTO feishu_inbound_queue(status='QUEUED', rank=max+1, payload=...)
├─ 发送排队交互卡片,回填 card_message_id
└─ return null(无文本回复)
A 任务结束(COMPLETED/FAILED/CANCELLED)
→ handler 执行收尾 finally / CrashRecoveryRunner.onExecutionFinished
→ FeishuTaskQueue.drainNext(sessionId)
├─ CAS 认领队首:status QUEUED→RUNNING
├─ PATCH 排队卡 → 进度卡('任务已接收,正在准备执行。')
├─ 重置 phase=RUNNING → saveUserMessage(payload) → prepareMessage → execute
└─ 循环直至队列为空
```
### 6.3 按钮回调时序(card.action.trigger)
```
按钮点击 → EventDispatcher('card.action.trigger')
→ 解析 action.value = { kind:'feishu_queue', queueId, act:'run'|'cancel' }
→ 按 queueId 查队列行(行不存在 → toast「消息已失效,请重新发送」)
→ operator.open_id !== sender_open_id → toast「仅消息发送者可操作」
→ act='cancel'
├─ CAS QUEUED→CANCELLED(失败=已开始执行 → toast「该消息已开始执行」)
└─ PATCH 卡片为「已取消本次任务」终态 + 移除按钮 → DELETE 行
→ act='run'
├─ 行已 RUNNING/终态 → toast「该消息已在执行」
├─ rank_no = 本会话当前最小 rank − 1(跳队首)
├─ PATCH 卡片为「已插队,正在切换执行…」
└─ 打断当前执行:cancelFlag.set(true) + shellManager.closeByConversation(sessionId)
(当前任务收尾钩子 drainNext 会自动认领本条开始执行)
```
## 7. 数据库设计
新增 `backend-ts/db/migration/V088__feishu_inbound_queue.sql`:
```sql
CREATE TABLE feishu_inbound_queue (
id BIGINT NOT NULL AUTO_INCREMENT,
bot_id BIGINT NOT NULL COMMENT '飞书Bot应用ID(feishu_bot.id)',
session_id BIGINT NOT NULL COMMENT '所属会话ID',
message_id VARCHAR(64) NOT NULL COMMENT '飞书原始消息messageId',
card_message_id VARCHAR(64) NULL COMMENT '排队交互卡片messageId(按钮定位键)',
sender_open_id VARCHAR(64) NOT NULL COMMENT '原消息发送者open_id(按钮鉴权)',
mao_user_id BIGINT NULL COMMENT '绑定的mao用户ID(executionUserId)',
rank_no BIGINT NOT NULL COMMENT '消费排序号(会话内越小越先)',
status VARCHAR(16) NOT NULL DEFAULT 'QUEUED' COMMENT 'QUEUED/RUNNING/CANCELLED',
payload MEDIUMTEXT NOT NULL COMMENT 'buildMessage产物+入站上下文快照(JSON)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_bot_message (bot_id, message_id),
KEY idx_session_status_rank (session_id, status, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='飞书入站任务队列';
```
约定:
- `payload` 存 `buildMessage()` 的完整产物(`string | ContentPart[]`)及消费所需的上下文字段(chatType、senderLabel、chatId 等)。媒体在入队时一次性下载落盘,消费时不重复下载。data URI 图片随 payload 一起入库——与现状一致(现路径同样会把含 data URI 的用户消息写入 `session_messages`),不引入新的量级。
- `rank_no`:入队取 `max(rank_no)+1`;插队取 `min(rank_no)-1`。用 BIGINT 保证可反复减一。重启恢复时按原 rank 保序(含已发生的插队结果)。
- `uk_bot_message(bot_id, message_id)`:对 `claimInboundMessage` 的二级保险,事件重投不会重复入队。
- 终态行(消费完成/取消)即时 `DELETE`,不做历史归档;启动恢复只捞 `status IN ('QUEUED','RUNNING')`。
## 8. 后端详细设计
### 8.1 新增 `backend-ts/src/feishu/inbound-queue.service.ts`
```ts
export class FeishuTaskQueueService {
enqueue(item): Promise<FeishuInboundQueueRow>; // 追加到队尾
jumpToFront(queueId): Promise<boolean>; // rank=min-1,仅 QUEUED 生效
cancel(queueId): Promise<'CANCELLED'|'ALREADY_STARTED'>; // CAS QUEUED→CANCELLED
delete(queueId): Promise<void>; // 终态清理
claimNext(sessionId): Promise<Row|null>; // 最小rank+CAS认领 QUEUED→RUNNING
complete(queueId): Promise<void>; // 执行收尾删行
hasPending(sessionId): Promise<boolean>;
hydrate(): Promise<number[]>; // 启动恢复:对RUNNING行按「消息是否已落库」分支(已落库→删除行由崩溃恢复重放;未落库→复位QUEUED重新消费),返回有待消费QUEUED行的会话集合,
// 返回有待消费行的sessionId集合
findByCardMessage(cardMessageId): Promise<Row|null>;
}
```
配套 `inbound-queue.repository.ts`,所有状态变更均带 `WHERE status=?` 条件以实现 CAS 语义。
### 8.2 重构 `agent-inbound-handler.ts`
- **删除**:`generations` 代际计数、「新消息先取消上一代」的逻辑、`CancelFlag` 注释中对微信时序的对齐描述。
- **保留**:会话锁 promise 链(执行串行化的底层保障)、`createCancelFlag/releaseCancelFlag`。
- 新增内存 `busy: Set<sessionId>`:`onMessage` 到达时判断空闲/繁忙;空闲走原直发路径并在结束时触发 `drainNext`。
- 繁忙路径:buildMessage → enqueue → 发排队卡 → return null。
- `drainNext(sessionId)`(幂等,可被多处调用):若会话 busy 则直接返回;否则 `claimNext` 成功者进入执行(busy 置位 → PATCH 排队卡为进度卡 → `updatePhase('RUNNING')` → saveUserMessage → prepareMessage → execute),finally 解除 busy 并递归 `drainNext`。
- 被打断(「立即发送」触发 cancelFlag)的收尾分支:进度卡 `cancel()` 文案改为「已被下一条指令中断。」,之后 `cleanupIncompleteTail` 照旧,最后 `drainNext`。
### 8.3 卡片动作处理:新增 `backend-ts/src/feishu/card-action.service.ts`
- 在 `monitor.service.ts` 的 `eventDispatcher.register({})` 中追加 `'card.action.trigger'` 处理器(泛型扩展,`as never` 规避内置类型缺项),校验 `header.appId` 与 Bot 一致(与消息事件同规则)。
- 按 §6.3 时序分派:查行 → 鉴权 → CAS → PATCH 卡片 → 打断/通知。
- 结果反馈一律通过 PATCH 卡片呈现;toast 仅用于无效操作的轻提示(利用卡片回调响应体回传 toast JSON,失败静默不影响主流程)。
- 按钮定义:`action.value = JSON.stringify({ kind: 'feishu_queue', queueId, act })`,卡片 schema 2.0,`config.update_multi=true` 与进度卡一致。
### 8.4 排队卡片构建(并入 `create-app.ts` 卡片构建区)
```
[markdown] ⏳ **任务排队中**
你有一条新消息进入队列,当前任务执行完成后将自动开始处理:
[quote] {senderLabel}:{消息摘要≤60字}
[markdown] 队列位置:第 {n} 位
[action] [primary「立即发送」value={act:'run'}] [default「取消本次任务」value={act:'cancel'}]
```
- 插队成功后 PATCH 为:`🚀 已插队,正在中断当前任务并执行这条消息…`
- 取消成功后 PATCH 为:`✖️ 这条消息已取消,未进入执行。`(按钮随整体卡片重写移除)
- 排队卡发送失败时降级发送纯文本「当前任务执行中,你的消息已排队等待处理。」,保证用户知情(此分支无按钮,仅提示)。
### 8.5 启动恢复与消费接力
- 新增启动逻辑(create-app.ts 组装处,仿 `pendingBindingMessages.listRecoverable` 现有模式):
1. `hydrate()`:对崩溃时在途执行的 `status='RUNNING'` 行按「消息是否已写入会话历史」分支——已落库则删除该行(消息由 CrashRecovery 从历史重放、不重插用户消息,删除避免重复消费);未落库则复位为 `QUEUED`(消息从未持久化、无法恢复,由队列重新消费,保证不丢)。仅保留 `QUEUED` 行待消费;
2. 返回的每个 `sessionId` 挂一个延迟探测 `drainNext(sessionId)`。
- 接线 `CrashRecoveryRunner` 的 `onExecutionFinished(sessionId, userId)` 钩子(构造函数既有参数):钩子内调用 `FeishuTaskQueue.drainNextIfFeishuSession(sessionId)`(内部先查队列是否有 pending 行,非飞书会话零开销返回)。
- 链路闭环验证点:重启 → CrashRecovery 续跑被中断的旧任务 A → A 收尾触发钩子 → 队列中的 B、C 依次被消费。
### 8.6 竞态规则(定案)
| 场景 | 行为 |
|------|------|
| 点击时该消息已被 drain 认领开始执行 | CAS 失败,toast「该消息已开始执行」 |
| 「立即发送」与当前任务自然终结几乎同时 | 当前任务先终结则无可打断对象,drain 正常消费队首(若被点击者是队首,等于正常开跑);打断先生效则走插队打断路径。两种先后结果均符合语义,无需额外协调 |
| 多次快速连点同一按钮 | 第一次 CAS/PATCH 生效,后续命中「已在执行/已失效」分支,天然幂等 |
| 双实例同时 drain 同一会话 | 行级 CAS 保证单行单实例消费;实例内 busy Set 保证会话串行 |
| 排队期间会话被管理员删除 | 执行时 session 不存在报错,按现有 catch 链路记 FAILED、PATCH 卡片错误提示、删队列行 |
## 9. 测试计划
单元测试(Vitest,遵循 `backend-ts/src/**/*.spec.ts` 约定):
1. `inbound-queue.service.spec.ts`:enqueue 排序、jumpToFront 仅影响本会话、cancel CAS 分支、claimNext 并发竞争(模拟两次认领只成功一次)、hydrate 删除 RUNNING 行并去重返回会话、`(bot_id,message_id)` 唯一键拒绝重复入队。
2. `agent-inbound-handler.spec.ts` 更新:
- 忙时新消息**不**触发旧任务 cancelFlag(回归锁定:移除自动打断);
- 忙时入队、返回 null、卡片创建失败的文本降级;
- 任务结束后按序 drain 两条排队消息,各自 saveUserMessage 与 execute;
- 被「立即发送」打断的旧任务走 cleanupIncompleteTail 且卡片文案为新文案;
- 排队期间第三条消息到达不影响前两条顺序。
3. `card-action.service.spec.ts`:动作分发、open_id 鉴权(非本人 → toast、不改动状态)、cancel/run 各 CAS 失败分支、appId 不匹配忽略。
4. `monitor.service.spec.ts`:注册 `card.action.trigger` 处理器可达。
手工验收清单:
- [ ] 私聊:忙时连发三条,各收一张排队卡;A 完成后 B、C 依次自动执行且卡片逐张升级、终态正确
- [ ] 群聊:多用户交叉排队,各消息按钮仅本人可操作,他人点击收到 toast
- [ ] 对中间一条点「立即发送」:当前任务进度卡显示「已被下一条指令中断」,目标消息跳队首执行,其余保持原序
- [ ] 点「取消本次任务」:卡片变已取消态,当前任务不受影响,后续队列照常推进
- [ ] 手动 restart-backend 后:在途 RUNNING 行被清理(消息由崩溃恢复重放)、队列继续消费、卡片按钮依然可用
- [ ] 重启期间点旧卡片按钮:toast「消息已失效,请重新发送」
Playwright 不涉及(飞书为外部通道,自动化依赖真实租户)。
## 10. 落地清单
代码(均在 `backend-ts/`,不改前端五端其余部分):
- [ ] `db/migration/V088__feishu_inbound_queue.sql` 新建表
- [ ] `src/feishu/types.ts`:队列行类型、`FeishuCardActionContext`、handler options 调整(移除 onGenerationCancelled → 新增 onCancelRunning/interrupt 注入)
- [ ] `src/feishu/inbound-queue.repository.ts` + `.spec.ts` 新建
- [ ] `src/feishu/inbound-queue.service.ts` + `.spec.ts` 新建
- [ ] `src/feishu/agent-inbound-handler.ts` 重构 + spec 更新
- [ ] `src/feishu/card-action.service.ts` + `.spec.ts` 新建
- [ ] `src/feishu/monitor.service.ts` 注册 `card.action.trigger`
- [ ] `src/feishu/card-progress-listener.ts`:`cancel()` 文案与「打断/自然取消」区分参数
- [ ] `src/create-app.ts`:排队卡构建与发送、Bot ID 补齐、CrashRecoveryRunner onExecutionFinished 接线、启动 hydrate 挂载
- [ ] 全量回归:`cd backend-ts && npm test`;`npm run build`
部署与配置:
- [ ] Flyway 迁移随启动自动执行(FLYWAY_ENABLED 已有开关,无需额外动作)
- [ ] **飞书开放平台人工配置(上线前置条件)**:每个已启用的 Bot 应用,在开发者后台「事件与回调」中将卡片交互回调的接收方式设置为**使用长连接接收**(与现有 `im.message.receive_v1` 同一连接通道),否则按钮点击事件不会下发
- [ ] `/opt/mao` 线上目录拉取代码、`restart-backend.sh` 重启,观察 `hydrate` 日志与首批消息冒烟
发版记录:
- [ ] 完成后在根 `CHANGELOG.md` 顶部新增版本小节,backend-ts 条目说明新交互(排队卡片、双按钮、移除自动打断)
风险备忘:无上限 FIFO 意味着长时间无人值守的会话可能连续消耗较多 token(一条接一条自动执行);如后续观察到滥用,再评估会话级限量策略,本期不做。