数据截至 (上游 commit 877a71568f6d)
本地守护进程:认领、准备、执行、上报
30 秒导读:
internal/daemon是一个跑在用户自己机器上的常驻进程。它向 Multica 服务端注 册"这台机器上装了哪些编码 agent CLI(Claude、Codex、Cursor……)",然后不停地认领服务端派来的任务,给每个任务搭一个隔离的运行环境,把真正的 agent CLI 拉起来跑,并把进度、结果、失败、用量实时回报回服务端。它是 01-agent-runtime(把 15+ 种 CLI 抽象成一种执行)与 03-task-dispatch-lifecycle(服务端的任务状态机)之间的那座桥。
1. 这是什么(零基础也能懂)
一句话定义: daemon 是"派活方(服务端)"和"干活的 agent CLI(装在你机器上的 Claude/Codex 等)"之间的本地经纪人。
为什么需要它? Multica 的服务端在云上,但真正干活的编码 agent 装在用户的机器上——因为只有你的机器上才有你的代码、你登录好的 CLI、你的密钥。服务端不能直接执行你机器上的命令,于是需要一个常驻的本地进程来"接单、跑活、回报"。这就是 daemon。
它负责的四件事(本章标题的四个词):
| 阶段 | 干什么 | 白话 |
|---|---|---|
| 认领(claim) | 从服务端批量领取分配给本机 runtime 的任务 | "有我的活吗?有就领走" |
| 准备(prepare) | 为任务搭隔离工作目录、仓库缓存、skill、prompt | "先把工位、代码、说明书摆好" |
| 执行(execute) | 拉起对应的 agent CLI 子进程,流式跑 | "让 Claude/Codex 真正开工" |
| 上报(report) | 把 dispatched→running→completed/failed 回写服务端 | "随时汇报进度和结果" |
用起来什么样? 用户基本感知不到它——装好 CLI 后跑 multica daemon start(或桌面端自动拉起),它就在后台常驻。make daemon 是仓库里的开发入口(依据:仓库 Makefile / CLAUDE.md Commands 节)。之后你在 Multica 网页里把一个 issue 指派给某个 agent,几秒内这台机器上的 daemon 就认领并开跑了。
一句话直觉: 把 daemon 想成一家外卖店的前台兼后厨调度——平台(服务端)把订单推过来,前台接单(认领),后厨按订单备料摆台(准备工作目录),叫厨师开火(执行 agent CLI),再实时把"制作中/已出餐/出餐失败"同步回平台(上报)。
2. 顶层全景(它大概怎么转)
2.1 部件一句话职责
daemon 包(server/internal/daemon)不是一个大函数,而是一堆各管一段、并发运行的循环 + 客户端。核心部件:
| 部件 | 干什么 | 在哪(符号) |
|---|---|---|
Daemon | 总状态机:持有配置、runtime 索引、各种并发锁 | daemon.go:220 type Daemon struct |
Run | 启动编排:预检、注册、拉起所有后台循环,最后进主 poll 循环 | daemon.go:999 Run |
Client | 与服务端的 HTTP 控制面客户端(认领/上报/心跳/注册) | client.go:91 type Client |
| batch poller | 唯一的"认领+派发"循环:抢槽位→批量认领→分发 | daemon.go:2952 runBatchPoller |
handleTask | 单任务生命周期外壳:锁、取消监视、跑、回报 | daemon.go:3215 handleTask |
runTask | 真正干活:解析 agent、备环境、StartTask、拉起 CLI | daemon.go:4052 runTask |
repocache.Cache | 裸仓库缓存 + 部分克隆 + worktree | repocache/cache.go:161 type Cache |
execenv | 为各 provider 搭隔离运行环境(HOME/CODEX_HOME/skills…) | execenv/execenv.go:366 Prepare |
SkillBundleCache | 磁盘上的 skill 包缓存 | skill_cache.go:17 SkillBundleCache |
| task-wakeup WS | 长连接:收"有活了"推送、发心跳、驮 WS RPC | wakeup.go:99 runTaskWakeupConnection |
wsRPCClient | 在 WS 连接上跑"请求/响应"式 RPC(认领走这条更快) | wsrpc.go:84 wsRPCClient |
| 各后台循环 | 心跳 / workspace 同步 / GC / 自更新 / token 续期 | daemon.go:1075-1082(Run 里 go d.xxxLoop) |
2.2 一张图:daemon 内部怎么摆
怎么读:上半是"和服务端说话的两条线"(HTTP + WS);中间是唯一的认领派发循环;下半是任务落地要用到的本地资源。箭头是控制/数据流。
Multica 服务端(云上)
┌──────────────────────┬──────────────────────┐
│ HTTP 控制面 │ WebSocket 控制连接 │
│ (client.go) │ (wakeup.go) │
└──────────┬───────────┴───────────┬───────────┘
│ │
认领/上报/心跳/注册 "有活了"推送 + 心跳 + WS RPC
│ │ 收到 EventDaemonTaskAvailable
│ ▼ → 唤醒 poller(不阻塞)
│ ┌──────────────────────┐
└─────────────▶│ batch poller(唯一) │
│ runBatchPoller │
│ ① 抢空闲执行槽位 │
│ ② ClaimTasksWSFirst │
│ ③ 每个任务开一 goroutine│
└──────────┬───────────┘
│ 每任务
▼
┌──────────────────────┐
│ handleTask │
│ local_dir 锁 / 取消监视│
└──────────┬───────────┘
▼
┌──────────────────────┐
│ runTask │
│ 备仓库→备环境→StartTask│
│ →组 prompt→拉起 CLI │
└──────────┬───────────┘
┌────────────────────┼────────────────────┐
▼ ▼ ▼
repocache execenv 01-agent-runtime
(裸仓+部分克隆) (隔离 HOME/skills) (agent.New 执行)
2.3 主线走一遍(高层,不进代码)
一条任务从被认领到完成,大致是:
服务端有活
→ (WS 推送 or 轮询到点) 唤醒 poller
→ poller 抢到执行槽位,批量认领 N 个任务
→ 每个任务开一个 goroutine 进 handleTask
→ handleTask 拿 local_directory 锁、装好"取消监视器"
→ runTask 备好仓库缓存 + 隔离环境 + skill + prompt
→ StartTask 把服务端状态从 dispatched 翻到 running
→ 拉起 agent CLI 子进程,流式跑,期间上报进度/用量
→ 跑完:CompleteTask(成功)或 FailTask(失败),释放槽位
关键设计点先记住一句:先抢本地槽位、再向服务端认领(slot-before-claim)——这样一个被认领的任务绝不会卡在服务端 dispatched 却在本地没有产能去跑它(daemon.go:2972-2999,runBatchPoller 注释)。
3. 核心机制(逐个拆解)
3.1 注册与 runtime profiles:先探测本机有什么,再告诉服务端
要解决的小问题: 服务端要把任务派给"能跑它的 runtime",但它不知道这台机器上到底装了哪些 agent CLI、什么版本。得由 daemon 探测后上报。
探测:并发跑 --version。 detectBuiltinRuntimes 对配置里每个 agent(d.cfg.Agents)并发执行版本探测:先自愈可执行路径,再 detectAgentVersion 跑 --version,再用 checkAgentMinVersion 卡最低版本,过关的才进注册清单。
// 真实实现节选(daemon.go:1240-1264),已省略并发骨架
entry, _ = d.resolveAgentEntry(ctx, name, entry) // 自愈被升级删掉的路径
version, err := detectAgentVersion(ctx, entry.Path) // 跑 `<cli> --version`
if err != nil { return nil } // 探测不到就跳过,不报错
if err := checkAgentMinVersion(name, version); err != nil {
return nil // 版本太老也跳过
}
d.setAgentVersion(name, version) // 记下版本,后续策略要用
探测本身用 errgroup 并发、结果按 provider 名排序,让注册载荷跨次运行稳定(daemon.go:1269-1274,便于顺序敏感的测试)。
注册:按 workspace 注册。 registerRuntimesForWorkspace 把内置 runtime 清单 + 该 workspace 的自定义 runtime profiles一起提交(daemon.go:1292)。
- 自定义 profile(MUL-3284)是"用户自己在 workspace 里配的一条命令"——
appendProfileRuntimes拉取该 workspace 启用的 profiles,只有命令能在本机 PATH 上解析的才注册进来,并把绝对路径 + 固定参数按profile_id记下,供后续runTask直接拉起(daemon.go:1365)。 - 这一步是尽力而为:拉 profile 失败(旧服务端 404、网络抖动)绝不让注册失败,daemon 继续用已收集的内置 runtime(
daemon.go:1366-1368注释)。
profile 漂移与自愈。 用户在 UI 上改了 profile,服务端会推一条 EventDaemonRuntimeProfilesChanged,daemon 走 refreshWorkspaceRuntimeProfiles 重新注册,而不用重启(daemon.go:1774)。为避免重复通知反复重注册,appendProfileRuntimes 返回一个 profile 列表的内容签名(profileSig),签名没变就当没事(MUL-3332)。
agent 路径自愈(self-heal,MUL-4486)—— 一个很实用的坑处理。 daemon 在启动时把每个 agent 的绝对路径钉死,防止后来 PATH 变化把任务重定向到别的二进制。但版本管理器(Homebrew Cask、nvm/fnm)原地升级时会删掉旧版本目录,让这个钉死的路径失效。healAgentPath 的处理:
钉死路径还在?
├─ 在 → 直接用(绝不二次猜测,反重定向保证成立)
└─ 没了 → 用原始 command 重新解析一次
├─ 解析出新二进制 → 探版本 + 过最低版本门槛 → 采纳,记 {path, version} 一对
└─ 解析失败/版本不够 → 保留旧(失效)路径,让下游报错,绝不启动可疑二进制
关键细节:path 和 version 成对发布(healedAgent 结构体,daemon.go:433),任何看到新路径的读者必然看到匹配的版本——避免"新二进制却跑在旧版本策略下"的窗口(healAgentPath,daemon.go:519)。多个排队任务同时撞见刚升级的 agent 时,用 singleflight.Group 合并成一次探测(resolveAgentEntry,daemon.go:499)。
3.2 认领循环:唯一的 poller、批量认领、WS 优先、多层退路
要解决的小问题: 怎么高效、无重复地把服务端的任务领到本机来跑?
只有一个 poller。 早期是"每个 runtime 一个轮询器",一个慢认领会拖住那个 runtime。现在改成机器级单循环 runBatchPoller:一次调用就跨本机所有 runtime 认领(daemon.go:2952)。
slot-before-claim(先抢槽位再认领)。 每一轮:
① waitForTaskSlot:先抢到 ≥1 个执行槽位(短暂阻塞)
② drainAvailableSlots:再顺手把其它空闲槽位都拿上
③ tryEnterClaim:自更新屏障——升级在即就不认领
④ ClaimTasksWSFirst(daemonID, runtimeIDs, len(slots)):一次要 len(slots) 个任务
⑤ 每个返回的任务开一个 goroutine 跑 handleTask;槽位跑完由 defer 归还
为什么先抢槽位?因为认领了却没产能跑,任务会卡在服务端 dispatched 并被派发超时清扫器误伤(runBatchPoller 注释,daemon.go:2942-2947)。
认领走哪条线?ClaimTasksWSFirst 的三层策略(MUL-4257)。 认领既可以走 HTTP,也可以驮在 WS 控制连接上(更快、免握手)。优先级与退路(wsrpc.go:315):
| 情况 | 走哪条 | 依据 |
|---|---|---|
| 服务端没有批量 路由(曾 404) | 直接 legacy 逐 runtime 认领 | batchClaimUnsupported.Load() |
| WS 连接在、且协商了 rpc-v1 | WS RPC tasks.claim | wsRPC.supportsRPCV1() |
| WS 失败但确定没到服务端 | 落回 HTTP 批量认领 | 缓冲满/未发出的超时 |
| WS 已发出但结果未知 | 不立刻重试,等一个安全窗口 | 见下 |
| HTTP 批量返回 404 | 落回 legacy 逐 runtime | isBatchClaimUnsupported |
"已发出但结果未知"为什么最危险? 因为 WS 帧发出去了、连接却断在响应之前——服务端可能已经提交了这次认领。此时马上换 HTTP 再认领同样的空槽位,就会双重认领同一批任务。所以代码把它单独标成 errWSRPCUncertain,设一个延迟窗口 wsClaimUncertainFallbackDelay,期间跳过认领;真提交了的话由服务端的 stale-reclaim 兜底,没提交的话过窗口后 HTTP 恢复(wsrpc.go:24、wsrpc.go:350-364)。
legacy 退路的语义。 claimTasksLegacy 逐个 runtime 调老接口 ClaimTask;只有在还没领到任何任务时才把单 runtime 错误上抛,否则返回已领到的部分、下轮再补(client.go:271)。这保证新 daemon 能对着没升级的老服务端工作。
批量认领用一个短的请求级超时 batchClaimRequestTimeout = 5s(client.go:224),而不是共享的 30s 控制面超时——因为批量调用覆盖所有 runtime,一个慢认领会拖住全部;5s 封顶最坏饿死,超时后提交的认领由下轮 ReclaimStaleDispatchedTasks 恢复。