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

实时管道:一条事件穿五层,从子进程 stdout 到父卡片

子会话是被 --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

为什么只有 Task 工具、且只认 tool_call 事件 progressCallback 仅当 call.Name == "Task" 时才装配 loop.go:1013;而 OnToolProgress 里又只对 EventToolCallspecialistProgressMsg 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/EventSpecialistStopParentSessionID。 子会话有它自己那份完整 events.jsonl(第七课),父这边只需要一张摘要卡。 resume 时,session.go 顺父日志重放:见 Start 渲一张卡 session.go:468,见 Stop 就就地更新同一张卡而不是再加一张 :473 —— 与实时管道的「临时键→认领」是同一种「一个子会话只有一张卡」的纪律。

用量是「卷总」一条,不是逐条搬运 —— 又见非幂等纪律 子会话烧的 token 不逐条搬进父日志,而是 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
进程内的 mutex 兜不住跨进程的竞争 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 那段注释,它把「为什么进程内锁不够、必须文件锁原子化」讲得很透。

我是你的老师 —— 随时问我。 适合现在追问: 「background specialist(独立进程、无 stdout 回传给父 TUI)的进度靠什么回流 —— TaskOutput 轮询 + recordBackgroundTaskAccounting(accounting.go:68)是怎么补上实时管道缺席的?」、 「swarm 成员(MemberAutonomy)和 Task specialist 在权限/自治级别上差在哪(memberAwareAutonomy)?」, 或者换个方向:「压缩恢复的触发判定 —— compactor.recover 怎么认出上下文超限、又怎么保证只压一次?」