长任务运行:状态、调度、恢复与收口

从一个跨天代码升级任务出发,拆解 Agent 怎样保存状态、等待外部事件、处理结果未知、恢复执行,并以可验证证据结束任务。

本文使用humanizerdocumd-visuals

一个 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 获得租约后推进有限步数,然后释放资源。

典型唤醒来源有四类:

  1. 用户创建任务或补充消息;
  2. 工具、CI 和外部系统通过 Webhook 回传事件;
  3. Timer 到期,例如退避重试或定时检查;
  4. 运维操作,例如恢复、取消或重新入队。

所有入口最后都归一成任务事件。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 从哪里生成,完成由哪些证据判定。任何一项只有“模型会处理”,都说明责任仍未落地。

长任务能够暂停、恢复和结束,靠的是普通分布式系统能力:持久化状态、消息、租约、幂等、对账和状态机。模型负责开放判断,运行时负责让这些判断跨过时间和故障后仍然有效。

参考资料