跳转至

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 目录执行:

npm run checkpoint:chord

实际运行文件是 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 认证、真实网络重连、集群热更新和持久化已经成功。

flowchart LR Owner[owner client] -->|memory link:接收方盖身份| Writer[Evidence writer 对象] Guest[guest client] -->|另一条 memory link| Reader[Evidence reader 对象] Writer -->|self.get,host 身份| Store[同一 local EvidenceStore] Reader -->|self.get,host 身份| Store Store -->|rows 状态| Writer Store -->|rows 状态| Reader Writer -->|add 方法| Store Reader -.->|没有 add 成员| Denied[not_in_variant]

注意图中 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,待提交阶段再启动。

flowchart TD Get[首次获取对象] --> Factory[同步执行 factory,登记声明] Factory --> Resources[按 deps 等待并打开各 resource] Factory --> Refs[并行等待 references ready] Resources --> Join[等待资源与引用全部 settled] Refs --> Join Join -->|成功| Hydrate[等待暴露和派生 state 的 hydration] Hydrate --> Init[并行运行各 state.init 并验证] Init --> Runtime[连接 derive / listeners,启动 tasks] Runtime --> Live[对象 live,ready 可完成] Join -->|任一失败或 readiness 超时| Unwind[清理已取得资源,拒绝等待者] Hydrate -->|失败或超时| Unwind Init -->|失败或值不合法| Unwind Unwind --> Retry[下一次获取可重试 activation]

打开资源与等待 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 猜测。状态队列与窗口。

sequenceDiagram participant C as client participant H as provider Host participant S as state stream C->>H: add 引用 H->>H: 验证参数并修改 rows par 返回方法结果 H-->>C: result and 传播可观察状态 H->>S: ops / 当前值 reset S-->>C: rows 达到目标状态 end Note over C,S: 背压下 result 可先到;业务界面要等待自己的状态条件

状态同步不是审计事件逐条送达协议;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 到安全点。

flowchart TD Trigger[显式 reload] --> Prepare[加载 facet,验证 contract,准备候选实现] Prepare --> Gate{有状态版本或独占资源变更} Gate -->|否| Ready[候选可提交] Gate -->|是| Cut[建立 gate,停止相关 tasks,等待已准入 roots] Cut -->|安全点到达| Migrate[替换必要资源,初始化与迁移状态] Migrate --> Ready Prepare -->|失败| Rollback[丢弃候选并清理,保留旧版本] Cut -->|超过 gateDeadline| Rollback Migrate -->|失败| Rollback Ready --> Commit[提交新的实现与注册表] Commit -->|成功| Notify[发布状态和形状,启动新 tasks] Commit -->|提交自身抛错| Failed[Host terminal fail,不能假装回滚] Notify --> Retire[旧 roots 结束后退休旧代码]

源码 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 练习:从演示到自己的产品

  1. 新增第二个 project,验证两个项目 rows 分开;然后把 input 中 project 写错,观察为什么 schema 通过仍可能访问错误业务对象。
  2. 给 guest 增加 requestReview 方法,但不要给它 add。把审核申请写入持久业务存储,向 owner 暴露待处理投影。
  3. 让 owner 连续追加两条引用,验证 UI 等待的是内容条件,而不是“一定收到两次回调”。
  4. 修改 cell 形状与版本,设计成功迁移和失败回滚两条路径;参考固定源码的 reload tests,再运行自己的 fixture。现有 memory-link 实验没有覆盖这项练习。
  5. 如果重连后上一次 add 的结果未知,使用业务 requestId 在持久层去重。Chord 的 reference 重建不能自动证明方法上次没执行。

做到这里,你应能解释:对象和 reference 为什么分开、principal 为什么参与身份、共享内部 store 为什么使用 host 身份、方法返回为什么不是状态投递屏障,以及热更新和任务恢复为什么是两个独立问题。