跳到主要内容

第 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.Runtimedb.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_available Wakeup;
  • Heartbeat Request/Ack;
  • 带 Correlation ID 的通用 RPC;
  • Event ID 去重;
  • 串行 Write Pump 和慢连接处理。

连接注册/注销时 Hub 维护房间计数。Redis Relay 可以把通知送到持有目标 Runtime 连接的另一 Server 实例。

7.9 WS-first Claim

Daemon 的现代 Claim 过程:

  1. 收到 Task Wakeup 或定时 Poll;
  2. 计算空闲槽位;
  3. 通过 Daemon WS 发送 daemon:rpc_request(method=tasks.claim)
  4. Server 在同一连接身份下校验 Runtime 列表并执行 Batch Claim;
  5. 返回 daemon:rpc_response
  6. Daemon 把 Task 分配到已预留槽位。

优点:

  • 少一次独立 HTTP/TLS 连接;
  • Wakeup 与 Claim 共享活连接;
  • Server 可从连接 Identity 直接约束 Scope;
  • 多个 Runtime 合并为一次机器级请求。

相关实现:wakeup.gowsrpc.gohandler/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 在线但不领任务”

建议依次检查:

  1. Daemon 内 Runtime Set 是否包含目标 ID/Workspace/Provider;
  2. Server agent_runtime.status/last_seen_at
  3. Agent 是否绑定同一个 Runtime,且并发未满;
  4. Daemon 是否有空闲 Slot 或正处于 Restart Barrier;
  5. WS Ack 是否新鲜,HTTP Fallback 是否启用;
  6. Batch Claim 返回空、阻止原因还是认证失败;
  7. Task 是否 queued、deferred 未到期、或已被另一 Daemon Claim;
  8. Client/Daemon 版本是否支持当前 Claim Payload。

7.20 本章源码导航

7.21 本章结论

Daemon 的本质不是“轮询并 exec”,而是一套机器级 Runtime 控制器:

  • 持续发现和收敛能力;
  • 维护双通道活性;
  • 在本地槽位与服务端队列之间做安全 Claim;
  • 管理取消、更新和终态上报;
  • 为下一章的执行环境提供生命周期边界。

下一章进入 Task 被领取后的本地世界:仓库、Worktree、用户目录、Provider Home 和 GC 怎样避免相互污染。


上一章:任务队列与状态机
下一章:执行环境、仓库、本地目录与 GC