第 7 章:Daemon、Runtime 与任务抢占
7.1 Daemon 与 Runtime 的区别
Daemon 是一台机器上的常驻进程;Runtime 是服务端可调度的一项具体能力。
例如一台开发机安装了 Claude、Codex 和 Pi,并加入两个 Workspace,可能形成:
Daemon machine-7
├── workspace A / claude runtime
├── workspace A / codex runtime
├── workspace A / pi runtime
├── workspace B / claude runtime
└── workspace B / codex-custom-profile runtime
Daemon 负责这些 Runtime 的共同连接、槽位、磁盘和进程;Server 按 Runtime Provider/Workspace/Owner 做权限与任务路由。
数据结构可从 daemon.Runtime 与 db.AgentRuntime 对照。
7.2 启动入口
CLI 命令树在 cmd_daemon.go,核心生命周期在 Daemon.Run。
启动大体经历:
Profile 不只是显示名称,它隔离配置文件、Daemon ID、Workspace 选择、状态目录和 Runtime Profile,允许同一台机器连接 dev/staging/prod 而不混淆。
7.3 稳定 Daemon Identity
EnsureDaemonID 为当前 Profile 持久化机器 ID。稳定 ID 用于:
- Server 识别重启前后的同一 Daemon;
- 合并旧版根据 Hostname 生成的 Runtime;
local_directory资源绑定特定机器;- 自定义名称继承;
- 失活与重新注册协调。
源码还维护 Legacy IDs/UUIDs,用于升级时收敛旧 Runtime,避免每次升级都在 UI 多出一组“新机器”。
7.4 Provider 探测与路径固定
Daemon 启动时探测各 Provider 命令:Claude、Codex、CodeBuddy、Copilot、OpenCode、DevEco、OpenClaw、Hermes、Pi、Cursor、Kimi、Kiro、Antigravity、Qoder、Trae、Grok、Qwen。
每个 AgentEntry 同时保留:
Path:启动时解析并尽量固定的可执行文件路径;Command:原始命令名/环境配置;- 可选 Model。
为什么不每次只 exec.LookPath("claude")?版本管理器或 Homebrew 升级可能在 Daemon 运行期间替换 Symlink。固定路径保证一个任务使用明确二进制;若路径后来消失,resolveAgentEntry 会尝试从原 Command 自愈并重新解析。
7.5 自定义 Runtime Profile
Workspace 可以定义 Runtime Profile:Provider、Command Name、路径、固定参数等。Daemon 定期同步 Profile,并把它们注册成带 profile_id 的 Runtime。
这解决:
- 同一 Provider 的多个版本或包装器;
- 团队统一的启动脚本;
- 非标准 PATH;
- Built-in Provider 与自定义环境共存。
Server 通过 Daemon WS 发送 runtime_profiles_changed,Daemon 触发重新协调,而不必等下一次长轮询。
7.6 注册不是一次性 POST
Daemon 会按 Workspace 注册 Runtime 集合,并维护一个收敛循环:
- Workspace 新增/移除;
- CLI 安装或路径自愈;
- Runtime Profile 变更;
- Server 返回 Runtime Gone;
- Token/Workspace 列表续期;
- 旧 Runtime 合并。
注册响应包含服务端 Runtime ID。Daemon 在内存中原子替换集合,并通知 WS/Poller 使用新 Runtime Set。
为避免某个 Workspace 注册失败拖死所有 Workspace,源码保留每 Workspace 状态、错误与重试节奏。
7.7 Heartbeat 的双通道
正常情况下,Daemon WebSocket 同时承载 Heartbeat:
- 定期发送 Runtime ID 与能力;
- Server 更新
last_seen_at、状态和可选动作; - Ack 可携带模型列表、更新、Skill 导入等请求;
- Daemon 记录最近 WS Ack。
若 WS 断开或 Ack 不新鲜,HTTP Heartbeat 重新接管。WS 恢复后再抑制冗余 HTTP。
这不是简单“双写”:Daemon 必须确认某一时刻至少有一条有效 Liveness 路径,同时避免 WS 和 HTTP 并发把同一动作执行两次。
7.8 为什么单独有 Daemon WebSocket
server/internal/daemonws/hub.go 提供:
- 机器 Identity 与 Workspace 授权;
- Runtime/Workspace/User 三种定向通知;
task_availableWakeup;- Heartbeat Request/Ack;
- 带 Correlation ID 的通用 RPC;
- Event ID 去重;
- 串行 Write Pump 和慢连接处理。
连接注册/注销时 Hub 维护房间计数。Redis Relay 可以把通知送到持有目标 Runtime 连接的另一 Server 实例。
7.9 WS-first Claim
Daemon 的现代 Claim 过程:
- 收到 Task Wakeup 或定时 Poll;
- 计算空闲槽位;
- 通过 Daemon WS 发送
daemon:rpc_request(method=tasks.claim); - Server 在同一连接身份下校验 Runtime 列表并执行 Batch Claim;
- 返回
daemon:rpc_response; - Daemon 把 Task 分配到已预留槽位。
优点:
- 少一次独立 HTTP/TLS 连接;
- Wakeup 与 Claim 共享活连接;
- Server 可从连接 Identity 直接约束 Scope;
- 多个 Runtime 合并为一次机器级请求。
相关实现:wakeup.go、wsrpc.go、handler/daemon_rpc.go。
7.10 HTTP Fallback 为什么必须“确认安全”
若 WS RPC 请求已经被 Server 处理,但 Response 在网络中丢失,Daemon 立即用 HTTP 再 Claim,可能获得另一批 Task,超出槽位或造成代际混乱。
当前实现用 WS RPC Generation、Pending Request、可取消 Outbound Frame 和连接生命周期来判断:
- 旧连接中的请求是否可能已经发出;
- Response 是否还可能到达;
- Fallback 是否会与新连接重复;
- 哪个 Generation 的 Ack 可接受。
只有确认安全时才回落 HTTP。连接重建会取消旧代请求,避免“双 Claim”。
7.11 一个机器级 Batch Poller
早期直觉是“每个 Runtime 一个 HTTP Poll Loop”。当前设计改为一个机器级 Poller:
- 汇总所有 Runtime;
- 共享空闲槽位;
- 一次 Batch Claim 最多领取 32 个左右的 Payload;
- Wakeup 只唤醒一个 Poller;
- Runtime Set 变化通过 Watcher 触发重算。
这降低空队列轮询、连接和竞争开销,也避免某个 Provider Loop 独占机器并发。
Daemon WS Read Limit 提高到 64 MiB,是因为 Batch Claim 可能包含多份带 Issue Context、Skills 和附件元数据的大 Task Payload;这是协议形状反向影响网络参数的例子。
7.12 槽位分配
Daemon 使用本地 Semaphore/Slot Number 限制整机并发:
- Claim 前拿到 Slot;
- 每个 Task 获得稳定 Slot 用于日志/目录命名;
handleTask结束时释放;- 终态完成会触发下一轮 Poll;
- Restart Barrier 可以阻止新 Claim,等待在途任务收敛。
Agent 自身还有 max_concurrent_tasks,由 Server Claim SQL 检查。机器并发与 Agent 并发是两层限制。
7.13 handleTask 的骨架
Daemon.handleTask 大致如下:
迟到结果不能覆盖 Server 已接受的取消或删除。Daemon 在终态上报前后重新读取状态,保守丢弃冲突结果。
7.14 Cancellation Watcher
任务进程无法只依赖 Browser→Server→WS Push,因为任一连接可能断开。Daemon 对每个运行 Task 启动约 5 秒轮询:
GetTaskStatus返回 cancelled/terminal 时取消 Context;- WS 重连后主动 Nudge 检查,减少最坏等待;
- Watcher 与主 Task Context 同寿命;
- 取消后仍允许有限时间 Drain 输出。
这是“Push 降低延迟,Poll 保证最终可见”的组合。
7.15 Terminal Reporting 的可靠性
Provider 结束后,Daemon 先汇总:
- Result Status/Output/Error;
- Session ID;
- Workdir/Branch;
- Usage;
- Failure Reason;
- Resume Rejected/Fallback 状态。
终态上报可能因临时网络失败而重试,但不能无限阻塞 Slot。Server API 的状态前置条件确保重复 Complete/Fail 幂等地失败或读取既有终态。
GC Meta 最后写,是因为它表示“这个执行环境已经完成并可由 GC 按业务状态判断”。过早写 Meta 会让 GC 把仍在上报结果的环境当成已完成候选。
7.16 Runtime Gone 与重新收敛
Server 若返回 Runtime Not Found,Daemon 不能只把当前请求重试:
- Runtime 可能被用户删除;
- Workspace 成员关系可能变化;
- 注册行可能在 Server 部署中重建;
- 旧 ID 不能继续 Claim。
Daemon 会移除本地旧 Runtime、重新注册该 Workspace、刷新 WS Room 与 Poller Runtime Set,并进行必要的孤儿恢复。
7.17 Token Renewal 与 Workspace Sync
Daemon 的长期运行还需要:
- 预检 Token;
- 在支持时续期;
- 定期获取当前用户可用 Workspace;
- 新 Workspace 自动注册 Runtime;
- 失去权限的 Workspace 将 Runtime 收敛为零;
- 通知本地连接和 Server 清理旧状态。
机器进程不能假设启动时的 Workspace 列表永远不变。
7.18 Daemon 自更新
Heartbeat Ack 可以携带 Pending Update。Daemon:
- 校验目标版本与更新命令;
- 设置 Claim Barrier,停止领取新 Task;
- 等待或协调在途任务;
- 运行更新;
- 上报 Update Result;
- 由外层进程机制重启新二进制。
更新也是一种状态迁移,不能在任意执行中直接覆盖正在运行的二进制。
7.19 调试“Runtime 在线但不领任务”
建议依次检查:
- Daemon 内 Runtime Set 是否包含目标 ID/Workspace/Provider;
- Server
agent_runtime.status/last_seen_at; - Agent 是否绑定同一个 Runtime,且并发未满;
- Daemon 是否有空闲 Slot 或正处于 Restart Barrier;
- WS Ack 是否新鲜,HTTP Fallback 是否启用;
- Batch Claim 返回空、阻止原因还是认证失败;
- Task 是否 queued、deferred 未到期、或已被另一 Daemon Claim;
- Client/Daemon 版本是否支持当前 Claim Payload。
7.20 本章源码导航
- Daemon 主体:
server/internal/daemon/daemon.go - 配置:
server/internal/daemon/config.go - Identity:
server/internal/daemon/identity.go - WS Wakeup/RPC:
server/internal/daemon/wakeup.go、wsrpc.go - HTTP Client:
server/internal/daemon/client.go - Daemon WS Hub:
server/internal/daemonws/hub.go - Server Claim Handler:
server/internal/handler/daemon.go
7.21 本章结论
Daemon 的本质不是“轮询并 exec”,而是一套机器级 Runtime 控制器:
- 持续发现和收敛能力;
- 维护双通道活性;
- 在本地槽位与服务端队列之间做安全 Claim;
- 管理取消、更新和终态上报;
- 为下一章的执行环境提供生命周期边界。
下一章进入 Task 被领取后的本地世界:仓库、Worktree、用户目录、Provider Home 和 GC 怎样避免相互污染。
上一章:任务队列与状态机
下一章:执行环境、仓库、本地目录与 GC。