第五部分 · 进程架构与界面
第 14 章:守护进程与会话 worker
终端只是窗口:一次关于进程所有权、崩溃语义与倒置看门狗的完整设计
为什么要有 daemon
第 13 章的四个长任务原语有一个共同的前提:会话必须活得比终端久。goal 要在用户睡觉时续跑,heartbeat 要在无人时被触发,子代理要在父会话 detach 后继续可寻址。这要求执行从「终端的子进程」变成「常驻的服务」——Prime 的答案是一套三类进程的守护架构:
- client(TUI / print / JSON / RPC):只管渲染与输入,不拥有任何执行状态;
- daemon supervisor:常驻,负责发现、路由、连接管理、跨 agent 消息投递;
- session worker:每棵会话树一个进程,拥有 root 会话 + 全部 RLM 后代 + scheduler + kernels。
这三个角色跑的是同一个二进制的同一个入口。main.ts 的分发直白得可爱:
if (appMode === "daemon" && parsed.listModels === undefined) {
if (isDaemonWorkerProcess()) {
await runDaemonMode({ ... }); // worker:环境变量 PRIME_AGENT_INTERNAL_DAEMON_WORKER=1
} else {
await runDaemonSupervisorMode({ ... });
}
return;
}
catalog(saved-session 扫描)同理,由环境变量 PRIME_AGENT_INTERNAL_DAEMON_CATALOG=1 分流。名字里的 INTERNAL 说明这是进程间内部协议,不是用户配置。
协议:版本三元组与能力协商
client 与 daemon 之间的公共 socket(Unix 下 $TMPDIR/prime-agent-<uid>/daemon.sock,Windows 下命名管道)走 JSONL 分帧。协议头三件套:
export const DAEMON_PROTOCOL_VERSION = 7; export const DAEMON_SCHEMA_REVISION = 16; export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49";
协议版本与 schema revision 独立演进:兼容新增走 capability 门控或 schema revision 递增,不兼容的 wire 变更才升 protocol version。v7 起命令必须包信封({type:"command", id, protocol:{name,version}, clientId?, command});每条事件带 cursor: {generation, sequence}——generation 的语义下一章展开。
能力协商是双向的:客户端 attach 时声明能力(attach_snapshot、event_sequence、chunked_snapshot、extension_ui……),服务端在 daemon_hello 里宣告能力(model_catalog、heartbeat_management、delete_rlm_subagent……)外加身份五元组与 appVersion。兼容性表 DAEMON_COMMAND_COMPATIBILITY 按命令名记录最低协议/schema/capability 要求,客户端发送前逐条校验——旧客户端对新 daemon 的每个越界请求得到的是明确的能力错误,不是畸形行为。
docs/daemon.md 仍自称 "Public Daemon Protocol v4",代码已是 v7,且信封最低版本同为 7——意味着旧的裸命令路径在公共协议上已经不存在。本书以代码为准;这也是阅读活跃开源项目时值得养成的习惯。
启动拓扑与所有权
交互式 prime-agent 的启动序列:client 在 CLI 早期就 fire-and-forget 预热 daemon(ensureInteractiveDaemonRunning),探测现有 daemon 的版本——current 则复用;stale(更新过的二进制对着旧 daemon)则仅当没有 busy 会话才 shutdown 替换,否则抛 StaleDaemonError 给出 shutdown --force 指引;不存在则 spawn 一个 detached supervisor 并轮询探测直至 current。
supervisor 的启动有一整套所有权仪式(daemon-supervisor-ownership.ts):
- ownership 注册表:每个 generation 一个
<generation>.owner/目录(owner.json:token、pid、processStartId、socketPath、phase……外加一份最小可判定冲突的 scope.json)。获取用 candidate 目录原子 rename;冲突判定是「socketPath 相同或 descriptorDir 相同」——后者防止两个不同 socket 的 supervisor 共管同一批 worker。 - startup fence:新 supervisor 在绑定 socket 前等前任进程真正退出(10s)——fence 里记录的是前任的身份五元组。
- shutdown admission:关停流程持有一个 5 秒租约(1s 刷新)——持有期间任何新 supervisor 的获取直接失败。它挡住的恰恰是下一节的自愈机制:关停与自愈必须互斥,租约一断(进程卡死)自愈又可发生。
worker 的启动同样讲究。launchWorker() spawn 一个 detached 进程组,stdio 里 stderr 被 supervisor 当作 JSONL 结构化日志通道读取,而 fd 3 是启动门管道:worker 的 main 阻塞等待 supervisor 先落盘 descriptor、再写入 "start\n"。这个顺序保证了一个崩溃窗口性质:descriptor 永远先于 worker 运行而存在——supervisor 任何时刻崩溃重启,都能从 descriptor 目录看到全部 worker 并决定收养还是恢复。
supervisor ↔ worker:序列化一次,路由头转发
worker 与 supervisor 之间是私有 socket 上的二进制帧:
u32 BE header 长度 | u32 BE payload 长度 | JSON 路由头 | 不透明 payload
设计目标一句话:worker 只序列化一次公共事件,supervisor 只读路由头、把同一 payload buffer 转发给符合条件的 client——热路径上零解析、零重新序列化。唯一的例外是 assistant 流式:worker 发的是去掉不断增长的 partial 全文的 CompactAssistantDelta(只带增量 + 种子),supervisor 用重建器为每个观播 client 还原完整 message_update;重建失败就给客户端补一次快照。
attach 的画面恢复走快照流水线:supervisor 先查 SnapshotTranscriptCache(512KiB 块;≤4MiB 全内存,超过落盘),未命中才向 worker 拉取。缓存复用的校验严格到偏执:worker 若对同一 snapshotId 再次 begin,supervisor 会逐 chunk 字节比对既有缓存,全部一致才复用,否则作废——缓存损坏或 worker 行为漂移都会被当场抓住,而不是静默扩散。
supervisor 的职责清单
剥去启动仪式,supervisor 的日常是四件事:
- 命令路由:统一入口
handleLine——解析、prompt admission 登记、ownership 断言、命令 journal 查询(见下)、阶段门控,然后分流:supervisor 本地处理的(list/create/attach/detach/聚合类)、send_message(第 7 章讲过:catalog 解析 saved session、必要时唤醒 worker、家族可达性再验证)、其余带 activeSessionId 的转发给对应 worker。 - 连接管理:attach 流水线、背压与 catchup——client 的 socket 写返回 false 即标记 backpressured,该会话事件停发只记 catchup 标记,drain 后一次性补快照。supervisor 不维护无界的 per-client 队列。
- worker 生命周期:健康、恢复(下一节)、空闲驱逐——扫描间隔取驱逐分钟数的三分之一并夹在 1–5 分钟;整树空闲停 worker,否则发
worker_passivate_idle_children(第 6 章的钝化,每 worker 每轮至多 2 个子代理),驱逐前排空变更。 - 跨 worker 协调:agent 消息路由、
syncAgentPeers(向每个 worker 广播全局 agent 摘要——路由表)、两阶段更新重启(worker 并行建 checkpoint → 聚合 manifest 校验 → 全成才 commit;任一失败则全部释放继续跑)。
崩溃语义:不重放,不撒谎
这套架构最硬核的部分是崩溃处理。三条主线:
worker 崩溃:收养或恢复
supervisor 收到 worker 关闭 → lifecycle "recovering" → 按 [250ms, 1s, 5s] 三轮重试:先查 pid + processStartId——进程还活着且身份匹配就重连收养(adopt),会话零中断;身份无法验证的活进程拒绝替换("Cannot safely replace live session worker without a verified process identity");确认死亡则收割——SIGKILL 旧进程组、按孤儿日志回收 detached 的 bash/kernel、读 WorkerRecoveryJournal 把所有 busy 记录经 catalog 写进转录(一条可见的 <prime_agent_worker_interrupted> 标记),然后复用原 workerId/token/socket/activeSessionId 重启 worker。三轮失败置 failed,daemon retry 可手动再试。
这里的核心纪律是:不重放任何不确定操作。WorkerRecoveryJournal 在 turn_start/turn_end/compaction/bash_end 等检查点记录 {busy, operation, sessionFile};崩溃后 busy=true 的记录变成转录里的中断标记,而不是悄悄重试。配合命令层的三态幂等(下节),整个系统的崩溃语义是一致的:要么完成并有凭据,要么显式标记为不确定。
命令 journal:new / complete / uncertain
变更命令(mutating)的重复执行与丢失由 CommandRecoveryJournal 防御:接收记录在分发前落盘,键是 [clientId, commandId],生命周期 received → result → acknowledged(ack 由 ack_result 命令触发,条目即删)。重放查询返回三态:complete → 直接回放存储的响应;pending → 返回 command_result_uncertain 错误;new → 正常分发。类注释把哲学写得很清楚:崩溃后缺失结果的命令被视为 uncertain 而绝不重放。客户端配合:重连后以同一 commandId 重发同一 wireData——幂等键不变,三态判定接管一切。
倒置看门狗:worker 重建 supervisor
supervisor 自己崩溃了怎么办?Prime 没有外部服务管理器,答案是倒置的看门狗:worker 监控公共 socket——无已认证 supervisor 连接、socket 连不上、又没有 shutdown admission——就发起替换。竞选靠原子目录锁(全局只有一个 worker 执行启动),新 supervisor 经 startup fence 绑 socket、读 descriptors、收养存活 worker、恢复死亡的。旧 supervisor 若复活,它的 worker_auth claim 会因 owner 记录指纹不匹配被 worker 栅栏化(supervisor_generation_stale)。
为什么倒置?因为被监控者(worker)寿命更长、数量更多,由它们选举重建者,比让一个单点的 supervisor 保活自己更稳。监控关系反过来了,自愈能力反而更强。
一份贯穿全系统的并发配方
读到这里你可能已经注意到一个反复出现的结构。session lease(第 11 章)、supervisor ownership、替换 supervisor 的启动锁、stale 回收——四份代码用的是同一个配方:
candidate 目录 + 原子 rename + lockfile 守卫(外层互斥) + 进程身份(pid + processStartId)判活 + 死亡则 rename .stale-* 回收
其中 processStartId 是对 PID 复用的一等防御:Linux 读 /proc/<pid>/stat 的启动时间字段、macOS 用 ps -o lstart=、Windows 用 PowerShell 查 StartTime.Ticks。判活永远是「pid 活着且启动时间匹配」。这个配方在孤儿日志(第 3 章)、会话租约(第 11 章)里也出现过——在 Prime 的代码库里,「谁还活着」从来不是靠猜的。
catalog:为什么扫描要单独一个进程
saved-session 的遍历(解析每个 jsonl 的 header/info)是重 IO/CPU 操作,Prime 把它放进独立的 catalog 子进程(Node IPC 通信),理由有二:故障隔离——catalog 崩溃只失败当前请求,不打断 supervisor 的路由与活动 worker,超时直接 SIGKILL 重启;性能隔离——扫描大目录不阻塞 supervisor 的事件循环(它还要做 fanout、背压、心跳)。连「把中断标记写进转录」这类 transcript 文件 IO 也委托给 catalog——supervisor 自己不碰会话文件。
实践应用
- 把「谁拥有什么」画成进程边界。client 拥有呈现、supervisor 拥有路由、worker 拥有执行——每条所有权都对应一个崩溃域。模糊的所有权产生模糊的恢复。
- 崩溃语义要写成三态。完成/失败/不确定——把不确定显式化(错误码、转录标记),而不是在「重放」与「丢弃」之间赌博。至少一次投递的副作用风险,值得用一个显式错误来换。
- descriptor 先于进程存在。启动门(fd 3)保证管理者任何时刻都能清点被管理者——这是「收养还是恢复」决策的前提。
- 让被监控者选举重建者。单点保活自己永远有盲区;数量更多、寿命更长的下属进程做看门狗,配合租约与栅栏防双主,是更稳的拓扑。
- 并发原语做成配方。candidate+rename+身份判活+stale 回收,四处同构。一致的配方降低每一处实现与审计的成本。
总结
daemon 架构把执行从终端解绑:同一个二进制按环境变量分成 supervisor、worker、catalog 三种角色;协议用版本三元组 + 能力协商管理演化;私有二进制帧让热路径事件序列化一次、路由头转发;启动门、所有权注册表、三道栅栏把进程生命周期做成显式状态;崩溃语义统一为「收养、恢复或标记不确定,绝不重放」;倒置看门狗让 worker 群自己重建 supervisor。
下一章站到 client 一侧,看这条边界的另一半:AgentConnection 如何用快照与 generation 把「断线重连」变成一次无感的状态对齐。