Pi 源码拆解(四):客户端如何通过会话服务共享状态

📅
2 分钟阅读
·

本文是「Pi 源码拆解」系列第 4 篇。系列目录:

第 1 篇讲分层时提到,仓库中还有 protocol、server、client、storage 一组包,均标记 experimental,本文进一步做源码分析。

当前代码处于过渡状态。server 包 README 第一行写明 “Experimental… may change or be removed without notice”;服务端核心接口 PiSessionBackend 全仓库仅有测试实现,无生产消费者;client 侧 RemoteSession 已合入 coding-agent 但无文档、未默认启用;旧的 JSONL IPC 和子进程 supervisor 仍保留在 server 包的 legacy/ 子目录。下图展示新增会话服务与旧路径并存的位置。下文分析 repository、协议和测试接口目前定义的会话状态与访问行为。

Pi 实验性客户端/服务端会话架构:客户端经协议包访问 PiServer,PiServer 通过 backend 打开持久化会话;legacy supervisor 和 RPC 子进程路径仍被保留

旧模型:一个前端独占一个子进程

拆分前,pi 的程序化接口是 RPC 模式:pi --mode rpc 启动一个无头 agent 进程,命令从 stdin 进入、事件从 stdout 输出,全部采用 JSONL 分帧(packages/coding-agent/docs/rpc.md,文档专门指出 Node 的 readline 会把 U+2028 当作换行,不符合协议的严格 LF 分帧)。每个前端要一个会话就 spawn 一个子进程;两个前端即两个子进程、两份互不相见的会话。会话文件本身持久化在磁盘,pi -c/-r 可以恢复,但活跃会话同一时刻只属于一个进程。从 TUI 切到另一个前端查看同一会话时,需要退出后重新打开。

server 包的 legacy/ 目录保留了基于该模型的 supervisor:它接受 spawn/list/stop/status/rpc 请求(legacy/ipc/protocol.ts 中的 RequestMap),为每个 spawn 创建一个 pi --mode rpc 子进程(packages/server/src/legacy/rpc-process.ts:50-59,spawn 参数即 ["--mode", "rpc"]),supervisor 与子进程之间同样走 JSONL 文本行。supervisor 解决的是批量管理子进程,但一前端对一进程的一对一形态没有改变。

拆分前后对比:前端独占子进程与多个前端 attach 到 server

会话存储接口

拆分进程前,需要先明确会话状态由谁管理。旧代码中会话读写散落在 harness 各处;直接引入进程边界会使状态所有权、持久化和一致性成为跨进程协议的问题。git log 显示,存储改造早于 protocol/client/server 三个包:先隔离 Node 文件系统依赖,随后加入 SQLite 后端。一周内实现 protocol/client/server 后,提交 “clarify session persistence ownership” 和 “compose session storage through repositories” 又继续调整存储职责。这些接口仍在演进。

SessionRepository 定义了五个会话操作(packages/agent/src/harness/session/repository.ts:22-32):create、open、list、delete、fork。jsonl-repo 沿用会话文件的 JSONL 树格式;packages/storage/sqlite-node 使用 SQLite,开启 WAL(sqlite-node/src/sqlite/repo.ts:39),并以独立的 FTS5 虚拟表处理搜索(sqlite-node/src/sqlite/search-backend.ts:41),只对搜索提供只读投影。另有 memory-repo 用于测试和嵌入场景。harness 通过该接口访问会话,不直接处理文件路径或数据库连接。提交 “reject unsupported session search” 规定:后端不支持搜索时直接报错,不退回全表扫描。接口还包含 fork 操作。createSessionForkSelectionrepository.ts:51-56)将”从某条 entry 之前或之处分叉”转换为 entry 序列拷贝,分叉不再依赖旧进程内存中的树。这些接口和后端定义了跨进程共享会话所需的存储行为,但生产 backend 尚未接入。

协议如何处理字节流和消息

protocol 包不绑定任何传输。README 写明 “This package does not bundle a transport”,输入和输出均为字节流,处理顺序为 bytes → frames → CBOR → TypeBox 校验后的消息。

framing 采用 4 字节无符号大端长度前缀(packages/protocol/src/framing.ts:27-28),默认单帧上限 16 MiB(framing.ts:6)。FrameDecoder 是增量式的,接受任意切分与合并的字节块,因此流、socket、自定义传输均可使用。

CBOR 是手写的 RFC 8949 严格子集,不依赖现成库。子集只接受 null 与布尔、有限数(整数限在 JavaScript 安全范围,非整数一律编码为 float64)、UTF-8 字符串、字节串、定长数组和 map;tag、不定长项、BigInt 均不支持,因此编码结果可确定。选择 CBOR 而非 JSON 的原因是它是自描述的确定性二进制格式,与长度前缀帧配合时无需处理转义与空白。解码器还设置了防御性上限:payload 16 MiB、容器 100 万元素、嵌套 64 层(cbor/options.ts:6-8),超限直接抛 CborError。协议面处理不受信输入,解码器需要拒绝恶意构造的深嵌套和巨型容器。手写实现使项目能明确限制可接受的 CBOR 子集、容器规模和嵌套深度。

