Zero 架构 · 第十六课
第十五课把 specialist 子会话挂到父会话上(EventSessionChild 双向书签)。这一课接着问:
子 agent 跑起来后,它的进度 —— 调了几次工具、烧了多少 token、成功还是失败 —— 怎么实时爬回到父会话 TUI 里那张
specialist 卡片上?答案是两条截然不同的通道喂同一张卡片,新手最容易只看到一条。
对齐 mission:一个能派 sub-agent(Task 工具 / swarm 成员)的 coding agent,父这边不能变成黑盒 ——
用户得看见「worker 正在读 foo.go、已 12 次工具调用、1,840 tokens」。但子会话是另一个进程,
它的进度既要当场滚动给用户看,又要在重启/resume 后还能重建。一条管道满足不了这两个需求,
所以 Zero 用了两条 —— 看懂它俩的分工,就看懂了「实时」与「持久」在 agent 里的边界。
| 实时管道 | 持久账本 | |
|---|---|---|
| 载体 | 子进程 stdout 的 stream-json | 父会话日志里的事件 |
| 粒度 | 每一次工具调用都滚 | 只记 start / stop / usage |
| 存活 | 进程内,重启即失 | 落盘,resume 可重建 |
| 显示 | ↳ 当前工具 + 计数递增 | 卡片的最终状态与用量 |
| 键 | toolCallId(子还没 id) | childSessionId |
子会话是被 --output-format stream-json 拉起的独立进程
exec.go:257。它每吐一行 JSON,父这边就接力传一次:
子进程 stdout ──每行──► runChildProcess 解析 ──► progress(event) // exec.go:799
└► Task 工具的 Progress // task_tool.go:110
└► loop 里只给 Task 装的 progressCallback(带上 toolCallID) // loop.go:1013
└► options.OnToolProgress(toolCallID, event) // model.go:4377
└► runtimeMessageSink(specialistProgressMsg{…}) → tea.Msg // model.go:4379
最后这条 specialistProgressMsg 进 TUI 的 Update
model.go:1992,只做两件事:给卡片的工具计数 +1
(incrementToolCount :2000)、更新「↳ 正在 read_file foo.go」这行实时预览
(setCurrentTool :2001)。这条链正是第六课「回调 → tea.Msg → Elm 循环」
在子会话上的延伸 —— 只不过源头从本地 agent 换成了另一个进程的 stdout。
progressCallback 仅当 call.Name == "Task" 时才装配
loop.go:1013;而 OnToolProgress 里又只对
EventToolCall 发 specialistProgressMsg model.go:4378。
于是卡片计的是子 agent 的工具调用数,而不是它的碎碎念(text/reasoning)—— 因为「调了几次工具」才是「进度」的有意义单位。
难点在于:实时管道启动时,子进程还没创建自己的 session id —— 那个 id 要等子进程跑起来才知道。
所以卡片一出生就先用 Task 的 toolCallId 当键(start
specialist_card.go:54),期间所有进度也都按 toolCallId 累加。
等子会话完成、真 id 回来了,specialistCompleteMsg 再做一次身份认领:
m.specialists.complete(msg.toolCallID, …) // 先按临时键结算 model.go:1976
if msg.childSessionID != "" && msg.childSessionID != msg.toolCallID {
m.specialists.reconcileSessionID(msg.toolCallID, msg.childSessionID) // 改键 :1978
}
reconcileSessionID specialist_card.go:134
把这条卡片的键从 toolCallId 改写成真正的 childSessionId —— 这样用户之后点开卡片钻进子会话
(subchat)时,才能拿真 id 去 store 里找到子会话的事件。临时键让卡片能立刻显示,真身键让它事后可导航。
实时管道一断电就没了。要让 resume 之后卡片还能重建,得把里程碑落盘 —— 而且刻意落在父会话的日志里:
recordSpecialistStart accounting.go:36 /
recordSpecialistStop :41 各写一条
EventSpecialistStart/EventSpecialistStop 到 ParentSessionID。
子会话有它自己那份完整 events.jsonl(第七课),父这边只需要一张摘要卡。
resume 时,session.go 顺父日志重放:见 Start 渲一张卡
session.go:468,见 Stop 就就地更新同一张卡而不是再加一张
:473 —— 与实时管道的「临时键→认领」是同一种「一个子会话只有一张卡」的纪律。
rollUpSpecialistUsage
accounting.go:63 把整个子运行的用量聚合成一条 EventUsage 记到父头上。
这正是第十五课 Fork 跳过 usage、第十/十二课「非幂等不重放」在账上的第三次回响:同一份 token 只能算一次。
麻烦在于 stop/usage 可能被多个路径同时写:前台 onExit 可能和一次 TaskOutput 轮询、
或另一个进程里的 background reaper 撞在一起。一个朴素的「先查有没有、没有再写」会漏 —— 两个都查到「没有」,于是各写一条,用量翻倍。
Zero 的解法是把检查和追加原子化:
appendSpecialistEventOnce(…) // accounting.go:160
└► store.AppendEventUnlessExists(parent, event, matcher) // 锁内 check+append :166
accountingMu accounting.go:23 只是进程内的快路径;
真正的去重保证来自 AppendEventUnlessExists —— 它在 session 锁(进程内 mutex + 跨进程文件锁)下把
「存在性检查」和「追加」合成一个原子操作。因为竞争方可能在另一个进程(background 任务),
只有落到文件锁这一层才关得住。matcher(:120)按
childSessionId+runId 认「同一次」,还带一条 catch-all:没 runId 的早期 stop 能和后来带 runId 的匹配上,免得同一个子会话记两条。
卡片(specialistTracker)这个内存状态,同时被两条通道喂:实时管道给它滚动的 ↳ 预览和递增计数,持久账本给它最终状态和用量。
这与第七课的骨架一脉相承 —— 事件日志是真相,TUI 状态是派生。实时管道只是架在持久真相之上的一层加速视图,
让用户不必等落盘就能看见进度;而真正能扛住重启的,永远是那几条写进父日志的事件。
子会话进度用两条通道回流父卡片,它们的根本分工是什么?
实时管道启动时,卡片为什么先用 toolCallId 当键、事后再改?
为什么进程内的 accountingMu 兜不住 stop/usage 的重复写?
less +799 ../zero/internal/specialist/exec.go # runChildProcess:读子进程 stdout,每行 progress(event)
less +1013 ../zero/internal/agent/loop.go # progressCallback:只给 Task 装、绑定 toolCallID
less +4377 ../zero/internal/tui/model.go # OnToolProgress → specialistProgressMsg(只认 tool_call)
less +134 ../zero/internal/tui/specialist_card.go # reconcileSessionID:临时键→真 id 认领
less +36 ../zero/internal/specialist/accounting.go # recordStart/Stop + rollUpUsage:写进父日志
less +160 ../zero/internal/specialist/accounting.go # appendSpecialistEventOnce:锁内原子 check+append
读的顺序:先顺实时管道走一遍那五层接力(exec.go:799 → loop.go:1013 → model.go:4377),体会「一条 stdout 事件怎么变成一次 tea.Msg」。
再看 reconcileSessionID 的临时键把戏。最后啃 accounting.go 的持久侧 —— 尤其
appendSpecialistEventOnce 那段注释,它把「为什么进程内锁不够、必须文件锁原子化」讲得很透。
TaskOutput 轮询 + recordBackgroundTaskAccounting(accounting.go:68)是怎么补上实时管道缺席的?」、
「swarm 成员(MemberAutonomy)和 Task specialist 在权限/自治级别上差在哪(memberAwareAutonomy)?」,
或者换个方向:「压缩恢复的触发判定 —— compactor.recover 怎么认出上下文超限、又怎么保证只压一次?」