Skip to content

Feishu 审批严格状态机改造(修复旧队列顶新卡、乱序回写与 stale 回调误伤) #642

@Cai-Tang-www

Description

@Cai-Tang-www

背景

当前 internal/feishuadapter 的审批卡链路在多审批请求、重复事件、旧卡回调晚到、网络更新乱序等场景下,存在旧状态覆盖新卡面的风险:

  • 新的审批卡/审批成功态可能被旧队列状态顶掉;
  • 旧卡回调晚到时,可能 fallback/remap 到当前 pending request;
  • permission_requested 重复事件可能尝试把已处理请求重新推回 pending;
  • sleep + 重扫 ApprovalRecords 的切换逻辑在并发插入新 pending 时存在 stale 回写窗口;
  • 全局 request_id 直连语义和 resolved 缓存没有严格按 run 生命周期隔离,长期存在跨 run 污染风险。

本 issue 目标:将飞书审批链路改为“单一真相 + 单调版本 + 新队列优先(LIFO)+ 严格回调匹配”的状态机,彻底消除旧状态覆盖新卡面的路径。


当前实现风险点

1. 状态分散,不是单一真相

当前审批状态分散在多张 map 和 sessionBinding.ApprovalRecords 中,例如:

  • requestRuns
  • permissionCards
  • runPermissionCards
  • permissionCardRuns
  • runPermissionActiveRequest
  • resolvedPermissions
  • sessionBinding.ApprovalRecords

这些结构分别维护 request -> run、request -> card、run -> card、card -> run、active request、resolved decision、状态卡渲染列表,容易出现某一处更新成功、另一处未更新或旧回写覆盖新状态的问题。

2. 旧回调 fallback/remap 会误伤新请求

当前 resolvePermissionWithFallback 在 primary request not found 后,会通过卡片/run 查询当前 pending request 并 fallback resolve。

这在“旧卡回调晚到 + 新请求已入队/已展示”的情况下,会把旧按钮动作错误作用到新 request。

本次改造后应彻底移除该语义:旧回调只能 ACK + log stale,不能 remap 到任何其它 request。

3. sleep + 重扫 切换 pending 存在 stale 回写

当前 updateApprovalStatus 在 resolved 卡片短暂停留后,通过 time.Sleep 再重新扫描 ApprovalRecords 找下一条 pending。

问题:

  • 扫描顺序天然偏 FIFO,不符合“新队列优先(LIFO)”;
  • sleep 期间可能插入新 pending、收到重复 callback、run terminal、untrackRun;
  • 旧 sleep 协程醒来后仍可能尝试修改 active request 或卡面。

应改为一次状态迁移驱动:resolve active 后立即从 pending_stack 栈顶 promote 下一条 pending,并生成对应 outbox。


设计原则

1. FSM 是唯一真相

新增按 run_key 管理的 ApprovalFSMState。审批业务状态只能由 FSM 驱动。

sessionBinding.ApprovalRecords 不再作为独立真相,只作为从 FSM 派生出来的状态卡渲染快照。

建议结构:

type ApprovalFSMState struct {
    RunKey          string
    Generation      string
    Version         uint64
    ActiveRequestID string
    PendingStack    []string // LIFO: 最新 pending 在栈顶
    Requests        map[string]*ApprovalRequestState
    CardID          string
    LastRenderedVersion uint64
}

type ApprovalRequestState struct {
    RequestID string
    Seq       uint64
    State     ApprovalRequestStatus
    ToolName  string
    Operation string
    Target    string
    Reason    string
    Decision  string
    CreatedAt time.Time
    UpdatedAt time.Time
}

2. 状态迁移锁内完成,outbox 只做网络副作用

重要约束:业务状态必须在锁内一次性提交,不能等网络调用成功后再提交。

正确模型:

锁内:
  queued -> displaying_pending
  active_request_id = request_id
  version++
  生成 outbox

锁外:
  SendPermissionCard / UpdatePendingPermissionCard / UpdatePermissionCard

回锁:
  只确认 card_id / last_rendered_version / last_error / delivered_at
  不再决定 active_request_id / request.state / pending_stack

