0%

Multica 学习 02:整体认识 Multica

如果只看界面,Multica 像一个可以把任务分给智能体的看板;如果只看后端,它又像一组带 WebSocket 的 Go API。真正把这些部分串起来的,是一条很具体的工作链路:人创建一项任务,服务器把这项工作排进队列,运行时所在机器上的守护进程领取它,守护进程再启动本机已经安装好的 agent CLI,最后把进度、评论和结果写回同一项任务。

这篇文章从这条链路出发,介绍 Multica 的模块边界和协作方式。代码以仓库当前实现为准(commit:ea94c7cd5bbce9c8e1f28c5fa049c47ee7651d02),示例中的路径都可以在源码中直接找到。

先看全局

Multica 是一个 monorepo,客户端和服务端各自有清晰的职责。先用一张纯文本结构图看清主要连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Web(Next.js)       Desktop(Electron)       Mobile(Expo / React Native)
│ │ │
└─────────────── HTTPS / WebSocket ──────────────┘
│
Go API(Chi)
┌──────────┼──────────┐
│ │ │
PostgreSQL Realtime Hub Redis
│ (/ws) (多实例转发)
│
agent_task_queue
│
本地 Daemon
│
Claude Code / Codex / Cursor 等 CLI

可以把系统分成四层来理解:

  • 应用层:Web、Desktop、Mobile 提供不同设备上的界面和交互。
  • 平台层:packages/core 放 API 客户端、查询、认证、实时事件和共享类型;packages/ui 放通用 UI;packages/views 放不依赖具体路由框架的业务页面。
  • 服务层:server/ 负责认证、工作区权限、任务状态、集成和实时广播。
  • 执行层:守护进程运行在用户自己的电脑或云主机上,真正启动 agent CLI 并管理代码目录。

这种划分解决了一个实际问题:服务器可以负责记录和协调,但不需要把用户的代码仓库搬到服务器;客户端可以共享业务逻辑,但不会把 Next.js 或 Electron 的 API 带进核心包。

目录地图

理解目录结构时,可以先把仓库看成“应用、共享包、服务端、运维工具”四个区域。顶层目录的关系大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
multica/
├── apps/
│ ├── web/ # Next.js Web 应用和 Web 平台适配
│ ├── desktop/ # Electron 主进程、预加载脚本和渲染进程
│ ├── mobile/ # Expo / React Native 移动端
│ ├── docs/ # Fumadocs 文档站
│ └── ui-lab/ # UI 组件实验和可视化文档
├── packages/
│ ├── core/ # API、查询、认证、实时同步、共享类型和状态
│ ├── ui/ # 通用 UI 原语、样式和设计 token
│ ├── views/ # Web / Desktop 共用的业务页面与组件
│ ├── plugin-sdk/ # 插件开发使用的 SDK
│ └── ... # ESLint、TypeScript 等工程配置包
├── server/
│ ├── cmd/ # server、multica CLI、迁移工具等可执行程序
│ ├── internal/ # Handler、服务层、daemon、实时、集成和后台任务
│ ├── pkg/ # 可复用协议、agent、sqlc 生成代码和公共 API
│ └── migrations/ # PostgreSQL 数据库迁移
├── deploy/ # Docker、Helm 等部署配置
├── e2e/ # Playwright 端到端测试
├── scripts/ # 代码生成、构建和开发辅助脚本

这个结构背后有一条明确的依赖方向。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
2
3
4
5
6
7
8
9
10
11
12
// apps/web/app/layout.tsx
<ThemeProvider>
<WebProviders
locale={locale}
resources={resources}
apiBaseUrl={apiBaseUrl}
wsUrl={wsUrl}
>
{children}
</WebProviders>
<Toaster />
</ThemeProvider>

WebProviders 再把 Web 平台的实现注入共享核心:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// apps/web/components/web-providers.tsx
<CoreProvider
apiBaseUrl={apiBaseUrl}
wsUrl={wsUrl || deriveWsUrl()}
cookieAuth={cookieAuth}
identity={identity}
locale={locale}
resources={resources}
localeAdapter={localeAdapter}
>
<WebNavigationProvider>
<WebScrollRestorationProvider>{children}</WebScrollRestorationProvider>
</WebNavigationProvider>
</CoreProvider>

