长任务运行:状态、调度、恢复与收口
从一个跨天代码升级任务出发,拆解 Agent 怎样保存状态、等待外部事件、处理结果未知、恢复执行,并以可验证证据结束任务。
一个 Agent 接到依赖升级任务:修改三个服务,等待 CI,失败后定位原因,等负责人批准,再创建合并请求。模型实际思考的时间可能只有几分钟,任务却会跨过数小时,甚至第二天才继续。
如果整个任务只存在于一个进程、一段对话和一个 HTTP 连接里,任何一次重启、超时或人工等待都可能让它失忆。重新运行也不安全,因为上一次创建分支、发送通知或触发流水线的结果未必已知。
这篇文章回答一个工程问题:怎样让 Agent 在时间、进程和模型调用都不连续的情况下,仍然知道自己做到哪里、还能做什么,并且最终可靠地停下来。
讨论范围是 Agent 外部的运行时。OpenAI 的 Background mode 可以让单次 Response 在断开连接后继续生成,Temporal 一类 Durable Execution 系统可以保存 Workflow 历史并在 Worker 故障后重放。这两者都很有用,但它们不会替业务定义任务状态、外部动作的幂等语义和完成条件。
一、长任务不等于长模型调用
先区分三种常被混在一起的时间。
| 时间 | 例子 | 谁负责 |
|---|---|---|
| 单次推理时间 | 模型生成计划或分析日志 | 模型 API 与调用方 |
| 一次 Agent Run | 多轮模型与工具循环 | Agent Harness |
| 业务任务生命周期 | 等 CI、审批、定时器和外部回执 | 持久化任务系统 |
一个模型请求可以运行很久,却仍然不是 Durable Task。反过来,一个跨三天的业务任务也不需要让模型持续运行。大多数时间里,它应该处于等待状态,不消耗模型 Token,也不占用 Worker。
OpenAI Background mode 把一次 Response 异步执行,调用方可以轮询 queued、in_progress 和终态,也可以在流中断后按事件序号续接。它解决的是模型请求与客户端连接解耦。Agent 若要等待 GitHub webhook、人工批准和次日定时检查,还需要自己的任务记录与调度入口。
因此长任务的最小结构是一个可以反复被唤醒的状态机:读取持久化状态,推进一小段,记录新事实,然后结束当前执行。下一次由外部事件、定时器或用户操作再次唤醒。
二、把任务状态从聊天记录里拿出来
消息历史适合给模型看,不适合作为唯一状态源。历史里会混入解释、猜测、工具输出和过期计划;压缩后还可能丢掉字段。运行时需要一份模型之外的结构化状态。
task_id: upgrade-payment-sdk-20261003
status: WAITING_CI
goal: 将 payment-sdk 从 4.x 升级到 5.x
revision: 18
owner: user_42
workspace:
repo: payments
branch: agent/payment-sdk-v5
pending:
kind: webhook
key: ci/run/98127
last_action:
type: trigger_ci
idempotency_key: upgrade-payment-sdk-20261003:ci:1
evidence:
changed_files: [pom.xml, PaymentClient.java, PaymentClientTest.java]
test_run: 98127
budget:
model_calls: 14
tool_calls: 39
cost_usd: 1.82
这份状态回答程序问题:任务是否还能推进,当前在等什么,哪些动作已经提交,恢复时从哪里继续。给模型的 Context 可以从中派生,却不能反过来覆盖它。
状态至少要分四层。任务层保存目标、身份、权限和终态;步骤层记录当前阶段、尝试次数和等待条件;动作层记录每次有副作用的请求与回执;证据层保存测试、Diff、审批和外部对象 ID。把四层揉成一个 messages 数组,后续很难安全重试,也无法查询“有哪些任务卡在审批超过两天”。
这四层还要明确谁有写权限。任务目标和授权范围由入口服务创建,Agent 可以提出变更建议,却不能自行扩大;步骤状态由运行时根据合法转换推进;动作回执由工具执行器写入;证据由产生它的系统签名或附带来源。若模型生成一段 JSON 就能同时改目标、审批状态和工具回执,结构化存储只换了外观,事实仍然由模型随意改写。
可以把状态分成三类来源。第一类是用户事实,例如目标、约束和审批;第二类是外部事实,例如 CI 状态、Commit SHA 和工单 ID;第三类是 Agent 判断,例如“最可能是兼容性问题”。前两类要保存来源、时间和版本,第三类可以被后续推理覆盖。恢复时若三类信息都被压成一句自然语言,系统就分不清什么可以重新判断,什么必须原样保留。
状态机要允许等待和结果未知
只设计 RUNNING / SUCCESS / FAILED 三个状态不够。真实任务至少会遇到:
WAITING_EVENT:等待 CI、Webhook 或其他系统回调;WAITING_USER:需要补充信息或人工批准;RETRY_SCHEDULED:已知可以重试,但尚未到下一次执行时间;ACTION_UNKNOWN:请求超时,外部动作可能已经发生;CANCELLING:已经收到取消请求,正在停止或补偿;NEEDS_ATTENTION:运行时无法自动决定,交给人处理。
ACTION_UNKNOWN 尤其重要。创建合并请求时连接超时,不能直接判断失败,也不能无条件再创建一次。任务要先用幂等键或业务查询确认外部状态,再决定补发、等待还是人工介入。
状态转换本身也应经过校验。WAITING_CI 只能在保存 CI run ID 后进入;WAITING_USER 必须带审批对象、截止时间和负责人;COMPLETED 必须通过验收器。这样的不变量比状态名称更重要,因为它让数据库约束和测试能够发现非法路径。仅凭模型说“下一步等待 CI”就修改状态,容易留下没有唤醒键的永久等待任务。
三、快照负责读,事件负责解释
只存一份最新状态,读取很快,却无法回答它为什么变成这样。只存事件,审计完整,但每次恢复都从头回放,历史长后成本会上升。工程上常把二者结合:追加不可变事件,并定期生成快照。
{"seq": 41, "type": "tool.requested", "tool": "trigger_ci", "key": "task-7:ci:1"}
{"seq": 42, "type": "tool.unknown", "reason": "client_timeout"}
{"seq": 43, "type": "ci.discovered", "run_id": "98127", "source": "reconcile"}
{"seq": 44, "type": "task.waiting", "condition": "ci/run/98127"}
事件需要单调递增的序号或版本号。Worker 读取 revision 18,推进后只能写 revision 19;如果另一个 Worker 已经写入 19,当前提交必须冲突,而不是静默覆盖。这个乐观并发控制可以阻止重复唤醒同时推进同一任务。
Temporal Workflow Execution 展示了更完整的 Event History 与 Replay 模型:Worker 重新执行 Workflow 代码,并检查生成的命令是否与历史一致,从最近已记录事件恢复。它要求 Workflow 逻辑保持确定性,把网络和外部副作用放进 Activity。自建系统不一定需要复刻 Temporal,但“决策可重放,副作用独立记录”的边界值得保留。
哪些信息进入快照
快照保存恢复必需的当前事实:任务状态、版本、待处理条件、动作回执、预算、工作区引用和必要证据。大段日志、仓库文件和模型原始输出应该放在对象存储或专门的 Trace 中,快照只保存引用与摘要。
快照也要有 Schema 版本。任务跨过一次部署后,新代码可能读到旧状态。新增可选字段通常容易兼容;重命名状态、改变工具结果含义或删除字段则需要迁移。长任务系统发布前要用旧快照做恢复测试,不能只验证新任务。
恢复依赖确定性边界
恢复的难点不是把进程重新启动,而是判断哪些计算可以重做。纯计算可以重新执行,例如根据已保存的测试结果生成摘要;读取动作通常可以重试,但要接受数据已经变化;写动作不能在没有确认的情况下重放。
因此每一步最好声明执行属性:pure、read、idempotent_write 或 non_idempotent_write。调度器发现 Worker 在步骤中途消失时,可以自动重跑纯计算和带幂等键的写入;普通读取要保存读取版本,避免恢复后用新数据解释旧决策;不可幂等写入则先进入对账路径。
重放还受代码版本影响。旧任务在 policy@12 下把金额 500 视为可自动处理,新版本若把阈值改成 300,直接用新代码重放会改变历史决策。任务应绑定运行清单,至少包含 Workflow、Prompt、策略和工具 Schema 版本。需要迁移时,迁移本身产生事件,记录旧版本、新版本、转换后的字段以及谁批准了变化。
Temporal 通过确定性 Workflow 与 Event History 守住这一边界。自建系统也可以采用更轻的规则:已经形成外部效果的决定不重算,尚未执行的候选计划可以丢弃;恢复从最后一个已确认检查点开始,而不是从最后一条模型消息开始。
四、调度器只决定何时获得一次推进机会
调度器不应该直接“运行到完成”。它负责把可运行任务放进队列,Worker 获得租约后推进有限步数,然后释放资源。
典型唤醒来源有四类:
- 用户创建任务或补充消息;
- 工具、CI 和外部系统通过 Webhook 回传事件;
- Timer 到期,例如退避重试或定时检查;
- 运维操作,例如恢复、取消或重新入队。
所有入口最后都归一成任务事件。Webhook 接口先验签、去重、落库,快速返回成功,再由 Worker 异步处理。OpenAI 的 Webhook 文档 明确说明事件可能重复投递,并建议用 webhook-id 去重;非平凡处理应交给后台 Worker,以免接收端超时导致继续重试。这也是通用 Webhook 消费方式。
租约、防重与公平性
队列的“至少一次投递”意味着同一消息可能被多个 Worker 看见。任务表需要租约字段,例如 leased_by 和 lease_until。Worker 定期续租;进程崩溃后,租约过期,其他 Worker 才能接管。
租约不能替代幂等。旧 Worker 可能在网络隔离期间继续运行,新 Worker 也已接管。每个有副作用的动作仍要使用业务幂等键,并在提交前检查任务 revision。
多租户环境还需要公平调度。一个用户创建一百个深度研究任务,不应占满所有并发。可以按租户设置运行槽、Token 预算和工具并发,再用优先级队列区分交互任务与批处理任务。优先级必须带老化机制,否则低优先级任务可能永久饥饿。
调度还要区分“可运行”和“值得现在运行”。任务具备全部依赖只是可运行;是否获得 Worker,还取决于租户配额、下游健康、剩余预算和期限。一个需要浏览器的任务在浏览器集群熔断时不应反复出队,而应进入带唤醒条件的阻塞状态。否则系统会用队列吞吐量制造无效重试。
租约需要 fencing token。每次接管任务都递增一个单调序号,Worker 对状态和工具网关的写入必须携带当前 token。旧 Worker 即使在网络恢复后继续执行,也会因为 token 过期被拒绝。单纯依赖 lease_until 不够,因为两台机器对时间的观察可能不同,已经失去租约的进程也不会自动停止。
定时任务通常由 Timer 表或延迟队列承载。Timer 记录 task_id、wake_at、原因和唯一键。处理器先把到期 Timer 转为任务事件,再标记已消费;重复扫描只会得到同一个事件。把“睡 30 分钟”留在 Worker 进程里,会占用资源,也无法跨重启恢复。
五、副作用要有自己的执行账本
模型调用失败可以重新生成,有副作用的工具不能按同一规则重试。发送邮件、发布版本、退款和创建工单都需要动作账本。
actions(
task_id,
logical_action,
idempotency_key,
request_hash,
status,
external_id,
receipt,
created_at,
updated_at
)
idempotency_key 绑定业务动作,不绑定一次模型生成的 call_id。同一个键只能对应同一组参数;如果参数变了,系统应拒绝复用,防止“重试”变成另一笔操作。
工具执行分为准备、提交和确认。准备阶段做权限、参数和预算检查;提交阶段调用外部系统;确认阶段保存回执。如果提交后进程崩溃,恢复逻辑从确认开始,通过幂等键或外部查询寻找结果。没有查询接口且动作不可重复时,只能进入 NEEDS_ATTENTION,让人核对。
所谓 exactly-once 往往是业务效果上的目标,不是网络传输保证。可靠实现通常由至少一次投递、幂等处理、唯一约束和对账共同组成。把“工具超时”统一映射成失败,会在这一步埋下重复执行事故。
幂等、对账与补偿解决不同问题
幂等避免同一业务意图重复生效。对账解决“请求结果未知”:运行时用幂等键、资源 ID 或业务唯一字段查询外部系统,确认动作是否已经发生。补偿处理动作确实成功但业务后来无法继续的情况,例如已经创建临时环境,后续审批被拒绝,需要删除环境并回收凭证。
三者不能互相替代。支付接口支持幂等键,仍要在客户端超时后对账;对账确认退款成功,也不代表整个售后任务可以完成;补偿调用本身同样可能超时,仍需动作账本。涉及多个系统时,可以采用 Saga 思路,为每个正向动作定义补偿动作和不可补偿边界,但不要假设补偿能恢复到完全未发生的世界。邮件已经被用户看见、代码已经被外部系统拉取,这些效果只能追加更正。
动作账本最好保存请求哈希和响应摘要。相同幂等键收到不同参数时立即拒绝;响应中若含敏感数据,只保存对账所需字段和加密引用。对账器不需要模型参与,它按工具类型执行确定查询。只有外部系统缺乏查询能力、返回结果互相矛盾或需要业务判断时,才把任务交给人。
PREPARED -> SUBMITTED -> CONFIRMED
| |
v v
UNKNOWN COMPENSATING -> COMPENSATED
|
v
RECONCILING -> CONFIRMED | RETRYABLE | NEEDS_ATTENTION
这套状态属于动作,不属于根任务。一个任务可以同时拥有已确认的 CI 触发、待对账的 PR 创建和已补偿的临时环境。如果只给根任务放一个 FAILED,恢复逻辑无法知道哪些外部效果还在。
六、等待、取消与恢复是三套协议
等待意味着任务仍然开放,但当前没有可执行动作。记录等待类型、关联键和截止时间后,Worker 就应退出。轮询只能作为没有事件接口时的退路,并使用指数退避与抖动,避免成千上万个任务同一秒醒来。
取消是协作协议,不是一条 kill -9。运行时先把状态改为 CANCELLING,阻止新动作,再通知正在运行的模型请求和工具。可中断动作尽快停止,不可中断动作等待回执。已经完成的外部副作用是否补偿,由业务定义。
终止用于安全事故或失控任务,可以跳过正常清理,但必须记录操作者、原因和当时状态。暂停则保留任务,禁止调度,之后可以恢复。四个词在界面上看起来相近,语义不能混用。
Temporal 的状态模型 也区分 Running、Paused、Cancelled、Completed、Failed、Terminated 和 Timed Out。它还提醒了一个常见陷阱:长 Workflow 通常不应靠一个总超时表达业务期限,内部 Timer 更适合触发提醒、升级或分支处理。
取消还要处理竞态。用户点击取消时,工具可能刚刚提交。运行时先持久化 cancel_requested,让后续推进看见;工具完成后检查取消标记,不再启动下一步,并根据动作类型决定保留、补偿或等待人工处理。不能先调用远端取消接口、最后才写本地状态,因为进程若在中间崩溃,任务会恢复成仍可运行。
等待用户也需要关联版本。审批应绑定 Commit SHA、工具参数或方案哈希。Agent 在等待期间修改了内容,旧审批自动失效。否则用户批准的是 A,恢复后的 Agent 执行了 B,审计日志却显示“已批准”。
七、恢复时不要把整段历史重新塞给模型
运行时恢复和模型恢复不是同一件事。程序先从快照与事件恢复确定状态,再为下一轮模型调用组装 Context。
一份恢复摘要应该包含:原始目标、当前阶段、已确认事实、已完成动作及回执、未解决问题、允许的下一步、剩余预算。原始日志和旧消息按需检索,不必全部重放。
目标:升级 payment-sdk 到 5.x,保持 API 行为兼容
当前:CI 98127 失败,失败测试 PaymentClientTest#retryOnTimeout
已完成:依赖升级、编译修复、单元测试新增;分支 agent/payment-sdk-v5
禁止:修改 payments 模块以外文件;未经批准不得创建 PR
下一步:读取失败日志与相关实现,提出最小修复
预算:最多 6 次工具调用,完成后重新触发 CI
恢复摘要必须来自结构化状态与证据,不能让模型凭旧对话“回忆”。Context 压缩可以重写表达,不能改动工具回执、审批状态和权限边界。对高风险字段可附带哈希或引用 ID,让运行时在执行前再次校验。
Context 构建器可以把信息分为固定区、任务区和按需区。固定区包含系统规则和工具契约;任务区包含目标、当前状态、审批与剩余预算;按需区通过检索加载相关日志、代码和历史分析。固定区和任务区有版本并参与 Trace,按需区记录来源。这样能够复现模型当时看见的事实,又不必永久保存一份无法管理的巨型 Prompt。
压缩摘要本身不是权威状态。可以让模型生成候选摘要,再由程序把目标、资源 ID、数值、禁止动作和未决事项重新注入。摘要若遗漏“不得修改数据库 Schema”,程序仍会在下一轮恢复这一约束。对关键字段做差异检查,能在上线前发现压缩策略把事实写错的问题。
八、收口需要独立于模型的完成条件
模型说“已经完成”只是候选判断。任务系统要检查契约中的完成条件,然后才能进入 COMPLETED。
代码升级任务的收口可能要求:
- 允许范围内的文件 Diff 已生成;
- 指定测试与构建通过;
- CI 对应当前提交,而不是旧 Commit;
- 人工批准已绑定同一个 Diff;
- 合并请求已创建,并保存 URL;
- 没有状态未知的工具动作;
- 最终摘要列出改动、验证和剩余风险。
结束还要关闭资源:释放工作区与租约,取消无用 Timer,撤销临时凭证,标记未消费事件,并冻结最终证据。清理失败不应把已完成的业务结果改成失败,可以记录为独立的 cleanup 告警并重试。
无法满足条件时,也要产生明确终态。预算耗尽、外部依赖长期不可用、权限永久拒绝和用户取消不是同一种失败。终态分类决定后续能否重开、是否计入 Agent 质量指标,以及用户看到什么操作入口。
验收器最好由确定性代码和少量受控评测组成。文件是否越界、测试是否对应当前 Commit、审批是否匹配动作参数,都能由程序判断。只有“迁移说明是否解释了兼容风险”这类语义条件才需要模型评审,而且评审结果应作为证据之一,不应覆盖硬条件。
任务完成后仍可能收到迟到的 Webhook。终态任务默认不再被推进,但事件不能悄悄丢弃:保存为迟到事件,并按类型选择忽略、更新只读证据或触发告警。若一次迟到的失败回执说明此前的完成结论错误,系统要产生新的纠正流程,而不是把已经对外发布的终态直接改回 RUNNING。
九、把升级任务完整跑一遍
任务创建后,系统写入目标、仓库范围和预算,状态为 READY。调度器投递第一次推进,Agent 制定计划并修改工作区。工具执行器记录文件 Diff 与测试结果,Harness 触发 CI,动作账本保存幂等键和 run ID,任务转为 WAITING_EVENT。
Webhook 到达两次。接收端用事件 ID 去重,写入 ci.failed。Worker 恢复任务,读取失败步骤与日志摘要,给模型组装新的 Context。模型修复测试,再次触发 CI。第二次 CI 成功后,状态转为 WAITING_USER,页面显示 Diff、测试和成本,请负责人批准。
审批发生在第二天。恢复前系统检查批准所指向的 Commit 与当前工作区一致,然后创建合并请求。客户端在响应前超时,动作进入 ACTION_UNKNOWN。对账过程用幂等键查询,找到已经创建的 PR,于是补写回执,没有重复创建。
验收器检查 CI、审批、PR 和未知动作均符合契约,将任务标记为 COMPLETED。最终摘要来自已保存证据。无论期间有多少次 Worker 重启,模型都不会负责记住业务事实。
把这条路径落到存储层,可以得到五张核心表:tasks 保存快照与 revision,task_events 追加状态变化,actions 保存副作用,timers 保存未来唤醒,artifacts 保存日志、Diff 和报告引用。队列消息只携带 task_id 与 wakeup ID,不承载权威状态。Worker 每次都从数据库读取最新 revision,避免一条滞留消息把任务恢复到过去。
推进循环可以写得很短:
async function advance(taskId: string, wakeupId: string) {
const lease = await acquireLease(taskId)
if (!lease) return
const task = await loadTask(taskId)
if (task.seenWakeups.includes(wakeupId) || task.isTerminal) return
const next = decideDeterministicStep(task)
if (next.kind === "WAIT") return persistWait(task, next, lease.fencingToken)
if (next.kind === "RECONCILE") return reconcileAction(task, next.actionId)
const context = await buildContext(task, next)
const proposal = await runAgent(context)
const checked = await validateProposal(task, proposal)
await commitTransition(task.revision, checked, lease.fencingToken)
}
代码刻意把确定性决策放在模型调用之前。任务已经取消、动作等待对账、预算耗尽或验收条件已满足时,不需要再问模型。模型只处理剩余的开放问题。这个边界同时降低成本,也减少恢复后产生不同计划的机会。
Inbox 与 Outbox 补上数据库和消息队列之间的缝
任务状态提交成功、队列消息发送失败,会留下再也醒不过来的任务;消息先发送、事务后回滚,又会让 Worker 读到不存在的状态。可以在同一个数据库事务里写任务变化和 Outbox 记录,再由独立 Publisher 把 Outbox 投递到队列。投递完成后标记发送状态,重复投递由 wakeup ID 去重。
Webhook 使用 Inbox 做对称处理。接收端先按供应方事件 ID 插入 Inbox,唯一约束挡住重复事件;同一事务再把事件关联到任务或标记为待匹配。Worker 消费的是内部事件,不直接信任外部请求。暂时找不到任务的事件不会丢失,可以在相关外部 ID 写入后重新匹配。
begin;
update tasks
set status = 'WAITING_CI', revision = revision + 1
where task_id = :task_id and revision = :expected_revision;
insert into outbox(event_id, aggregate_id, event_type, payload)
values (:event_id, :task_id, 'task.waiting_ci', :payload);
commit;
事务影响行数为零时,说明 revision 已变化,当前 Worker 放弃提交并重新读取。Outbox Publisher 是否重复运行不影响业务状态。这个模式没有提供神奇的全局 exactly-once,却把数据库与队列的不一致收敛为可以检测和重试的记录。
大文件与工具日志不能和任务事务一起写入对象存储。先上传内容并得到不可变引用,再在任务事务里登记 artifact;事务失败后由孤儿清理器回收未引用对象。反过来先写引用再上传,任务可能恢复时拿到一个不存在的证据。
十、什么时候需要 Durable Execution 平台
数据库任务表、队列和定时器足以支持第一版,前提是团队愿意自己处理租约、去重、状态迁移、重试、可见性和运维工具。任务数量少、生命周期短、外部动作有限时,这个方案容易理解。
当任务频繁跨天、拥有大量 Timer 和事件、恢复语义复杂,或团队已经在使用 Temporal、AWS Step Functions 等工作流系统时,可以让它承担持久化调度。Agent Loop 作为一个或多个 Activity 运行,模型之外的审批、等待、重试和补偿进入 Workflow。
不能把所有模型循环逐 Token 写进工作流历史。细粒度流式事件放 Trace 或对象存储,工作流只记录会影响恢复的状态转换。否则历史迅速膨胀,Replay 和调试成本都会增加。
Cron 也不是长任务运行时。Cron 能按时间启动一次工作,却不保存某个任务的阶段、外部回执和取消语义。它适合产生事件,例如“每天扫描需要提醒的任务”,不适合充当任务本身。
选择平台时重点比较责任,不要只比较 DSL。谁保存 Event History,谁提供 Timer 和信号,Workflow 如何版本化,Activity 结果未知时怎样处理,任务能否人工修改,运行历史如何检索,都会直接影响 Agent。平台替你实现持久化调度,不会替你定义退款是否允许补偿、审批绑定什么版本,以及什么证据算完成。
第一版自建系统常见的拐点不是吞吐量,而是运维复杂度。当团队开始维护大量扫描器来找丢失事件、手写 Timer 分片、处理跨版本状态和制作任务可视化工具时,成熟 Durable Execution 平台的价值才明显。若任务只有几分钟且全部动作可重试,一张任务表和队列反而更容易维护。
十一、怎样验证恢复真的可靠
正常路径跑通不能证明 Durable。测试要主动在边界处制造故障:
| 故障注入 | 期望结果 |
|---|---|
| 工具提交后、保存回执前杀掉 Worker | 恢复后对账,不重复副作用 |
| 同一 Webhook 投递三次 | 只产生一次有效状态转换 |
| 两个 Worker 同时获得唤醒 | revision 冲突阻止双重推进 |
| 等待审批时升级状态 Schema | 旧任务仍能恢复或完成迁移 |
| Context 压缩后继续 | 目标、权限和未决动作不变 |
| 用户在工具运行中取消 | 不再启动新动作,现有动作有确定去向 |
| 任务达到预算 | 进入可解释终态,不继续消耗 |
线上至少监控可运行队列延迟、任务各状态停留时间、租约超时数、重复事件数、未知动作数、恢复成功率和终态分布。平均完成时长容易掩盖卡死任务,分位数与年龄分桶更有用。
比明确失败更危险的是任务长期保持 RUNNING,没有新事件也没人负责。为每个开放状态定义最大静默时间和责任人,系统才能发现“还活着但不会再前进”的任务。
测试还要覆盖版本矩阵。保存上一版与上上版的真实快照,使用新代码恢复;让任务在 Prompt、工具 Schema 和策略升级的不同阶段等待;确认固定版本和迁移版本都能完成。只测数据库字段能否反序列化,不能证明旧动作语义仍然正确。
建议建立一个故障脚本库,在每个关键提交点前后随机终止 Worker,并重复发送事件。相同输入经过几十次故障后,外部系统只能出现一份业务效果,任务最终状态一致,动作账本没有无法解释的空洞。这个测试比在代码审查里讨论“应该不会重复”有说服力。
状态机可以用属性测试验证,而不是只枚举几条示例。随机生成创建、唤醒、超时、取消、重复回调和 Worker 崩溃序列,持续检查不变量:终态不能自行回到运行态;同一幂等键只有一个业务效果;没有批准不能出现高风险动作;每个等待状态都有唤醒条件;每个未知动作最终进入确认、重试或人工处理。
恢复测试还要校验用户可见结果。某次故障可能没有重复退款,却重复发送了三封通知;任务最终完成,却把旧 Commit 的 CI 结果当成验收证据。把外部效果和证据绑定在断言里,测试才能覆盖完整契约。
压测还要覆盖恢复压力。创建大量跨天 Timer、集中触发一批 Webhook、让 1% 的写动作进入未知状态,再观察队列年龄、对账能力和数据库热点。很多实现正常路径每秒能推进数千任务,故障恢复时却被同一状态索引或同一租户的惊群打垮。
线上恢复率也不能只统计“重新入队成功”。恢复后完成原任务、转入明确人工处理或产生可解释终态才算有效。任务在 RUNNING 与 RETRY_SCHEDULED 之间循环,虽然每次 Worker 都成功启动,业务上仍然没有恢复。
运维界面应能回答一组具体问题:任务最后由什么事件唤醒,当前谁持有租约,下一次 Timer 在何时,哪些动作仍未知,当前状态停留多久,恢复使用哪个版本,用户能做哪些操作。若这些信息只能从日志里手工拼接,系统规模上来后,值班人员会把大量时间花在重建任务事实。
十二、一份落地顺序
第一版先实现结构化任务状态、动作账本、幂等键和明确终态。随后接入事件去重、Timer、租约和取消。任务真的跨版本运行后,再补 Schema 迁移、事件历史与自动对账。不要在还没有一条真实长任务时,先造通用工作流平台。
设计评审时逐项回答:事实存在哪里,谁能推进状态,重复事件会怎样,工具超时后如何确认,等待如何唤醒,取消影响哪些动作,恢复 Context 从哪里生成,完成由哪些证据判定。任何一项只有“模型会处理”,都说明责任仍未落地。
长任务能够暂停、恢复和结束,靠的是普通分布式系统能力:持久化状态、消息、租约、幂等、对账和状态机。模型负责开放判断,运行时负责让这些判断跨过时间和故障后仍然有效。
参考资料
如果这篇文章对你有帮助