outbox 回写只确认“副作用结果”,不能提交业务状态,否则网络调用期间进来的 callback / 新 pending / run terminal 会看到旧状态并制造新竞态。

3. 单调版本 + generation 栅栏

每个 run 内 FSM 维护单调 version,每次状态变更递增。

每个 outbox 必须携带:

  • run_key
  • generation
  • version
  • request_id
  • op_type
  • card_id(如已有)

outbox 网络调用完成后回锁确认时,必须同时匹配:

fsm != nil
fsm.generation == op.generation
op.version == fsm.version 或 op.version 仍符合当前可确认范围

不匹配则作为 stale outbox 丢弃,只记录日志,不修改业务状态。

generation 用于防止 untrackRun 后旧 outbox 复活同名 run 或污染新 run。

4. 严格状态迁移表,禁止逆迁移

request 状态固定为:

  • queued
  • displaying_pending
  • resolving
  • resolved_approved
  • resolved_rejected
  • archived

允许迁移:

queued -> displaying_pending
queued -> archived

displaying_pending -> resolving
displaying_pending -> archived

resolving -> resolved_approved
resolving -> resolved_rejected
resolving -> archived

resolved_approved -> archived
resolved_rejected -> archived

archived -> no-op

禁止任何逆迁移,例如:

  • resolved_* -> displaying_pending
  • resolved_* -> queued
  • archived -> *
  • 重复 permission_requested 把已 resolved request 重新入队

逆迁移一律 ACK/ignore/log,不影响卡片和 gateway。

5. 队列策略固定为 LIFO

本 issue 拍板使用“新队列优先”:

  • permission_requestedpending_stack 栈顶;
  • active 为空时,立即 promote 栈顶为 displaying_pending
  • resolve active 后,立即从栈顶取最新 pending 进入 displaying_pending
  • 不再通过扫描 ApprovalRecords 隐式决定下一条。

状态卡建议显示队列信息,避免用户误以为旧请求丢失:

当前审批:perm-newest-3
队列中还有:2 条
策略:最新优先

6. 严格回调匹配,移除 remap/fallback

resolvePermissionWithFallback 应改为 resolvePermissionStrict

回调必须精确匹配当前 run 内正在展示的 request,才允许 resolve。

建议判断顺序:

1. 从 callback action value 解析 run_key;若没有,则用 card_id 查 run_key。
2. run_key 不存在:ACK + log stale。
3. request_id 不存在:ACK + log stale。
4. request_id != active_request_id:ACK + log stale,不 resolve。
5. request.state != displaying_pending:ACK + log duplicate/stale,不 resolve。
6. 锁内置 request.state = resolving,version++,生成 resolve outbox。
7. 锁外调用 gateway.resolvePermission。
8. gateway 成功后锁内置 resolved_approved / resolved_rejected,version++,并 promote next pending。

严禁 fallback 到“当前 pending request”。

7. run 终态与 untrackRun 完整回收

run terminal / untrackRun 时应完整回收:

  • ApprovalFSMState
  • request index
  • card index
  • pending stack
  • active request
  • resolved 缓存
  • historical card refs

旧 outbox 回写必须因 generation/version 不匹配被丢弃。


Implementation Changes

1. 状态模型与键空间重构

  • internal/feishuadapter 内新增 ApprovalFSMState,按 run_key 管理。
  • 所有审批相关映射统一改为 run 作用域,键使用 (run_key, request_id)
  • 移除全局 request_id 直连语义。
  • sessionBinding.ApprovalRecords 改为从 FSM 派生的渲染快照。
  • run 终态与 untrackRun 时,完整回收该 run 的 FSM 与所有审批缓存。

2. 状态迁移与版本栅栏

  • 每次审批状态变更都在锁内生成单调 version,并产出 outbox。
  • 锁外只执行网络调用。
  • 网络调用完成后回锁只确认副作用结果,不提交业务状态。
  • stale outbox 仅 ACK/记录,不改 FSM。
  • 删除 sleep + 重扫 式切换逻辑。

