ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cherry Studio 工具审批状态合并(Tool-Approval State Consolidation):从四分屏到单一权威源的拆分脑修复设计

Cherry Studio 工具审批状态合并(Tool-Approval State Consolidation):从四分屏到单一权威源的拆分脑修复设计 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→ truetool-output-*→ falseB状态缓存topic.stream.statuses.topicId.awaitingApprovalAnchors仅在terminal广播宽限期回收后仍残留重启即丢失ChatStreamLifecycle.onTerminalC数据库message.data.parts[].stateapproval-requested/approval-responded 决策持久化——唯一能穿越宽限期与重启的表示terminal 持久化 Ai_ToolApproval_Respond写入 prepareContinueDispatch重写D渲染层审批卡片渲染窗口期审批状态派生自 C——ToolUIPart的approval-requested状态来自消息 partsuseToolApproval、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-aid1. 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 同时看到 Bawaiting 而 Cresponded。正如文档所说当前代码逐个修补矛盾窗口overlay-only分支、CR-001、CR-002这正是典型的修好一个窗口打开另一个的拆分脑困局。目标设计单一权威 无状态投影文档给出的目标模型是流式重构中已使用的1 个权威 无状态投影 1 个信号 纯选择器范式唯一事实源 C数据库message.data.parts。审批生命周期approval-requested → approval-responded 决策只存在于数据库中因为它是唯一能穿越宽限期/重启的表示。Aexec.awaitingApproval降级为瞬态投影。仅用于推导该主题正等待人类的实时状态指示器不再充当审批身份的权威。渲染卡片继续锚定 C。useToolApproval/ToolBlockGroup已从消息 parts 推导审批状态无需新增基于 anchor 的审批信号BawaitingApprovalAnchors仅保留活跃轮次指示器职责。一次原子写 一个信号 续发读取已提交行。批准 一次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-approvalMCPneedsApproval步骤结束活跃目标提示依赖 terminal 的awaitingApprovalAnchors广播因此当批准派发时流已非活跃send()走 start 路径而非 inject-drop 路径。该问题被推迟未增加awaitTopicSettledPhase 3 将彻底移除 inject-drop 接缝让续发直接读取已提交的 C。overlay-only分支与prepareContinueDispatch的双写是 Phase 2–3 要删除的创可贴。阶段化重构计划由于渲染层已锚定 C这主要是一次主进程写入路径的折叠让Ai_ToolApproval_Respond成为唯一权威写入者续发读取已提交的 C删除创可贴。四个小而独立、可分别合入的步骤每步保持测试套件全绿。Phase 1 — 原子审批写入 ✅ 已完成CR-002MessageService.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带入续发。从MainContinueConversationRequestdispatch.ts移除approvalDecisionsprepareContinueDispatch改为读取已提交的 anchor parts而非applyApprovalDecisions(...) messageService.update(...)buildHistory直接反映已提交状态无需重新应用。CR-001 担忧的 inject-drop 接缝随之消失——没有需要丢弃的模型续发只是从 C 重建。Phase 4 — 钉死 B 的职责清理生命周期接缝在文档与测试中断言awaitingApprovalAnchorsB仅是活跃轮次指示器 / 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 要消灭的路径。提交后通知实际写入后发布受影响消息的读模型notifyReadModelChangenotifyDataApiDataChange投影消息这是1 个信号中渲染层刷新卡片的通道。ChatStreamLifecycle.onTerminalB 的构造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 3prepareContinueDispatch测试从已提交 C 构建历史无approvalDecisions载荷、无第二次写入。Phase 4测试 B 缺失回收/重启后时卡片仍正确源自 C活跃目标提示仅表现为关闭。范围与合入节奏重构横切src/mainAiService.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),仅供参考
返回列表