第 6 章:任务队列与状态机
6.1 Task 是 Multica 的“进程控制块”
agent_task_queue 不是一个简单待办队列。它同时记录:
- 工作要交给谁、在哪个 Runtime 执行;
- 当前处于哪个可见状态;
- 谁触发、谁负责、证据是什么;
- 已交付哪些评论与能力;
- Provider Session 和 Workdir 如何恢复;
- 每次尝试的错误、结果、消息、用量和时间;
- 是否是 Squad Leader、Autopilot、Chat、Quick Create 或 Retry。
因此 Task 状态机是控制平面与执行平面的共同协议。状态不能由 Daemon 或 UI 自由赋值,只能通过受约束的迁移函数推进。
6.2 主状态机
当前主要状态如下:
协议事件在 server/pkg/protocol/events.go 中显式标注了用户可见迁移:task:queued、task:dispatch、task:waiting_local_directory、task:running、task:completed、task:failed、task:cancelled。
数据库的实际允许前态比图更细,事实源是 agent.sql 的条件更新。
6.3 入队:先冻结执行快照
创建 Task 时会写入:
- Agent、Runtime、Issue/Chat/Autopilot 关联;
- Trigger Comment 与 Coalesced Comments;
- Task 类型所需的 Context JSON;
- Priority、Attempt、Max Attempts;
- Originator/Accountable/Evidence;
- Runtime MCP Overlay 与 Connected Apps;
- Squad/Leader/Handoff;
force_fresh_session等恢复策略。
成功提交后,TaskService.NotifyTaskEnqueued 做两件事:
- 发布
task:queued给客户端; - 通过 Daemon Wakeup Notifier 提醒对应 Runtime“可能有工作”。
Wakeup 不是工作本身。Daemon 收到后仍必须 Claim,Server 仍是唯一仲裁者。
6.4 Claim 前为什么先看本地槽位
旧式设计常先 Claim,再等待本地 Semaphore。这样数据库已经显示 dispatched,但任务可能在 Daemon 内部排队很久,租约和用户状态都变得误导。
当前 Daemon 的批量 Poller 先获得本地并发槽位,再把可用 Runtime 与最大任务数发给 Server:
空闲槽位 = 3
候选 Runtime = [claude-runtime, codex-runtime, pi-runtime]
Claim max = 3
Server 再按 Runtime、Agent 并发、优先级和可领取状态原子 Claim。这样“dispatched”更接近“Daemon 已有容量开始准备”。
6.5 原子 Claim
Claim SQL 需要同时防:
- 两个 Daemon/Server 实例领取同一 Task;
- Agent 已达到
max_concurrent_tasks; - Task 的 Runtime 已变化或离线;
- Deferred 的
fire_at未到; - 另一个事务刚取消/领取;
- 批量 Claim 在多个 Runtime 之间不公平。
典型手段是事务、受限 UPDATE、行锁与 SKIP LOCKED。只有成功从 queued 改成 dispatched 的行才返回给 Claim 响应。
Claim 后 prepare_lease_expires_at 被设置,保护“已领取但尚未 Start”的准备阶段。
6.6 为什么需要 Prepare Lease
环境准备可能包含:
- 解析/下载 Skill Bundle;
- Fetch 仓库;
- 创建 Worktree;
- 校验
local_directory; - 写 Provider Home 与配置;
- 注入上下文和 MCP;
- 恢复旧 Workdir/Session。
这些动作可能超过普通 Claim Recovery Window。Daemon 每隔约 15 秒调用 ExtendTaskPrepareLease,Server 给约 45 秒有效期。
若 Daemon 在准备中崩溃:
- Lease 不再续期;
- Sweeper 可识别过期的 dispatched/waiting Task;
- 任务被标为基础设施失败并按规则重试。
若准备健康但耗时长,Lease 心跳阻止误判。
6.7 Start 是一个重要提交点
Daemon 完成关键准备并即将启动 Provider 时调用 StartTask:
dispatched或waiting_local_directory→running;- 清理等待原因;
- 发布
task:running; - 更新 Agent 可见状态;
- 取消某些为该 Task 准备的 Deferred Escalation。
在 Start 前失败,用户看到“准备阶段失败”;Start 后失败,系统有真实运行时长、消息和用量。
6.8 waiting_local_directory 为什么是显式状态
同一真实目录不能同时让两个写任务运行。Daemon 在发现路径锁被占用时:
- 将 Task 从
dispatched改为waiting_local_directory; - 写
wait_reason,告诉用户在等哪个目录/任务; - 继续延长 Prepare Lease;
- 监听 Server 取消;
- 获得锁后调用 Start,进入
running。
显式状态避免把正常串行等待误报为 Runtime 卡死,也让 UI 能给出可操作原因。
6.9 Mid-flight Session Pinning
Provider 可能在运行早期才返回 Session ID。若 Daemon 必须等到终态才保存它,那么进程中途崩溃会丢掉最有价值的恢复线索。
Daemon 因此在拿到 Session/Workdir 后尽早调用 Pin:
- Task 行记录
session_id与work_dir; - 仍保持
running; - Orphan Recovery 创建重试时可以继承;
- Provider 不支持 Resume 时再由 adapter/Daemon 降级为 Fresh。
这是一种增量 Checkpoint,而不是完成结果。
6.10 运行中的消息不是状态迁移
Agent Stream 会产生 Text、Thinking、Tool Use、Tool Result、Status、Error、Log。Daemon 批量上报为 task_message 并发 task:message。
Task 主状态仍是 running;消息序号用于稳定排序。Progress 则是较轻的结构化摘要,带 Step/Total,可单独广播。
将消息与状态拆开有两个好处:
- 大量流式事件不会修改核心队列行;
- 丢失一条实时消息不改变 Task 最终事实,客户端可重新读取历史。
6.11 Complete 的业务副作用
TaskService.CompleteTask 不只是 status=completed:
- 原子写结果、Session、Workdir、Completed At;
- 对 Chat Task 写 Assistant Message;
- 对 Issue Task 生成/更新 Comment 或状态;
- 同步 Autopilot Run;
- 标记 Agent 状态;
- 发布 Task/Chat/Issue 事件;
- 处理 Quick Create Inbox;
- Reconcile 运行期间到达的新评论;
- 通知 Daemon 新槽位可能释放。
终态提交后才做可重算的广播/通知,数据库结果仍是事实源。
6.12 Fail 的分类比错误字符串重要
Task 同时记录:
error:面向诊断的文本;failure_reason:供机器决策的粗分类。
分类决定:
- 是否自动重试;
- 是否允许 Resume;
- 是否生成系统 Comment;
- Prometheus 标签;
- Autopilot 是否同步失败;
- UI 显示哪类行动建议。
不要从任意错误字符串现场猜重试策略;应尽早映射到稳定 Reason Code。
6.13 自动重试规则
当前明确允许自动重试的主要是基础设施形状:
runtime_offline;runtime_recovery;timeout;codex_semantic_inactivity;agent_error.provider_network。
普通编译失败、模型拒绝或 Agent 给出的业务失败不自动重试,因为它们不是偶发运输故障。
默认 max_attempts=2 表示首次 + 一次重试。Provider Network 有专用三级策略:
- 首次失败;
- 立即重试;
- 若仍失败,约 5 秒后最后一次 Deferred 重试。
max_attempts <= 1 明确禁用自动重试,专用策略也不能偷偷提高它。
6.14 为什么 Autopilot Task 不走通用自动重试
Autopilot 有自己的 Run、Trigger、Plan Time 与投递幂等语义。若 TaskService 在底层自动重试,Scheduler/Webhook Worker 又重试整个 Run,可能产生双重执行或重复 Issue。
因此 retryEligible 明确排除关联 autopilot_run_id 的 Task。Autopilot 的失败恢复由第 17 章的上层状态机负责。
6.15 会话毒化与 Fresh Session
并非所有失败都适合 Resume。当前 Poison Set 包括:
iteration_limit;agent_fallback_message;api_invalid_request;codex_semantic_inactivity;agent_error.context_overflow;- 防御性识别的
400 invalid_request_error。
这些失败通常说明对话历史本身不可继续:重放同一 Session 只会再次卡死或超限。重试可以复用 Workdir 中的代码修改,但必须使用 Fresh Provider Session。
这是“文件状态恢复”与“对话状态恢复”分离的典型例子。
6.16 取消的两阶段现实
数据库取消可以立即把 Task 变为 cancelled,但正在运行的本地进程需要时间停止:
- Daemon 每约 5 秒轮询 Task Status;
- WebSocket 重连/唤醒会加速检查;
- 收到取消后 Cancel Provider Context;
- 尽量 Drain 已产生的消息与用量;
- 向 Server Ack Cancelled。
对于 Chat,若用户刚发送消息但 Agent 还没产生 Transcript,立即写一个终态 Assistant Message 可能覆盖稍后到达的真实流。系统支持 Deferred Chat Finalization:
- 先记录取消;
- 等 Daemon Flush Ack;
- 或超过 Grace Period 后由 Sweeper 写
Stopped./ 恢复 Draft; - 发布
chat:cancel_finalized。
控制平面的“已取消”和执行进程的“已完全停止”是两个时刻。
6.17 Runtime Orphan Recovery
Daemon 启动/重新注册时,会询问并恢复属于这些 Runtime 的孤儿 Task:
dispatched;waiting_local_directory;running。
Server 将它们标为 runtime_recovery 失败,再按重试预算创建新 Task。若 Session/Workdir 已 Pin 且未毒化,新 Task 可继续;否则 Fresh。
为什么不是原地把 running 改回 queued?因为旧进程也许仍在某处完成,保留旧 Task 终态与创建新 Attempt 更容易审计和 Fence。
6.18 Sweeper 与“健康长任务”
长任务不能只用 started_at < now - timeout 判断失活,否则健康的 2 小时任务会被误杀。
Multica 区分:
- Prepare 阶段:看 Task Prepare Lease;
- Running 阶段:看所属 Runtime 的 Heartbeat/Liveness;
- Queued 阶段:看排队过期策略;
- Deferred:看
fire_at; - Daemon Startup:主动 Orphan Recovery。
只要 Runtime 持续健康,运行时间长本身不是失活证据;Provider 自身 Timeout/Idle/Semantic Watchdog 是另一条控制线。
6.19 事件与状态的映射
| 数据库变化 | 事件 | 客户端典型动作 |
|---|---|---|
| 新建 queued | task:queued | 刷新 Agent/Issue 运行快照 |
| Claim | task:dispatch | 显示准备中 |
| 等路径锁 | task:waiting_local_directory | 显示等待原因 |
| Start | task:running | 开始 Live Peek/计时 |
| Message | task:message | 追加运行流或失效消息 Query |
| Progress | task:progress | 更新阶段摘要 |
| Complete | task:completed | 刷新结果、Issue、Chat、用量 |
| Fail | task:failed | 显示原因,检查是否已有 Retry Child |
| Cancel | task:cancelled | 停止交互;Chat 等待可选 Finalized |
事件不是状态迁移 API;它只是数据库迁移成功后的可见通知。
6.20 状态机的核心不变量
- 终态不原地复活,重试/Rerun 创建新 Task。
- 每次迁移 SQL 限定允许前态。
- Claim 前有本地槽位,Claim 后有 Prepare Lease。
- Resume 同时受 Session、Workdir、Failure Poison 与 Provider 能力约束。
- 取消必须同时处理数据库状态与本地进程收尾。
- Autopilot 与普通 Task 的重试所有权不能重叠。
- WebSocket 丢失不影响 PostgreSQL 中的终态事实。
6.21 本章源码导航
- 队列 Service:
server/internal/service/task.go - 原子 SQL:
server/pkg/db/queries/agent.sql - Task 模型:
server/pkg/db/generated/models.go的AgentTaskQueue - 协议事件:
server/pkg/protocol/events.go - Daemon API:
server/internal/handler/daemon.go - 故障分类:
server/pkg/taskfailure
6.22 本章结论
Multica 用持久化状态机把不可靠网络、远程进程和不同 Provider 包在可审计边界里。Task 的价值不只是“排队”,而是为一次执行提供:
- 唯一所有权;
- 明确前态与终态;
- 准备/运行期活性证据;
- 可选择的文件与会话恢复;
- 失败分类与重试血缘;
- 客户端可重建的事实。
下一章切到执行平面,看看 Daemon 如何把这些抽象状态变成机器上的 Runtime、连接、槽位和进程。
上一章:Issue、Comment 与工作触发
下一章:Daemon、Runtime 与任务抢占。