Paseo 协议兼容性工程实践:App 与 Daemon 跨版本共存的契约设计
2026/9/21 15:18:52 网站建设 项目流程

Paseo 协议兼容性工程实践:App 与 Daemon 跨版本共存的契约设计

【免费下载链接】paseoOrchestrate multiple coding agents from desktop and mobile项目地址: https://gitcode.com/gh_mirrors/pa/paseo

导读

Paseo 的桌面端/移动端 App 与后台 Daemon 是分开发布的两个产品:用户通过应用商店或桌面自动更新升级 App,而 Daemon 则按自己的节奏升级,因此在真实环境中"新 App 配旧 Daemon""旧 App 配新 Daemon"甚至两端相差数月的组合都会出现。本文基于仓库中的协议兼容性规范文档,系统讲解 Paseo 如何通过协议契约(永远可解析)与特性契约(按特性一次性门控)双轨机制,保证任意版本组合下既有功能不回归、新功能优雅降级,并给出COMPAT兼容垫片标记、客户端能力宣告、自有订阅(owned subscriptions)协商等可落地的工程细则与仓库源码佐证。读完本文,你将掌握在多端异步发布架构下维护长生命周期协议的正确姿势。

一、问题背景:App 与 Daemon 永远存在版本组合

Paseo 中,App 与 Daemon 是两个独立产品:

  • App通过应用商店或桌面自动更新机制升级;
  • Daemon由用户按需自行升级。

这导致开发环境与生产环境的一个关键差异:在开发过程中两端永远是同版本,这正是贡献者最容易忽略约束的地方——开发时同版本,发布后不同版本才是常态。任何一端发布新功能,另一端可能落后数月。因此协议层必须同时满足两个方向上的兼容性。

从这一前提推导出两条必须遵守的契约:

  1. 协议契约:schema 变更不得破坏任何方向的解析;
  2. 特性契约:新特性按特性单独门控,旧 Daemon 不支持就明确告知用户升级,而不是降级模拟。

二、协议契约:schema 永远双向可解析

2.1 核心规则

一条 schema 变更必须保证:旧 App 仍能解析新 Daemon 发来的消息,新 Daemon 仍能解析旧 App 发来的消息。具体约束如下:

  • 新字段必须声明为.optional()并带有合理默认值;
  • 禁止将 optional 翻转为 required、删除字段或收窄类型——string收窄为enum、nullable 收窄为 non-null 都属于收窄;
  • 某个字段你停止发送后,接收端仍要继续接受它——"停止写入"不等于"停止读取";
  • wire schema 必须是纯结构化声明:WebSocket 消息 schema 上不允许.transform().catch().preprocess(),归一化逻辑必须在校验之后的显式 pass 中完成。原因见 协议校验文档:入站校验器是生成的,而生成器只编译纯 schema;
  • 当所有分支共享字面量 tag 时,禁止使用普通z.union(),必须使用z.discriminatedUnion()
  • .default()只能放在原始类型叶子字段上,绝不能放在大数组内的条目 schema 或大型入站容器上。

2.2 提交 schema 变更前回答两个问题

在提交任何 schema 变更前,必须能对以下两个问题同时回答"是":

  1. 一个六个月前的旧 App 还能解析这条消息吗?
  2. 一个六个月前的旧 Daemon 发来的内容,这个 App 还能接受吗?

两个都答"是",变更才算完成。

2.3 Schema 与 RPC 命名规范

所有协议 schema 集中在 packages/protocol/src/messages.ts。以server_info为例,其features对象中每一个布尔标志都带COMPAT注释说明引入版本与移除日期,例如:

// COMPAT(sessionPermissions): optional while clients support older daemons. permissions: z.array(DaemonPermissionSchema).optional(), // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot providersSnapshot: z.boolean().optional(),

新增 RPC 的命名必须遵循 RPC 命名空间规范:使用点号(不是斜杠)分层命名,方向作为最后一段,例如checkout.forge.set_auto_merge.request/checkout.forge.set_auto_merge.response;普通请求必须有同前缀的成对响应,requestId同时保留在请求与响应中作为关联键;不要新增扁平命名(如旧的checkout_pr_merge_request),旧名称在兼容窗口内保持接受。

2.4 测试佐证:wire 兼容回归

仓库用 packages/protocol/src/messages.wire-compat.test.ts 固化这一契约,例如:

  • hello消息在有无 project update 能力时都能解析;
  • server_info能剥离未知的遗留 features,同时接受旧的 turn identity;
  • 旧的sub_agenttool-call payload 仍能按 v0.1.65-beta.3 的 schema 解析;
  • 旧客户端解析带 rewind 能力的 agent snapshot、新客户端解析不带 rewind 能力的 snapshot 均成功。

这些用例正是"六个月前旧 App 仍可解析"这一要求在测试层的落地。

三、特性契约:按特性门控一次,绝不降级

