Multica Daemon 架构设计分析

一个管理多种 AI Coding Agent CLI 进程的本地 supervisor

Go Process Management WebSocket + HTTP Multi-Agent Runtime

概述

Multica Daemon 是一个运行在用户本地机器上的后台进程,负责从远端 server 领取任务(task), 然后在本地 spawn 对应的 Agent CLI 进程(Claude Code、Codex、Copilot、Gemini 等 11 种后端)来执行。 它本质上是一个 多 runtime、多 workspace 的分布式 task executor, 但设计上有若干独到之处值得学习。

整体架构

┌─────────────────────────────────────────────────────────────────┐
│                        Multica Server                            │
│  ┌─────────┐  ┌──────────┐  ┌────────────┐  ┌───────────────┐  │
│  │ Task Q  │  │ WS Hub   │  │ Heartbeat  │  │ Runtime Sweep │  │
│  └────┬────┘  └────┬─────┘  └─────┬──────┘  └───────┬───────┘  │
└───────┼─────────────┼──────────────┼─────────────────┼──────────┘
        │             │              │                 │
   ClaimTask    task_available   heartbeat_ack    45s offline
        │             │              │           → mark offline
        ▼             ▼              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Multica Daemon (Local)                      │
│                                                                  │
│  ┌────────────┐  ┌──────────────┐  ┌─────────────────────────┐  │
│  │ pollLoop   │  │ taskWakeup   │  │ heartbeatLoop           │  │
│  │            │  │ Loop (WS)    │  │ (per-runtime goroutine) │  │
│  │ per-runtime│  │              │  │                         │  │
│  │ pollers    │◄─┤ fan-out      │  │ WS ack → suppress HTTP  │  │
│  └─────┬──────┘  │ wakeup       │  └─────────────────────────┘  │
│        │         └──────────────┘                                │
│        ▼                                                         │
│  ┌──────────────────────────────────────┐                        │
│  │ Task Slot Semaphore (cap=20)         │                        │
│  │  acquire BEFORE claim, not after     │                        │
│  └────────────────┬─────────────────────┘                        │
│                   ▼                                               │
│  ┌──────────────────────────────────────┐                        │
│  │ handleTask → runTask → agent.Execute │                        │
│  │                                      │                        │
│  │  Claude | Codex | Copilot | Gemini   │                        │
│  │  OpenCode | OpenClaw | Hermes | ...  │                        │
│  └──────────────────────────────────────┘                        │
│                                                                  │
│  ┌───────────┐  ┌───────────┐  ┌──────────────┐                 │
│  │ gcLoop    │  │ autoUpdate│  │ workspaceSync│                 │
│  │ (1h tick) │  │ (6h tick) │  │ (30s tick)   │                 │
│  └───────────┘  └───────────┘  └──────────────┘                 │
└─────────────────────────────────────────────────────────────────┘

七个独到的设计模式

1

Slot-Before-Claim:先拿槽位再领任务

大多数 worker 系统是 "先领任务再检查容量",multica 反过来:先从 semaphore 获取一个执行槽位,才发起 ClaimTask HTTP 请求

// 从 semaphore 拿槽位 → 成功后才 ClaimTask
select {
case slot = <-sem:         // 拿到了,继续
case <-pollerCtx.Done():   // daemon 关了
    return
default:                    // 满了 → 不 claim,sleep
    sleepWithContextOrWakeup(...)
    continue
}

// 有槽位了才去 server 认领
task, err := d.client.ClaimTask(pollerCtx, rid)

为什么这很重要:Server 端有一个 5 分钟的 dispatch timeout 扫描器。 如果先 claim 再排队等槽位,claimed 但未开始执行的任务会被 server 标记为 timeout/failed。 先拿槽位确保一旦 claim 成功,task 可以立即开始执行。

2

双通道心跳:WebSocket 主 + HTTP 兜底

每个 runtime 独立维护 HTTP 心跳,但如果 WebSocket 通道最近有过 ack,HTTP 心跳就自动跳过。 WS 断开时,立即清空所有 freshness 记录,让 HTTP 在下一个 tick 无缝接管。

