数据截至 (上游 commit 877a71568f6d)
服务端:从 issue 到 run 的判定、任务状态机与路由
30 秒导读: 用户在看板上给一个 issue 换指派人、把它拖出 backlog,或者一条定时规则到点了 ——服务端要在这一瞬间回答两个问题:这次写操作该不该启动一个 agent run?该给谁跑? 本章讲清楚这个判定怎么做(单一谓词
WillEnqueueRun)、任务被创建后在数据库里怎么走完一生(queued → dispatched → running → terminal状态机,以及多个 daemon 抢同一个任务时怎么不打架),以及一整套「为什么没跑」的稳定错误码怎么在不泄密的前提下解释结果。
本章属于 Multica 讲解系列。上游把活派到服务端之后,真正执行发生在别处: 本地守护进程怎么认领并执行、agent 运行时怎么把十几种 CLI 抽象成一种执行。想先看全局,回到 index。
1. 这是什么(零基础也能懂)
一句话定义
这是 Multica 服务端的「派单中枢」:它决定每一次会改动 issue 的写操作要不要变成一个 agent 的活儿,把活儿写进一张任务队列表,然后管这张表里每条任务从排队到终结的全过程。
它解决谁的什么问题
把 agent 当成团队里的「人」来用,就会撞上一堆调度问题:
- 我把 issue 从 A agent 改派给 B agent,B 要不要立刻开跑?改派本身算不算一次「开工信号」?
- 一个 agent 同时最多能跑几个任务?超了怎么办?
- 两台 daemon(两台开发者的笔记本)同时在线,同一个任务会不会被两边同时抢走、跑两遍?
- 一条评论触发 agent 跑,agent 跑完又发评论——会不会自己触发自己,无限循环?
- 一个「私有 agent」别人看不见,那当别人试图触发它时,服务端拒绝的理由要怎么写才既能解释又不暴露「这个 agent 存在」?
这一章就是这些问题的服务端答案。
它管的东西:一张队列表
所有「agent 要干的活」都落在一张表 agent_task_queue 里。一行就是一个任务(task),核心字段:
| 字段 | 含义 |
|---|---|
status | 生命周期状态(queued / dispatched / running / completed / failed / cancelled …) |
agent_id | 谁来跑 |
runtime_id | 跑在哪个 runtime(哪台 daemon)上 |
issue_id / chat_session_id / autopilot_run_id | 这活儿的来源(三选一,或都空 = quick-create) |
priority | 优先级,认领时高优先先出队 |
originator_user_id / accountable_user_id | 谁的授权、谁担责(见 §8 attribution) |
一句话直觉
把它想成餐厅后厨的挂单夹:前台(HTTP 写操作)判断「这单要不要下给厨房」,判断为是就把小票夹上挂单夹(INSERT 一行 queued 任务);厨师(daemon)来取单时,一把把小票从夹子上「原子地」揭下来(UPDATE ... status='dispatched'),同一张小票不可能被两个厨师同时揭走。
2. 顶层全景(它大概怎么转)
先看一次「人给 issue 改状态 → agent 真的跑起来」的完整链路。怎么读这张图:从上到下是时间顺序;左半是「判定+入队」,右半是「认领+执行」,中间隔着一张数据库表。
┌──────────────────────── 服务端(本章) ────────────────────────┐
HTTP │ │
写操作 │ ①判定「要不要跑/给谁跑」 ②把活写进队列表 │
────► │ WillEnqueueRun(单一谓词) ──► CreateAgentTask │
(改派/ │ · 私有 agent 门禁 INSERT status='queued' │
改状态)│ · self-loop 抑制 │ │
│ · pending 去重 ▼ │
│ ┌───────────────────────┐ │
│ │ agent_task_queue │ │
│ Autopilot / cron / webhook │ (一行 = 一个 task) │ │
│ ────► DispatchAutopilot ──►│ queued│dispatched│... │ │
│ (自动建 issue/直发) └───────────────────────┘ │
└───── ──────────────────────────────────┬──────────────────────┘
│ daemon 轮询 / 被唤醒
▼
┌──────────────────── daemon 侧(见 02 章) ─────────────────────┐
│ ③认领:ClaimTask / ClaimTasksForRuntimes │
│ UPDATE ... status='dispatched' ← 原子 CAS,抗并发抢占 │
│ ④上报:StartTask→running→CompleteTask/FailTask(终态) │
└───────────────────────────────────────────────────────────────┘
部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
WillEnqueueRun | 单一谓词:这次 issue 写操作要不要跑、给谁跑 | internal/service/issue_trigger.go:97 |
IssueTriggerProbe | 把「私有 agent 门禁」「self-loop 判断」这些请求级检查注入谓词 | internal/service/issue_trigger.go:43 |
CreateAgentTask | 真正 INSERT 一行 queued 任务 | enqueueIssueTask, internal/service/task.go:1140 |
ClaimTask | 单 agent 认领:容量判定 + 原子出队 | internal/service/task.go:3086 |
ClaimTasksForRuntimes | 一次给一整台机器的多个 runtime 批量认领 | internal/service/task.go:3440 |
ReasonCode | 跨层稳定错误码枚举,解释「为什么没跑」且不泄密 | internal/dispatch/reason.go |
DispatchAutopilot | 定时/webhook 触发:准入 → 建 run → 建 issue 或直发任务 | internal/service/autopilot.go:119 |
applyAttributionFallback | fail-closed:解析不出担责的人就拒绝入队 | internal/service/task.go:689 |
主线走一遍(高层)
- 用户 PATCH 一个 issue(改派或改状态)。Handler 先在 HTTP 边界把 UUID 解析、把私有 agent 门禁走一遍(§9)。
- Handler 调
WillEnqueueRun(§3):传入「这个 issue 写完之后长什么样」,谓词回答(给谁跑, 要不要跑)。 - 要跑 →
dispatchIssueRun→EnqueueTaskForIssueWithHandoff→CreateAgentTask落一行queued(途中做 attribution,§8)。 - 广播
task:queued,唤醒对应 runtime 的 daemon。 - daemon 调
ClaimTask/批量认领(§4):容量够就用一条UPDATE把任务原子地从queued改成dispatched。 - daemon 准备好后
StartTask(→running),跑完CompleteTask/FailTask(→终态)。
3. 单一真理:WillEnqueueRun 谓词
它要解决的小问题
「改派 / 改状态 / 新建」三种 issue 写操作,过去各自有一段「要不要开跑」的判断代码,四个入口(单条更新、批量、创建、preview 预览)慢慢长歪了:有的漏了 squad 分支、有的漏了 self-loop、口径不一致。结果:preview 告诉你「会启动 2 个 run」,真写的时候只跑了 1 个。这就是 MUL-3375。
思路
把判断收敛成一个纯谓词,让所有写路径和 preview 端点逐字共用同一个函数。谓词只回答两件事:会不会跑(bool)、给谁跑(IssueRunTrigger)。凡是判断,一个 地方改,所有入口一起动。
依据:internal/service/issue_trigger.go:72-97(函数注释明确说它替代了「四个入口飘移」的旧实现)。
输入怎么建模
谓词不直接吃 HTTP 请求,而是吃一个「写完之后的 issue 快照 + 哪几个字段被动过」:
// internal/service/issue_trigger.go:44 —— 一次 issue 写操作的抽象
type IssueTriggerInput struct {
Issue db.Issue // 写「之后」的形态
PrevStatus string // 写「之前」的状态
IsCreate bool // 全新 issue(无旧任务可取消、无 self-loop)
AssigneeChanged bool // 这次写动了指派人吗
StatusChanged bool // 这次写动了状态吗
}
判定分两级:先看「哪种开工信号」,再看「目标能不能跑」
第一级——这次写算不算一次开工信号(source),只有两种能开工:
| source | 触发条件 | 关键规则 |
|---|---|---|
assign | 创建 or 改派(`IsCreate | |
status | 已指派的 issue 从 backlog 提升到活动状态 | 且新状态不是 done/cancelled;要过 self-loop 抑制 |
两者都不是 → 直接返回「不跑」。依据:WillEnqueueRun 的 switch,internal/service/issue_trigger.go:123-140。
第二级——目标(agent 或 squad)当下能不能跑。这里三道闸门顺序卡:
目标是 agent / squad?
│
▼
①目标存在且可跑? agent: RuntimeID 有效 且 未 archived
│ squad : leader 过 AgentReadiness(runtime online)
▼
②私有 agent 门禁? canAccess(agent) —— 见下「门禁为什么在这留个钩子」
│
▼
③(仅 status 源)已有 pending 任务? hasPendingRun → 有就不跑(去重)
│
▼
给谁跑:agent 自己 / squad 的 leader
依据:agent 分支 internal/service/issue_trigger.go:142-163;squad 分支 :136-163。
私有 agent 门禁:为什么在谓词里留个钩子却传「全放行」
IssueTriggerProbe.CanAccessAgent 是私有 agent 的访问闸(internal/service/issue_trigger.go:44)。微妙点在于写路径和 preview 传的东西不一样:
- 写路径:门禁其实已经在 HTTP 边界执行过了(改派时
validateAssigneePair,squad 时canEnqueueSquadLeader),所以写路径给谓词传一个「全放行」的探针(CanAccessAgent: nil→ 被当作allowAllAgents),避免把同一道闸重复跑、或把它下沉进 service 层。依据:internal/handler/issue_trigger.go:25-42的issueTriggerWriteProbe。 - preview 端点:preview 不经过写边界的那道闸,所以它必须给谓词传