协议契约保证的是既有功能跨版本不回归;而新特性通常需要新的 Daemon 能力,旧 Daemon 并不具备。因此特性的策略是:

  • 不建降级路径:不要为旧 Daemon 构建劣化版特性,不要通过打散到遗留 RPC 来模拟不存在的能力。用户要么升级主机,要么没有这个特性;
  • 不把防御分支散布在特性代码里:能力检测只发生在一处,下游所有代码读取的都是一个干净的形状;
  • 能力标志集中在server_info消息的features字段中(定义见 packages/protocol/src/messages.ts 的ServerInfoStatusPayloadSchema)。

features中每个布尔标志都代表一项可由 App 检测的 Daemon 能力,例如directorySyncworkspaceLabelspluginscheckoutForgeSetAutoMergeforgeSearch等,每个都带COMPAT注释标注引入版本与移除门槛。App 在连接时读取这些标志,决定运行新特性还是提示用户更新主机。

值得强调的是:特性门控永远不能替代协议契约。既有功能继续工作靠的是协议契约,新特性靠门控,两者分工明确。

四、客户端能力归属:能力默认值全量宣告

4.1 能力清单与默认值

客户端包负责宣告自己实现的协议行为。每个新能力都要加入其穷尽式默认值,并在该处实现对应的订阅或解码行为;App、CLI 和插件继承这些默认值,只补充主机资源(如浏览器自动化)或显式覆盖。

能力枚举定义在 packages/protocol/src/client-capabilities.ts:

export const CLIENT_CAPS = { ownedSubscriptions: "owned_subscriptions", explicitEventSubscriptions: "explicit_event_subscriptions", allProviders: "all_providers", selectiveAgentTimeline: "selective_agent_timeline", reasoningMergeEnum: "reasoning_merge_enum", customModeIcons: "custom_mode_icons", terminalReflowableSnapshot: "terminal_reflowable_snapshot", providerSubagents: "provider_subagents", projectUpdates: "project_updates", compactProviderSnapshots: "compact_provider_snapshots", timelineReplacementInvalidation: "timeline_replacement_invalidation", timelineNotifications: "timeline_notifications", pluginTimelineItems: "plugin_timeline_items", workspaceSetupBlocked: "workspace_setup_blocked", browserHost: "browser_host", } as const;

每个能力都带有精确的引入版本与淘汰日期注释,例如customModeIcons是因为旧客户端把AgentModeIcon钉死在封闭枚举上、遇到未知值会崩溃,所以 Daemon 在该能力缺失时把图标降级为ShieldCheck

4.2 为什么 schema 接受不等于支持

一个关键原则:schema 能接受某条消息,并不代表客户端支持其投递语义。因此客户端必须显式宣告能力。默认宣告位于 packages/client/src/connection/index.ts:

// Protocol support belongs to the installed client. Only browser hosting needs // a resource supplied by the caller. Keep this exhaustive as the protocol evolves. export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.ownedSubscriptions]: true, [CLIENT_CAPS.allProviders]: true, [CLIENT_CAPS.selectiveAgentTimeline]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, [CLIENT_CAPS.providerSubagents]: true, [CLIENT_CAPS.projectedSubagentTimeline]: true, [CLIENT_CAPS.projectUpdates]: true, [CLIENT_CAPS.compactProviderSnapshots]: true, [CLIENT_CAPS.providerSnapshotReferences]: true, [CLIENT_CAPS.timelineReplacementInvalidation]: true, [CLIENT_CAPS.timelineNotifications]: true, [CLIENT_CAPS.pluginTimelineItems]: true, [CLIENT_CAPS.workspaceSetupBlocked]: true, [CLIENT_CAPS.explicitEventSubscriptions]: true, } satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;

注意browserHost被排除在默认值之外——它需要调用方提供真实的主机资源,属于"主机资源"而非"协议行为"。连接测试 packages/client/src/connection.test.ts 中有专门用例断言"普通客户端宣告全部协议能力且不宣告 browser host",且注释明确"每个新能力都需要一个有意的默认值或主机资源例外"。

4.3 订阅成员关系归属

在有能力(capable)的 Daemon 上,连接本身不产生任何 timeline 或 event 需求

  • 客户端订阅拥有自己的网络成员关系,取消订阅时释放,重连后恢复;
  • 原始消息观察者(raw message observers)只检视流量,不请求流;
  • 应用缓存、可见的 agents 集合等由调用方持有,不归连接层管理。

五、自有订阅(Owned Observations):能力协商与遗留路径

5.1 协商机制

owned_subscriptionsserver_info.features.ownedSubscriptions共同协商"源自有"契约(底层 WebSocket 协议描述见 架构文档)。客户端在连接边界处一次性选定投递行为;App 工作流在两种模式下使用同一个观察接口。

协商逻辑在 packages/client/src/connection/index.ts 中一目了然:

const owned = info.features?.ownedSubscriptions === true && clientCapabilities[CLIENT_CAPS.ownedSubscriptions] === true;

