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由用户按需自行升级。
这导致开发环境与生产环境的一个关键差异:在开发过程中两端永远是同版本,这正是贡献者最容易忽略约束的地方——开发时同版本,发布后不同版本才是常态。任何一端发布新功能,另一端可能落后数月。因此协议层必须同时满足两个方向上的兼容性。
从这一前提推导出两条必须遵守的契约:
- 协议契约:schema 变更不得破坏任何方向的解析;
- 特性契约:新特性按特性单独门控,旧 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 变更前,必须能对以下两个问题同时回答"是":
- 一个六个月前的旧 App 还能解析这条消息吗?
- 一个六个月前的旧 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 能力,例如directorySync、workspaceLabels、plugins、checkoutForgeSetAutoMerge、forgeSearch等,每个都带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_subscriptions与server_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.ts中observeRequest的实现正是如此:在遗留模式下,广播时代的主机没有确认机制,就绪状态直接本地接受快照;请求失败时通过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 贡献协议相关代码时的自检清单:
- schema 变更:新字段 optional + 默认值;不删字段、不收窄类型;不在 wire schema 上用 transform/catch/preprocess;共享 tag 的 union 用
z.discriminatedUnion();default 只放叶子。 - 双问题自检:六个月前的 App 还能解析吗?六个月前的 Daemon 还能被接受吗?
- 新特性:在
server_info.features加布尔标志,客户端检测一次,不建降级路径,不散布防御分支。 - 新能力:加入 client-capabilities.ts 的
CLIENT_CAPS与 connection/index.ts 的DEFAULT_CLIENT_CAPABILITIES,并实现对应订阅/解码行为。 - 兼容垫片:打
COMPAT(name)标签,注明版本与移除日期(默认六个月),条件满足时连同标签一起删除。 - 命名:新 RPC 按 rpc-namespacing.md 的点号分层命名,不新增扁平名称。
- QA:改动
packages/protocol时在 PR 中书面论证双向兼容。
这套"协议契约保底、特性契约门控、能力显式宣告、垫片限期清理"的组合拳,就是 Paseo 能在 App 与 Daemon 各自异步发布的现实约束下,长期保持任意版本组合可用性的核心工程方法论。
【免费下载链接】paseoOrchestrate multiple coding agents from desktop and mobile项目地址: https://gitcode.com/gh_mirrors/pa/paseo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考