消息层全部用 TypeBox 描述,StrictObject 一律 additionalProperties: falseschemas.ts:7-8),未知字段直接拒绝。PROTOCOL_VERSION 当前为 2(schemas.ts:3)。

会话协议定义了 9 个命令:list、create、attach、detach、prompt、steer、abort、set_model、set_thinking(schemas.ts:287-320)。错误码有 6 个:auth、version、busy、session_locked、not_found、invalid_request(schemas.ts:266-273)。工具执行、compaction 和扩展事件不经过协议;协议只传输 transcript 和少量控制指令。

prompt 和 steer 的区分沿用第 2 篇的运行时词汇:prompt 是开启新一轮的用户输入,steer 是运行中的 steering 注入,在 turn 边界生效,不打断当前流。协议层只负责送达,不解释这些语义。下行方向定义了 4 种事件:server_snapshot、session_snapshot、session_progress、session_removed(schemas.ts:397-406)。ServerSnapshot 除会话列表外还携带 models 清单(schemas.ts:257-264),前端连接后可获得可用模型、各自的认证状态和计价。握手响应发出前,server 还执行一次 revision 检查:取快照期间若有新变更则补发最新版本(server.ts:247-253),使客户端在握手完成后获得最新快照。

连接从 hello 握手开始:客户端第一帧必须是 hello,携带整数版本号和 bearer token(schemas.ts:380-386)。server 侧对 token 做 SHA-256 摘要后用 timingSafeEqual 比对(packages/server/src/server.ts:257-259),版本不符报 version 错误,握手默认 5 秒超时(server.ts:30)。连接的握手路径为 awaitingHello → handshaking → ready 三段(server.ts:131-140, 186-220),握手完成前到达的请求挂在握手 promise 之后等待。

传输由调用方注入。server 唯一内建的传输是 unix socket(transports/unix/),client 侧对应 unix.ts;ByteTransport 接口只定义 send 和 close 两个方法(packages/client/src/transport.ts:1-6)。server 包还导出 ./testing 子路径,内含 WireChannel 抽象和一套传输一致性测试,第三方实现新传输时可运行同一套测试验证行为。protocol 包本身不 import 任何 node: 模块,与第 1 篇所述的 agent 核心采用相同的依赖隔离方式。

客户端以会话快照更新状态

多个前端连接同一会话时,客户端需要区分可写入本地状态的消息。pi 将完整 SessionSnapshot 作为状态来源。server 每次操作完成后广播携带单调递增 revision 的完整快照;大多数命令的响应也直接携带新快照(schemas.ts:324-351)。TranscriptProgress 仅报告当前生成过程中的增量活动,schema 注释写明 “Normalized incremental activity. Snapshots remain authoritative.”(schemas.ts:203),protocol README 也说明 progress 是 transient UI hints,“must not be reduced into authoritative state”。

client 包 README 规定,客户端用快照和成功响应更新状态,不将 progress 合并为会话数据。coding-agent 中的 RemoteSession 将远端会话表示为本地 transcript,progress 存在独立的 progressItems 中,读取时才叠加到快照 transcript 上(packages/coding-agent/src/client/transcript.ts:78):收到新快照后整体替换 transcript,progress 只影响渲染,不写回会话状态。

快照还包含 queuedSteer 和 queuedSteerCount(schemas.ts:251-252)。已排队但尚未到达 turn 边界的 steering 消息也在快照中,因此中途 attach 的前端可获得尚未生效的 steering 状态。

该方案不实现自动重连、事件重放或对账协议。PiClient 不自动重连(README 原话 “PiClient does not reconnect automatically”),由调用方决定何时 reconnect;重连后 hello 响应携带全量 ServerSnapshot,再 attach 获取会话快照。限制是闪断期间的 progress 不会恢复;会话状态通过快照重新获取。

客户端如何限制同一会话的并发操作

多个前端可以连接同一会话,但不能同时发起相互冲突的操作。acquireSession(sessionId, { mode }) 返回 SessionLease 对象,记录客户端对会话的使用权限。mode 分为 exclusive 和 shared:createSession 默认取得 exclusive(packages/client/src/client.ts:144),attachSession 对应 shared(client.ts:148-150)。client 在本地按会话记录 SessionLease 数量:exclusive 不能与任何已有 SessionLease 共存,shared 不能与 exclusive 共存;发生冲突时直接抛 PiSessionOwnershipError(client.ts:382-394),无需等待网络响应。最后一个 SessionLease 释放时,client 自动向 server 发送 detach。

server 侧的约束写在接口注释中:PiSessionRuntime 的 doc comment 为 “Conflicting operations must reject rather than queue.”(packages/server/src/types.ts:42)。runtime 忙时第二个写操作收到 busy 错误,不排队。LiveSessionManager 维护 attach 关系:每个会话命令先经过 requireAttached 检查(sessions.ts:316-325),attach 将连接加入会话的 connections 集合(sessions.ts:307-314),活跃会话对外的快照一律标记 locked: true(sessions.ts:292)。连接断开时的清理由 disconnect 统一处理:将该连接从它挂载过的所有会话中移除,逐个触发回收检查(sessions.ts:127-137),不需要客户端在断开前发送任何消息。

