如果只看界面,Multica 像一个可以把任务分给智能体的看板;如果只看后端,它又像一组带 WebSocket 的 Go API。真正把这些部分串起来的,是一条很具体的工作链路:人创建一项任务,服务器把这项工作排进队列,运行时所在机器上的守护进程领取它,守护进程再启动本机已经安装好的 agent CLI,最后把进度、评论和结果写回同一项任务。
这篇文章从这条链路出发,介绍 Multica 的模块边界和协作方式。代码以仓库当前实现为准(commit:ea94c7cd5bbce9c8e1f28c5fa049c47ee7651d02),示例中的路径都可以在源码中直接找到。
先看全局
Multica 是一个 monorepo,客户端和服务端各自有清晰的职责。先用一张纯文本结构图看清主要连接:
1 | Web(Next.js) Desktop(Electron) Mobile(Expo / React Native) |
可以把系统分成四层来理解:
- 应用层:Web、Desktop、Mobile 提供不同设备上的界面和交互。
- 平台层:
packages/core放 API 客户端、查询、认证、实时事件和共享类型;packages/ui放通用 UI;packages/views放不依赖具体路由框架的业务页面。 - 服务层:
server/负责认证、工作区权限、任务状态、集成和实时广播。 - 执行层:守护进程运行在用户自己的电脑或云主机上,真正启动 agent CLI 并管理代码目录。
这种划分解决了一个实际问题:服务器可以负责记录和协调,但不需要把用户的代码仓库搬到服务器;客户端可以共享业务逻辑,但不会把 Next.js 或 Electron 的 API 带进核心包。
目录地图
理解目录结构时,可以先把仓库看成“应用、共享包、服务端、运维工具”四个区域。顶层目录的关系大致如下:
1 | multica/ |
这个结构背后有一条明确的依赖方向。apps/web 和 apps/desktop 可以依赖 packages/views、packages/core、packages/ui;packages/views 只能依赖共享包,不能反过来依赖某个应用的路由或 Electron API;packages/core 保持平台无关,浏览器存储、桌面 IPC 和移动端安全存储分别由各应用注入。server 是独立的 Go 模块,不通过前端包共享实现,只通过 HTTP、WebSocket 和协议类型与客户端沟通。
进入具体目录后,也可以按同样的方式继续拆分。比如 packages/core/issues 放任务相关的查询、类型和 store,packages/core/realtime 放跨平台 WebSocket 客户端和事件同步,packages/views/issues 放任务看板和详情页;服务端则把对应职责分成 server/internal/handler、server/internal/service 和 server/pkg/db。前端目录按功能组织,后端目录按领域和基础设施组织,两边最终通过 API 边界汇合。
三个客户端
Web 端
Web 端是 apps/web 下的 Next.js App Router 应用。Next.js 负责页面路由、服务端布局和运行时配置,真正的工作区页面大多来自 packages/views。根布局把这些能力装配起来:
1 | // apps/web/app/layout.tsx |
WebProviders 再把 Web 平台的实现注入共享核心:
1 | // apps/web/components/web-providers.tsx |
这里的重点不是 Provider 数量,而是依赖方向。CoreProvider 只知道“有一个存储适配器、一个导航适配器和一个 API 地址”,不知道浏览器的 localStorage 细节,也不知道 Next.js 的路由对象。浏览器实现放在 apps/web/platform,因此同一套查询和业务页面可以被 Desktop 复用。
工作区身份由 URL 中的 slug 决定。apps/web/app/[workspaceSlug]/layout.tsx 会先用 workspaceBySlugOptions(workspaceSlug) 查询工作区,确认成员关系后调用 setCurrentWorkspace(workspaceSlug, workspace.id)。之后 API 客户端和 WebSocket 都会带上这个工作区上下文。这样,切换工作区首先是路由变化,然后才是数据和实时连接的切换。
Desktop 端
Desktop 使用 Electron,渲染进程仍然消费 packages/views 和 packages/core。它的路由由 React Router 管理,但应用不是只有一个页面:apps/desktop/src/renderer/src/stores/tab-store.ts 维护工作区标签页,routes.tsx 创建应用路由,tab-content.tsx 为当前标签页挂载 RouterProvider。
这解释了 Desktop 为什么需要单独的平台适配层。共享页面不能直接调用 react-router-dom,而是使用 NavigationAdapter;Desktop 的适配器再把导航转换为标签页切换、窗口打开或普通路由跳转。窗口外壳、IPC、系统托盘和本机 daemon 管理都留在 apps/desktop,不会渗入共享视图。
Mobile 端
Mobile 位于 apps/mobile,是独立的 Expo / React Native 客户端。它共享 @multica/core 中的平台无关类型和纯函数,但拥有自己的页面、状态、请求封装和国际化资源。根布局能看出它的装配顺序:
1 | // apps/mobile/app/_layout.tsx |
Mobile 没有复用 Web 的页面组件,因为 React Native 的生命周期和网络条件不同。比如 apps/mobile/data/realtime/ws-client.ts 会在后台暂停连接、回到前台时重新连接,并使用带抖动的退避;这比简单照搬浏览器定时重连更适合 iOS 被挂起、网络在 Wi-Fi 和蜂窝网络之间切换的情况。
Go 服务端
整体结构
服务端不是一个把所有逻辑都放进 HTTP Handler 的单体文件,而是按“启动入口、业务内部模块、协议与数据包”分成几层。核心目录可以这样看:
1 | server/ |
这里的分层对应不同的变化速度。cmd/server/main.go 关心“服务怎样启动和停止”,不应该承载某个任务的业务规则;internal/handler 关心 HTTP 如何进出,internal/service 关心一次业务操作需要哪些读取、写入和事件;pkg/protocol 和数据库生成代码则把跨模块使用的结构固定下来。
服务启动时,main.go 会创建 PostgreSQL 连接池、Redis 客户端(如果配置了 REDIS_URL)、实时 Hub、事件 Bus 和 daemon Hub,然后把这些依赖传给 NewRouterWithOptions。路由构造完成后,主程序还会启动 runtime sweeper、心跳调度器、Webhook worker 和数据库调度器等后台组件。HTTP 请求和后台 worker 共用同一组服务对象,因此任务从 API 创建、被 daemon 领取到最终完成,使用的是同一套状态转换逻辑。
从一次请求的角度看,调用顺序通常是:
- Chi 路由匹配路径,并运行全局中间件。
- 认证和工作区中间件确认调用者身份及资源范围。
- Handler 解析 JSON、路径参数和查询参数,组装业务输入。
- Service 执行跨表校验、事务和状态转换,并通过 sqlc 查询数据库。
- Service 发布领域事件;实时层、通知和活动记录监听这些事件。
- Handler 把服务结果编码成 API 响应。
server/internal/handler、server/internal/service 和 server/pkg/db 之间的关系,是阅读服务端时最值得先建立的地图。Handler 解决“请求怎么进来”,Service 解决“业务应该怎么变”,数据库查询解决“事实怎样保存”;实时 Hub 和事件 Bus 则把已经提交的变化传播出去。
路由层
服务端入口在 server/cmd/server。main.go 负责读取配置、创建数据库连接池、启动 Hub 和后台工作协程;真正的 HTTP 路由集中在 router.go 的 NewRouterWithOptions。
初始化顺序很有代表性:
1 | // server/cmd/server/router.go |
handler.Handler 持有查询对象、服务对象和各种基础设施适配器;Chi 路由只负责把请求交给正确的入口,并在入口前套上认证、CORS、工作区成员校验等中间件。路由文件中可以看到几类不同边界:
/api/workspaces、/api/issues、/api/agents等是登录用户使用的业务 API。/api/daemon是守护进程 API,使用 daemon token 或用户令牌认证,包含注册、心跳、领取运行、上报进度和完成运行。/ws是面向客户端的实时连接。/api/webhooks/*、插件 API 和聊天渠道回调各自使用 URL token、签名或专用 bearer token,不和浏览器会话混用。
这种分组让“谁可以调用”和“调用后能访问哪个工作区”在路由边界上就能看见,而不必把所有判断散落在业务函数里。
业务服务层
Multica 的 Handler 更像传输层适配器:读取 JSON、解析路径参数、做请求级权限判断,然后把已经验证的输入交给服务层。以任务为例,server/internal/service/issue.go 中的 IssueService 明确声明自己不依赖 http.Request:
1 | // IssueService is the single service-layer entry point for creating issues. |
这样,Web API、Lark 命令、自动化或未来的 CLI 入口可以共用一套创建逻辑。重复检查、编号、附件关联、广播和自动排队不会因为入口不同而产生不同结果。
数据层
数据库层
服务器使用 pgxpool 连接 PostgreSQL,SQL 以 server/pkg/db/queries/*.sql 的形式保存,sqlc 生成 server/pkg/db/generated 中的类型安全 Go 代码。业务代码通过 db.Queries 调用生成方法,而不是在 Handler 中拼接 SQL。
工作区隔离会落实到查询条件中。例如任务列表查询会同时限制 workspace_id 和请求过滤条件:
1 | -- server/pkg/db/queries/issue.sql |
workspace_id 不是 UI 约定,而是数据库查询的基本边界。成员关系由中间件和服务层共同校验,查询本身仍然保留工作区条件,避免一个资源 ID 被跨工作区使用。
客户端数据管理
前端同时面对两种状态:一种来自服务器,另一种只存在于当前设备。任务、评论和运行时由服务端创建并保存,浏览器只是读取它们;筛选条件、编辑器草稿和弹窗状态则由用户在当前页面产生。Multica 使用两个定位不同的库来处理这两类状态:TanStack Query 管服务器数据,Zustand 管客户端状态。
TanStack Query 的背景。 TanStack Query 的前身是 React Query,后来扩展为 TanStack 项目的一部分。它解决的是前端经常遇到的“异步服务器状态”问题:如何请求数据、缓存结果、共享请求、显示加载和错误、判断数据是否过期,以及在更新后重新获取。它不是一个通用的全局变量容器,而是围绕 query、mutation 和 QueryClient 组织数据访问。核心实现与框架适配层分开,Multica 使用的是它的 React 适配包 @tanstack/react-query。
项目在 packages/core/query-client.ts 创建共享的 QueryClient,并明确设置了查询缓存策略:
1 | // packages/core/query-client.ts |
这里的 staleTime: Infinity 并不是说数据永远不变,而是把“什么时候需要重新获取”的决定交给项目自己的 mutation 和 WebSocket 同步逻辑。任务被修改后,实时事件可以更新对应缓存或让列表查询失效;网络重新连通时,Query 再按配置发起请求。任务页面因此不需要自己维护一套“请求中、上次结果、是否需要刷新”的状态。
Zustand 的背景。 Zustand 是一个轻量的 JavaScript / TypeScript 状态管理库,名字来自德语“状态”。它不要求组件树包裹一个大型 Provider,通常通过 create 创建一个 store,再由 React 组件用 selector 读取需要的字段。store 可以包含状态和修改状态的函数,也可以通过 middleware 实现持久化等能力。它适合保存交互过程中产生的本地状态,但不会替代服务器请求、权限检查或数据库。
例如收件箱筛选器就是一个 Zustand store:
1 | // packages/core/inbox/filter-store.ts |
筛选器按工作区保存,是因为它描述的是用户在当前客户端的查看偏好,而不是服务器上的收件箱实体。
因此,Multica 把任务、评论、智能体、运行时和收件箱结果交给 TanStack Query,把筛选器、草稿、弹窗、标签页布局和工作区指针交给 Zustand。这个分工避免把同一份服务器对象复制到多个 store,也避免把用户尚未保存的草稿当成服务器已经接受的内容。按照仓库的状态规则,普通 Zustand store 不直接调用 api.*;服务器交互由 query 或 mutation 负责,认证和工作区等基础 store 才有直接访问 API 的职责。
WebSocket 到来时,实时同步层会根据事件类型更新或失效 Query 缓存。WebSocket 只负责告诉客户端“哪里发生了变化”,TanStack Query 负责维护页面使用的服务器数据,Zustand 则继续保存用户当前的操作状态。
API 响应在边界处经过 Zod schema 和 parseWithFallback,网络上的 snake_case 也在 API 客户端转换成内部使用的 camelCase。这层看似多了一步,却能把后端升级时的缺字段、未知枚举和旧版本客户端问题限制在边界内。
实时消息
实时连接
Multica 有两种容易混淆、但职责不同的 WebSocket:
/ws连接浏览器、Desktop 和 Mobile,用来推送任务、评论、通知、运行状态等工作区事件。/api/daemon/ws连接守护进程,用来唤醒运行时、传递领取提示和处理 daemon RPC。
实时连接需要先确定“订阅哪个工作区的消息”。客户端因此会在握手 URL 中带上工作区的 slug,也就是用于网址的可读标识。例如,任务页面地址是 /acme/issues,其中 acme 就是工作区 slug,对应的连接地址可以是 wss://example.com/ws?workspace_slug=acme。它通常比一长串 UUID 更容易辨认,但不等同于工作区显示名称;服务端会用它查出数据库中的工作区 UUID,再用 UUID 处理后续的数据访问和消息分发。
指定 slug 只是在选择工作区,并不代表有权访问。Cookie 模式下,服务端会在把 HTTP 连接升级为 WebSocket 之前完成认证和成员校验;token 模式下,则在升级后读取第一条 auth 消息,验证令牌和成员关系。两种方式都必须确认用户属于目标工作区,才会允许订阅消息。下面是解析 slug 的源码摘录:
1 | // server/internal/realtime/hub.go |
业务代码不会直接调用每个客户端。它发布的是领域事件,server/internal/events/bus.go 的 Bus 在进程内同步分发事件;事件监听器再把消息交给实时 Hub、通知系统、活动记录或分析模块。Event 同时携带 WorkspaceID、ActorType、ActorID 和可选的任务或聊天会话作用域,实时层因此能把消息发到正确的房间。
如果只启动一个 Go 服务端进程,所有客户端的 WebSocket 都连接到它。这个进程里的 Hub 就是连接的管理者:它记住哪些连接属于哪个工作区,收到“任务已更新”这样的消息后,把消息发送给订阅该工作区的客户端。这里的“内存中广播”指的是用进程内保存的连接信息分发消息,不需要另一个服务帮忙转发。
当部署两份 Go 服务端时,情况就不同了。假设小王的浏览器连接到实例 A,小李的浏览器连接到实例 B,两人正在看同一个工作区。小王修改了一项任务,请求由 A 处理,A 的 Hub 可以通知小王,却无法直接通知小李:小李的 WebSocket 连接由 B 管理,A 的内存里没有这条连接。两个实例共享数据库,也不意味着它们会自动共享实时消息。
Redis 在这里充当实例之间的消息中转站。配置好 REDIS_URL 后,服务端会启用 Redis relay,也就是把消息写入 Redis、再从 Redis 读取并转发的组件。上述更新会沿着这条路径传播:
- A 保存任务修改,然后通过自己的 Hub 通知连接到 A 的相关客户端。
- A 同时把更新事件写入 Redis,供其他实例读取。
- B 读到事件后,按工作区或资源的订阅范围筛选自己的连接,再通过自己的 Hub 通知小李。
源码中的 DualWriteBroadcaster(server/internal/realtime/redis_relay.go)正是先分发给本实例,再写入 Redis,因此本实例的客户端不用等待消息绕一圈。每条消息还带有事件 ID;消息经 Redis 回到原实例时,Hub 会据此去重,避免同一个客户端收到两次。
最后要区分“通知发生了变化”和“保存变化后的数据”。任务内容、评论、运行状态和结果保存到 PostgreSQL;Redis relay 传递的是让各实例及时通知客户端的消息。当前默认的 relay 使用 Redis Streams,并保留有限时间的消息用于实例恢复时补读,但它不承担完整业务历史的存储。客户端重新查询任务时,读到的仍然是 PostgreSQL 中的业务数据。Redis 还用于其他缓存和协调功能,那是它在实时转发之外的职责。
一次运行的生命周期
任务入队
创建任务时,服务端先在事务里写入任务及其关联数据。如果任务负责人是智能体,IssueService 会调用 TaskService.EnqueueTaskForIssue,写入 agent_task_queue。队列行保存的是一次运行的状态、目标智能体和关联任务,而不是把所有上下文都塞进消息里;守护进程领取后再通过 CLI 获取所需数据。
入队和唤醒有固定顺序,源码注释把它写得很清楚:先广播“已排队”,再通知 daemon,避免客户端先收到“开始运行”却还没看到“已排队”。
1 | // server/internal/service/task.go |
NotifyTaskEnqueued 在单机模式下唤醒进程内的 daemon Hub;多实例模式下由 Redis relay 传播。唤醒只是提示,真正的任务仍然要通过带权限和状态条件的领取接口从数据库取得。
任务执行
守护进程是安装在用户机器上的 Multica CLI 进程。它注册自己所在的 daemon 和 runtime,持续发送心跳,并通过 /api/daemon 或 daemon WebSocket 领取运行。领取成功后,本地代码会准备工作目录、注入任务上下文,再选择对应的 provider backend。
这里有一个重要的边界:Multica 不内置 Claude 或 Codex 模型。daemon 只负责按照 runtime 配置找到本机可执行的 CLI,随后由 server/pkg/agent 统一处理不同 CLI 的启动、输出收集、工具事件和错误分类。新增 provider 通常意味着增加一个 runtime 描述或 backend,而不需要改变任务队列的基本协议。
运行过程中的状态回写也走 daemon API:
start把领取到的队列项变成运行中。progress和messages写入过程信息。usage记录 token 和费用统计。complete或fail以终态事务结束这次运行。
完成接口由 TaskService.CompleteTaskWithTransition 处理,并使用数据库事务完成状态切换。对聊天运行来说,运行完成和会话 resume 指针也在同一事务中更新,避免下一条消息在中间窗口里读到旧会话。
1 | // server/internal/service/task.go |
服务端不会因为收到一次重复回调就重复发布终态副作用。CompleteTaskWithTransition 会告诉调用方这次请求是否真正赢得了 running -> completed 的状态转换;已经结束的重复请求可以安全地返回幂等结果。
后台工作和可靠性
这里需要区分两种生命周期:HTTP 请求的生命周期和一次 agent 运行的生命周期。前者通常只有几百毫秒到几秒,负责提交一个动作并返回结果;后者可能持续几分钟甚至更久,实际运行在 daemon 所在的机器上。服务端不会让创建任务的那个请求一直等待 agent 完成。
一次任务大致经过下面几个彼此独立的阶段:
- 用户创建任务或把智能体设为负责人。这个请求进入 API,服务层写入任务和
agent_task_queue,发送任务已入队事件,然后返回 HTTP 响应。到这里,请求已经结束,浏览器收到的是“任务已创建/已排队”,而不是 agent 的最终输出。 - daemon 通过 daemon WebSocket 的唤醒消息,或下一次
/api/daemon/tasks/claim请求发现队列里有工作。领取请求只负责把队列项变成已领取状态并返回任务数据,返回后这次请求也结束。 - daemon 在本机启动 agent CLI,独立执行代码。执行期间,daemon 按需发起
start、progress、messages和usage等新的 HTTP 请求,把阶段性信息写回服务端。服务端不需要占用最初的连接来等待这些请求。 - CLI 退出后,daemon 再发起一次
complete或fail请求。服务端在事务中完成终态转换并广播结果,客户端通过 WebSocket 收到变化,或者在下一次查询时读到更新后的数据。
可以把它想象成“寄存器 + 取件”:创建任务的请求只是把工作登记到 PostgreSQL,daemon 稍后取件并执行;执行结果通过后续请求交付。即使创建请求返回后浏览器关闭,队列中的任务仍然存在,daemon 仍可领取;即使某一次进度上报因为网络中断失败,运行本身也不会因为那条 HTTP 连接结束而被强制终止。
服务端还会在 HTTP 请求之外启动自己的后台组件。server/cmd/server/main.go 在启动 HTTP 服务的同时启动:
- runtime liveness sweeper:根据心跳把失联运行时标记为离线,并处理它持有的运行。
- heartbeat scheduler:批量刷新运行时心跳,减少高频数据库写入。
- webhook、聊天渠道和附件相关 worker。
- DB-backed scheduler:执行用量汇总、搜索索引清理、Issue 唤醒、自动化调度和插件定时任务。
这些组件有自己的循环和可取消的 context,不依赖某个用户请求的 context。比如 sweeper 会按固定间隔检查心跳,发现 runtime 长时间没有更新就把它标记为离线,并处理仍挂在该 runtime 上的运行;数据库调度器则根据持久化的计划执行周期任务。服务器收到停止信号时,再统一取消这些 context,等待 worker 完成清理后退出。
因此,HTTP 层只负责短暂的命令和数据交换,任务状态由 PostgreSQL 保存,daemon 负责长时间执行,后台 worker 负责监控和补偿。网络抖动影响的是某一次上报或唤醒,已经提交到数据库的队列和终态记录仍然存在;daemon 重连后可以继续领取、补报或让服务端根据持久状态恢复处理。
小结
Multica 的核心并不是某一个页面或某一个 agent CLI,而是把“协作记录”和“本地执行”连接起来的一条链路。Web、Desktop 和 Mobile 负责呈现工作区、接收用户操作;共享包负责跨客户端复用 API、查询、认证、实时同步和业务页面;Go 服务端负责身份验证、工作区权限、事务和任务调度。
在这条链路中,PostgreSQL 保存任务、运行和评论等业务事实,TanStack Query 管理客户端看到的服务器数据,Zustand 保存筛选器、草稿和布局等本地状态。WebSocket 负责把变化及时推送到在线客户端,多实例部署时再通过 Redis relay 把消息送到其他服务端实例。Redis 解决的是转发和协调问题,业务结果仍以 PostgreSQL 为准。
当任务被分配给智能体后,服务端把一次运行写入 agent_task_queue,daemon 从队列领取任务,在用户自己的机器上启动已经安装的 CLI,并把开始、进度、用量、完成或失败状态回传。这样,服务器可以统一管理权限和审计,代码执行则保留在用户控制的运行时中。
理解这几个边界后,项目中的目录和组件就能对应起来:客户端负责交互,服务端负责协调,数据库负责持久化,实时层负责传播变化,daemon 负责连接平台与本地工具。Multica 支持多端界面和多种 agent CLI,也正是因为这些职责被拆开后,通过稳定的 API、WebSocket 和任务协议重新组合在了一起。