这里的重点不是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// apps/mobile/app/_layout.tsx
<MobileI18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider value={NAV_THEME[colorScheme]}>
<AuthInitializer>
<SessionActivityBoundary>
<Stack screenOptions={{ headerShown: false }} />
</SessionActivityBoundary>
</AuthInitializer>
</ThemeProvider>
</QueryClientProvider>
</KeyboardProvider>
</SafeAreaProvider>
</MobileI18nProvider>

Mobile 没有复用 Web 的页面组件,因为 React Native 的生命周期和网络条件不同。比如 apps/mobile/data/realtime/ws-client.ts 会在后台暂停连接、回到前台时重新连接,并使用带抖动的退避;这比简单照搬浏览器定时重连更适合 iOS 被挂起、网络在 Wi-Fi 和蜂窝网络之间切换的情况。

Go 服务端

整体结构

服务端不是一个把所有逻辑都放进 HTTP Handler 的单体文件,而是按“启动入口、业务内部模块、协议与数据包”分成几层。核心目录可以这样看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server/
├── cmd/
│ ├── server/ # 启动 HTTP API、WebSocket 和后台 worker
│ ├── multica/ # multica CLI:登录、任务、daemon 等命令
│ └── migrate/ # 数据库迁移程序
├── internal/
│ ├── handler/ # HTTP 请求入口和响应编码
│ ├── service/ # Issue、Task、Agent、Chat 等业务流程
│ ├── middleware/ # 认证、工作区成员、限流和请求上下文
│ ├── realtime/ # 面向客户端的 WebSocket Hub 和 Redis relay
│ ├── daemonws/ # 面向 daemon 的 WebSocket Hub 与 RPC
│ ├── daemon/ # 本机运行时、CLI 探测和任务执行
│ ├── events/ # 进程内领域事件总线
│ ├── scheduler/ # 数据库支持的周期任务
│ ├── integrations/ # Git、Slack、Lark、Telegram 等外部集成
│ └── storage/ # 本地或 S3 附件存储
├── pkg/
│ ├── db/ # SQL、sqlc 生成的查询和数据库模型
│ ├── protocol/ # 客户端、daemon 与服务端共享的事件协议
│ ├── agent/ # 不同 agent CLI 的执行适配
│ └── publicapi/ # 对外公开 API 的版本化契约
└── migrations/ # PostgreSQL 表结构和索引的变更记录

这里的分层对应不同的变化速度。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 领取到最终完成,使用的是同一套状态转换逻辑。

从一次请求的角度看,调用顺序通常是:

  1. Chi 路由匹配路径,并运行全局中间件。
  2. 认证和工作区中间件确认调用者身份及资源范围。
  3. Handler 解析 JSON、路径参数和查询参数,组装业务输入。
  4. Service 执行跨表校验、事务和状态转换,并通过 sqlc 查询数据库。
  5. Service 发布领域事件;实时层、通知和活动记录监听这些事件。
  6. 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
2
3
4
5
6
7
8
9
10
11
12
13
// server/cmd/server/router.go
queries := db.New(pool)
h := handler.New(
queries, pool, hub, bus, emailSvc, store,
cfSigner, analyticsClient, signupConfig, daemonHub,
)

r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.ClientMetadata)
r.Use(middleware.RequestLogger)
r.Use(chimw.Recoverer)
r.Use(middleware.ContentSecurityPolicy)

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
2
3
4
5
6
7
8
9
// IssueService is the single service-layer entry point for creating issues.
// Both the HTTP handler and the future Lark /issue command call into Create.
// The service deliberately does NOT depend on http.Request.
type IssueService struct {
Queries *db.Queries
TxStarter TxStarter
Bus *events.Bus
TaskService *TaskService
}

这样,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
2
3
4
5
6
7
8
9
10
-- server/pkg/db/queries/issue.sql
SELECT i.id, i.workspace_id, i.title, i.status, i.priority,
i.assignee_type, i.assignee_id, i.project_id
FROM issue i
WHERE i.workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR i.status = sqlc.narg('status'))
AND (sqlc.narg('assignee_id')::uuid IS NULL
OR i.assignee_id = sqlc.narg('assignee_id'))
ORDER BY i.position ASC, i.created_at DESC
LIMIT $2 OFFSET $3;

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
2
3
4
5
6
7
8
9
10
11
12
13
// packages/core/query-client.ts
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: 10 * 60 * 1000,
refetchOnReconnect: true,
retry: 1,
},
},
});
}