WebSocket 心跳

  • - 在 task wakeup 连接上复用
  • - 每 15s per-runtime 发一次
  • - ack 成功 → 记录 freshness
  • - 断开 → clearWSHeartbeatAcks()

HTTP 心跳

  • - 独立 goroutine per-runtime
  • - WS fresh 时自动跳过
  • - freshness window = 2× interval
  • - 容忍 1 次 WS 丢包

设计哲学:WS 降低延迟和 DB 写入频率,HTTP 作为不可能失联的保底。 Server 端 45s 无心跳就标 offline——freshness window 设为 30s(2×15s), 保证即使 WS 完全断了,HTTP 仍能在 offline 判定前续上。

3

Runtime-Set Pub/Sub:多 supervisor 协调

pollLoop、heartbeatLoop、taskWakeupLoop 都需要在 runtime 集合变化时做出反应。 一个简单的 buffered channel 只能通知一个 subscriber。 Multica 实现了一个自定义的 runtimeSetWatcher

type runtimeSetWatcher struct {
    mu          sync.Mutex
    subscribers map[chan struct{}]struct{}
}

func (w *runtimeSetWatcher) notify() {
    w.mu.Lock()
    defer w.mu.Unlock()
    for ch := range w.subscribers {
        select {
        case ch <- struct{}{}:  // 通知
        default:                // 已有未处理的通知,跳过(合并信号)
        }
    }
}

每个 subscriber 拿到一个 cap=1 的 channel,多次变化合并成一个信号。 Subscriber 收到信号后,去 allRuntimeIDs() 重新拉取当前全集—— 而不是依赖信号本身携带的数据。这消除了 edge-count 竞态。

4

Auto-Update Claim Barrier:两阶段空闲检查

自动升级时要保证不中断正在运行的 task。但 "检查空闲" 和 "开始升级" 之间有时间窗口, 这期间可能有新 task 被 claim。Multica 用两阶段方案关闭这个竞态窗口:

1

廉价预检

activeTasks.Load() > 0 → 直接跳过,不浪费 GitHub API rate limit

2

拉取 release 元数据

HTTPS 调用 GitHub,耗时数十 ms 到数秒不等

3

严格屏障 trySetClaimBarrier()

在 claimMu 下原子地检查 claimsInFlight + activeTasks,全为 0 才 flip pauseClaims=true

4

升级成功 → triggerRestart

留着 barrier 不放(process 即将退出),避免 shutdown 窗口内新 claim 进来

核心原则:"升级过程中如果有 task 进来,延后升级而不是中断 task"。 所有的锁只在微秒级持有(决策用),实际的 ClaimTask HTTP 和 升级动作都不持锁。

5

Poisoned Session 检测:打破无限重试循环

Daemon 支持 session resume——下一个 task 可以继承上一次的会话。但如果上次的会话状态是 "有毒的" (iteration limit、API 400 等),resume 只会无限重现相同错误。

// 输出中毒分类:短输出 + 包含已知 fallback marker
func classifyPoisonedOutput(output string) (string, bool) {
    if len(trimmed) > poisonedOutputMaxLen { // 320 字符
        return "", false  // 长输出不是 fallback
    }
    // 匹配 "i reached the iteration limit" 等
}

// 错误中毒分类:API 400 + invalid_request_error
func classifyPoisonedError(errMsg string) (string, bool) {
    // 400 + invalid_request_error = 请求体本身有问题
    // 每次 resume 都会重放同样的请求体 → 永远 400
}

被标记为 poisoned 的 task 会带上特殊的 failure_reason,server 端的 GetLastTaskSession 查询会排除它们, 下一个 task 从全新会话开始而不是继承坏状态。

6

Runtime-Gone 合并恢复:三路检测一路修

当 server 端删除了某个 runtime 行(admin 操作、超时清理等),三个独立路径会几乎同时检测到: HTTP heartbeat 收 404、poller claim 收 404、WS ack 标记 RuntimeGone。 但恢复操作只需要做一次。

runtimeGoneInflight[runtimeID] — 同一 runtime 的并发恢复立即去重

reregisterNextAttempt[workspaceID] — 30s 合并窗口,同一 workspace 的多个 runtime 删除合并为一次 re-register

