本地守护进程:认领、准备、执行、上报
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:134 type Cache |
execenv | 为各 provider 搭隔离运行环境(HOME/CODEX_HOME/skills…) | execenv/execenv.go:259 Prepare |
SkillBundleCache | 磁盘上的 skill 包缓存 | skill_cache.go:15 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 恢复。
3.3 工作目录准备:仓库缓存 + 隔离环境 + skill 缓存
任务落地前,runTask 要摆好三样东西。
(a) 仓库缓存与部分克隆(repocache)
daemon 不是每个任务都从头 git clone。它维护一个裸仓库缓存(bare repo cache),任务要用时从缓存长出 worktree:
Cache.Sync按 workspace 的仓库配置维护裸仓库;CreateWorktree从裸仓库拉出一个任务用的 checkout(repocache/cache.go:169、:453)。- 部分克隆(partial clone)省带宽:裸仓库用
--filter=blob:none创建(只要提交历史、不要文件内容,用到才懒加载)。常量partialCloneFilter = "blob:none"(repocache/cache.go:806)。 - 一个坑:
git clone --local不会把 promisor 相关的两个 config 键复制过去,导致继承了不完整对象库的 checkout 拉不到懒加载对象。configurePromisorRemote手动补回remote.origin.promisor与remote.origin.partialclonefilter两个键(repocache/cache.go:823),isPartialClone用前者判定(repocache/cache.go:810)。
值得注意:agent 的工作目录一开始是空的——仓库不预先 checkout,而是让 agent 按需跑 multica repo checkout <url>(execenv.Prepare 注释,execenv/execenv.go:256-258)。runTask 只把 task.Repos 登记进 workspace 允许列表和本地缓存(registerTaskRepos,daemon.go:1601 / 调用点 :4083)。
(b) 隔离运行环境(execenv)
execenv.Prepare 为任务建一棵目录树,并按 provider 定制隔离(execenv/execenv.go:259):
{workspacesRoot}/{workspaceID}/{task_id_short}/ ← RootDir(可预测,见 PredictRootDir)
├── workdir/ ← agent 的 cwd(WorkDir)
├── output/
└── logs/
Environment 结构体(execenv/execenv.go:189)按 provider 携带不同隔离点,都是为了不污染用户的全局配置:
| 字段 | 给谁 | 作用 |
|---|---|---|
CodexHome | codex | 每任务 CODEX_HOME,skills 不落到系统 ~/.codex |
TaskHome | codex(Linux) | 沙箱里真实 HOME 只读,重定向 HOME/XDG 到可写目录 |
OpenclawConfigPath | openclaw | 每任务合成配置,把 workspace 钉到 WorkDir |
CursorDataDir | cursor | 隔离 MCP 审批,不碰用户 ~/.cursor |
HermesHome | hermes | 每任务 overlay,软链用户 skills |
LocalDirectory | 全部 | 标记 WorkDir 是不是用户自己的目录(见 3.6 GC) |
PredictRootDir 让调用方在 Prepare 跑之前就能算出 RootDir 路径,好提前向 GC 声明"这块地我占了"(execenv/execenv.go:249,由 handleTask 的 markActiveEnvRoot 用)。
复用 vs 新建。 同一个 (agent, issue) 的下一个任务可以复用上一个任务的 workdir(execenv.Reuse,execenv/execenv.go:481),省掉重新 clone;runTask 里 shouldReusePriorWorkdir 决定走复用还是新 Prepare(daemon.go:4306)。但 local_directory(agent 直接在用户自己的仓库里跑)刻意不复用——复用会丢掉 GC 需要的 envRoot 关联,而对着稳定用户路径重跑 Prepare 很便宜(daemon.go:4214-4221 注释)。
(c) skill 缓存
任务可能带 skill 引用(轻量指针),真正的 skill 包按需下载并缓存到磁盘:
ensureTaskSkillBundles把任务里的 skill 引用换成实体(daemon.go:3842)。- 未命中缓存时
Client.ResolveSkillBundle一次下一个 skill(不是整包原子下载),每个下载吃自己的、按大小缩放的超时,慢链路下也能增量推进(client.go:301,GitHub #4505)。 SkillBundleCache.Load/Store是磁盘缓存,校验失败会删掉重下(skill_cache.go:24)。
3.4 prompt 组装与 handoff
要解决的小问题: agent CLI 拿到的第一段话(prompt)该说什么?
BuildPrompt 按任务类型分派出不同 prompt(prompt.go:17):
| 任务类型 | 分支 | prompt 主旨 |
|---|---|---|
| 聊天会话 | buildChatPrompt | 对话续接 |
| 评论触发 | buildCommentPrompt | 回复评论线程 |
| autopilot | buildAutopilotPrompt | 自动化触发 |
| 快速创建 | buildQuickCreatePrompt | 把一句话变成 multica issue create |
| 普通 issue | 默认分支 | 先 multica issue get,再干活 |
设计哲学:prompt 保持极简,详细规则住在 CLAUDE.md/AGENTS.md 里、由 execenv.InjectRuntimeConfig 注入到 workdir(prompt.go:10-12 注释)。
handoff(交接,MUL-3375): 指派者可以留一段自由文本的"交接说明"。默认 prompt 会把它框成"交接指令、不是评论"——让 agent 照它缩小范围,而不是把它当成要回复的评论(prompt.go:36-39)。
线程命名。 deriveTaskThreadName 从一串候选(线程名、autopilot 标题、快创 prompt、聊天消息、触发评论)里挑第一个非空的,规整并截断到 120 字符,给 Codex 之类需要线程名的 provider 用(thread_name.go:7)。
3.5 状态回报与终态:dispatched → running → completed/failed
要解决的小问题: 服务端要实时知道任务到哪一步了,且绝不能把没真跑成的活显示成"完成"。
状态机翻页在哪发生。 StartTask 把服务端状态从 dispatched(或 waiting_local_directory)翻到 running(client.go:321)。关键时机(issue #3999 race A):它在 execenv.Prepare/Reuse 把 workdir 落盘之后才调用——否则读到 running 的消费者去解析 workdir 路径会在 os.MkdirAll 之前的微秒窗口里撞上 FileNotFound(daemon.go:4373-4387)。
一条任务上报的接口全景(都在 client.go):
| 接口 | 时机 | 符号 |
|---|---|---|
ClaimTask(s) | 认领 | client.go:204 / :236 |
StartTask | dispatched→running | client.go:321 |
ReportProgress | 跑的过程中报进度 | client.go:349 |
ReportTaskMessages | 批量报执行消息 | client.go:367 |
ReportTaskUsage | 报 token 用量 | client.go:387 |
PinTaskSession | 中途钉住 session_id/workdir,防崩溃丢失续接点 | client.go:412 |
CompleteTask | 成功终态(带重试) | client.go:373 |
FailTask | 失败终态(带重试) | client.go:396 |
取消监视(cancellation watch)。 agent 一旦开跑,handleTask 起一个 watchTaskCancellation 后台轮询服务端任务状态(daemon.go:3298 / :3165)。判断是否要中断的纯函数 shouldInterruptAgent(daemon.go:3154):
- 状态进了终态(completed/failed/cancelled)→ 中断,让本地 agent 别白跑;
- 404 "task not found"(任务行被删,如 issue 被删/重指派)→ 中断,别对着死任务继续发工具调用;
- 其它错误(网络抖动、5xx)故意不中断——下一 tick 重试,不让抖动误杀在跑的 agent。
终态"fail closed"。 reportTaskResult 只有在结果状态明确是 "completed" 时才走 CompleteTask;其它一切(blocked/cancelled/或将来忘了枚举的状态)一律走 FailTask(daemon.go:3552)。这样"没产出真结果的一次跑"永远不会在 UI 上显示成绿色的"已完成"(比如 provider 429、余额耗尽、runtime 崩溃)。
一个微妙权衡:CompleteTask 内部重试耗尽后仍是 5xx/不可达(瞬时错误),此时不把它翻成 fail——那会丢掉 agent 的真实结果、在 UI 上误报红色。而是把任务留在 running,等未来的清扫器恢复;只有永久性的服务端拒绝(4xx,非 408/429)才走 legacy fallback 报 fail(daemon.go:3564-3601)。