这里的 staleTime: Infinity 并不是说数据永远不变,而是把“什么时候需要重新获取”的决定交给项目自己的 mutation 和 WebSocket 同步逻辑。任务被修改后,实时事件可以更新对应缓存或让列表查询失效;网络重新连通时,Query 再按配置发起请求。任务页面因此不需要自己维护一套“请求中、上次结果、是否需要刷新”的状态。

Zustand 的背景。 Zustand 是一个轻量的 JavaScript / TypeScript 状态管理库,名字来自德语“状态”。它不要求组件树包裹一个大型 Provider,通常通过 create 创建一个 store,再由 React 组件用 selector 读取需要的字段。store 可以包含状态和修改状态的函数,也可以通过 middleware 实现持久化等能力。它适合保存交互过程中产生的本地状态,但不会替代服务器请求、权限检查或数据库。

例如收件箱筛选器就是一个 Zustand store:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// packages/core/inbox/filter-store.ts
export const useInboxFilterStore = create<InboxFilterState>()((set) => ({
filtersByWorkspace: {},
toggleUnreadOnly: (wsId) =>
set((state) => {
const current = state.filtersByWorkspace[wsId] ?? EMPTY_INBOX_FILTERS;
return {
filtersByWorkspace: {
...state.filtersByWorkspace,
[wsId]: { ...current, unreadOnly: !current.unreadOnly },
},
};
}),
}));

筛选器按工作区保存,是因为它描述的是用户在当前客户端的查看偏好,而不是服务器上的收件箱实体。

因此,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// server/internal/realtime/hub.go
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
if slug := r.URL.Query().Get("workspace_slug"); slug != "" && resolveSlug != nil {
resolved, err := resolveSlug(r.Context(), slug)
if err != nil {
http.Error(w, `{"error":"workspace not found"}`, http.StatusNotFound)
return
}
workspaceID = resolved
}
}

// Cookie 模式或首帧 token 模式都必须经过成员校验。
if !mc.IsMember(r.Context(), userID, workspaceID) {
http.Error(w, `{"error":"not a member of this workspace"}`, http.StatusForbidden)
return
}

业务代码不会直接调用每个客户端。它发布的是领域事件,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
2
3
// server/internal/service/task.go
s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, task)
s.NotifyTaskEnqueued(ctx, task)

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
2
3
4
5
6
7
8
// server/internal/service/task.go
qtx.CompleteAgentTask(ctx, db.CompleteAgentTaskParams{
ID: taskID,
Result: result,
SessionID: pgtype.Text{String: sessionID, Valid: sessionID != ""},
WorkDir: pgtype.Text{String: workDir, Valid: workDir != ""},
BranchName: pgtype.Text{String: branchName, Valid: branchName != ""},
})

服务端不会因为收到一次重复回调就重复发布终态副作用。CompleteTaskWithTransition 会告诉调用方这次请求是否真正赢得了 running -> completed 的状态转换;已经结束的重复请求可以安全地返回幂等结果。

后台工作和可靠性

这里需要区分两种生命周期:HTTP 请求的生命周期和一次 agent 运行的生命周期。前者通常只有几百毫秒到几秒,负责提交一个动作并返回结果;后者可能持续几分钟甚至更久,实际运行在 daemon 所在的机器上。服务端不会让创建任务的那个请求一直等待 agent 完成。

一次任务大致经过下面几个彼此独立的阶段:

  1. 用户创建任务或把智能体设为负责人。这个请求进入 API,服务层写入任务和 agent_task_queue,发送任务已入队事件,然后返回 HTTP 响应。到这里,请求已经结束,浏览器收到的是“任务已创建/已排队”,而不是 agent 的最终输出。
  2. daemon 通过 daemon WebSocket 的唤醒消息,或下一次 /api/daemon/tasks/claim 请求发现队列里有工作。领取请求只负责把队列项变成已领取状态并返回任务数据,返回后这次请求也结束。
  3. daemon 在本机启动 agent CLI,独立执行代码。执行期间,daemon 按需发起 start、progress、messages 和 usage 等新的 HTTP 请求,把阶段性信息写回服务端。服务端不需要占用最初的连接来等待这些请求。
  4. 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 和任务协议重新组合在了一起。