3. 队列与回调策略

  • 队列策略固定为 LIFO:最新 permission_requested 优先展示。
  • 旧卡回调命中过期 request 时,彻底拒绝 remap。
  • callback 必须匹配当前 run 内 active request 才允许 resolve。
  • 卡片更新以 FSM 快照版本为准,旧版本回写不能覆盖新版本。

4. 内部接口调整

  • resolvePermissionWithFallback -> resolvePermissionStrict
  • markPermissionPendingupdateApprovalStatusupsertPermissionCard 改为围绕 FSM 单路径迁移。
  • 外部 RPC 协议不变:gateway.resolvePermission 入参不变。
  • 改造仅在 Feishu Adapter 内部生效。

重点文件:

  • internal/feishuadapter/adapter.go
  • internal/feishuadapter/adapter_test.go

Test Plan

1. 保留并修复红绿用例

  • 保留并修复 TestPermissionQueueSwitchPrefersNewestPendingAfterResolve
  • 要求 resolve 首条后切到最新 pending,例如 perm-newest-3

2. 改写旧 fallback 测试

当前旧语义测试:

  • TestPermissionActionFallsBackToCurrentPendingRequestWhenStale

改造后应改为:

  • TestPermissionActionDoesNotFallbackToCurrentPendingRequestWhenStale

期望:

  • stale callback 不会 resolve 新 request;
  • 绝不能出现 resolve:perm-stale-2:<decision>
  • 旧 callback 被 ACK/记录,不返回用户可见错误;
  • 卡面不被更新成新 request 的 resolved 状态。

3. 新增回归场景

  • 多 pending(>=3)下,始终按 LIFO 切换。
  • 旧回调晚到不会 remap 到新请求。
  • 重复 callback / 乱序 callback 不会导致 resolved_* -> pending 回退。
  • 重复 permission_requested 不会把已 resolved request 重新入队。
  • 锁外网络调用期间插入新 pending,旧 outbox 回写因 version/generation 不匹配被丢弃。
  • untrackRun 后同名 request_id 或同名 run_key 不受旧 run resolved/outbox 污染。
  • run terminal 时 pending/resolving 请求被 archived,不再允许 callback 触发 gateway resolve。

4. 测试稳定性要求

不建议继续只靠 time.Sleep 制造竞态。建议 fake messenger 增加 barrier/hook:

  • beforeUpdatePending
  • blockUpdatePending
  • afterUpdatePending
  • beforeUpdateResolved
  • afterUpdateResolved

用于精确制造:

1. FSM 生成 outbox v1。
2. 网络调用卡住。
3. 插入新 pending,FSM version -> v2。
4. 放行旧 outbox。
5. 断言旧 outbox 回写被丢弃。

5. 执行门槛

  • go test ./internal/feishuadapter -run TestPermission -count=1 全绿。
  • 状态机专项用例全绿。
  • 关键乱序/竞态用例建议 -count=50 压测,且不引入 flaky。

Assumptions

  • 不改 Gateway / Runtime 对外协议,只做 Feishu Adapter 内部状态治理。
  • 用户体验目标优先级:避免错误覆盖 > 保留旧 remap 兼容行为。
  • 审批卡“先短暂显示 resolved 再切下一条 pending”的视觉策略可保留,但切换目标必须由 FSM 栈顶决定。
  • ApprovalFSMState 必须成为唯一业务真相,不能在旧 map 外再套一层 FSM。

DoD

  • ApprovalFSMState 落地,替代分散 map 驱动审批业务状态。
  • sessionBinding.ApprovalRecords 改为 FSM 派生快照。
  • resolvePermissionWithFallback 移除,改为严格匹配的 resolvePermissionStrict
  • 所有审批状态迁移均走白名单状态表。
  • LIFO pending stack 生效,resolve active 后 promote 最新 pending。
  • outbox 网络副作用具备 version/generation 栅栏。
  • run terminal / untrackRun 完整回收 FSM 与审批缓存。
  • stale callback / stale outbox 只 ACK/log,不改变业务状态。
  • go test ./internal/feishuadapter -run TestPermission -count=1 通过。
  • 关键乱序/竞态用例 -count=50 不 flaky。

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions