22 · 重写后的 Chord:引用、身份、状态同步与热更新¶
如果你的研究 Agent 只有一个命令行进程,完成前面的 Core/SDK 实验后就可以交付。若产品还需要浏览器、长驻 worker、多人只读/可写视图和插件热更新,才会遇到本章的问题:怎样让多个进程操作同一个应用对象,同时控制身份、生命周期和代码版本?
Pi 1.3.0 的 @earendil-works/chord 围绕 contract、object、reference、principal 和 root 重建。旧教程中的 facet host、旧 client/server/protocol 拼装方式不能直接照搬。coding-agent 已移除对 Chord 的依赖;Chord 是可选的应用构建层,不是所有 Pi Agent 都必须经过的一层。当前导出。
本章延续证据研究场景:研究者可追加引用,访客只能观察引用列表。先跑两个 memory link 的小产品,再追踪真正的对象身份与同步机制。这里只保存演示用内存状态;要持久化研究过程,必须接入第 11 章的 Durable 或自己的业务存储。
22.1 实验:同一份证据,两种权限视图¶
在 examples 目录执行:
实际运行文件是 src/chord-checkpoint.ts,公开于实验源码。它创建一个 server Host、两个 client Host,以两条真实 memory link 连接。输出应包含:
{
"checkpoint": "chord",
"transport": "two memory links",
"owner": "writer",
"guest": "reader",
"sharedRows": ["Fixed source evidence"],
"malformedRejected": true,
"guestWriteRejected": true
}
这个实验没有 LLM。这样可以先把“应用身份与状态传播是否正确”独立验明,而不用把模型的随机选择混入结果。它不证明 WebSocket 认证、真实网络重连、集群热更新和持久化已经成功。
注意图中 writer 与 reader 是两个对象,共享的是它们取得的底层 local store。若把每个 principal 的引用列表直接存在各自的视图对象里,访客可能永远看不到研究者的追加。这是本章第一个需要理解的设计选择。
22.2 Contract 描述运行时能保证的形状¶
实验定义两类 contract:
| 类型 | 用途 | 实验中的成员 |
|---|---|---|
local(...) |
同一 Host 内的对象;允许 native/opaque 值 | rows 和 add |
remote(...) |
可以跨 Host 引用,输入与状态/方法用 schema 描述 | reader 的 rows;writer 的 rows/add |
remote contract 必须声明 placement,例如 hostNamed('server')。另一种 hostFamily(prefix, key) 由规范化 input 的字段决定目标 Host。消费者获取对象时不应另造一个与 contract 冲突的主机地址。contract 构造与兼容。
const Evidence = remote('course.evidence', {
version: 1,
input: Type.Object({ project: Type.String() }),
host: hostNamed('server'),
variants: {
reader: { state: { rows: Type.Array(Type.String()) } },
writer: {
state: { rows: Type.Array(Type.String()) },
methods: { add: method(Type.Object({ quote: Type.String() })) },
},
},
});
这是说明 contract 形状的摘录,完整实验还限制 quote 非空。remote schema 会在调用边界检查,包括同一 Host 内对 remote 类型的调用;local 方法不是每次自动做同样的远程边界验证。业务方法仍须检查引用属于哪个项目、来源是否合法,不能把字符串类型通过当作业务许可。
版本号有两层。 contract 的 version 管对象可见 API;命名 state cell 的 version 管状态迁移。新增可选能力可能同版本兼容;删除成员、增加必填参数、改变类型或语义通常需要 contract 版本升级。值从数字改成对象则还涉及 cell schema/version/migrate。两种版本不能互相代替。
源码的兼容规则不是“版本更大就自动兼容”:同一 contract 版本要求 provider 的描述覆盖 consumer;跨版本依赖显式 accepts adapter。语义变化若形状没有改变,runtime 无法替作者识别,仍需正确提升版本。兼容定义与绑定。
22.3 对象身份:type、规范化 input、principal¶
实例身份建立在类型名、规范化输入和 principal key 上。输入会应用默认值、规范化并验证,canonical identity 会省略等于 schema 默认值的字段;因此 {} 与显式填写默认值可能指向同一对象。不要用不同 JSON 属性顺序或“忘记写默认值”人为分割业务对象。实际地址构造。
principal 是应用给出的身份。accept(link, { principal, kind }) 在可信接入端决定连接身份;客户端在 payload 写 principal: 'owner' 不应成为权限来源。中继只接受父链接提供的转发身份,route 还会在 resolve/spawn 前后检查授权。这样的边界避免未授权请求先触发昂贵 worker 创建。接入调用与 root 处理。
实验里远程 factory 接收到 owner/guest,再通过 self.get(Store, input) 取得内部 store。对象内部代码以 Host 自身身份行动,从而把不同访问者的视图汇聚到同一个底层 store。这也意味着:内部服务若需要原始业务操作者,必须显式传入经验证的 author;不能以为所有内部调用的 ctx.principal 都仍是浏览器用户。
principalKey 应反映实际授权身份,必要时包含权限修订号和来源。使用同一 key 的 principal 会共享对象;错误地把不同租户映射到同一 key,是应用身份设计错误,Chord 无法替你推断。
22.4 Factory 同步声明,activation 异步就绪¶
factory 不是 async 初始化函数。它同步声明需要的 state、reference、resource、listener、task,然后返回实现。异步工作放进 resource.open 或 state.init,由 activation 管理顺序。
下图限定普通当前代码版本的首次激活。只有 pendingCodeVersion 的 reload 候选对象会延迟启动 runtime,待提交阶段再启动。
打开资源与等待 references 在 open() 中并发汇合;单个 resource 的 deps 还可能要求先等待另一个 resource 或 reference。不能从上面的阶段图推导“所有资源必须先于所有引用就绪”。
在 factory 内调用占位 reference 的方法、读取尚未 open 的资源,可能得到 not_ready。两个 state.init 并行,不能假设书写在前面的那个已完成。init 只是从外部状态生成初值;它本身没有给 cell 增加磁盘持久化。对象 activation 与初始化。
对开发者的好处是资源依赖可追踪、异常清理和 reload 可以统一处理;代价是不能把任意副作用塞进 factory,再期待它只调用一次。factory 在 reload 准备阶段可能重新执行。
22.5 引用所有权决定生命周期¶
get 返回 reference,而不是实现对象本身。它有 loading/live/released/gone 状态,late-bound 方法与状态成员;热更新后 reference 可以继续使用,但仍受当前 variant、契约兼容和代码版本规则约束。reference 与 ctx 类型。
| 取得方式 | 谁持有引用 | 何时释放 |
|---|---|---|
self.get(...) |
当前对象 | 对象处置,或新实现不再声明该引用 |
ctx.get(...) |
当前调用 | 调用结束 |
host.get(...) / host.hold(...) |
外部使用者 | 显式 release,或宿主结束 |
| 来自连接的远程 hold | 连接与相应 origin | release、断线或撤销 |
lifetime: 'host' |
Host 自己 | Host 关闭或对应 facet 被移除 |
默认 referenced 对象在最后一个引用释放后结束,可配置 linger 延迟回收。lifetime: 'host' 只适用于输入为空/只有默认值的 local 类型,不能给 remote 对象随意加这个选项。只有这种 Host 常驻对象可以注册 registry contributions。
关闭时先处置引用别人的对象,再处置被引用者,使 resource.close 还能使用其依赖;链接最后关。强制撤权会立即让引用 gone 并中止调用上下文,实际资源清理仍需协作。若 close 里调用一个已经断开的远程链接,应设置取消边界,避免 shutdown 永久等待。Host 关闭主干。
22.6 State、snapshot 与 delta:不要把通知当完成屏障¶
状态是按约定不可变的 JSON。通过 cell.change(ctx, draft => ...) 修改受控 draft,或者通过 replace 转移新值所有权;不要拿 rows.value 后直接 push。对象 cell、独立 replicatedState 和底层 delta tracker 有各自 API,不能因为都叫 subscribe 就混写它们的投递规则。
初次远程 hold 会先得到 shape 及所需状态 snapshot,足够 hydrate 后才能成为 ready reference。后续按成员传播 ops/reset;reload 或 variant 改变会更新形状。一个没有订阅回调但仍被持有的 remote reference,依然可以保持状态 hydration。
远程链接的背压主要限制 state:对支持 ack 且报告帧大小的链路,按 Link.send 报告的编码大小累计未确认量:JSON 文本帧用 JavaScript 字符串长度,packed 二进制帧用 byteLength(启用压缩时计压缩后的大小);IPC 等不报告大小的链路不应用此窗口限制。等待中的状态可以合并,超过 pendingCap 时转为最新状态 reset。方法结果与控制信息不会与这些等待状态一同堵住。因此 方法 Promise 已返回,不保证你的 UI 已观察到该方法引起的状态更新。实验明确订阅访客 rows 的目标条件,再 await 该状态,而非用一个固定 sleep 猜测。状态队列与窗口。
状态同步不是审计事件逐条送达协议;UI 可以跳到较新的状态。需要每条业务记录完整保存,应写入 Durable/数据库,再把当前投影提供给 Chord,而不是把 UI 回调次数当作账本。
22.7 root 与代码版本:为什么不要在方法里等待人类几小时¶
root 是短调用树,带 rootId 和 codeVersion。Host 在入口创建 root;同一调用链把 ctx 传下去,维持代码版本。浏览器不会以自己的版本号命令服务器运行旧实现;服务器为它的调用建立受控 root。root 建立与加入。
假如 approve() 方法一直 await 人类回复,这个 root 可能长时间占住旧代码,阻止需要迁移的对象进入安全切点。更好的应用接口是:短调用记录 pendingApproval,另一次短调用提交 answer;等待作为状态或 Durable 任务存在。
self.task 与 Durable phase 都不是贯穿全部生命周期的长 root。它们发出的每次 Chord 调用开始新的短 root;Durable 自己另外管理 phase 的 registry snapshot。这样应用对象的热更新与持久任务的恢复可以分别管理,而不必强行共享一个“全局版本”。
22.8 热更新的实际顺序与失败路径¶
先区分兼容换代码与需要迁移状态/独占资源的变更。前者可以让在途 root 继续旧实现、新 root 使用新实现;后者需要 gate,等待已准入的旧 root 到安全点。
源码 prepare() 依次等待旧版本退休条件、加载、检查 provider、准备对象、限制受影响的新对象准入、检查 surviving references、构造注册表并进入 gate。gate 不是暂停任意新调用直到“总会恢复”:部分新准入会以 upgrade_conflict 拒绝,避免把依赖链挂死。prepare、gate 与 migrate。
上图的保留旧版本回滚仅适用于提交之前的失败。提交自身若抛错,可能已有部分状态发布,源码会调用 host.fail() 使 Host 进入终止失败状态,不以 rollback 假装恢复完整旧世界。应用必须处理宿主级恢复。
同名、版本兼容的 cell 与 resource 可以延续;升 cell version 时迁移旧值,失败会拒绝更新。替换独占资源必须在 gate 中关闭旧的再打开新的,不能把“候选准备失败”笼统描述成“从来没碰过外部世界”。rollback 会尝试恢复资源;恢复失败必须暴露 unavailable,而不是假装旧资源仍好用。
Host 最多保留 current/previous 两代代码;旧 root 超过退休边界可能被中止,后续调用得到 superseded,需要从新 root 重试。新 root 重试同样不能无条件重复外部副作用。
跨 Host 更新由协调者组织直接子 Host 的 prepare/vote/commit;浏览器不是提交投票者。1.3.0 的提交决定不持久化,断线时链接不恢复已在途调用,子 Host 的重协调也有已知限制。不能把它写成数据库式持久分布式事务。实现限制。
22.9 和 Durable 的组合方式¶
你可以把 Durable Harness 放进一个 Host 生命周期的 local object,用 resource.open 打开存储和 Harness,在 resource.close 关闭。插件通过 registry 提供 Durable extension;一轮热更新替换完整注册表,使新 phase 读取新 snapshot,已经运行的 phase 保持自己持有的 snapshot。
由此得到一个实用分工:
| 层 | 研究 Agent 中的责任 |
|---|---|
| Durable | 持久记录提交、任务、工具结果,决定恢复与重放 |
| Chord local service | 管理 Harness、存储连接、业务服务的资源生命周期 |
| Chord remote view | 为 owner/guest 返回合适的可观察状态与方法 |
| Env | 把工具文件/命令操作放在选定机器执行 |
| 应用验收 | 判断引用是否来自本次读取、任务是否真的回答问题 |
这是可选的产品组装方案,不是把 coding-agent 的 AgentSession 自动转换成 Durable,也不是导入 Chord 后就获得一个现成 Web 应用。真实 WebSocket 服务仍需实现认证与接入、部署和业务存储。
22.10 练习:从演示到自己的产品¶
- 新增第二个 project,验证两个项目 rows 分开;然后把 input 中 project 写错,观察为什么 schema 通过仍可能访问错误业务对象。
- 给 guest 增加
requestReview方法,但不要给它add。把审核申请写入持久业务存储,向 owner 暴露待处理投影。 - 让 owner 连续追加两条引用,验证 UI 等待的是内容条件,而不是“一定收到两次回调”。
- 修改 cell 形状与版本,设计成功迁移和失败回滚两条路径;参考固定源码的 reload tests,再运行自己的 fixture。现有 memory-link 实验没有覆盖这项练习。
- 如果重连后上一次 add 的结果未知,使用业务 requestId 在持久层去重。Chord 的 reference 重建不能自动证明方法上次没执行。
做到这里,你应能解释:对象和 reference 为什么分开、principal 为什么参与身份、共享内部 store 为什么使用 host 身份、方法返回为什么不是状态投递屏障,以及热更新和任务恢复为什么是两个独立问题。