青雲的博客
深入浅出 Pi 第七部:同一套运行时怎样服务不同入口 第 41 章

`pi-server` 管的是进程,还是 Agent

从 server CLI、本地 IPC、ServerSupervisor 与 RPC 子进程的调用关系,判断实例元数据、进程生命周期和 Agent 运行状态分别归谁所有。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

第 40 章把 RPC 模式拆成了一个可收命令、可发事件的长驻进程。仓库里的 packages/server 又在它外面加了一层:启动多个 RPC 子进程,给它们分配实例 id,再通过本地 IPC 提供 spawn、status、stop、rpc 和 rpc-stream。这里最容易写错的一句话是“pi-server 管理多个 Agent”。

它确实能间接控制多场 Agent 会话,但直接所有的是进程句柄、实例元数据和桥接关系。AgentSession、底层 Agent、消息队列与工具执行仍由各个 RPC 子进程持有。这个区别决定了 server 崩溃后还能恢复什么,也决定了 instances.json 能不能作为运行事实。

先把名称说准。npm 包叫 @earendil-works/pi-server,实际注册的可执行命令是 server。固定版本的 README 还把整个包标为 experimental,明确说 CLI、API 与行为可能变化或移除。因此本章是在解释 v0.83.0 的源码形态,不把它描述成已经稳定的远程 Agent 平台。

spawn 之后真正多了哪个进程

server serve 先在配置目录下创建本地 socket,挂上 IPC handler,然后调用 recoverAfterRestart()。其他 CLI 子命令本身不持有 Agent:server spawnserver listserver rpc 等只是连接这条 socket,发送一条请求并打印响应;rpc-stream 才会保持连接,把 stdin JSONL 与 socket 双向转发。

ServerSupervisor.spawnInstance() 收到 spawn 后,先生成 UUID,把一条 starting 记录写进内存 map 和 instances.json,再创建 RpcProcessInstance。后者调用 Node spawn(),工作目录使用实例 cwd,stdio 全部设为 pipe;Node 分发下运行 coding-agent 的 rpc-entry,编译成 Bun binary 时则执行相邻的 pi --mode rpc。所以每个 server instance 对应的是一个独立的 RPC 子进程,不是 supervisor 内的一份 Agent 对象。

从所有权看,层次是这样的:

flowchart LR
  accTitle: pi-server 直接管理的是 RPC 子进程
  accDescr: server CLI 通过本地 IPC 请求 supervisor;supervisor 持有实例记录和子进程句柄,命令进入子进程后才由 AgentSession 与 Agent 执行。
  CLI["server CLI"] --> IPC["local IPC socket"]
  IPC --> SUP["ServerSupervisor"]
  SUP --> META["instances.json metadata"]
  SUP --> CHILD["RpcProcessInstance / child process"]
  CHILD --> RPC["coding-agent RPC mode"]
  RPC --> SESSION["AgentSession"]
  SESSION --> AGENT["Agent + model + tools"]
  AGENT --> SESSION
  SESSION --> RPC
  RPC --> CHILD
  CHILD --> SUP
  SUP --> IPC

这张图也解释了为什么 supervisor 没有 prompt()abort()setModel() 这样的 Agent 方法。它只把 RpcCommand 写入子进程 stdin,按 request id 保存 pending promise,再从 stdout JSONL 中分流 response、extension UI request 与普通 session event。真正理解命令并修改会话的仍是第 40 章里的 RPC mode。

桥接事件,不复制 Agent 状态

bindRpcProcess() 给子进程绑定三类出口:AgentSession event 广播给当前 subscribers,进程退出进入异常处理,extension UI request 转交给当前 stream handler。openRpcStream() 则把一条外部 socket 流接到这些出口;收到 RPC command 时,它调用子进程 send(),必要时再补一次 get_state 来刷新记录。

需要刷新的命令只有 new_sessionswitch_sessionforkcloneset_session_nameprompt。记录里也只有 id、status、cwd、时间、label、sessionId、sessionFile 等摘要字段。模型流中的完整消息、steering/follow-up queue、当前 tool call 和 abort signal 都没有复制进 supervisor。

这意味着 event bridge 是观察与控制通道,不是第二份 Agent 状态机。某个事件已经从子进程发出,能证明它抵达过 supervisor;它不能证明 server 有能力在子进程消失后从该事件继续执行。instances.json 同理,它通过普通 JSON 文件整表读写保存摘要,可供 list/status 使用,却没有 event cursor、未完成工具的 durable receipt 或可重建 pending request。

异常退出、重启与正常关闭留下不同结果

RPC 子进程发生 errorexit 时,RpcProcessInstance 会拒绝所有 pending request,并通知 exit listeners。supervisor 若发现实例不是主动 stopping/stopped,就先把持久状态改为 error,清掉绑定和进程引用,再从 live map 删除。记录仍在 instances.json,因此之后的 list/status 可以看到 error 摘要,但 RPC 已经不能再发给它。

server 自身重新启动时也不会扫描记录并重新 spawn。recoverAfterRestart() 只把旧的 onlinestarting 改成 stopped,更新时间后重新保存。它没有恢复子进程,更没有恢复中断在模型流或工具调用中的 Agent run。

主动 stop 走另一条路径:状态先变为 stopping,清理绑定并 dispose RPC 子进程,最后从 live map 和持久实例列表删除。server 收到 SIGINT、SIGTERM 或 fatal error 时会调用 supervisor.shutdown(),逐个执行相同的 stop。这里的 shutdown 是进程清理合同,不是“保留所有在线实例等待下次恢复”。

不启动服务,也能复核所有权

下面的实验只读取固定 tag。它不会创建 socket、写入用户的 ~/.pi/server,也不会留下 RPC 子进程:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"

git -C "$repo" show v0.83.0:packages/server/package.json |
  nl -ba | sed -n '1,16p'

git -C "$repo" show v0.83.0:packages/server/src/rpc-process.ts |
  nl -ba | sed -n '25,60p;86,98p'

git -C "$repo" show v0.83.0:packages/server/src/supervisor.ts |
  nl -ba | sed -n '99,134p;244,255p;270,339p'

复核时只问三个问题:谁持有 ChildProcess,谁解析 RpcCommand,谁在重启后重建 active run。固定版本的答案分别是 RpcProcessInstance、子进程中的 RPC/AgentSession、没有任何一层。于是标题也有了准确回答:pi-server 是 RPC 进程与实例摘要的 supervisor,Agent 只是它通过协议控制的子进程内部状态。

这个结论留下一个新的验证难题。进程能被启动、命令能被转发,不等于 Agent 一定完成了预期行为。第 42 章转向仓库里的 Evals,看 Pi 如何把真实模型输出、工具调用、会话状态与用量整理成可断言的结果,也看清这种证据仍然会受模型和凭据影响。