数据截至 (上游 commit 877a71568f6d)
两套 WebSocket:daemon 通道、浏览器广播与多实例扇出
30 秒导读: Multica 是「托管型 agent 平台」——你在网页/桌面端派活,本地的 agent 进程(daemon)自动认领、执行、回报,你只管看进度。要做到这种「set it and forget it(设好就不用管)」的体验,页面不能靠轮询,必须服务端主动推。本章讲支撑这套推送的实时层:它由两套各司其职的 WebSocket 组成,中间用 Redis Stream 把多个服务端实例连成一张网。
本章在 Multica 全景中的位置(其余各章见 index):
- 上游是任务判定与状态机(03-task-dispatch-lifecycle)——它决定「有活了」;
- 下游是本地守护进程(02-local-daemon)——它认领并执行;
- 另一端是多端前端(05-frontend-architecture)——它消费本章推来的事件。
本章只讲中间那条实时管道:线协议、两个 hub、多实例扇出、前端如何消费。
1. 这是什么(零基础也能懂)
一句话定义
实时层 = 一条从「服务端」通向「本地 daemon」的控制通道 + 一条从「服务端」通向「浏览器」的广播通道,两条都跑在 WebSocket 上,但用途完全不同。
为什么要两套,而不是一套
它们连接的对象、信任模型、消息方向都不一样,硬塞进一套只会互相拖累:
| 维度 | daemon 通道 | 浏览器通道 |
|---|---|---|
| 连的是谁 | 用户机器上的 daemon 进程 | 网页 / 桌面 / 手机端 |
| 谁主动说话 | 双向:服务端唤醒 daemon,daemon 发 RPC/心跳回来 | 基本单向:服务端推,前端只发订阅/心跳 |
| 认证方式 | Authorization 头 + daemon token / PAT | Cookie 或首帧 token(浏览器设不了自定义头) |
| 核心用途 | 「有活了,来认领」+ 认领 RPC + 心跳 | 「这条 issue 变了,去刷新」 |
| 代码位置 | internal/daemonws/ | internal/realtime/ |
两个端点也是分开注册的(cmd/server/router.go:1211 是浏览器 /ws,cmd/server/router.go:1307 是 daemon 的 h.DaemonWebSocket)。
一句话直觉
- daemon 通道像「工头对讲机」:工头(服务端)喊一声「3 号工位有活」,工人(daemon)听到后自己跑去领工单——喊话只是提醒,真正领活还要走正式流程(见 §4 的「best-effort 唤醒」)。
- 浏览器通 道像「广播喇叭」:办公室里所有人(浏览器标签)都听得到「A 项目进度更新了」,但喇叭只说「变了」,不念全文——听到的人自己去公告栏(数据库)取最新内容(见 §6 的「失效信号」)。
用起来什么样(一次真实往返)
你在网页点「让 agent 修这个 bug」,接下来这条链路全自动:
你点派活
→ 服务端把任务落库、判定该谁干(03 章)
→ 服务端通过 daemon 通道喊:"runtime X 有活了"(best-effort)
→ 你机器上的 daemon 听到,发起 tasks.claim RPC 认领
→ daemon 跑 agent,每有进展就 POST 一条 progress
→ 服务端把 progress 变成一条 task:progress 事件,走浏览器通道广播
→ 你和同事的页面收到,刷新看板上那张卡片的状态
你全程没刷新过页面。这就是本章要拆开讲的东西。
2. 顶层全景(它大概怎么转)
一张图看清两套通道
┌─────────────────────────────────────────┐
│ 服务端(可多实例) │
本地机器 │ │ 浏览器 / 桌面 / 手机
┌──────────┐ daemon │ ┌───────────────┐ 事件总线 ┌──────┐ │ 广播 ┌──────────────┐
│ daemon │◀────WS───▶│ │ daemonws.Hub │ │ bus │ │ WS │ realtime.Hub │◀──▶│ 浏览器标签 │
│ (agent) │ 控制通道 │ │ byRuntime/… │ └──┬───┘ │ ◀────▶│ rooms(scope) │ └──────────┘
└──────────┘ │ └──────┬────────┘ │ │ └──────┬───────┘
│ │ 唤醒/RPC/心跳 │广播 │ │ 房间扇出
│ │ ▼ │ │
│ │ ┌──────────────────┐ │
│ └─────────────▶│ Broadcaster │◀─────────┘
│ │ (DualWrite) │
└────────────────────────┴────────┬─────────┘
│ XADD / XREAD
┌──────▼──────┐
│ Redis Stream │ ← 多实例扇出:
│ relay │ 一个实例发,所有实例都收得到
└─────────────┘
怎么读这张图:左半边是 daemon 通道,右半边是浏览器通道,两者在服务端内部通过「事件总线 + Broadcaster」相连;最底下的 Redis Stream 让「多个服务端实例」表现得像一个。
部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
protocol.Message | 所有 WS 消息的统一信封(type + payload) | pkg/protocol/messages.go:76 |
| 事件常量 | task:progress、daemon:rpc_request 等字符串枚举 | pkg/protocol/events.go |
daemonws.Hub | daemon 连接注册表 + 唤醒推送 + RPC 分发 + 心跳 | internal/daemonws/hub.go:186 |
realtime.Hub | 浏览器连接的「房间」管理与扇出 | internal/realtime/hub.go:277 |
Broadcaster | 事件producer 只依赖的抽象接口 | internal/realtime/broadcaster.go:23 |
DualWriteBroadcaster | 本地即时扇出 + Redis 跨实例扇出,二者去重 | internal/realtime/redis_relay.go:641 |
ShardedStreamRelay | 固定分片的 Redis Stream 中继(当前默认) | internal/realtime/sharded_stream_relay.go:156 |
RelayNotifier | 把 daemon 唤醒也塞进 Redis relay,让每个实例都能就近投递 | internal/daemonws/notifier.go:14 |
useRealtimeSync | 前端总入口:把事件翻译成缓存失效/打补丁 | packages/core/realtime/use-realtime-sync.ts:732 |
主线走一遍(不进代码)
一条事件的一生:producer 发布到事件总线 → registerListeners 决定发给谁(个人/工作区/daemon)→ Broadcaster 本地扇出并写 Redis → 别的实例从 Redis 读回来、就近扇出 → 浏览器收到 → 前端把它当失效信号刷新缓存。