VINO/WANG返回博客 ←

BLOG / POST

从 Multica 源码看 Web 如何连接本地 Agent Runtime

沿着 Multica 的 WebSocket、daemon、任务队列与本地子进程源码,拆解网页如何调度 NAT 和浏览器沙箱之后的本地 Agent。

  • agent
  • websocket
  • runtime
  • go

浏览器不能直接启动 codexclaude,也不能任意读取用户的 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 启动后,大致经历以下阶段:

  1. 从 CLI 配置读取服务端地址和登录凭证。
  2. 同步当前用户可用的工作区,以及各工作区的自定义 runtime profile。
  3. 探测 PATH 和本地配置中的 Agent CLI,并读取版本。
  4. 按“工作区 × Provider × daemon”向 /api/daemon/register 注册运行时。
  5. 启动健康检查、工作区同步、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 子进程的消息会先回到服务端,再进入浏览器:

  1. daemon 消费 Provider Session.Messages
  2. daemon 批量调用 POST /api/daemon/tasks/{taskId}/messages
  3. 服务端对文本、工具输入和工具输出执行敏感信息脱敏。
  4. 服务端将消息写入 task_message
  5. 服务端向工作区实时 Hub 发布 task:message
  6. Web 按 task_id + seq 合并进 React Query cache,原地更新时间线。

任务完成和失败同样通过 HTTP 回传。服务端提交状态事务后广播 task:completedtask: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_IDMULTICA_AGENT_IDMULTICA_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 有机会长期运行,并进入多人协作流程。

参考资料