Cherry Studio 工具审批状态合并(Tool-Approval State Consolidation):从四分屏到单一权威源的拆分脑修复设计
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
本文基于 CherryHQ/cherry-studio 仓库中的设计文档 tool-approval-state-consolidation.md,梳理工具审批(tool approval)状态在内存流、状态缓存、SQLite 与渲染层四处并存导致的"拆分脑"(split-brain)问题,给出"单一权威源 + 无状态投影"的目标设计,并逐阶段讲解已落地的原子化写入重构与后续收尾计划。读完本文,你将掌握 Cherry Studio 主进程中Ai_ToolApproval_Respond处理链路的真实数据流、MessageService.applyToolApprovalDecisions的原子写实现,以及如何在三端异步传播下设计可收敛的一致性方案。
问题背景:一个审批状态,四个持有者
当一个工具调用(如 MCP 工具)请求人类批准时,Cherry Studio 需要回答两个问题:"这个工具是否正在等待审批"以及"审批的决策结果是什么"。在 v2 重构中,这两个问题的答案被同时存放在四个互不隶属的表示里,它们生命周期不同、更新通道不同、甚至各自被当作"权威"对待:
| # | 表示 | 生命周期 | 更新方 |
|---|---|---|---|
| A | 内存流exec.awaitingApproval | 流生命周期 + 30 秒宽限期 | AiStreamManager.onChunk(收到tool-approval-request→ true;tool-output-*→ false) |
| B | 状态缓存topic.stream.statuses.<topicId>.awaitingApprovalAnchors | 仅在terminal广播;宽限期回收后仍残留;重启即丢失 | ChatStreamLifecycle.onTerminal |
| C | 数据库message.data.parts[].state(approval-requested/approval-responded+ 决策) | 持久化——唯一能穿越宽限期与重启的表示 | terminal 持久化 +Ai_ToolApproval_Respond写入 +prepareContinueDispatch重写 |
| D | 渲染层审批卡片 | 渲染窗口期 | 审批状态派生自 C——ToolUIPart的approval-requested状态来自消息 parts(useToolApproval、ToolBlockGroup作为"唯一事实源");B 仅被KeyedMessageActivityStore当作活跃轮次指示器与 composer 覆盖绑定使用,不是审批状态权威 |
从表中可以看出,渲染侧(D)已经基本收敛到 C——卡片状态完全由消息 parts 推导。真正剩余的拆分脑在主进程的写入侧:同一个决策被应用在四处——IPCapprovalDecisions载荷、Ai_ToolApproval_Respond的数据库写入、prepareContinueDispatch的数据库重写(见 PersistentChatContextProvider.ts),以及重建模型历史时的buildHistory——外加一个overlay-only窗口:当 C 落后于实时部分时,决策只存在于续发载荷里。
为什么"同时一致"在架构上不可实现
三个独立状态持有者(主进程流、SQLite、渲染器)通过异步通道(broadcast / IPC / SWR)相连,不存在横跨三者的单个事务,因此传播延迟不可避免。设计文档明确指出:可达成的目标不是"零延迟",而是**"单一事实源 + 通过单一信号实现的最终一致"**——消除的是"两个权威互相矛盾",而非延迟本身。当前实现的缺陷正是:存在多个权威(B 与 C 都被当作真相),且决策在多个位置写入。
三个具体的不一致窗口
文档列举了三个可复现的矛盾窗口,每个都对应一处现有代码中的"创可贴"(band-aid):
1. overlay-only 窗口。tool-approval-requestchunk 先于 terminal 持久化到达渲染层(D 已渲染出卡片),此时C 中还没有对应 part。在此窗口内点击"批准",applyToolApprovalDecisions会发现targetPresent === false,跳过数据库写入,决策只存在于续发 payload 中——即Ai_ToolApproval_Respond处理器中注释为overlay-only的分支,正是为这个窗口打的补丁。
2. B 与 C 的生命周期错配。B(活跃轮次指示器)只在 terminal 广播,宽限期回收时不清理、重启后消失,而 C 一直停留在approval-requested。于是"活跃目标高亮"(B)与"持久化审批真相"(C)在回收/重启后分叉:卡片仍从 C 渲染,但"这是活跃轮次"的视觉提示与 composer 绑定(依赖 B)已经过期或缺失。
3. 批准 → 续发的间隙。C 已翻转为responded,但 B 仍显示awaiting-approval,直到续发的新流广播pending。短暂地,D 同时看到 B="awaiting" 而 C="responded"。
正如文档所说,当前代码逐个修补矛盾窗口(overlay-only分支、CR-001、CR-002),这正是典型的"修好一个窗口,打开另一个"的拆分脑困局。
目标设计:单一权威 + 无状态投影
文档给出的目标模型是流式重构中已使用的"1 个权威 + 无状态投影 + 1 个信号 + 纯选择器"范式:
- 唯一事实源 = C(数据库
message.data.parts)。审批生命周期(approval-requested → approval-responded+ 决策)只存在于数据库中,因为它是唯一能穿越宽限期/重启的表示。 - A(
exec.awaitingApproval)降级为瞬态投影。仅用于推导"该主题正等待人类"的实时状态指示器,不再充当审批身份的权威。 - 渲染卡片继续锚定 C。
useToolApproval/ToolBlockGroup已从消息 parts 推导审批状态,无需新增基于 anchor 的审批信号;B(awaitingApprovalAnchors)仅保留活跃轮次指示器职责。 - 一次原子写 + 一个信号 + 续发读取已提交行。批准 = 一次
withWriteTx写入(CR-002 方法)→ 发出专用的Topic_*失效信号 → 续发流程读取已提交的数据库行,而不是携带approvalDecisions并在prepareContinueDispatch中重写。这把"IPC 载荷 + 批准写 + 续发写"三元组折叠为"一次写 + 一次读"。
与 steer-queue 评审(#15935)的关系
该设计文档诞生于对 steer-queue PR 的评审(vaayne CR-001/CR-002),两项评审意见与本重构的关系清晰:
- CR-002(用
withWriteTx序列化审批的读-改-写)是迈向此目标的第一步,现已落地(MessageService.applyToolApprovalDecisions,从已提交行做 pending 检查)。 - CR-001(续发对话与实时流竞态)在正常流程中不可达:请求审批的活跃轮次会 terminal 化为
awaiting-approval(MCPneedsApproval步骤结束),活跃目标提示依赖 terminal 的awaitingApprovalAnchors广播,因此当批准派发时流已非活跃,send()走 start 路径而非 inject-drop 路径。该问题被推迟(未增加awaitTopicSettled),Phase 3 将彻底移除 inject-drop 接缝,让续发直接读取已提交的 C。 overlay-only分支与prepareContinueDispatch的双写是 Phase 2–3 要删除的创可贴。
阶段化重构计划
由于渲染层已锚定 C,这主要是一次主进程写入路径的折叠:让Ai_ToolApproval_Respond成为唯一权威写入者,续发读取已提交的 C,删除创可贴。四个小而独立、可分别合入的步骤,每步保持测试套件全绿。
Phase 1 — 原子审批写入 ✅ 已完成(CR-002)
MessageService.applyToolApprovalDecisions(anchorId, decisions)在单个withWriteTx内完成"读 → 应用 → 写"并返回已提交的 parts。Ai_ToolApproval_Respond使用它,并从已提交 parts 计算anyStillPending。涉及文件:MessageService.ts、AiService.ts。
Phase 2 — 在approval-requestedpart 发出时立即持久化(关闭 overlay-only 窗口)
当前approval-requestedpart 只在terminal持久化时才落入 C,因此快速批准会命中 overlay-only 路径(applyToolApprovalDecisions找不到 part → 不写入 → 决策搭 IPC 载荷走)。修复思路:在tool-approval-requestchunk 被捕获时就持久化该 part(在PersistenceListener投影 / 已设置exec.awaitingApproval的 chunk 处理器中),使 C 在卡片可点击之前就已携带该 part。不变量:飞行中的approval-requestedpart 恰好持久化一次,且对 terminal 投影幂等。
Phase 3 — 续发读取已提交 C,去掉载荷与第二次写入
Phase 1 写入后,C 已持有approval-responded,因此Ai_ToolApproval_Respond无需再把approvalDecisions带入续发。从MainContinueConversationRequest(dispatch.ts)移除approvalDecisions,prepareContinueDispatch改为读取已提交的 anchor parts,而非applyApprovalDecisions(...) + messageService.update(...);buildHistory直接反映已提交状态,无需重新应用。CR-001 担忧的 inject-drop 接缝随之消失——没有需要丢弃的模型,续发只是从 C 重建。
Phase 4 — 钉死 B 的职责,清理生命周期接缝
- 在文档与测试中断言
awaitingApprovalAnchors(B)仅是活跃轮次指示器 / composer 绑定,绝不是审批状态权威; - 在宽限期回收时清理 B(广播 terminal-cleared 状态,或让渲染层把缺失的活跃条目视为"非活跃目标"),关闭回收/重启后的陈旧 B 窗口;无论哪种方式,C 都是持久真相。
从源码验证 Phase 1 的实际实现
设计文档标注 Phase 1 已落地,我们可以在仓库中直接验证这条写入链。
AiService.respondToolApproval:处理器的完整流程
处理器入口 AiService.ts 中,respondToolApproval先分派 Claude-Agent 路径(AgentSessionRuntimeService.respondToolApproval,由运行时结算持久化交互卡片并解除对应canUseTool调用的阻塞);MCP 路径则依次执行:
- 参数校验:缺少
topicId/anchorId时按无上下文处理,返回{ ok: false }。 - 实时流预检查:
AiStreamManager.hasLiveStream(topicId)为真时拒绝批准。源码注释解释了原因——批准卡片在tool-approval-requestchunk 到达的瞬间(实时 overlay)即可点击,响应可能落在流仍活跃的窗口;此时派发续发对话会命中send()的注入路径,静默丢弃已批准的轮次(模型被丢、工具永不执行、行停留在pending),却仍返回成功形状的响应。 - 构造决策对象:
{ approvalId, approved, ...(reason), ...(updatedInput) },显式携带在 IPC payload 中。 - 原子写入:调用
messageService.applyToolApprovalDecisions(payload.anchorId, [decision]);返回null表示 anchor 行已删除(过期点击),按结果形状解析而非抛出。 - 重复决策防护:
appliedApprovalIds为空且alreadySettledApprovalIds包含该approvalId时,视为已结算的重复响应,返回{ ok: true }而不再次派发续发。 - 多工具轮次的 pending 判定:
anyStillPending = committedParts.some(p => isToolUIPart(p) && p.state === 'approval-requested')——只有当该轮所有审批都被决策后才续发,未决策的保持卡片。读取提交后的 parts,保证并发响应方对"谁触发续发"达成一致。 - 派发续发:
aiStreamManager.dispatch(subscriber, { trigger: 'continue-conversation', topicId, parentAnchorId, approvalDecisions: [decision] })——末尾的approvalDecisions正是 Phase 3 要删除的兜底载荷(源码注释注明"对条件写入幂等;当 part 不在行上时作为安全网")。派发失败(与实时提交竞态)时按结果形状返回,让渲染层重置卡片。
MessageService.applyToolApprovalDecisions:单事务序列化读-改-写
实现见 MessageService.ts。关键点:
- 整个读-改-写包在
DbService.withWriteTx内:先按anchorId选行,rowToMessage还原,applyApprovalDecisions(parts, decisions)计算新 parts,再回写data.parts与stats。 - 为什么必须序列化:一个多工具轮次可能在同一行上请求多个批准,两个并发响应若各自读取同一份陈旧 parts 再整数组回写,后写者会抹掉先写者的决策,且各自从自己的陈旧副本计算"still pending",导致决策丢失、轮次永远等待。单事务内串行化后,返回的已提交 parts 让调用方从权威的提交后状态做 pending 判定。
- overlay-only 分支:
appliedApprovalIds为空(没有决策命中存在的approval-requestedpart)时行保持原样,返回的仍是 overlay parts,由调用方把决策带给续发、由续发权威应用——这正是 Phase 2 要消灭的路径。 - 提交后通知:实际写入后发布受影响消息的读模型(
notifyReadModelChange+notifyDataApiDataChange投影消息),这是"1 个信号"中渲染层刷新卡片的通道。
ChatStreamLifecycle.onTerminal:B 的构造
ChatStreamLifecycle.ts 中onTerminal聚合活跃执行项:if (exec.pendingApprovalToolCallIds?.size) awaitingApprovalAnchors.push(entry),最终广播awaitingApprovalAnchors。可以看到 B 由"仍有待批准工具调用"的执行项集合而成,天然是轮次级指示器,与 C 的part 级持久真相在粒度上就不同——这正是 Phase 4 要钉死"B 只做活跃目标指示"的源码依据。
全阶段不变量与验证
无论处于哪个阶段,以下不变量必须始终成立(对应测试保障):
- 已决策的工具永不回退为
approval-requested(无丢失更新); - 恰好一个续发恢复多工具轮次(提交最后决策的响应者触发);
- 决策穿越重启(它在 C 中);重启后 B 消失不得导致决策丢失或重复;
- 被批准的工具恰好执行一次(无双重续发 / 双重运行)。
文档给出的验证矩阵与测试位置对应:
- Phase 1(已完成):
MessageService.test(每次调用重读提交态 + null/overlay 用例)与AiService.test(处理器使用原子方法)。对应仓库中 MessageService.test.ts 与 AiService.test.ts。 - Phase 2:测试
approval-requestchunk 在 terminal 之前就把 part 持久化到 C,使applyToolApprovalDecisions能找到它(无 overlay-only 路径)。 - Phase 3:
prepareContinueDispatch测试从已提交 C 构建历史,无approvalDecisions载荷、无第二次写入。 - Phase 4:测试 B 缺失(回收/重启后)时卡片仍正确(源自 C),活跃目标提示仅表现为关闭。
范围与合入节奏
重构横切src/main:AiService.ts、AiStreamManager/ChatStreamLifecycle、PersistentChatContextProvider、topic.stream.statuses缓存契约,以及渲染层活跃目标 hook 的轻量触碰。渲染层卡片推导(useToolApproval)已读取 C,无需改动。文档明确告诫:不要把它折进某个窄分片 PR——Phase 1 随 steer-queue PR 落地后,Phase 2–4 各自作为独立的小 PR 依次合入。
结语:可落地的"最终一致"模板
tool-approval 状态合并是 Cherry Studio v2 流式重构中"多权威 → 单权威"思路的典型样本:在异步通道无法被单事务跨越的前提下,放弃不可能的"同时一致",转而选出一个能穿越所有生命周期边界的持久表示作为唯一真相,让其余表示降级为无状态投影,并通过单一失效信号驱动渲染收敛。文中验证的withWriteTx原子写、提交后读模型通知、anyStillPending从提交态计算等实现细节,均可作为同类三端状态一致性问题的可复用模式。
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考