BLOG / POST
从 Multica 源码看 Web 如何连接本地 Agent Runtime
沿着 Multica 的 WebSocket、daemon、任务队列与本地子进程源码,拆解网页如何调度 NAT 和浏览器沙箱之后的本地 Agent。
- agent
- websocket
- runtime
- go
浏览器不能直接启动 codex、claude,也不能任意读取用户的 Git 仓库。即使网页知道本机端口,让浏览器长期连接 localhost 仍会遇到端口冲突、权限、HTTPS 混合内容和跨设备协作等问题。
Multica 保留了浏览器的安全边界,在 Web 与本地 Agent 之间加入服务端控制面和本地 daemon。浏览器与 daemon 都主动连接服务端:浏览器表达人要做什么,daemon 把任务变成本机进程,服务端负责持久化、授权、排队和事件转发。
这套结构最值得关注的地方在于,它把实时性、可靠性和本地权限交给了不同组件。
本文基于 Multica revision 1869b1b5e 做静态源码阅读,没有进行运行时验证或独立安全审计。文章延续《从 Multica 源码看 Web 与 CLI 的双凭证设计》的阅读路径,继续追踪 WebSocket、daemon、任务队列和本地子进程之间的数据流。
源码阅读范围
本文主要沿着以下实现展开:
| 区域 | 主要职责 |
|---|---|
packages/core/api/ws-client.ts |
Web 与桌面端的 WebSocket 连接、认证和重连 |
packages/core/realtime/ |
把服务端事件写入 React Query cache,或让相关查询失效 |
server/internal/realtime/ |
面向 Web 与桌面客户端的工作区实时 Hub |
server/internal/daemonws/ |
面向 daemon 的唤醒、心跳和 RPC Hub |
server/internal/service/task.go |
任务入队、状态机、事件广播和 daemon 唤醒 |
server/internal/handler/daemon*.go |
daemon 注册、领取、进度、消息与终态 API |
server/internal/daemon/ |
本机运行时注册、领取、执行、回传和恢复 |
server/pkg/agent/ |
Codex、Claude Code 等本地 CLI 的统一适配层 |
源码中存在两套 WebSocket。理解它们的职责差异,是理解整套架构的起点。
浏览器、服务端与本地机器
完整拓扑可以压缩为下面这张图:
浏览器 / Desktop
│
│ HTTP 写操作 + /ws 实时订阅
▼
Multica Server ───────── PostgreSQL
│ 任务、消息、终态
│
│ /api/daemon/ws 控制连接
│ + daemon HTTP API
▼
本机 multica daemon
│
│ 启动子进程、传入任务环境
▼
Codex / Claude Code / Cursor / 其他 Agent CLI
│
└── 本地仓库、Git、Shell、已登录的 Provider
浏览器与本机之间没有任意 TCP 隧道。daemon 主动建立出站连接,因此不需要公网 IP、路由器端口映射或入站防火墙规则。协议也没有开放任意文件读取或 shell RPC;Agent 是否调用本地工具,最终取决于任务内容、本地 Provider 和权限策略。
Web 与本地 Runtime 通过同一个服务端状态机协作。服务端在这里承担任务事实来源,而非透明中继。
两条 WebSocket 服务两类连接
| 连接 | 客户端 | 鉴权与作用域 | 主要消息 |
|---|---|---|---|
/ws |
Web / Desktop renderer | Web 使用 HttpOnly Cookie;token 模式在首帧认证;连接绑定一个工作区 | issue、评论、任务状态、执行消息等 UI 事件 |
/api/daemon/ws |
本地 daemon | Authorization: Bearer;连接固定到已授权的 runtime 集合 |
任务唤醒、心跳、配置刷新、工作区变更、tasks.claim RPC |
Web 侧的 WSProvider 会跟随当前路由中的 workspace slug 建立连接。用户切换工作区时,旧连接关闭,新连接再绑定目标工作区。收到事件后,useRealtimeSync 直接修补或失效 React Query cache,服务端数据没有再复制到另一套客户端状态中。
daemon 侧使用机器级控制连接。URL 携带当前 runtime ID 集合,Header 同时发送客户端版本、操作系统和能力列表。服务端在升级连接前逐个检查 runtime 是否属于该 daemon,或当前用户能否访问对应工作区;连接建立后,授权范围固定到 ClientIdentity。
两条连接因此无法互相冒充:浏览器订阅工作区事件,daemon 操作经过授权的运行时。工作区广播可以针对 UI 高频更新优化,daemon 连接则可以围绕心跳、能力协商和 RPC 超时演进。
daemon 如何注册本地 Runtime
multica daemon start 启动后,大致经历以下阶段:
- 从 CLI 配置读取服务端地址和登录凭证。
- 同步当前用户可用的工作区,以及各工作区的自定义 runtime profile。
- 探测
PATH和本地配置中的 Agent CLI,并读取版本。 - 按“工作区 × Provider × daemon”向
/api/daemon/register注册运行时。 - 启动健康检查、工作区同步、WebSocket 控制连接、心跳、任务领取、GC 和自动更新循环。
一个 daemon 可以注册多个 runtime。假设一台 Mac 同时安装 Codex 和 Claude Code,并观察两个工作区,服务端会看到四个调度目标:
daemon: MacBook
├── 工作区 A × Codex
├── 工作区 A × Claude Code
├── 工作区 B × Codex
└── 工作区 B × Claude Code
runtime 是服务端的调度目标,daemon 是本机常驻进程,Provider CLI 是最终执行任务的子进程。这一层拆分让同一台机器可以提供多种 Agent 能力,也让 Agent 配置始终绑定一个明确的执行目标。
从网页操作到本机进程
以用户在 Web 中把 issue 分配给 Agent 为例,完整链路如下:
Web
│ 1. HTTP:分配 issue / 发送 chat 消息
▼
Server + PostgreSQL
│ 2. 写入 agent_task_queue,状态 queued
│ 3. 向 Web 广播 task:queued
│ 4. 向 daemon 发送 daemon:task_available
▼
daemon 控制连接
│ 5. 唤醒本机 batch poller
│ 6. WS RPC tasks.claim;不可用时回退 HTTP
▼
Server
│ 7. 原子领取任务,返回上下文和 task token
▼
daemon
│ 8. 准备工作目录、skill、MCP 与 Provider 配置
│ 9. 标记 running,启动 Agent CLI 子进程
│ 10. 通过 HTTP 批量回传消息、用量和终态
▼
Server + PostgreSQL
│ 11. 脱敏、持久化,再向 Web 广播 task:* 事件
▼
Web React Query cache
这条链路中有三个关键的正确性设计。
数据库先于 WebSocket 唤醒
服务端先创建 agent_task_queue,再广播 task:queued,最后调用 daemon wakeup。这个顺序保证 Web 不会先看到 task:dispatch,之后才补上 task:queued。
daemon:task_available 只是一条“可能有工作”的提示。任务本体保存在 PostgreSQL,daemon 收到提示后仍需向服务端 claim。数据库保存事实,WebSocket 负责降低等待延迟;即使唤醒帧丢失,任务仍留在队列中。
领取复用 WebSocket,同时保留 HTTP
daemon 在 heartbeat ack 中看到服务端声明 rpc-v1 后,会优先通过控制连接发送 tasks.claim:
{
"type": "daemon:rpc_request",
"payload": {
"request_id": "...",
"method": "tasks.claim",
"timeout_ms": 5000,
"body": {
"daemon_id": "...",
"runtime_ids": ["..."],
"max_tasks": 20
}
}
}
服务端没有为 WS RPC 另写一套任务领取逻辑,而是在进程内构造 HTTP request,复用 ClaimTasksByRuntime handler。两种传输因此共享鉴权、校验、任务 payload 和状态转换。
当 WS 尚未连接、写缓冲区已满,或服务端不支持 rpc-v1 时,daemon 会调用 /api/daemon/tasks/claim。发送前已确定失败时可以立即回退;帧已经发出、连接却在响应前断开时,结果处于不确定状态。源码会等待服务端执行预算和响应宽限期,不立即补发 HTTP claim,避免同一个空闲 slot 重复领取两个任务。
WebSocket RPC 最难的部分由此显现:断线边界上的幂等性需要明确语义。
本地容量先于服务端领取
daemon 默认最多并发执行 20 个任务。batch poller 先从本地 semaphore 取得空闲 slot,再按 slot 数量向服务端 claim。任务进入 dispatched 时,本机已经预留执行容量,避免领取后才发现机器满载。
服务端返回的 task payload 包含 Agent 配置、issue 或 chat 上下文、项目资源、skill 引用、Provider、历史 session 和 workdir,以及本次执行专用的 mat_ task token。daemon 准备好目录后调用 /start,把状态从 dispatched 转为 running,随后通过 server/pkg/agent 的统一接口启动实际 CLI。
统一接口将不同 Provider 的输出收敛为文本、思考、工具调用、工具结果、状态、错误和日志等消息。上层 daemon 无需理解每一家 CLI 的 stdout 格式。
本地输出如何回到网页
Agent 子进程的消息会先回到服务端,再进入浏览器:
- daemon 消费 Provider
Session.Messages。 - daemon 批量调用
POST /api/daemon/tasks/{taskId}/messages。 - 服务端对文本、工具输入和工具输出执行敏感信息脱敏。
- 服务端将消息写入
task_message。 - 服务端向工作区实时 Hub 发布
task:message。 - Web 按
task_id + seq合并进 React Query cache,原地更新时间线。
任务完成和失败同样通过 HTTP 回传。服务端提交状态事务后广播 task:completed 或 task:failed,页面再让任务列表、Agent 状态、issue 执行记录和用量查询失效。
“先持久化、后广播”增加了一次服务端中转,也获得了三个能力:其他成员可以同时观看,断线后可以从数据库补齐历史,终态与审计不会依赖某一张打开的网页。最终形成的是一条可恢复的协作事件流。
WebSocket 承担控制,HTTP 承担回传
daemon WebSocket 当前复用了多种控制消息:
daemon:task_available:提示 runtime 可能有新任务daemon:heartbeat/heartbeat_ack:更新在线状态并领取待执行控制动作daemon:runtime_profiles_changed:刷新自定义运行时配置daemon:workspaces_changed:重新同步工作区集合daemon:rpc_request/rpc_response:当前用于批量 claim
注册 runtime、获取仓库和 skill bundle、上报消息和用量、开始或结束任务、查询取消状态等操作仍使用 HTTP。
WebSocket 适合服务端主动通知和低延迟控制,当前 claim 响应也会携带一批完整任务上下文。HTTP 更容易提供清晰的超时、状态码、重试和幂等语义,因此持续产生的执行消息和终态继续由它承载。Multica 只把对延迟敏感的控制路径放入长连接,没有为了全双工而改写全部接口。
断线后的恢复路径
实时系统不能把 WebSocket 当作可靠消息队列。Multica 在多个层次保留了恢复能力。
唤醒丢失后继续轮询
daemon 的默认领取间隔为 30 秒。正常情况下,task_available 会立即唤醒 poller;连接不可用或事件丢失时,下一次周期领取仍能看到 PostgreSQL 中的 queued task。
WS 心跳失败后恢复 HTTP 心跳
daemon 默认每 15 秒为每个 runtime 发送一次 WS heartbeat。收到新鲜 ack 时,同周期 HTTP heartbeat 会跳过;连续没有 WS ack 时,新鲜度窗口过期,HTTP 路径自动恢复。
服务端当前使用 150 秒 stale threshold 和 30 秒 sweeper 周期判定离线,最坏约 3 分钟收敛。这个窗口需要容纳热 liveness store 批量刷新数据库的延迟。
UI 从权威状态恢复
WebSocket 客户端使用指数退避和 jitter 无限重连。页面的权威数据仍来自 HTTP 查询与 React Query,task_message 也已持久化。重连后页面重新取得当前服务端状态,无需依赖完整事件回放。
取消仍使用短轮询
任务启动由 WebSocket 唤醒驱动,运行中取消则由每个 task 默认每 5 秒查询一次服务端状态。用户取消、重新分配,或 sweeper 将任务置为终态后,本地 watcher 会取消 Agent context。
启动追求低延迟,取消采用短轮询保持简单且易恢复,代价是终止本地进程最多存在一个轮询周期的延迟。未来即使把 cancellation 放入 daemon WebSocket,轮询仍适合作为断线兜底。
三层凭证划分权限
这条链路至少涉及三层凭证:
| 主体 | 常见凭证 | 权限范围 |
|---|---|---|
| Web | JWT HttpOnly Cookie | 当前用户,再由 workspace membership 授权 |
| 本地 daemon | 默认读取 CLI 保存的 mul_ PAT;中间件也支持 mdt_ daemon token 和 mcn_ Cloud Node PAT |
用户可访问的多个工作区,或机器凭证绑定的范围 |
| 单次 Agent 子进程 | claim 时签发的 mat_ task token |
绑定 agent、task 和 workspace 的短期身份 |
最后一层尤其关键。daemon 启动 Agent CLI 时不会把自己的 PAT 直接交给子进程,而是注入本次任务的 mat_ token,以及 MULTICA_TASK_ID、MULTICA_AGENT_ID、MULTICA_WORKSPACE_ID 等上下文。任务完成或失败后,服务端尽快删除对应 token,自然过期作为兜底。
“本地执行”描述的是计算位置与本机能力授权,其边界还包括:
- 服务端没有本机文件系统的任意读取能力,也不能直接启动本机进程。
- Provider 登录状态、Git 凭证和本地工具链可以留在机器上。
- daemon 主动回传的消息、工具输入与输出、最终结果和用量会进入服务端。
- 配置在服务端 Agent custom env 中的 secret 会随 task payload 下发给 daemon,不能视为仅在本地保存。
因此,本地 Runtime 不等于端到端加密或零知识执行。
从源码看到的架构取舍
第一,服务端同时承担 broker 和事实来源。网页无需发现本机,也不受 NAT 限制,还能支持多成员共同观察;网络中断时,Web 无法充当本地离线控制台。
第二,WebSocket 的第一职责是让系统从“等待下次轮询”切换为“现在检查”。任务可靠性仍来自数据库队列、原子 claim 和状态机,而非某一个瞬时帧。
第三,两套 WebSocket 隔离了人和机器的协议面。代价是服务端需要维护两套 Hub、两套鉴权和跨节点 relay。
第四,同一个领取动作保留 WS 与 HTTP 两种传输。复用 handler 降低了业务语义漂移,但系统仍需区分“确定未发出”和“可能已经提交”。tasks.claim 对不确定结果的处理,是整套架构中细致且必要的正确性设计。
第五,本机进程权限与 SaaS 用户权限被拆开。task token 避免 Agent 继承完整用户身份;默认 daemon 仍使用 CLI PAT 支持多工作区,机器凭证最小权限与多工作区便利之间仍有收紧空间。
第六,取消路径尚未完全事件化。5 秒轮询直接、易恢复,却会让常驻长任务持续产生查询。将来可以增加 WebSocket cancellation,同时保留轮询兜底。
为什么 Agent 产品需要这套结构
传统 SaaS 客户端主要向服务端提交表单。Agent 产品还需要使用用户机器上的代码、凭证、编译器、浏览器、GPU 和企业内网资源。把这些能力全部搬进云端成本很高,也很难复刻每个人真实的开发环境。
Multica 把 Web 作为协作与控制界面,把 daemon 作为本地执行边界,再用 Provider adapter 统一不同 Agent CLI。于是,人可以在 Web 中分配 issue、补充上下文、观察进度和审阅结果;服务端统一处理成员权限、排队、并发、审计与多端同步;Agent 则继续使用已经存在于本机的仓库、工具链和 Provider 登录状态。
同一个工作区还可以连接多台机器和多种 Runtime,而无需改变 Web 的操作模型。网页最终把本地 Agent 组织成一个可授权、可排队、可观察、可恢复的团队执行单元。
总结
Multica 没有让浏览器连接 localhost,也没有把 daemon WebSocket 扩展成任意隧道。Web 与 daemon 分别建立工作区实时连接和机器控制连接,服务端以 PostgreSQL 任务状态机把两端串联起来。
任务入队后,服务端先持久化并广播 UI 状态,再通过 WebSocket 唤醒 daemon。daemon 优先使用 tasks.claim RPC 领取任务,必要时回退 HTTP,并始终保留 30 秒轮询兜底。Agent 在本机子进程中执行,消息经 HTTP 脱敏和持久化,再由另一条 WebSocket 更新网页。
这套架构把低延迟交给 WebSocket,把可靠性交给数据库与 HTTP,把本机权限交给 daemon,把最终授权留在服务端。它比“网页调用一条本地命令”的演示链路复杂许多,但这些边界也让本地 Agent 有机会长期运行,并进入多人协作流程。