跳到主要内容

第 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:queuedtask:dispatchtask:waiting_local_directorytask:runningtask:completedtask:failedtask: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 做两件事:

  1. 发布 task:queued 给客户端;
  2. 通过 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

  • dispatchedwaiting_local_directoryrunning
  • 清理等待原因;
  • 发布 task:running
  • 更新 Agent 可见状态;
  • 取消某些为该 Task 准备的 Deferred Escalation。

在 Start 前失败,用户看到“准备阶段失败”;Start 后失败,系统有真实运行时长、消息和用量。

6.8 waiting_local_directory 为什么是显式状态

同一真实目录不能同时让两个写任务运行。Daemon 在发现路径锁被占用时:

  1. 将 Task 从 dispatched 改为 waiting_local_directory
  2. wait_reason,告诉用户在等哪个目录/任务;
  3. 继续延长 Prepare Lease;
  4. 监听 Server 取消;
  5. 获得锁后调用 Start,进入 running

显式状态避免把正常串行等待误报为 Runtime 卡死,也让 UI 能给出可操作原因。

6.9 Mid-flight Session Pinning

Provider 可能在运行早期才返回 Session ID。若 Daemon 必须等到终态才保存它,那么进程中途崩溃会丢掉最有价值的恢复线索。

Daemon 因此在拿到 Session/Workdir 后尽早调用 Pin:

  • Task 行记录 session_idwork_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 有专用三级策略:

  1. 首次失败;
  2. 立即重试;
  3. 若仍失败,约 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 事件与状态的映射

数据库变化事件客户端典型动作
新建 queuedtask:queued刷新 Agent/Issue 运行快照
Claimtask:dispatch显示准备中
等路径锁task:waiting_local_directory显示等待原因
Starttask:running开始 Live Peek/计时
Messagetask:message追加运行流或失效消息 Query
Progresstask:progress更新阶段摘要
Completetask:completed刷新结果、Issue、Chat、用量
Failtask:failed显示原因,检查是否已有 Retry Child
Canceltask:cancelled停止交互;Chat 等待可选 Finalized

事件不是状态迁移 API;它只是数据库迁移成功后的可见通知。

6.20 状态机的核心不变量

  1. 终态不原地复活,重试/Rerun 创建新 Task。
  2. 每次迁移 SQL 限定允许前态。
  3. Claim 前有本地槽位,Claim 后有 Prepare Lease。
  4. Resume 同时受 Session、Workdir、Failure Poison 与 Provider 能力约束。
  5. 取消必须同时处理数据库状态与本地进程收尾。
  6. Autopilot 与普通 Task 的重试所有权不能重叠。
  7. WebSocket 丢失不影响 PostgreSQL 中的终态事实。

6.21 本章源码导航

6.22 本章结论

Multica 用持久化状态机把不可靠网络、远程进程和不同 Provider 包在可审计边界里。Task 的价值不只是“排队”,而是为一次执行提供:

  • 唯一所有权;
  • 明确前态与终态;
  • 准备/运行期活性证据;
  • 可选择的文件与会话恢复;
  • 失败分类与重试血缘;
  • 客户端可重建的事实。

下一章切到执行平面,看看 Daemon 如何把这些抽象状态变成机器上的 Runtime、连接、槽位和进程。


上一章:Issue、Comment 与工作触发
下一章:Daemon、Runtime 与任务抢占