背景
当前 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_requested 入 pending_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。
markPermissionPending、updateApprovalStatus、upsertPermissionCard 改为围绕 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
背景
当前
internal/feishuadapter的审批卡链路在多审批请求、重复事件、旧卡回调晚到、网络更新乱序等场景下,存在旧状态覆盖新卡面的风险:permission_requested重复事件可能尝试把已处理请求重新推回 pending;sleep + 重扫 ApprovalRecords的切换逻辑在并发插入新 pending 时存在 stale 回写窗口;request_id直连语义和 resolved 缓存没有严格按 run 生命周期隔离,长期存在跨 run 污染风险。本 issue 目标:将飞书审批链路改为“单一真相 + 单调版本 + 新队列优先(LIFO)+ 严格回调匹配”的状态机,彻底消除旧状态覆盖新卡面的路径。
当前实现风险点
1. 状态分散,不是单一真相
当前审批状态分散在多张 map 和
sessionBinding.ApprovalRecords中,例如:requestRunspermissionCardsrunPermissionCardspermissionCardRunsrunPermissionActiveRequestresolvedPermissionssessionBinding.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。问题:
应改为一次状态迁移驱动:resolve active 后立即从
pending_stack栈顶 promote 下一条 pending,并生成对应 outbox。设计原则
1. FSM 是唯一真相
新增按
run_key管理的ApprovalFSMState。审批业务状态只能由 FSM 驱动。sessionBinding.ApprovalRecords不再作为独立真相,只作为从 FSM 派生出来的状态卡渲染快照。建议结构:
2. 状态迁移锁内完成,outbox 只做网络副作用
重要约束:业务状态必须在锁内一次性提交,不能等网络调用成功后再提交。
正确模型:
outbox 回写只确认“副作用结果”,不能提交业务状态,否则网络调用期间进来的 callback / 新 pending / run terminal 会看到旧状态并制造新竞态。
3. 单调版本 + generation 栅栏
每个 run 内 FSM 维护单调
version,每次状态变更递增。每个 outbox 必须携带:
run_keygenerationversionrequest_idop_typecard_id(如已有)outbox 网络调用完成后回锁确认时,必须同时匹配:
不匹配则作为 stale outbox 丢弃,只记录日志,不修改业务状态。
generation用于防止untrackRun后旧 outbox 复活同名 run 或污染新 run。4. 严格状态迁移表,禁止逆迁移
request 状态固定为:
queueddisplaying_pendingresolvingresolved_approvedresolved_rejectedarchived允许迁移:
禁止任何逆迁移,例如:
resolved_* -> displaying_pendingresolved_* -> queuedarchived -> *permission_requested把已 resolved request 重新入队逆迁移一律 ACK/ignore/log,不影响卡片和 gateway。
5. 队列策略固定为 LIFO
本 issue 拍板使用“新队列优先”:
permission_requested入pending_stack栈顶;displaying_pending;displaying_pending;ApprovalRecords隐式决定下一条。状态卡建议显示队列信息,避免用户误以为旧请求丢失:
6. 严格回调匹配,移除 remap/fallback
resolvePermissionWithFallback应改为resolvePermissionStrict。回调必须精确匹配当前 run 内正在展示的 request,才允许 resolve。
建议判断顺序:
严禁 fallback 到“当前 pending request”。
7. run 终态与 untrackRun 完整回收
run terminal /
untrackRun时应完整回收:ApprovalFSMState旧 outbox 回写必须因 generation/version 不匹配被丢弃。
Implementation Changes
1. 状态模型与键空间重构
internal/feishuadapter内新增ApprovalFSMState,按run_key管理。(run_key, request_id)。request_id直连语义。sessionBinding.ApprovalRecords改为从 FSM 派生的渲染快照。run终态与untrackRun时,完整回收该 run 的 FSM 与所有审批缓存。2. 状态迁移与版本栅栏
version,并产出 outbox。sleep + 重扫式切换逻辑。3. 队列与回调策略
permission_requested优先展示。4. 内部接口调整
resolvePermissionWithFallback->resolvePermissionStrict。markPermissionPending、updateApprovalStatus、upsertPermissionCard改为围绕 FSM 单路径迁移。gateway.resolvePermission入参不变。重点文件:
internal/feishuadapter/adapter.gointernal/feishuadapter/adapter_test.goTest Plan
1. 保留并修复红绿用例
TestPermissionQueueSwitchPrefersNewestPendingAfterResolve。perm-newest-3。2. 改写旧 fallback 测试
当前旧语义测试:
TestPermissionActionFallsBackToCurrentPendingRequestWhenStale改造后应改为:
TestPermissionActionDoesNotFallbackToCurrentPendingRequestWhenStale期望:
resolve:perm-stale-2:<decision>;3. 新增回归场景
resolved_* -> pending回退。permission_requested不会把已 resolved request 重新入队。untrackRun后同名 request_id 或同名 run_key 不受旧 run resolved/outbox 污染。4. 测试稳定性要求
不建议继续只靠
time.Sleep制造竞态。建议 fake messenger 增加 barrier/hook:beforeUpdatePendingblockUpdatePendingafterUpdatePendingbeforeUpdateResolvedafterUpdateResolved用于精确制造:
5. 执行门槛
go test ./internal/feishuadapter -run TestPermission -count=1全绿。-count=50压测,且不引入 flaky。Assumptions
ApprovalFSMState必须成为唯一业务真相,不能在旧 map 外再套一层 FSM。DoD
ApprovalFSMState落地,替代分散 map 驱动审批业务状态。sessionBinding.ApprovalRecords改为 FSM 派生快照。resolvePermissionWithFallback移除,改为严格匹配的resolvePermissionStrict。untrackRun完整回收 FSM 与审批缓存。go test ./internal/feishuadapter -run TestPermission -count=1通过。-count=50不 flaky。