数据截至 (上游 commit 60706feb348c)
04 · 客户端同步:live 求快、fetch 求准
30 秒导读: daemon 一边把 agent 的输出用 WebSocket 实时推给你(为了"快"),一边提供一个分页的历史拉取 RPC(为了"准")。本章讲客户端凭什么不把实时流当真相,以及它怎么用
epoch + seq把两条来源合成同一份最终一致的时间线——最后看同一份状态怎么长成手机、桌面、CLI、浏览器五种客户端。
前置章节:01 · 一条连接 讲这条 WebSocket 与中继;03 · AgentManager 与时间线 讲 daemon 侧的时间线唯一真相(epoch、seq、投影)。本章从"这些东西到了客户端之后怎么办"接着讲。
1. 先讲清楚:为什么要两条路
1.1 一句话
live 流负责让你立刻看见字在往外蹦,fetch 负责保证你最后看到的是对的。
1.2 场景化一点
你在手机上盯着一个跑在家里 Mac 上的 Codex。地铁进隧道,WebSocket 断了 8 秒;出隧道重连,这 8 秒里 agent 写了三个文件、发了两段消息。
如果客户端只信实 时流,那 8 秒的内容就永远丢了——而且它自己不知道丢了。所以必须有第二条路:一个能问"我停在第 137 条,给我 137 之后的"的接口。
1.3 两条路的分工
| 通道 | 消息 | 特点 | 能不能当真相 |
|---|---|---|---|
| 实时流 | agent_stream(WebSocket 推送) | 低延迟、可以是增量形状的生命周期更新(工具从 started 变 completed) | 不能。会漏、会乱序、会重 |
| 权威历史 | fetch_agent_timeline_request/response(RPC) | 一定返回完整投影后的条目,带 seqStart/seqEnd/sourceSeqRanges | 能。它就是 daemon 的账本切片 |
daemon 侧这个契约写在 docs/timeline-sync.md:3-6;实现在 packages/server/src/server/session.ts:6832(handleFetchAgentTimelineRequest)。
1.4 一张顶层图
怎么读:左边是 daemon,右边是客户端;两条箭头进客户端后分别落在两条车道,最后才合并成你看到的那一列消息。
daemon 客户端(app)
┌──────────┐ agent_stream(快) ┌──────────────────┐
│ 时间线 │ ───────────────────► │ head 车道(临时) │──┐
│ 唯一真相 │ └──────────────────┘ │
│ │ ├─► 渲染
│ epoch │ fetch(准/分页) ┌──────────────────┐ │
│ + seq │ ◄──────────────────► │ tail 车道(权威) │──┘
└──────────┘ └──────────────────┘
▲
epoch+seq 定序闸门
(对不上号就丢弃 + 触发补齐)
- tail:已被权威页面覆盖的历史,每条都带
timelineCursor: { epoch, seq }。 - head:还没拿到权威位置的实时叠加层,可以被整体替换掉。
一句直觉:把 head 当浏览器里的"乐观渲染",把 tail 当刚从服务器 200 回来的那份 HTML。
2. 服务端这一侧给了什么契约
本节只讲客户端要依赖的那几条服务端事实,daemon 内部的时间线实现见 03 章。
2.1 fetch 的请求形状
一次拉取由三个字段决定:方向、游标、条数。
| 字段 | 取值 | 默认(session.ts:6836-6839) |
|---|---|---|
direction | tail / after / before | 有 cursor 就是 after,否则 tail |
cursor | { epoch, seq } | 无 |
limit | 投影后条目数;0 表示"窗口内全部" | after 是 0,其余是 200 |
projection | projected / canonical | projected |
响应里客户端真正吃的是这几项(session.ts:6877-6917):epoch、window、startCursor、endCursor、hasOlder、hasNewer、以及每个条目的 seqStart / seqEnd / sourceSeqRanges。
sourceSeqRanges 是关键。 一次工具调用从 started 到 completed 可能占了 5 个源 seq,但投影后是一个条目。客户端要靠这个区间数组判断"这个投影条目是否覆盖了我 live 已经画过的那几条",而不是傻乎乎地把文本再拼一遍。
2.2 选择性投递:不看的 agent 不推流
老的 daemon 对每个连上来的客户端广播全部 agent 的流。手机上开着 20 个 agent 就是 20 路无用流量。
新协议让客户端声明能力 selective_agent_timeline(packages/protocol/src/client-capabilities.ts:5),然后用 agent.timeline.set_subscription.request 报一个"我现在看得见这些 agent"的集合(session.ts:2187-2202)。
分发逻辑在 forwardAgentStream(session.ts:1156),按 source(同一个 clientId 可能有多个 socket)逐个判定:
// session.ts:1114-1119,原文
if (
supportsSelectiveDelivery &&
!this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId)
) {
continue;
}
这段的意思是:声明了能力、又不在订阅集里的 agent,它的时间线事件直接不发。
但"要人注意"这件事不能被静音。 同一个函数在前面把 attention_required 事件改投成独立消息 agent_attention_required(session.ts:1186-1198)——通知照走,时间线不泄漏。
端到端测试把这套边界钉死了(packages/server/src/server/selective-timeline-delivery.e2e.test.ts:154):
| 测试断言的行为 | 证据行 |
|---|---|
| 订阅集外的 agent,capable 客户端收不到时间线 | :171-177、:190-200 |
| 从订阅集移除后立刻停推,legacy 客户端仍照收 | :202-218 |
| 重连后订阅集重置为空,不会自动恢复 | :220-232 |
attention 走独立消息且 timelineLeaked: false | :234-270 |
| 同一 clientId 降级成不支持能力后,恢复全局投递 | :272-286 |
| 订阅 ack 只回给发请求的那个 socket | :140-152 |
心跳不是投递门禁。 心跳(设备类型、可见性、聚焦 agent)只用于通知路由;一条过期的手机心跳可以影响你收不收得到推送,但不允许让时间线行从流里消失(docs/timeline-sync.md:22-31)。