跳到主要内容

本地守护进程:认领、准备、执行、上报

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、拉起 CLIdaemon.go:4052 runTask
repocache.Cache裸仓库缓存 + 部分克隆 + worktreerepocache/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 RPCwakeup.go:99 runTaskWakeupConnection
wsRPCClient在 WS 连接上跑"请求/响应"式 RPC(认领走这条更快)wsrpc.go:84 wsRPCClient
各后台循环心跳 / workspace 同步 / GC / 自更新 / token 续期daemon.go:1075-1082(Rungo 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-v1WS RPC tasks.claimwsRPC.supportsRPCV1()
WS 失败但确定没到服务端落回 HTTP 批量认领缓冲满/未发出的超时
WS 已发出但结果未知不立刻重试,等一个安全窗口见下
HTTP 批量返回 404落回 legacy 逐 runtimeisBatchClaimUnsupported

"已发出但结果未知"为什么最危险? 因为 WS 帧发出去了、连接却断在响应之前——服务端可能已经提交了这次认领。此时马上换 HTTP 再认领同样的空槽位,就会双重认领同一批任务。所以代码把它单独标成 errWSRPCUncertain,设一个延迟窗口 wsClaimUncertainFallbackDelay,期间跳过认领;真提交了的话由服务端的 stale-reclaim 兜底,没提交的话过窗口后 HTTP 恢复(wsrpc.go:24wsrpc.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.promisorremote.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 携带不同隔离点,都是为了不污染用户的全局配置:

字段给谁作用
CodexHomecodex每任务 CODEX_HOME,skills 不落到系统 ~/.codex
TaskHomecodex(Linux)沙箱里真实 HOME 只读,重定向 HOME/XDG 到可写目录
OpenclawConfigPathopenclaw每任务合成配置,把 workspace 钉到 WorkDir
CursorDataDircursor隔离 MCP 审批,不碰用户 ~/.cursor
HermesHomehermes每任务 overlay,软链用户 skills
LocalDirectory全部标记 WorkDir 是不是用户自己的目录(见 3.6 GC)

PredictRootDir 让调用方 Prepare 跑之前就能算出 RootDir 路径,好提前向 GC 声明"这块地我占了"(execenv/execenv.go:249,由 handleTaskmarkActiveEnvRoot 用)。

复用 vs 新建。 同一个 (agent, issue) 的下一个任务可以复用上一个任务的 workdir(execenv.Reuse,execenv/execenv.go:481),省掉重新 clone;runTaskshouldReusePriorWorkdir 决定走复用还是新 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回复评论线程
autopilotbuildAutopilotPrompt自动化触发
快速创建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
StartTaskdispatched→runningclient.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)。


3.6 reconcile / orphan 恢复:daemon 重启后怎么收拾残局

要解决的小问题: daemon 进程崩了/重启了,之前正在跑的任务会卡在服务端 dispatched/running;等服务端慢速心跳清扫器或 2.5h 任务超时太久了。

orphan 恢复。 daemon 一注册好某 workspace 的 runtime,就对每个 runtime 调 RecoverOrphans——告诉服务端:"上一个 daemon 进程在这些 runtime 上跑的任务,现在都是孤儿,失败并按需重试它们"(client.go:429,调用点 syncWorkspacesFromAPI daemon.go:2219)。这有两个触发点:

  1. 首次注册新 workspace 时(daemon.go:2218-2222)。
  2. runtime 被服务端删掉后重注册时(reregisterWorkspaceAfterRuntimeGone,daemon.go:874)——注意此路径RecoverOrphans,因为 runtime 是真没了;而 profile 漂移刷新路径故意不调,因为它幸存的 runtime 可能还在给用户跑活(daemon.go:891-905 注释)。

reconcile 广播——把粗粒度轮询变快。 有些循环跑在粗 ticker 上(取消轮询 5s、workspace 同步 30s)。WS 连接一旦(重)连上,服务端在断连间隙改的东西对这些循环不可见,得等下个 tick。reconcileBroadcaster 让 WS 连上时广播一下,把每个等待者立刻唤醒去重新对账(reconcile.go:35,broadcast :86)。它有几个精心的性质:

  • 边沿触发 + 单槽重放:广播时没订阅者,下一个 notify() 返回一个已关闭的 channel,让迟到订阅者恰好一次看到错过的事件——堵住 daemon 启动竞态。
  • 去抖:minBroadcastInterval 内的连续广播被丢弃,让抖动的 WS 连接不会把网络毛刺放大成一场 GetTaskStatus/ListWorkspaces 请求风暴。

workspaceChangeSignal 是另一条更轻的单消费者脏标志,专管 workspace 成员集变化(reconcile.go:104)。

GC 与 orphan 目录。 GC 循环(gcLoop,gc.go:18)周期扫本地 workspace 目录,清掉 issue 已 done/cancelled 且过了 TTL 的任务目录;认不出归属又老的目录走 gcActionOrphan(超过 GCOrphanTTL)。任务在跑期间用 markActiveEnvRoot 挂进程内引用计数,GC 绝不会在执行中把 envRoot 回收掉(handleTask daemon.go:3272-3282)。.gc_meta.json 在任务完成后最后才写——中途崩溃就把目录留成孤儿、交给 orphan TTL 清(daemon.go:3372-3391)。


3.7 心跳、wakeup、自更新(其余后台循环)

Run 在预检通过后一次性拉起所有后台循环(daemon.go:1075-1082):

go d.workspaceSyncLoop(ctx) // 发现新增/移除的 workspace,重注册(daemon.go:2049)
go d.taskWakeupLoop(ctx, ...) // WS 长连接:收"有活了"、发心跳(wakeup.go:46)
go d.heartbeatLoop(ctx) // 每 runtime 独立 HTTP 心跳(daemon.go:2261)
go d.gcLoop(ctx) // 周期清本地目录(gc.go:18)
go d.autoUpdateLoop(ctx) // 轮询 GitHub 新版本,空闲时自升级(auto_update.go:42)
go d.tokenRenewalLoop(ctx) // 续期 PAT(daemon.go:2000)

心跳为什么每 runtime 一个 goroutine? 一个 daemon 可能服务多个 workspace;共享一个心跳循环时,某 runtime 的 30s HTTP 超时会串行拖住所有 runtime 的心跳。改成各自独立后互不阻塞(heartbeatLoop 注释,daemon.go:2256-2260)。

自更新的"不打断在跑任务"屏障。 issue 要求"升级中如果有 task 进来,延后升级而不是中断 task"。tryAutoUpdate 用两道检查(auto_update.go:91):

① 便宜预检:activeTasks>0 就直接跳过(省掉去 GitHub 拉 release 的开销)
② 严格屏障 trySetClaimBarrier:在 claimMu 下检查 claimsInFlight + activeTasks
都为 0 才把 pauseClaims 翻 true;为真才升级

这样 poller 在 pauseClaims 时拒绝认领(daemon.go:2991 tryEnterClaim),而升级又只在没有在途认领/在跑任务时才启动——闭合了"拉 release 期间新任务溜进来又被 triggerRestart 的根 ctx 取消掉"的竞态(daemon.go 结构体 claimMu 注释 :307-322)。升级成功后 triggerRestart 取消根 ctx,Run 返回,父进程 re-exec 新二进制(daemon.go:2849)。


3.8 daemon ↔ server 的 WS RPC 通道

要解决的小问题: 认领这类高频调用走 HTTP 每次都要握手/建连,慢。能不能驮在已有的那条 WS 控制连接上?

taskWakeupLoop 维护一条到 /api/daemon/ws 的 WebSocket(wakeup.go:99)。这条连接多路复用三种东西:

  1. 服务端推的 EventDaemonTaskAvailable(有活了)→ signalTaskWakeup 唤醒 poller(wakeup.go:378-389);
  2. daemon 发的心跳帧(runWSHeartbeatSender);
  3. 通用请求/响应 RPC(wsRPCClient),tasks.claim 就走这条(MUL-4257)。

wsRPCClientrequest_id 把响应对回请求,支持多个 RPC 并发在途(wsrpc.go:84)。它和这个包里最难的并发正确性——避免双重认领——纠缠在一起,几处设计值得记:

  • 发送帧可取消(wsOutbound,wsrpc.go:51):beginWrite/cancel 在锁下竞争,谁赢谁定这帧到底发不发。RPC 调用方超时放弃时,如果帧还没离开 writer 就取消它(确定没发,可安全 HTTP 退路);如果已经开始发就是"结果未知"(不能退路)。
  • 拆连接时先关 socket、再让 pending RPC 落空(wakeup.go:209-230 的 defer):否则一个排队的 tasks.claim 帧会在 attach(nil) 已让 RPC 退到 HTTP 之后才刷到还活着的 socket,服务端就在 HTTP 退路之上又提交了这次 WS 认领——双重认领(Sol-Boy review)。
  • 读上限 64 MiB(taskWakeupReadLimit,wakeup.go:33):一个 tasks.claim 响应可含最多 32 个完整 Task 载荷;旧的 64 KiB 上限比单个合法响应还小,会让服务端提交了认领、daemon 却拒收响应,任务卡死在 dispatched。

attach 每次(重)连接自增 generation 并清掉上条连接协商的能力,让一个跨连接竞争的认领永远不会把上条连接授权的调用重定向到新连接上(wsrpc.go:111)。


4. 端到端:追一条「认领 → 执行 → 完成」

把前面的机制串成一条真实路径,一个普通 issue 任务:

① 服务端把 issue 派给本机某 runtime,WS 推 EventDaemonTaskAvailable
readTaskWakeupMessagesForConnection → signalTaskWakeup(runtimeID) wakeup.go:389

② pollLoop 收到 taskWakeups → nudge() daemon.go:2934

③ runBatchPoller 醒来: daemon.go:2952
waitForTaskSlot + drainAvailableSlots 抢槽位 daemon.go:2974-2987
tryEnterClaim 过自更新屏障 daemon.go:2991
ClaimTasksWSFirst(daemonID, runtimeIDs, N) wsrpc.go:315
└─ WS RPC tasks.claim(优先) / HTTP / legacy 退路
│ 拿到 []*Task
④ 每个任务 taskWG.Add(1); activeTasks++; go handleTask(parentCtx, t, slot) daemon.go:3027-3042

⑤ handleTask: daemon.go:3215
acquireLocalDirectoryLockIfNeeded (如需) daemon.go:3255
markActiveEnvRoot(predictedEnvRoot) 防 GC daemon.go:3272
watchTaskCancellation 起取消监视 daemon.go:3298
result, err := d.runner.run(runCtx, ...) daemon.go:3307

⑥ runTask: daemon.go:4052
registerTaskRepos daemon.go:4083
resolveAgentEntry / customProfile 选二进制+版本 daemon.go:4099-4113
ensureTaskSkillBundles 备 skill daemon.go:4122
Reuse 或 Prepare 搭隔离环境 daemon.go:4306 / 4352
StartTask ← dispatched→running(落盘后才调!) daemon.go:4384
ReportProgress "Launching codex" 1/2 daemon.go:4391
BuildPrompt + agentEnv(MULTICA_TOKEN 等) daemon.go:4439-4466
agent.New(provider, ...). 流式跑(见 01 章) daemon.go:4554
│ 返回 TaskResult
⑦ 回到 handleTask: daemon.go:3314-3370
ReportTaskUsage 报用量(即便被取消也报) daemon.go:3315
若被取消 → AckTaskCancelled,丢结果 daemon.go:3327
若 err → FailTask(带 failure_reason 分类) daemon.go:3342
否则 reportTaskResult: daemon.go:3370
status=="completed" → CompleteTask daemon.go:3556
其它 → FailTask(fail closed) daemon.go:3602
WriteGCMeta 最后写元数据 daemon.go:3387

⑧ handleTask 返回 → defer 归还槽位 + 唤醒 poller daemon.go:3032-3040

一句话:WS 推送唤醒 → 先抢槽后认领 → 每任务一个 goroutine 备环境+翻状态+拉 CLI → fail-closed 回报 → 归还槽位


5. 巧妙之处(可借鉴的技术)

  • slot-before-claim:先占本地产能再向服务端要活,从根上避免"认领了却跑不了"卡在 dispatched(daemon.go:2972-2999)。
  • "结果未知"当一等公民:WS 认领断在响应前不当作失败重试,而是等安全窗口,靠服务端 stale-reclaim 兜底——把"最多一次执行"的正确性挡在双重认领之前(wsrpc.go:24:350)。
  • path 与 version 成对发布:自愈路径时用一个结构体值原子携带 {path, version},杜绝"新二进制跑在旧版本策略下"的读时窗口(daemon.go:433:519)。
  • fail-closed 终态:只有明确 completed 才算成功,其余全部 fail;但 CompleteTask 瞬时失败时宁可留 running 也不误报 fail(daemon.go:3552-3601)。
  • 拆 WS 连接先关 socket:关闭顺序被特意从 LIFO defer 折进一个函数,先关 socket 让排队帧被丢弃,再让 pending RPC 落到 HTTP——否则会双重认领(wakeup.go:209-230)。
  • 部分克隆手动补 promisor config:git clone --local 丢的两个 config 键手动补回,让继承的不完整对象库仍能懒加载(repocache/cache.go:823)。
  • singleflight 合并自愈:多个排队任务同时撞见刚升级的 agent,只付一次 login-shell 探测(daemon.go:499)。

6. 边界与局限

  • daemon 只编排,不实现 agent 协议:把 15+ CLI 抽象成"一种执行"是 01-agent-runtimepkg/agent 干的;daemon 只调 agent.New(...).Run
  • 任务状态机的权威在服务端:daemon 只翻 dispatched→running→completed/failed,派单判定、重试策略、状态机规则在 03-task-dispatch-lifecycle
  • WS 帧的服务端半边不在这里:hub、多实例扇出、浏览器广播在 04-realtime-protocol;本章只讲 daemon 侧那条控制连接。
  • 靠本机环境:agent CLI 必须已装、已登录、在 PATH 上;探测不到就静默跳过注册,不会替用户装。
  • local_directory 任务串行:同一本地路径的任务靠 LocalPathLocker 串行跑,第二个会以 waiting_local_directory 状态阻塞等待(daemon.go:333-338acquireLocalDirectoryLockIfNeeded :3418)。
  • GC 与执行的竞态用引用计数硬防:envRoot / Codex store 都有 active 引用计数 + 删除保留位,代码里大量 #3999/MUL-4424 注释说明这些窗口被逐个堵过。

7. 横向对比(同组其它章)

  • 想知道"一种执行"怎么把 Claude/Codex/Cursor 抹平成一个接口 → 01-agent-runtime
  • 想知道服务端怎么从 issue 判定要不要开 run、任务状态机和路由 → 03-task-dispatch-lifecycle
  • 想知道本章那条 WS 连接的服务端半边、hub 与多实例扇出 → 04-realtime-protocol
  • 全局定位与阅读地图 → index

8. 代码地图(导航索引)

主题文件路径符号
daemon 总状态机server/internal/daemon/daemon.goDaemon(struct)
启动编排server/internal/daemon/daemon.goRun
内置 runtime 探测server/internal/daemon/daemon.godetectBuiltinRuntimes
按 workspace 注册server/internal/daemon/daemon.goregisterRuntimesForWorkspace / appendProfileRuntimes
agent 路径自愈server/internal/daemon/daemon.goresolveAgentEntry / healAgentPath / healedAgent
profile 漂移刷新server/internal/daemon/daemon.gorefreshWorkspaceRuntimeProfiles
唯一认领派发循环server/internal/daemon/daemon.gorunBatchPoller / pollLoop
单任务外壳server/internal/daemon/daemon.gohandleTask
真正执行server/internal/daemon/daemon.gorunTask
取消监视server/internal/daemon/daemon.gowatchTaskCancellation / shouldInterruptAgent
fail-closed 回报server/internal/daemon/daemon.goreportTaskResult
workspace 同步 + orphan 恢复server/internal/daemon/daemon.gosyncWorkspacesFromAPI / reregisterWorkspaceAfterRuntimeGone
local_directory 锁server/internal/daemon/daemon.goacquireLocalDirectoryLockIfNeeded / LocalPathLocker
自更新屏障server/internal/daemon/auto_update.gotryAutoUpdate / trySetClaimBarrier
HTTP 控制面客户端server/internal/daemon/client.goClient / ClaimTasks / StartTask / CompleteTask / FailTask / RecoverOrphans
WS 优先认领server/internal/daemon/wsrpc.goClaimTasksWSFirst / wsRPCClient
WS 长连接server/internal/daemon/wakeup.gotaskWakeupLoop / runTaskWakeupConnection
reconcile 广播server/internal/daemon/reconcile.goreconcileBroadcaster / workspaceChangeSignal
prompt 组装server/internal/daemon/prompt.goBuildPrompt
线程命名server/internal/daemon/thread_name.goderiveTaskThreadName
仓库缓存 / 部分克隆server/internal/daemon/repocache/cache.goCache / CreateWorktree / configurePromisorRemote
隔离运行环境server/internal/daemon/execenv/execenv.goPrepare / Reuse / Environment / PredictRootDir
skill 缓存server/internal/daemon/skill_cache.goSkillBundleCache
本地目录 GCserver/internal/daemon/gc.gogcLoop / runGC