一个管理多种 AI Coding Agent CLI 进程的本地 supervisor
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) │ │
│ └───────────┘ └───────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
大多数 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 可以立即开始执行。
每个 runtime 独立维护 HTTP 心跳,但如果 WebSocket 通道最近有过 ack,HTTP 心跳就自动跳过。 WS 断开时,立即清空所有 freshness 记录,让 HTTP 在下一个 tick 无缝接管。
WebSocket 心跳
HTTP 心跳
设计哲学:WS 降低延迟和 DB 写入频率,HTTP 作为不可能失联的保底。 Server 端 45s 无心跳就标 offline——freshness window 设为 30s(2×15s), 保证即使 WS 完全断了,HTTP 仍能在 offline 判定前续上。
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 竞态。
自动升级时要保证不中断正在运行的 task。但 "检查空闲" 和 "开始升级" 之间有时间窗口, 这期间可能有新 task 被 claim。Multica 用两阶段方案关闭这个竞态窗口:
廉价预检
activeTasks.Load() > 0 → 直接跳过,不浪费 GitHub API rate limit
拉取 release 元数据
HTTPS 调用 GitHub,耗时数十 ms 到数秒不等
严格屏障 trySetClaimBarrier()
在 claimMu 下原子地检查 claimsInFlight + activeTasks,全为 0 才 flip pauseClaims=true
升级成功 → triggerRestart
留着 barrier 不放(process 即将退出),避免 shutdown 窗口内新 claim 进来
核心原则:"升级过程中如果有 task 进来,延后升级而不是中断 task"。 所有的锁只在微秒级持有(决策用),实际的 ClaimTask HTTP 和 升级动作都不持锁。
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 从全新会话开始而不是继承坏状态。
当 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,让后续独立的删除事件可以立即认领。
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 循环用 reference-counted path tracking 标记当前活跃 task 的工作目录。 即使 issue 状态已变为 done,只要 task 还在跑(resume 场景),目录不会被回收。
Daemon 永远不替换 workspaceState 指针,只原地修改字段。
因为 ensureRepoReady 会长时间持有 repoRefreshMu,
如果指针被替换,旧指针上的锁就失效了。
macOS 上 GUI 启动的 daemon 不继承 shell PATH(fnm/nvm 路径消失)。
Config 加载时 spawn $SHELL -ilc,unalias + unset function,
用 command -v 解析后 canonicalize 路径。
Agent 可能因底层进程挂死(如 docker ps 对 frozen dockerd)而永远不返回。 30 分钟无消息且队列为空时强制停止。设得太短(之前是 5min)会杀掉长写入。
实际的 task 执行通过 taskRunner 接口注入。
测试可以 mock 整个执行路径而不需要 spawn 真正的 Agent 进程。
所有时间常量和外部调用也都有 var 间接层。
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。 没有迁移方案的身份方案是不完整的。