SessionLease 是一个轻量对象,并实现 AsyncDisposable(packages/client/src/session-handle.ts:84-86)。通过 await using 声明时,它会在作用域结束后自动发送 detach,避免客户端遗留未释放的会话连接。

会话数据的生命周期长于连接。attach 时如果 runtime 不在内存,manager 通过 backend.openSession 从存储重新创建它(sessions.ts:71-79);所有连接断开、没有进行中的操作且 phase 回到 idle 时,runtime 被 dispose 释放(sessions.ts:331-352),会话数据留在存储中。因此,server 进程和存储可长期运行,runtime 则按需创建和释放。

适配层如何限制协议字段

server 包中的 protocol.ts 是 pi-ai 消息模型与协议 DTO 之间的适配层。文件开头的编译期断言中,ExactKeys 逐个枚举 pi-ai 类型中被映射和被有意丢弃的字段,注释写明 “additions fail compilation here”(packages/server/src/protocol.ts:25-36)。上游 pi-ai 为 AssistantMessage 增加字段时,此处编译会失败,维护者需要决定新字段是否加入协议。thinkingSignature、diagnostics、cache 计价细分等字段留在 server 侧,不经过线缆传输。

legacy/ 子目录仍保留 JSONL IPC、supervisor 和 server CLI。README 说明新 API “is additive while the legacy child-process supervisor and server CLI are migrated”。RemoteSession 已合入 coding-agent,但未文档化、未默认启用;PiSessionBackend 的实现仅有 testing/ 目录中用于一致性测试的版本(testing/backend.ts)。该测试实现用于约束协议行为,尚未将 AgentHarness 桥接为生产后端。当前仓库同时保留 legacy 子进程路径、未默认启用的 RemoteSession 和测试 backend。

PiServer 的进程与关闭方式

PiServer 在进程内管理连接和活跃会话,不直接监听端口。PiServer 接收 PiServerListener[]:每个 listener 负责接受有序字节连接,再将连接交给 PiServer 处理(packages/server/src/listener.ts:3-9)。一个 PiServer 实例可以绑定一个或多个 listener。当前内建实现使用 Unix domain socket;README 中的 createUnixServer() 将 Unix listener 和 PiServer 组合为便捷入口。接入 TCP 或 WebSocket 时,需要提供相同接口的 listener 与字节连接实现。

代码没有提供跨进程的全局唯一服务机制。是否全局只启动一个实例,由宿主程序的部署方式决定。开发机上的常驻服务可启动一个进程、绑定稳定的 socket 路径,供多个客户端连接。测试、CI 任务或嵌入式调用可按任务创建实例,但每个实例仍需要独立的 listener 地址、token 和 backend。多个实例绑定同一个 Unix socket 路径会发生绑定冲突;多个实例共用同一存储后端时,backend 还需要处理并发访问和数据一致性。

CI 中应显式关闭服务。PiServer.close() 会先关闭所有 listener,再关闭已有连接、执行会话管理器的清理(packages/server/src/server.ts:153-168, 339-349)。Unix listener 在关闭时还会断开连接并移除由它创建的 socket 文件(packages/server/src/transports/unix/listener.ts:139-157)。测试代码也在 afterEach 中调用 await server.close()。因此,启动服务后应在 finally 或测试框架的清理钩子中调用 await server.close();不要只依赖 CI 进程退出。若服务由独立进程启动,则应在任务结束时向该进程发送终止信号,并等待其清理逻辑完成。

const server = createUnixServer(backend, options);
await server.start();

try {
  await runIntegrationChecks();
} finally {
  await server.close();
}

当前实现的范围和缺口

pi 的新模型让前端连接会话服务,而非各自启动并独占一个 agent 子进程。代码定义会话存储接口,提供可在有序字节传输上运行的协议,并实现 attach、会话快照和并发操作检查。客户端可以连接、创建或挂载会话;服务端负责认证、命令分发、会话生命周期和快照广播;repository 保存会话数据。

跨进程协议包含 9 个会话命令、完整快照和少量控制指令。工具执行、compaction 和扩展事件留在服务端运行时。客户端以成功响应和 SessionSnapshot 更新本地状态,progress 仅用于显示当前生成过程。连接中断后,调用方需要自行重连并重新 attach;闪断期间的 progress 不会补发。

限制是 PiSessionBackend 没有生产实现,RemoteSession 未默认启用,legacy 的 JSONL IPC、supervisor 和 server CLI 仍在使用。协议 schema、适配层和一致性测试已定义存储、传输和会话状态的接口行为。AgentHarness 的生产 backend 接入,以及 CLI 和旧 supervisor 的迁移,尚未完成。

下一篇讨论 pi-tui 的行数组差分渲染:组件产出字符串行,渲染器比较行数组并重绘变化区间。


906 字 · 47 段落
ximing

Follow onGitHub

相关文章