把编程 Agent 变成真实队友
像给同事派活一样把 issue 指派给 Agent——它自己接活、写代码、报阻塞、改状态,并累积可复用的 skill。Web/Desktop 双端共享同一套 headless 业务包。所有结论来自源码文本,含实际函数签名与类型定义。
# multica-ai/multica · Go + Next.js + Electron · 改良版 Apache-2.0
分层架构
一句话定位:Multica 把编程 Agent 当成真实队友。后端 Go(Chi + pgx/sqlc + gorilla/websocket),Web 端 Next.js(App Router),桌面端 Electron(electron-vite + react-router-dom),移动端 Expo/RN,数据库 PostgreSQL(pgvector/pg17)。
从 PostgreSQL 到双端 app,依赖方向严格单向:views → core + ui,而 core 与 ui 彼此独立、互不导入。所有共享包以原始 .ts/.tsx 源码导出(无预编译),由消费端 app 的打包器直接编译——这就是 “Internal Packages” 模式,换取零配置 HMR 与即时跳转定义。Web 与 Desktop 通过 @multica/core、@multica/ui、@multica/views 三个共享包复用全部业务逻辑与 UI。
本文基于对 server/、apps/web/、apps/desktop/、packages/core|ui|views|eslint-config、CLAUDE.md、各 package.json、turbo.json、pnpm-workspace.yaml 的源码级阅读,所有引用均来自实际读到的文件。
┌──────────────────────────────────────────────────┐
│ PostgreSQL │
│ pgvector/pgvector:pg17 · 单容器多 DB(worktree) │
│ agent / agent_task_queue / skill / agent_skill / │
│ issue / runtime / chat_session / autopilot … │
└───────────────▲──────────────────────────────────┘
│ pgx + sqlc(生成代码 server/pkg/db/generated)
┌───────────────┴──────────────────────────────────┐
│ Go Backend (server/) │
│ Chi router · gorilla/websocket Hub · events.Bus │
│ internal/handler/* 内部 service/* pkg/protocol │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────┐ │
│ │ TaskService │ │ AgentTmpl / │ │ Skill / │ │
│ │ (派活/认领/ │ │ Autopilot / │ │ builtin_ │ │
│ │ 生命周期) │ │ Squad service │ │ skills │ │
│ └──────────────┘ └───────────────┘ └──────────┘ │
└───▲───────────────▲────────────────────▲──────────┘
HTTP+WS(/ws) │ │ daemon WS(/tasks/claim, /start…)
│ │ │ 内嵌 CLI(multica)
┌────────────┴──┐ ┌────────┴──────────┐ ┌─────┴──────────────┐
│ apps/web │ │ apps/desktop │ │ 本地 daemon / 云 runtime│
│ Next.js · SSR │ │ (Electron) │ │ 真正跑 Claude Code/Codex│
│ platform/nav │ │ react-router-dom │ │ 等 Agent 二进制 │
│ (next/nav) │ │ 多 tab memory 路由 │ └─────────────────────┘
└──────┬────────┘ └─────────┬─────────┘
│ 都 import 都 import │
▼ ▼
┌───────────────────────────────────────────────────────────┐
│ 共享包 (packages/, workspace:*) │
│ @multica/views — 业务页面(零 next/*、零 react-router、零 store)│
│ │ 依赖 │
│ ▼ │
│ @multica/core ←───────────┐ @multica/ui (纯原子组件) │
│ Headless 业务逻辑 │ 零 @multica/core 导入 │
│ 零 react-dom / 零 localStorage Base UI + Tailwind v4 │
│ · Zustand stores(客户端状态) │ styles/ 设计 token │
│ · TanStack Query(服务端状态)─┘ ← views 同时依赖 ui │
│ · platform/CoreProvider · api/ClientIdentity + zod │
└───────────────────────────────────────────────────────────┘
│ 仅 import type(纯类型 + 纯函数,零运行时耦合)
▼
┌───────────────────────────┐
│ apps/mobile (Expo / RN) │ 独立:自有 React 版本、UI、状态、构建链
└───────────────────────────┘
严格单向
core 与 ui 互不导入,views 同时依赖二者;框架 API 只允许出现在各 app 的 platform/ 层。
源码直出
共享包不预编译,exports 直指 .ts/.tsx,消费端打包器编译——零配置 HMR + 即时跳定义。
同构多端
Web / Desktop / Mobile / CLI 四端 + Go 后端;mobile 与 core 之间仅 import type 纯类型耦合。
核心模块一览
以下基于对 server/ 与 packages/* 的逐文件阅读,把分层展开到「关键文件 + 关键代码段 + 设计 + 失败模式」的粒度。
| 模块 | 文件 / 目录 | 作用 |
|---|---|---|
| server/ 入口 | cmd/server · cmd/multica · cmd/migrate | 三个入口二进制:HTTP/WS 服务、multica CLI、迁移工具(module github.com/multica-ai/multica/server,Go 1.26.1) |
| server/ handler | internal/handler/*.go | Chi HTTP handler,按资源拆分(issue.go/agent.go/chat.go/daemon.go/daemon_ws.go…),统一 UUID 解析三态约定 |
| server/ task | internal/service/task.go | TaskService——派活、认领、生命周期推进、状态对账、指标的核心(2362 行) |
| server/ builtin | internal/service/builtin_skills/* | 随产品发给 Agent 的内置 skill(multica-working-on-issues、multica-squads、multica-autopilots),是“源码可追溯的契约” |
| server/ skill | internal/skill/frontmatter.go · reserved.go | SKILL.md frontmatter 解析(与前端 core/skills/frontmatter.ts 同构)、保留 slug |
| server/ tmpl | internal/agenttmpl/templates | Agent 模板(静态目录,工作区无关,前端缓存 staleTime: Infinity) |
| server/ realtime | internal/realtime · internal/events | Hub——WS 广播;Bus——进程内事件总线 |
| server/ runtime | internal/daemon · daemonws · cloudruntime | 本地 daemon 与云 runtime:真正执行 Agent 的 execenv/repocache |
| server/ db | pkg/db/queries/*.sql + pkg/db/generated | sqlc:手写 SQL → 生成类型安全 Go。改 SQL 后 make sqlc 重生成 |
| server/ protocol | pkg/protocol | WS/任务事件载荷类型(EventTaskQueued/Dispatch/Running/Completed/Failed、TaskCompletedPayload) |
| apps/web | app/[workspaceSlug]/(dashboard)/… · platform/navigation.tsx | 工作区作用域路由 + 路由组;Web 端唯一允许用 next/navigation 的地方,适配成共享 NavigationAdapter |
| apps/desktop | src/main · src/renderer/src/platform/navigation.tsx | Electron 主进程/preload 桥;Desktop 端唯一用 react-router-dom 的地方,多 tab + memory router;构建把 multica CLI 打进 app |
| packages/core | api/ · platform/ · <domain>/queries|mutations|stores | Headless 业务逻辑:零 react-dom/零 localStorage/零 process.env;TanStack Query + Zustand + WS 同步;react 仅为 peer |
| packages/ui | components/ui/* · styles/tokens.css | 原子 UI:shadcn 基于 @base-ui/react(非 Radix);没有 @multica/core,强制“纯 UI、零业务逻辑” |
| packages/views | issues/agents/inbox/squads/chat · navigation/* | 共享业务页面;定义 NavigationAdapter 抽象、<AppLink>;exports 与 deps 里没有任何 next/react-router-dom |
关键设计 · UUID 解析三态约定
源于真实事故 #1661(DELETE 返回 204 却匹配 0 行,因原 util.ParseUUID 对非法输入静默返回零 UUID)。CLAUDE.md 的 Backend Handler UUID Parsing Convention 把它定为硬约定:
- ① 资源路径参数(可能是
MUL-123也可能是 UUID)必须经loadIssueForUser/loadAgentForUser等 loader 解析,之后所有写查询用解析出的entity.ID; - ② 纯 UUID 的请求边界输入用
parseUUIDOrBadRequest,非法即写 400; - ③ 可信往返(sqlc 返回的 UUID)用
parseUUID(panic-on-invalid,由middleware.Recoverer兜成 500)。
Headless core
Zustand(客户端状态)+ React Query(服务端状态)严格分离。CLAUDE.md 把这条定为“架构保持连贯的根本约束”:TanStack Query 拥有全部服务端状态,Zustand 拥有全部客户端状态,二者绝不混用。
服务端状态用 queryOptions 描述,键以 wsId 开头——工作区切换即换 key、自动出对数据,无需手动失效。
export const agentTaskSnapshotKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-task-snapshot"] as const,
list: (wsId: string) => [...agentTaskSnapshotKeys.all(wsId), "list"] as const,
};
export function agentTaskSnapshotOptions(wsId: string) {
return queryOptions({
queryKey: agentTaskSnapshotKeys.list(wsId),
queryFn: () => api.getAgentTaskSnapshot(),
staleTime: 30 * 1000, // 仅作 fallback;主刷新信号是 WS 任务事件
refetchOnWindowFocus: true,
});
}
客户端状态——Zustand store 一律放在 packages/core(即便是视图相关的 filter/viewMode 也算纯状态),并用工作区感知的 persist;WS 事件只做失效、绝不直接写 store(保持 Query 缓存为唯一真相,避免竞态)。一个任务生命周期事件会把一组与 Agent presence 相关的 query 全部置 stale。
task: () => {
const wsId = getCurrentWsId();
if (!wsId) return;
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
qc.invalidateQueries({ queryKey: ["issues", "tasks"] }); // 前缀匹配所有 issue
}
// task:message 故意不走该前缀——长任务每条流式消息都来一次会引发失效风暴。
关键设计:Agent 的 presence(在线性 AgentAvailability × 负载 Workload)是纯前端派生(agents/types.ts、derive-presence.ts)——后端只提供 agent + runtime + 最近任务快照,前端从同一份 agentTaskSnapshot 缓存推导所有列表小圆点/悬浮卡,零额外网络。
- Zustand 无限重渲染:selector 返回新对象/数组(
s => ({a,b}))每次都不同——必须选基本量或用 shallow 比较(CLAUDE.mdCommon Zustand footguns)。 - 双真相源漂移:把服务端数据复制进 Zustand → 两个真相源必然漂移(硬规则禁止)。
- “无故空列表”:
agentRunningFilter故意 NOT 持久化——running 状态秒级变化,持久化开关会让用户回来面对空列表(partialize排除该字段)。
包边界强制
本仓库最反直觉的发现:边界不是靠 ESLint no-restricted-imports 或 dependency-cruiser 强制的(全仓搜索 no-restricted-imports/dependency-cruiser/no-restricted-paths 均无命中)。
强制手段是依赖声明 + TypeScript 解析失败 + phantom-dep lint 三者叠加:1) 包的 package.json 决定它能 import 什么。@multica/ui 的 deps 里根本没有 @multica/core;@multica/views 的 deps/peer 里根本没有 next 或 react-router-dom;@multica/core 把 react-dom 只放在 devDependencies(测试用),生产代码无法依赖它。2) phantom-dep 规则把“偷用未声明依赖”变成 CI 报错(eslint-plugin-import-x/no-extraneous-dependencies):
"import-x/no-extraneous-dependencies": ["error", {
devDependencies: [ "**/*.test.{ts,tsx}", "**/test/**",
"**/electron.vite.config.*", "**/src/main/**", "**/src/preload/**", /* … */ ],
peerDependencies: true,
}];
于是 @multica/ui 里若有人 import {…} from "@multica/core",因为 core 不在 ui 的 package.json,lint 直接 no-extraneous-dependencies 失败;views 里 import "next/navigation" 同理失败。CLAUDE.md 的 Dependency Declaration Rule 明确:禁止依赖 pnpm hoist 解析未声明的 import(会导致 peer-dep 变体下的生产构建失败)。3) CLAUDE.md Package Boundary Rules 把这些写成硬约束:core 零 react-dom/零 localStorage(用 StorageAdapter)/零 process.env;ui 零 @multica/core;views 零 next/*/零 react-router-dom/零 store;框架 API 只允许出现在各 app 的 platform/ 层。
唯一主动防火墙
唯一被 ESLint 主动“正向拦截”的边界是 i18n——packages/views/eslint.config.mjs 开 i18next/no-literal-string,JSX 里写裸字符串直接编译报错。
约定 + 评审
因为没有显式 import 防火墙,若有人把 next 误加进 views 的 package.json,边界即被打开——这是“约定 + 评审”而非“工具硬隔离”。
Agent 管理
任务分配、进度跟踪、技能积累——核心在 server/internal/service/task.go(TaskService,2362 行)。任务分配——polymorphic assignee:Issue 的 assignee 可以是 member 或 agent(assignee_type + assignee_id)。
把 issue 指派给 Agent(或在评论里 @ 它)即触发入队:
func (s *TaskService) enqueueIssueTask(ctx, issue, triggerCommentID, forceFreshSession) (db.AgentTaskQueue, error) {
if !issue.AssigneeID.Valid { return ..., fmt.Errorf("issue has no assignee") }
agent, _ := s.Queries.GetAgent(ctx, issue.AssigneeID)
if agent.ArchivedAt.Valid { return ..., fmt.Errorf("agent is archived") }
if !agent.RuntimeID.Valid { return ..., fmt.Errorf("agent has no runtime") }
task, _ := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{
AgentID: issue.AssigneeID, RuntimeID: agent.RuntimeID, IssueID: issue.ID,
Priority: priorityToInt(issue.Priority),
TriggerCommentID: triggerCommentID,
TriggerSummary: s.buildCommentTriggerSummary(ctx, triggerCommentID), // 200 rune 摘要
ForceFreshSession: ...,
})
// 先广播 queued,再通知 daemon——保证客户端按 queued→dispatch 顺序观测
s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, task)
s.NotifyTaskEnqueued(ctx, task)
}
入队后不存上下文快照——Agent 在 runtime 里通过 multica CLI 现取所需数据。除 issue 任务外还有 EnqueueChatTask、EnqueueQuickCreateTask(自然语言→multica issue create)、EnqueueTaskForSquadLeader(Squad 路由层,leader 代理再分派)等变体。认领——daemon pull + 容量约束:runtime(本地 daemon/云)调 /tasks/claim,服务端原子认领并尊重 max_concurrent_tasks:
func (s *TaskService) ClaimTask(ctx, agentID) (*db.AgentTaskQueue, error) {
agent, _ := s.Queries.GetAgent(ctx, agentID)
running, _ := s.Queries.CountRunningTasks(ctx, agentID)
if running >= int64(agent.MaxConcurrentTasks) { return nil, nil } // 无容量
task, err := s.Queries.ClaimAgentTask(ctx, agentID) // 原子取下一条 queued
if errors.Is(err, pgx.ErrNoRows) { return nil, nil }
s.ReconcileAgentStatus(ctx, agentID)
s.broadcastTaskDispatch(ctx, task)
}
ClaimTaskForRuntime 还有两个性能/正确性机制:EmptyClaim 缓存(Redis)让稳态空队列跳过 Postgres 扫描,并在每次 enqueue 时同步失效;ReclaimStaleDispatchedTaskForRuntime 用 90s 恢复窗口回收“claim 响应丢失”的 dispatched 任务,避免双派发。进度跟踪——状态机 + WS 事件:任务生命周期 queued → dispatched → running → completed/failed/cancelled,另有 waiting_local_directory(等本地路径锁)。每次转移都 broadcastTaskEvent,前端据此失效 presence 缓存。
StartTask/CompleteTask 注释明确 “Issue status is NOT changed here”)。CompleteTask 还保证“每个完成的 issue 任务至少留一条 Agent 评论”,并对 chat 任务在同一事务里更新 resume 指针避免竞态。技能积累——skill 表 + agent_skill junction + 内置 skill:Skill 是工作区级实体(skill 表含 content = SKILL.md 正文 + skill_file 子文件),Agent 与 skill 多对多:
-- name: AddAgentSkill :exec
INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2) ON CONFLICT DO NOTHING;
-- name: ListAgentSkills :many
SELECT s.* FROM skill s JOIN agent_skill ask ON ask.skill_id = s.id
WHERE ask.agent_id = $1 ORDER BY s.name ASC;
Agent 模板物化时按 name find-or-create skill(GetSkillByWorkspaceAndName,复用已存在的 skill_id,避开 UNIQUE(workspace_id,name))。前后端用同构的 frontmatter 解析(server/internal/skill/frontmatter.go 与 packages/core/skills/frontmatter.ts 共用同一正则)。此外 builtin_skills/* 是随产品发给 Agent 的源码可追溯契约——CLAUDE.md 要求改 CLI/API/行为时必须同 PR 更新对应 SKILL.md 与 references/*-source-map.md,否则会“悄悄教 Agent 过时行为”。
ListSkillSummariesByWorkspace/ListAgentSkillSummaries 故意不带 content 列——曾因每行带 SKILL.md 正文导致高延迟地区 CLI 15s 超时(GH #2174)。Monorepo 组织
Turbo + pnpm workspace。pnpm-workspace.yaml 声明 workspace(apps/*、packages/*)并用 catalog 钉版本——所有共享依赖写 "pkg": "catalog:",保证全仓单一版本。
packages:
- "apps/*"
- "packages/*"
catalog:
react: "19.2.3"
zustand: "^5.0.0"
"@tanstack/react-query": "^5.96.2"
zod: "^4.1.5" # 防御 API 漂移
tailwindcss: "^4"
vitest: "^4.1.0"
turbo.json 定义任务图——build/typecheck/test/lint 均 dependsOn: ["^typecheck"] 或 ["^build"](先建上游包),dev 标 persistent + cache:false:
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": [".next/**","dist/**","out/**"] },
"dev": { "cache": false, "persistent": true },
"typecheck": { "dependsOn": ["^typecheck"] },
"test": { "dependsOn": ["^typecheck"] },
"lint": { "dependsOn": ["^typecheck"] }
}
}
根 package.json 脚本全部经 turbo + --filter:dev:web=turbo dev --filter=@multica/web,build/typecheck/test/lint 都 --filter=!@multica/mobile(移动端独立、不进根流水线)。后端不在 turbo 图里——Go 侧走 Makefile(make server/make sqlc/make migrate-up/make test),make dev 一键拉起 DB+迁移+前后端,并自动识别 git worktree(每个 checkout 独立 DB 名 + 端口,共用一个 PostgreSQL 容器)。
exports 直接指向 .ts/.tsx(如 core 的 "./issues/queries": "./issues/queries.ts"),由消费端打包器编译,换取零配置 HMR + 即时跳定义。代价是消费端必须能编译这些源(Next/electron-vite 都配了 @source 扫描共享包让 Tailwind 看见类名)。关键技术与失败模式
下表汇总从源码与 CLAUDE.md 提炼的边界场景、触发条件与处理方式——多数源于真实事故编号(GH issue)。
| 场景 / 边界 | 触发条件 | 处理 / 后果 |
|---|---|---|
DELETE 返回 204 却 0 行(#1661) | 原 util.ParseUUID 非法输入静默返回零 UUID | 改为 loader 解析 + parseUUIDOrBadRequest 三态约定 |
| presence 误读为“离线/失败” | 历史任务结果污染当前状态 | presence 纯前端派生,archived 优先于 runtime 健康;只答“此刻” |
| 任务双派发 | claim 响应丢失,dispatched 无人推进 | ReclaimStaleDispatchedTaskForRuntime 90s 恢复窗口(> daemon 30+30s 超时) |
| 刚入队任务被空队列缓存挡住 | EmptyClaim 缓存命中 | enqueue 同步 bump version;preSelectVersion 比对关闭竞态 |
| 并发完成同一任务 | 多 Agent / 重复回调 | UPDATE … WHERE status='running' 无行 → 幂等成功 |
| 切换工作区看到旧数据 | query key 未带 wsId | 所有工作区作用域 query 必须以 wsId 为 key 前缀 |
| Zustand 无限重渲染 | selector 每次返回新对象/数组 | 选基本量或用 shallow 比较 |
| 持久化 filter 致“无故空列表” | running 状态秒级变化 | partialize 故意排除 agentRunningFilter,保持 ephemeral |
| 老 desktop 连新 server 白屏(#2143/#2147/#2192) | 响应 shape 漂移 | parseWithFallback+zod、禁裸 as、enum 漂移降级、switch 必带 default |
| 包边界被打开 | 误把 next/core 加进错误包的 package.json | 仅靠依赖图 + phantom-dep lint + 评审被动拦截(无显式防火墙)——潜在弱点 |
| CLI 高延迟地区 15s 超时(#2174) | skill 列表每行带 SKILL.md 正文 | 列表查询用 *Summaries(省略 content) |
| 保留 slug 与 TS 副本漂移 | 手改 reserved_slugs.json 未重生成 | CI 重跑 generate:reserved-slugs 并 fail-on-drift |
| 内置 skill 教 Agent 过时行为 | 改了 CLI/API/行为但没改 SKILL.md | CLAUDE.md 强制同 PR 更新 SKILL.md + *-source-map.md |
生态意义:vs Manus / Cursor / Windsurf
Multica 在“把 Agent 当队友”的赛道上有几处区别于现有产品的取舍。它的工程重心不在“更强的单个 Agent”,而在让一群 Agent 像团队成员一样被管理:可指派、可观测、可累积技能、人机同板协作。
无关模型的任务系统
不绑定单一模型——可对接 Claude Code、Codex、Copilot CLI、OpenCode、Gemini、Cursor Agent、Kimi、Kiro CLI。对比 Cursor Agent / Windsurf(深度耦合各自编辑器与模型、以单人 IDE 回合为中心),Multica 把执行下放给 daemon/云 runtime,自身只做项目管理层——更像“给 Agent 用的 Linear”。
拉取式带容量队列
agent_task_queue 的 queued→dispatched→running→completed/failed 生命周期、max_concurrent_tasks、stale 回收、空队列缓存,是一套面向多 Agent 长期运行的调度基础设施。对比 Manus 这类“一个自治 Agent 跑一长链”,Multica 强调多 Agent 并行、可观测进度、人随时介入。
技能可累积可追溯
skill 是工作区级实体(SKILL.md + 子文件),通过 agent_skill 多对多挂到 Agent,模板物化时 find-or-create 复用;内置 skill 是“源码可追溯契约”并由 CI 约束与代码同步。把 README 所说的 “compound skills over time” 落到 schema 与流程上,而非停留在 prompt。
真正的 Web + Desktop 同构
靠 NavigationAdapter + CoreProvider + 三层包边界(core 零 react-dom、ui 零 core、views 零框架)实现 Web 与 Electron 共享几乎全部业务代码,仅各自 platform/ 层接框架 API。这套“headless 业务 + 平台适配器”是开源、自托管平台同时维护多端而不爆成本的关键。
Squad 路由层为更大团队提供“把活派给一个由 Agent 领队的小组、领队再分派”的稳定抽象——这是把人类组织结构映射到 Agent 团队的早期尝试,超出了多数 Agent 工具“单 Agent/单会话”的边界。总体而言,Multica 用严格的状态分离与包边界保证这套“多 Agent 当队友”系统在 Web/Desktop 双端长期可维护。
- Stars 数未知(README 用 shields 动态徽章,仓库未含静态数值)。
- 是否存在 ESLint 之外的 CI 脚本显式校验包依赖图(防止
next/core被误加进错误包)——未在.github/workflows中逐项核实。 apps/mobile内部架构(UI/状态/构建链)未在本次阅读范围内展开,仅确认其与 core 之间为import type纯类型耦合。- LICENSE 为“改良版 Apache-2.0 + 附加商用/品牌条款”,附加条款的完整法律含义未逐条解读。