reregisterLastCompletedAt[workspaceID] — 成功完成时间戳,让"迟到的 straggler"识别出已被覆盖

特别设计:失败不 stamp lastCompletedAt——因为失败意味着没有覆盖任何状态, straggler 仍然有义务重试。成功才 stamp + 清 slot,让后续独立的删除事件可以立即认领。

7

稳定机器身份:UUIDv7 + Legacy Merge

Daemon 的身份不绑定 hostname——macOS 上 hostname 会因 mDNS 状态在 "foo""foo.local" 之间漂移,导致每次重启注册新 runtime。

身份持久化到 ~/.multica/daemon.id(UUIDv7),machine-scoped 而非 profile-scoped

注册时带上 legacy_daemon_ids(旧 hostname 格式 + .local 变体 + per-profile UUID),server 做行合并

写文件用原子 rename(tmp → target),权限 0600,损坏时自动重生

从旧版 per-profile daemon.id 一次性 promote 到新位置,零用户感知的迁移

其他值得注意的设计

GC 的 Active-Root 保护

GC 循环用 reference-counted path tracking 标记当前活跃 task 的工作目录。 即使 issue 状态已变为 done,只要 task 还在跑(resume 场景),目录不会被回收。

Workspace 指针稳定性

Daemon 永远不替换 workspaceState 指针,只原地修改字段。 因为 ensureRepoReady 会长时间持有 repoRefreshMu, 如果指针被替换,旧指针上的锁就失效了。

Login-Shell PATH 解析

macOS 上 GUI 启动的 daemon 不继承 shell PATH(fnm/nvm 路径消失)。 Config 加载时 spawn $SHELL -ilc,unalias + unset function, 用 command -v 解析后 canonicalize 路径。

Idle Watchdog

Agent 可能因底层进程挂死(如 docker ps 对 frozen dockerd)而永远不返回。 30 分钟无消息且队列为空时强制停止。设得太短(之前是 5min)会杀掉长写入。

可测试的 taskRunner 接口

实际的 task 执行通过 taskRunner 接口注入。 测试可以 mock 整个执行路径而不需要 spawn 真正的 Agent 进程。 所有时间常量和外部调用也都有 var 间接层。

WS Writer 序列化

gorilla/websocket 不允许并发 Write。Daemon 用一个带动态容量的 channel 把所有帧汇聚到单一 writer goroutine。满时丢弃心跳帧——HTTP 会在下一 tick 补上。

并发模型总结

资源 保护方式 设计意图
任务并发数 channel semaphore (cap=20) 带稳定 slot index,暴露为环境变量
claim vs update claimMu + pauseClaims + claimsInFlight 微秒级决策锁,不阻塞实际 HTTP
runtime 集合变化 runtimeSetWatcher pub/sub 多 subscriber 合并信号
runtime 恢复 runtimeGoneMu + coalesce window 三路检测合并为一次 API 调用
workspace 状态 d.mu + 指针稳定性约束 不替换指针,防 repoRefreshMu 失效
WS 连接写入 single writer goroutine + channel 满时丢帧,依赖 HTTP 兜底

设计启示

操作顺序决定系统语义

Slot-before-claim 和 Claim-before-slot 只是顺序不同,但前者让系统在 overload 时保持 task 在安全的 "queued" 状态, 后者会把 task 推入危险的 "dispatched" 状态。一个 swap 消除了整类 timeout failure。

信号合并优于精确计数

runtimeSetWatcher 不告诉你 "哪个 runtime 加了/删了",只告诉你 "有变化,你该重新看一遍全集"。 这消除了一大类 edge-count/ordering bug,代价仅仅是一次 O(n) 扫描。

降级不是失败

WS 断了 → HTTP 自动接管。心跳帧丢了 → 下一次 HTTP tick 补上。GC 读失败 → best-effort 继续。 每个子系统都有自己的降级路径,daemon 作为整体永远不会因单一故障完全失能。

身份问题需要 migration story

从 hostname-based 到 UUID-based 的迁移不是简单换个 ID——需要 promote 旧文件、 计算所有历史格式变体、在注册时带上 legacy IDs 让 server merge。 没有迁移方案的身份方案是不完整的。