Agent 怎么领活、汇报、被复盘:Multica 的任务生命周期
本文是 Multica 拆解系列的第 3 篇。前两篇讲了 Agent 的工位和大脑。这一篇讲同事最日常的事:领活、干活、汇报、出事了复盘。
这一篇要解决的问题
一个 Agent 被派了活,从"排队等领"到"跑完汇报"中间会发生什么?直觉上这是个简单流程,但工程上有三个硬问题:
第一,Agent 可能偷懒也可能卡死。怎么区分"正常的长任务"和"静默卡死"?第二,失败原因五花八门——密钥过期、配额用完、网络断了、模型不存在——怎么自动归类,决定哪些值得重试?第三,前端怎么实时显示进度,又不在一个长任务里把网络刷爆?
我通读了 server/internal/scheduler/、server/pkg/taskfailure/ 和前端的 use-realtime-sync.ts,找到了答案。核心是一条状态机加一套反直觉的缓存策略。
心智模型:任务是一条状态机
Multica 里 issue 和 task 是两层对象:一个 issue 可以反复分配、反复跑,每次都产生一个新的 task。task 的状态机是这样的:

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

我在 server/pkg/protocol/events.go 找到这些状态对应的真实事件名,每个状态迁移都广播一条:task:queued、task:dispatch、task:running、task:waiting_local_directory、task:completed、task:failed、task: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.
两件事被刻意排除在通用刷新路径之外:
task:message(每个流式消息都发)不走通用 invalidate——一个长任务可能发上百条 message,每条都刷新 issue 缓存会把网络刷爆。它走单独的订阅通道。task:completed/task:failed走的是另一条路:刷agent-task-snapshot(Agent 面板那个快照)+ chat 专用 handler,而不是刷整个 issue 列表。
全栈闭环长这样:

这个设计是"invalidate, never write"铁律的精确执行版:不是所有事件都要刷所有缓存。每个事件类型被仔细地路由到它该刷的那一块。盲目全刷会带来性能灾难,这正是为什么 specificEvents 这个集合要显式列出每一类——缓存失效的粒度是被刻意设计的,不是默认行为。
这就是 Multica 对"任务生命周期"的完整实现:状态机定义了任务的轨迹,三级超时保证不卡死也不误杀,字符串分类器让失败可归因,精确的缓存路由让进度实时又不爆网络。一个 Agent 能被放心地派出去干活,靠的就是这一整套。
下一篇:同事能干活了,还得能成长——Multica 的技能系统怎么把一个好 prompt 变成全队资产。
想看 AI 拆完下一篇?留个邮箱,自动推送。