Daemon:把二十多种 agent CLI 变成同一支队伍
Multica 不实现 agent,它驱动你机器上已有的 CLI。真正难的是:路径会漂移、目录会撞车、进程会失联,而每一种 CLI 的脾气都不一样。
Daemon:把二十多种 agent CLI 变成同一支队伍
这篇写给写过本地执行器、CLI 集成或 CI runner 的工程师。你不需要用过 Multica,但最好知道进程管理、工作目录隔离和 WebSocket 长连接的基本做法。读完可以判断:把”用户机器上的命令行工具”当成执行后端,需要补哪些工程。
Multica 的 daemon 是一个 Go 二进制,跑在用户机器上,通过 multica daemon start 启动。它不调用模型 API,也不管理 API key,只做四件事:发现本机有哪些 agent CLI、把它们注册成 runtime、领取任务、在隔离环境里把 CLI 拉起来并汇报结果。
听起来像是一个高级的 subprocess.run。实际难点在于:它面对的是二十多种互不兼容的 CLI,和一台你无法完全控制的机器。
一、Runtime:把”机器 + provider”变成一个可调度实体
服务端的 agent_runtime 表有一行定义:
UNIQUE (workspace_id, daemon_id, provider)
同一个 daemon 在同一 workspace 里,每个 provider 注册一个 runtime。Claude Code 一个、Codex 一个、Cursor Agent 一个。这样任务可以精确地派给”这台机器上的这个 CLI”,而不是笼统地派给”这台机器”。
runtime 状态只有 online / offline,靠心跳维持。daemon 默认每 15 秒发一次心跳,服务端按批落库(约 60 秒一次),领取任务时会检查 last_seen_at 是否在 150 秒内。这三个数字的配合在调度那篇讲过,这里不重复。
注册不是一次性的。机器上装了新 CLI、版本升级、路径变化、profile 切换,daemon 都会重新探测并同步。applyRegisterResponseInPlace 会对比新旧 runtime 集合,新增的记下来、消失的标成 offline,而不是把整个集合删掉重建——后者会让正在跑的任务失去归属。
二、探测:路径是会漂移的
resolveAgentEntryWithHeal 这个名字里的 “heal” 不是修辞。真实场景里,用户可能:
- 用 nvm / fnm 切换了 Node 版本,
claude的绝对路径变了; - 用 Homebrew 升级,二进制从
/opt/homebrew/bin换到了别处; - 在 CI 镜像里,PATH 在登录 shell 和非登录 shell 下不一样;
- 手动删了又重装,inode 变了。
daemon 保存的是”上次解析到的路径 + 版本”。每次启动任务前,它会重新解析;如果解析失败或版本不符,就走自愈:重新在 PATH 里找、核对 --version、更新记录,并把新路径回报给服务端。
agents_probe.go 里为每个 provider 写了探测逻辑:可执行文件名、版本参数、输出解析。有的 CLI 版本号在 stderr,有的要加子命令,有的启动很慢需要单独的超时。这些琐碎的适配,构成了”支持 26 种 provider”的大部分工作量。
三、隔离:每个任务一个目录,而且要证明所有权
标准模式下,一个任务的执行环境长这样:
~/multica_workspaces/
<workspace-slug>-<id 尾>/
<issue-key>-<id 尾>/
workdir/ # agent 的工作目录
output/ # 交付物
logs/ # 运行日志
multica-config/ # 任务级配置,权限 0700
agent 在 workdir 里启动,环境变量指向任务级配置。仓库不预克隆,agent 需要时自己跑 multica repo checkout <url>。这样做的好处是:不用的仓库不占磁盘,checkout 哪个由 agent 根据任务决定。
目录隔离有一个真实事故值得记。早期版本用 task id 的 UUIDv7 前缀做路径段。UUIDv7 的时间戳高位每 65 秒左右才变一次,于是同一时段创建的两个任务,路径前缀可能相同。结果是一个任务启动时 resetEnvRootContents 把另一个正在运行的任务的工作目录清空了。
修复分两层:
- 路径段改用 id 的随机尾部,让碰撞”极不可能”。
claimEnvRoot在清空目录前先原子地取得所有权:创建锁文件、校验 owner、再重置内容。注释写得很直白:
A task that refuses to start is recoverable; one that deletes a running sibling’s uncommitted work is not.
这是隔离设计的一条通用原则:不要假设路径唯一,要证明所有权。哈希碰撞、时间戳精度、大小写不敏感的文件系统,都会让”看起来唯一”的键失效。
四、两种工作目录:原地改,还是开分支
如果任务关联的是用户本地的代码目录(local_directory 资源),有两种执行模式。
原地模式(LocalWorkDir):agent 直接在用户的目录里工作。好处是所见即所得,坏处是同一个目录同时只能跑一个任务,daemon 要拿一把按路径的互斥锁。用户没提交的改动也在,agent 可能误改。
Worktree 模式(LocalWorktree):在 daemon 的 env root 里为这个任务建一个 git worktree,agent 在里面工作,结束后把改动提交成一个分支,交回用户仓库。同一目录可以并发跑多个任务。
worktree 的实现有三条明确保证,写在文件开头:
- agent 看到的就是用户看到的。直接
git worktree add会 checkout HEAD,把用户未提交的改动藏起来。所以它先把用户目录做一次快照(已跟踪的修改 + 未跟踪文件),再回放到 worktree 里。 - 绝不动用户的目录。worktree 里的一切都是可丢弃的;留在用户仓库里的只有一个分支,以及一个记录归属的隐藏 ref。
- 不静默丢弃。agent 结束时没提交的改动,会先提交到分支;用户自己的改动如果合并失败,下一轮会再提供一次,而不是假装已经交付。
这三条其实是同一个原则的三个面:执行环境可以脏,但用户的仓库不能。
五、执行:一条命令,但收尾很长
handleTask 拿到任务后,先做一串前置检查:runtime 是否还在、本地目录锁能否拿到、env root 是否能解析。然后进入 runTask:
- 准备环境(或复用上一次的 workdir)。
- 调
start,服务端确认租约后才真正启动。 - 构造 prompt,拉起 provider 对应的 CLI。
- 边跑边解析输出:工具调用、命令、错误、token 用量。
- 监听取消信号。daemon 每 5 秒轮询一次任务状态,服务端说取消就杀进程树。
- 收尾:把 output、分支名、session id、workdir 一起回报。
第 5 步的”杀进程树”值得单说。agent CLI 会 fork 子进程(测试、构建、语言服务器),只杀父进程会留下孤儿。daemon 里有专门的 processtree 包,按平台处理进程组;容器里还要考虑 PID 命名空间。
第 6 步的 session id 是续跑的关键。同一个 issue 的下一轮任务,会带着上一轮的 session id 启动,CLI 据此恢复对话;如果 session 丢了,prompt 里会插入一段”连续性提示”,告诉 agent 它失去了什么、去哪里重新读上下文。这个设计在 prompt 那篇展开。
六、连接:WebSocket 叫醒,轮询兜底
daemon 不能只靠轮询。几万台机器每 5 秒查一次库,成本不可接受。所以主路径是 WebSocket:
- daemon 建立
/api/daemon/ws,服务端在有新任务时发一个”可能有活”的提示。 - daemon 收到提示后,通过同一条连接发
tasks.claimRPC,直接把任务领回来。 - 连接断了,退化为 HTTP 轮询;重连按 1 秒到 30 秒指数退避,带 ±20% 抖动。
这条连接同时承载心跳和小型 RPC。读上限设成 64 MiB,因为一个 claim 响应最多包含 32 个完整任务,每个任务都带 issue 描述、评论、仓库和 skill 引用。早期 64 KiB 的上限会导致”服务端已提交、daemon 却读不下响应”的尴尬局面,随后被当成投递失败回收。
WS 和 HTTP 双通道也带来了双领风险。调度那篇讲过 errWSRPCUncertain:请求已发出但连接断了,daemon 不许回退 HTTP。这里补一个实现细节——发送帧带 sent / canceled 状态,调用方超时放弃时,如果帧还没写进 socket,就取消它;一旦写进去了,就不能再假装没发。这个原子状态机是为了避免”Backpressure 队列里排着一个过期的 claim,重连后又被发出去”。
七、崩溃与收尾:把结果先写到磁盘
daemon 可能被 kill -9、断电、系统休眠。任务结果不能只存在内存里。
terminal_report_queue 是一个落盘的待上报队列:任务完成或失败时,先把结果写进 ~/.multica/.../pending-terminal-reports,再尝试上报;上报失败就留在队列里,下次启动重试。这样即使服务端短暂不可用,结果也不会丢。
服务端侧有对应的 RecoverOrphanedTasks:daemon 重启后,可以查询”这个 runtime 上还有哪些 dispatched/running 但没有对应执行记录”的任务,把它们回收或标记失败。
磁盘侧还有 GC。~/.multica_workspaces 下的任务目录,完成后默认保留一段时间供排查;超过 GCOrphanTTL(默认 72 小时)且没有 .gc_meta.json 的目录,会被当成孤儿清理。GC 和正在运行的任务之间用引用计数保护:任务启动时 markActiveEnvRoot,结束时 unmark,GC 不会碰有活跃引用的目录。
八、代价
- 平台适配是长尾。二十多种 CLI,每种都有自己的参数、输出格式、会话恢复方式、退出码语义。代码里为此散落着大量 provider 分支,
execenv一个包就有五万多行。 - 单文件过大。
daemon.go11137 行,handleTask一个函数就近千行。注释密度很高,但改动时仍然需要很强的上下文。 - 本地环境不可控。用户的 shell、PATH、权限、磁盘空间、杀毒软件,都会变成支持工单。daemon 能做的是快速失败、留下清晰日志,而不是假装自己能修复一切。
小结
把 agent CLI 当执行后端,核心不是”启动一个进程”,而是三件事:发现它、隔离它、在它失联后收拾残局。
Multica 的答案分别是:带自愈的探测、带所有权证明的每任务环境、以及落盘的结果队列加孤儿回收。下一篇文章往上走一层,看 daemon 在启动 CLI 之前,往 prompt 里塞了什么、又刻意没塞什么。