阅读时间 7 分钟

Agent 怎么领活、汇报、被复盘:Multica 的任务生命周期

AI 拆解 Multica 的任务状态机、三级超时和失败归因。为什么 task:completed 刻意不触发 issue 缓存刷新,21 种失败原因怎么被自动分类。Multica 拆解系列第 3 篇。
Agent 怎么领活、汇报、被复盘:Multica 的任务生命周期

本文是 Multica 拆解系列的第 3 篇。前两篇讲了 Agent 的工位和大脑。这一篇讲同事最日常的事:领活、干活、汇报、出事了复盘。

这一篇要解决的问题

一个 Agent 被派了活,从"排队等领"到"跑完汇报"中间会发生什么?直觉上这是个简单流程,但工程上有三个硬问题:

第一,Agent 可能偷懒也可能卡死。怎么区分"正常的长任务"和"静默卡死"?第二,失败原因五花八门——密钥过期、配额用完、网络断了、模型不存在——怎么自动归类,决定哪些值得重试?第三,前端怎么实时显示进度,又不在一个长任务里把网络刷爆?

我通读了 server/internal/scheduler/server/pkg/taskfailure/ 和前端的 use-realtime-sync.ts,找到了答案。核心是一条状态机加一套反直觉的缓存策略。

心智模型:任务是一条状态机

Multica 里 issue 和 task 是两层对象:一个 issue 可以反复分配、反复跑,每次都产生一个新的 task。task 的状态机是这样的:

架构图

点开任意一个 issue,你能看到它的完整生命周期:状态流转、负责人、优先级,以及每一步的动态记录:

Multica Issue 详情:状态、负责人、优先级与活动时间线

我在 server/pkg/protocol/events.go 找到这些状态对应的真实事件名,每个状态迁移都广播一条:task:queuedtask:dispatchtask:runningtask:waiting_local_directorytask:completedtask:failedtask:cancelled

这条状态机不是摆设——它直接决定服务器怎么回收卡住的任务。

深度点一(免费):三级超时,区分"慢"和"死"

最反直觉的设计在超时上。朴素做法是设一个墙钟上限,比如 1 小时,超时就砍。但 AI 任务时长方差极大:一个简单的 rename 30 秒,一个跨文件重构可能跑 2 小时还正常产出。按固定时长砍,会误杀正常的长任务。

Multica 用的是分级看门狗。服务端每 30 秒扫描一次(scheduler 的 TickInterval),有两道服务端兜底:

情况 阈值 失败原因
派发后迟迟不启动 5 分钟 timeout(dispatched 超时)
运行得太久 2.5 小时 timeout(running 超时)

但这只是外层粗粒度兜底。真正区分"卡死"和"正常长任务"的是 daemon 的两个本地看门狗:

// MULTICA_AGENT_IDLE_WATCHDOG 默认 30 分钟
// 只在 agent 静默卡死(无任何事件产出)时终止
// 失败原因: idle_watchdog

// MULTICA_AGENT_TOOL_WATCHDOG 默认 2 小时
// 某个工具调用发出后长时间无输出(疑似卡死子进程)时兜底

关键点在于:只要 agent 还在持续产出事件(消息、工具调用),daemon 就不会因为跑得久而判它超时。MULTICA_AGENT_TIMEOUT 默认是 0(不设上限),意味着活动 = 活着。

反直觉:一个跑了 90 分钟、中间一直在发消息的 Agent,不会被砍;一个 30 分钟没动静的 Agent 会被 idle watchdog 砍。Multica 看的是活动,不是时长。这是从"墙钟超时"到"心跳式存活判定"的范式转变。

深度点二:21 种失败原因的自动分类

任务失败后,Multica 要回答一个关键问题:这次失败该不该自动重试?

要回答这个,得先知道失败是因为什么。但 13 个适配器返回的错误格式五花八门——有的是 API Error: 400 {...},有的是 stderr 尾巴的原文。坚持要结构化错误码,就得改 13 个适配器。Multica 选了一个务实的方案:一个字符串分类器。

// server/pkg/taskfailure/failure.go
// Classify maps a free-form error string to one of the
// agent_error.* sub-reasons. Always returns a valid Reason.

这个分类器把任意错误字符串映射到 21 个分类里(我在代码里数了):

ReasonTimeout / ReasonRuntimeOffline / ReasonRuntimeRecovery
ReasonAgentContextOverflow / ReasonAgentProviderQuotaLimit
ReasonAgentProviderServerError / ReasonAgentProviderNetwork
ReasonAgentMissingConfig / ReasonAgentRuntimeMissingExecutable
ReasonAgentTimeout / ReasonIterationLimit / ReasonQueuedExpired ...

匹配规则是大小写无关的子串匹配,而且更具体的规则排在更通用的前面——注释里举了个例子:context_overflow 必须排在 provider_quota_limit 前面,否则"token limit"会被 quota 桶错误地吃掉。

只有三类失败会自动重排进队列重试:runtime_offline(派发后 daemon 失联)、runtime_recovery(daemon 崩溃重启回收未完成的任务)、timeout。其余的(比如配额耗尽、密钥错误)重试也是同样结果,直接标记失败让人来处理。

这套分类还有个细节:它在 in-flight 写路径上跑(每个失败任务都过一遍),所以正则在 init 时编译好,不在每调用时重编。性能和可维护性的权衡在这里很清楚。

深度点三:为什么 task:completed 刻意不刷新 issue 缓存

这是这一篇最反直觉的全栈细节,也是我最喜欢的一处。

前端用 WS 事件刷新缓存,直觉做法是:收到任何 task:* 事件都 invalidate 相关查询。但我在 packages/core/realtime/use-realtime-sync.ts 看到一段刻意排除的注释:

// task:completed / task:failed deliberately NOT here.
// They go through both the task-prefix invalidate
// (refreshes the agent-task-snapshot cache) AND the
// chat-specific ws.on() handlers below.
//
// task:message stays out ... it fires per streamed
// message during a long run — invalidating on every
// message would flood the network.

两件事被刻意排除在通用刷新路径之外:

  1. task:message(每个流式消息都发)不走通用 invalidate——一个长任务可能发上百条 message,每条都刷新 issue 缓存会把网络刷爆。它走单独的订阅通道。
  2. task:completed / task:failed 走的是另一条路:刷 agent-task-snapshot(Agent 面板那个快照)+ chat 专用 handler,而不是刷整个 issue 列表。

全栈闭环长这样:

架构图

这个设计是"invalidate, never write"铁律的精确执行版:不是所有事件都要刷所有缓存。每个事件类型被仔细地路由到它该刷的那一块。盲目全刷会带来性能灾难,这正是为什么 specificEvents 这个集合要显式列出每一类——缓存失效的粒度是被刻意设计的,不是默认行为。

这就是 Multica 对"任务生命周期"的完整实现:状态机定义了任务的轨迹,三级超时保证不卡死也不误杀,字符串分类器让失败可归因,精确的缓存路由让进度实时又不爆网络。一个 Agent 能被放心地派出去干活,靠的就是这一整套。

下一篇:同事能干活了,还得能成长——Multica 的技能系统怎么把一个好 prompt 变成全队资产。


想看 AI 拆完下一篇?留个邮箱,自动推送。

react-admin.com · Multica GitHub