即:只有当 Daemon 宣告能力且客户端宣告能力时才启用自有订阅;否则回退到遗留订阅(LegacySubscriptions)。

5.2 与旧 Daemon 交互的遗留行为

面对旧 Daemon 时,客户端使用现有连接与遗留 RPC,并保持以下语义:

  • 目录订阅保持共享、last-query-wins(最后一次查询生效);
  • 本地 handle ID 仅标识监听者,不承诺独立的服务器过滤器;
  • timeline 与 event 成员关系保持既有共享行为;
  • 释放 handle 即分离其监听器,并在存在旧 unsubscribe 操作时使用它;
  • 仅广播(broadcast)的主机继续广播,此时"就绪"只代表本地监听已挂载,而非 Daemon 确认;
  • 不引入额外 socket 或复用模拟(multiplexing emulation)。

connection/index.tsobserveRequest的实现正是如此:在遗留模式下,广播时代的主机没有确认机制,就绪状态直接本地接受快照;请求失败时通过legacy.release发送旧式释放消息。相关能力在 packages/client/src/connection/legacy.ts 中以// COMPAT(ownedSubscriptions): added in v0.8.0; remove after 2027-03-11 once daemon floor >= v0.8.0.标记。

5.3 边界与职责划分

  • 保持既有工作流:独立过滤器(independent filters)与安静连接(quiet connections)需要有能力 Daemon;而打开 App、读取历史、使用终端则不需要
  • 预注册(pre-registry)的工作区分组与遗留事件归一化属于客户端边界内部职责;
  • 旧客户端在 Daemon 源边界保留其既有 wire 形状与槽位(slot)行为;
  • 适配器按物理 socket给遗留槽位定键,因此即使两个连接使用相同逻辑 client ID,旧连接也不能顶替现代同级的观察;
  • 可选的 wire ID 为解析兼容继续被接受,但现代请求不能选择自己的订阅 ID。

六、每个兼容垫片都要打标签并注明日期

兼容垫片(shim)如果是为了支持旧 App 或旧 Daemon 而存在,必须携带注释,注明名称、引入版本与可移除时间:

// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.

rg "COMPAT\("就是完整的清理积压清单(backlog),因此要求:

  • 每个垫片一个标签,放在必须被删除的代码位置;
  • 标签包含名称、版本、移除条件或日期——通常以六个月为默认窗口;
  • 绝不允许把兼容逻辑埋在未打标签的??兜底或可选链隧道里——未打标签的兼容代码永远不会被删除,因为没人能找到它。

当标签条件满足时,在同一处变更中同时删除垫片与标签。仓库中COMPAT(遍布 App 与协议层,例如 packages/app/src/components/add-project-flow.tsx 中的// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.,以及desktopManaged字段的// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.,都是这一规范的实例。

七、QA 要求:测试永远无法完全覆盖兼容性

测试无法穷尽所有版本组合,因此兼容性最终靠人工论证。规范要求:只要改动涉及 packages/protocol 包,就必须在 Pull Request 中说明:

  • 为什么旧 App 仍能解析你新增/修改的消息;
  • 为什么旧 Daemon 仍能满足你的 App。

详细 QA 流程见 qa.md。配合 协议校验文档 中"入站校验器由 zod-aot 在构建期生成、schema 必须保持纯净"的约束,这一论证通常可以落实到具体字段的 optional/默认值设置与生成代码回归测试上。

八、实践清单

综合全文,为 Paseo 贡献协议相关代码时的自检清单:

  1. schema 变更:新字段 optional + 默认值;不删字段、不收窄类型;不在 wire schema 上用 transform/catch/preprocess;共享 tag 的 union 用z.discriminatedUnion();default 只放叶子。
  2. 双问题自检:六个月前的 App 还能解析吗?六个月前的 Daemon 还能被接受吗?
  3. 新特性:在server_info.features加布尔标志,客户端检测一次,不建降级路径,不散布防御分支。
  4. 新能力:加入 client-capabilities.ts 的CLIENT_CAPS与 connection/index.ts 的DEFAULT_CLIENT_CAPABILITIES,并实现对应订阅/解码行为。
  5. 兼容垫片:打COMPAT(name)标签,注明版本与移除日期(默认六个月),条件满足时连同标签一起删除。
  6. 命名:新 RPC 按 rpc-namespacing.md 的点号分层命名,不新增扁平名称。
  7. QA:改动packages/protocol时在 PR 中书面论证双向兼容。

这套"协议契约保底、特性契约门控、能力显式宣告、垫片限期清理"的组合拳,就是 Paseo 能在 App 与 Daemon 各自异步发布的现实约束下,长期保持任意版本组合可用性的核心工程方法论。

【免费下载链接】paseoOrchestrate multiple coding agents from desktop and mobile项目地址: https://gitcode.com/gh_mirrors/pa/paseo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询