Cherry Studio 工具审批状态合并(Tool-Approval State Consolidation):从四分屏到单一权威源的拆分脑修复设计
2026/9/19 22:25:19 网站建设 项目流程

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[].stateapproval-requested/approval-responded+ 决策)持久化——唯一能穿越宽限期与重启的表示terminal 持久化 +Ai_ToolApproval_Respond写入 +prepareContinueDispatch重写
D渲染层审批卡片渲染窗口期审批状态派生自 C——ToolUIPartapproval-requested状态来自消息 parts(useToolApprovalToolBlockGroup作为"唯一事实源");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)降级为瞬态投影。仅用于推导"该主题正等待人类"的实时状态指示器,不再充当审批身份的权威。
  • 渲染卡片继续锚定 CuseToolApproval/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.tsAiService.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带入续发。从MainContinueConversationRequestdispatch.ts)移除approvalDecisionsprepareContinueDispatch改为读取已提交的 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 路径则依次执行:

  1. 参数校验:缺少topicId/anchorId时按无上下文处理,返回{ ok: false }
  2. 实时流预检查AiStreamManager.hasLiveStream(topicId)为真时拒绝批准。源码注释解释了原因——批准卡片在tool-approval-requestchunk 到达的瞬间(实时 overlay)即可点击,响应可能落在流仍活跃的窗口;此时派发续发对话会命中send()的注入路径,静默丢弃已批准的轮次(模型被丢、工具永不执行、行停留在pending),却仍返回成功形状的响应。
  3. 构造决策对象{ approvalId, approved, ...(reason), ...(updatedInput) },显式携带在 IPC payload 中。
  4. 原子写入:调用messageService.applyToolApprovalDecisions(payload.anchorId, [decision]);返回null表示 anchor 行已删除(过期点击),按结果形状解析而非抛出。
  5. 重复决策防护appliedApprovalIds为空且alreadySettledApprovalIds包含该approvalId时,视为已结算的重复响应,返回{ ok: true }而不再次派发续发。
  6. 多工具轮次的 pending 判定anyStillPending = committedParts.some(p => isToolUIPart(p) && p.state === 'approval-requested')——只有当该轮所有审批都被决策后才续发,未决策的保持卡片。读取提交后的 parts,保证并发响应方对"谁触发续发"达成一致。
  7. 派发续发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.partsstats
  • 为什么必须序列化:一个多工具轮次可能在同一行上请求多个批准,两个并发响应若各自读取同一份陈旧 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 只做活跃目标指示"的源码依据。

全阶段不变量与验证

无论处于哪个阶段,以下不变量必须始终成立(对应测试保障):

  1. 已决策的工具永不回退approval-requested(无丢失更新);
  2. 恰好一个续发恢复多工具轮次(提交最后决策的响应者触发);
  3. 决策穿越重启(它在 C 中);重启后 B 消失不得导致决策丢失或重复;
  4. 被批准的工具恰好执行一次(无双重续发 / 双重运行)。

文档给出的验证矩阵与测试位置对应:

  • 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 3prepareContinueDispatch测试从已提交 C 构建历史,approvalDecisions载荷、第二次写入。
  • Phase 4:测试 B 缺失(回收/重启后)时卡片仍正确(源自 C),活跃目标提示仅表现为关闭。

范围与合入节奏

重构横切src/mainAiService.tsAiStreamManager/ChatStreamLifecyclePersistentChatContextProvidertopic.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询