长任务跑到一半,它现在到底是什么状态?

把状态写成一张显式的转换表:8 个状态、7 种事件,表里没有的组合一律拒绝。再把状态和事件日志放进同一个事务持久化,用版本号和租约挡住并发与僵尸 worker。

测试 Agent 跑一轮 BugHunt 要十几分钟,中间会被取消、会失败重试、worker 会被杀掉。用几个布尔字段拼出来的"状态",迟早会出现"已取消但还在跑"的任务。

1为什么不用几个布尔字段

场景:BugHunt-Bench 里,一个"让测试 Agent 探索 Conduit 并提交 Bug 报告"的任务要跑十几分钟。用户可能中途取消;模型 API 偶尔 429,需要重试;跑任务的 worker 进程可能被 OOM 杀掉。

第一版代码常常是 is_running、is_done、is_cancelled 三个布尔字段,各处代码自己改自己关心的那个。三个布尔有 23 = 8 种组合,is_running=True, is_cancelled=True 算什么?是"已请求取消、worker 还没停",还是"取消失败"?没有人写下来,每个调用方各自理解。

状态机做的事很简单:把"当前在哪个状态、收到什么事件、能去哪"写成一张表。表里有的才允许,其他组合都是非法转换,直接拒绝并且不落库。这张表本身就是规格,测试可以逐格对照。

28 个状态、7 种事件、12 条合法转换

当前状态事件目标状态说明
submittedenqueuequeued进队列,等 worker 领取
submitted / queued / retry_waitcancelcancelled还没在跑,直接取消
queuedstartrunningworker 领取,attempts + 1,拿到租约
runningsucceedsucceeded终态
runningfailretry_wait 或 failed可重试且 attempts < max_attempts 才进 retry_wait,否则终态失败
runningcancelcancelling正在跑的任务不能瞬间停下,先标记"已请求取消"
retry_waitretry_duequeued退避时间到,回到队列(退避怎么算是下一章的内容)
cancellingcancel_ack / failcancelledworker 确认停下;停下前失败了也不再重试
cancellingsucceedsucceeded取消信号到达前已经跑完:副作用已经发生,如实记成功

按单元格数:8 × 7 = 56 个(状态, 事件)组合,其中 12 个合法转换(running + fail 算一格,目标取决于守卫条件),2 个幂等 no-op(对 cancelling 和 cancelled 再发一次 cancel:不报错、不写库、不记事件),剩下 42 个非法。

几个有争议的设计决定,都是取舍,不是唯一答案:
1)取消是请求不是命令。running → cancelling → cancelled,中间要等 worker 自己停下来。
2)cancelling 时收到 succeed 记成功而不是取消:副作用(比如已经提交的 Bug 报告)真实发生了,状态要和现实一致。
3)重复取消做成幂等 no-op,但对 succeeded / failed 发 cancel 是非法:客户端重试取消请求不该报错,但"取消一个已经完成的任务"应该让调用方知道。

3非法转换怎么测

  1. 56 格全覆盖:用 pytest.mark.parametrize 把每个(状态, 事件)组合都跑一遍。合法的断言目标状态和 version + 1;no-op 断言返回的是同一个对象;非法的断言抛 IllegalTransition。
  2. 期望表要独立写:测试里的 LEGAL 字典是照着规格手写的,不从实现里 import TRANSITIONS。从实现里抄,测试和实现就会错得一模一样,永远是绿的。
  3. 守卫条件单独测边界:max_attempts = 3 时,attempts 为 1、2 的失败进 retry_wait,第 3 次失败进 failed;不可重试的错误第 1 次就进 failed。
  4. 随机游走查不变量:固定种子生成 2,000 条长度 40 的随机事件序列,断言三条不变量:终态不会再变;attempts 不超过 max_attempts;version 等于真实发生的状态变化次数。
  5. 持久化层断言"拒绝即不落库":非法转换之后,tasks 表、租约字段、事件日志三样都要和之前完全一样(逐字段比较查询出的值)。

配套代码(week05_长任务可靠性/code/task_state_machine/)共 77 个测试,本地 python3.13 -m pytest -q 全部通过(约 2 秒)。为了确认这些测试真能抓 bug,mutation_check.py 往实现里故意埋了 5 个 bug,每个都至少让一个测试失败:

埋进去的 bug抓到它的测试
终态不再吸收:cancelled 还能 enqueue56 格全覆盖、随机游走、终态吸收、持久化层非法转换
重试上限差一:attempts <= max_attempts守卫边界、随机游走、重试计数、租约回收
去掉版本号检查两个 worker 抢同一任务(确定性交错和 8 线程各一个)
去掉租约持有者检查僵尸 worker
去掉所有显式事务(BEGIN IMMEDIATE / COMMIT / ROLLBACK)写日志时注入异常后的回滚测试

4状态持久化:同一个事务、版本号、租约

状态只放内存里,进程一重启就全丢了。这里用 SQLite(Python 标准库 sqlite3,本机 SQLite 3.50.4),两张表:tasks 存当前状态,task_events 存每一次转换。计划里提过 Redis 队列,本章先用 SQLite:单文件、有事务、不用起服务,测试里每个用例一个临时库。

  1. 状态和事件日志同一个事务写。连接用 isolation_level=None(Python 文档:设为 None 时 sqlite3 不会隐式开事务),自己写 BEGIN IMMEDIATE / COMMIT / ROLLBACK。测试在写事件日志时注入异常,断言 tasks 表的 UPDATE 也一起回滚了。
  2. 检查和写入必须是原子的。"先 SELECT 出 queued,在代码里判断,再 UPDATE"是两步,中间别人可以插进来。SQLite 文档说明 BEGIN IMMEDIATE 在 BEGIN 时就启动写事务(另一个连接已经在写时返回 SQLITE_BUSY,Python 的 timeout 参数默认等 5 秒),所以在事务里做"读 → 判断 → 写"是安全的。
  3. 版本号(乐观锁)。worker 轮询时读到的 version 会过期,领取时带上 expected_version,对不上就抛 VersionConflict;UPDATE 也带 WHERE version = ?,看 rowcount 是不是 1。在单个 SQLite 文件上,BEGIN IMMEDIATE 已经把写串行化,这个 WHERE 条件不会被触发(把它改成恒真,77 个测试照样全过)。但只要读和写之间没有一把写锁,它就是唯一的防线:比如不开显式事务、PostgreSQL / MySQL 在默认隔离级别下先读后写,或者 Redis(用 WATCH 实现同样的检查再写入)。
  4. 租约。start 时记下 lease_owner 和 lease_until,worker 定期心跳续约。只有租约持有者能上报 succeed / fail / cancel_ack。本实现的过期是惰性的:只核对持有者、不比较 lease_until,巡检回收之前,原持有者的心跳和上报仍然有效(设计选择)。
  5. 重启后回收。worker 被 kill -9 后,库里它的任务还是 running,没有人会再改它。巡检 reclaim_expired() 把租约过期的任务按一次可重试失败处理:attempts 还够就进 retry_wait,用完了就 failed,原来在 cancelling 的直接 cancelled。
  6. 事件日志可以重放。从 submit 开始把日志里的事件逐条 apply,结果必须和 tasks 表一致。测试里对 200 个随机任务做了这个检查。
僵尸 worker:worker-A 卡了 31 秒(租约 30 秒),任务被回收、重新排队、交给 worker-B。A 醒来后上报 succeed。不检查租约持有者,A 的迟到上报会把任务记成 succeeded,B 跑完再报反而被当成非法转换拒绝:最终记下的是过期 worker 的结果。检查租约后,状态层只认 B。注意租约只保护状态这一行:A 卡住前如果已经把 Bug 报告提交出去,这份报告照样存在。要防止外部副作用重复,得让副作用的接收方校验 fencing token / 幂等键,下一章讲。下面的交错演示可以单步看这个过程。

5对照:Codex 和 Celery 的状态

系统状态和本章的对应
Codex app-server(turn)Completed / Interrupted / Failed / InProgress只有 4 个,枚举里没有排队、重试等待这类中间状态。Interrupted 大致对应我们的 cancelled
Codex app-server(命令执行 item)InProgress / Completed / Failed / Declined多一个 Declined:主要是审批被拒(源码注释承认部分运行前的拒绝也会报成 Declined),共同点是命令根本没跑。"没跑"和"跑了失败"是两种状态
CeleryPENDING / STARTED / RETRY / FAILURE / SUCCESS / REVOKED文档说明 PENDING 并不是记录下来的状态,而是任何未知 task id 的默认状态;STARTED 默认不记录,要开 task_track_started,或给单个任务设 track_started=True;REVOKED 表示任务被撤销

Codex 引用基于 openai/codex commit 7993248:v2/turn.rs:33-38、v2/item.rs:1077-1082;Declined 的范围见 core/src/tools/events.rs:453-456 的注释。Celery 见 Next Steps、celery.result 和 Tasks 用户指南的 States 一节。

Celery 的 PENDING 是个经典测试坑:task id 拼错了,查到的也是 PENDING,看起来像"还在排队"。我们的实现里查不存在的 id 直接抛 KeyError,"不存在"和"在排队"是两回事。

▶互动演示 1:单步触发事件

点下面的事件按钮,对当前任务触发一次转换。"状态机"实现按转换表检查,非法的拒绝且不写库;"朴素写法"不做任何检查,每个事件直接把状态改成固定目标(fail 按次数)。下面是数据库里那一行的关键字段和事件日志。"worker 被 kill -9"模拟进程被杀:状态机实现等租约(30 秒)过期后由巡检回收,朴素写法没有租约,任务永远停在 running。

实现
事件
当前状态
attempts / max
version
租约持有者
被拒绝的非法事件
被接受的非法事件
task_events(事件日志,只有真正发生的转换才会写入)

▶互动演示 2:56 格转换矩阵

行是当前状态,列是事件。绿色 = 合法转换(格子里写目标状态),浅黄 = 幂等 no-op,✕ = 非法。点任意一格,演示 1 的任务会被放到该行的状态(attempts = 1)再触发该列的事件。按钮会把测试里的 56 个用例分别跑在两种实现上。

合法转换(12 格)通过
—
幂等 no-op(2 格)通过
—
非法转换(42 格)通过
—
还没跑。

▶互动演示 3:并发交错,一步一步看

三个确定性的交错场景,和配套代码 race_demo.py 的输出一致。每一步是某个参与者对数据库做的一次操作;表格右边几列是这一步之后数据库里那一行。切换实现可以对比朴素写法在哪一步出错;"不查租约"只在僵尸 worker 场景里和完整实现不同。

场景
实现

✎练习

题 1(数格子):一个状态机有 8 个状态、7 种事件,规格里有 12 个合法转换、2 个幂等 no-op。要做"非法转换"的全覆盖测试,需要多少个用例?
个
8 × 7 = 56 个组合,减去 12 个合法、2 个 no-op,剩 42 个非法。每一个都要断言"抛 IllegalTransition,并且数据库没变"。
题 2(算):max_attempts = 3,任务每次运行都以可重试错误失败。从 submit 开始到最终 failed,事件日志一共有多少条(含 submit 那一条)?
条
submit 1 条 + enqueue 1 条 + start 3 条 + fail 3 条 + retry_due 2 条(第 3 次失败直接进 failed,不再 retry_due)= 10 条,最终 version = 9。配套代码实际跑出来也是 10 条、v9。
题 3(测开视角):给状态机写转换表测试时,期望结果从哪来?
A 让测试和实现共用同一份数据,实现写错了测试也跟着错,永远是绿的。B 的快照是从实现里录的,第一次录下来的错误会被当成"正确"。只有 C 是独立的预言(oracle)。
题 4(持久化):worker 跑任务时被 kill -9,重启后数据库里这个任务还是 running。应该怎么处理?
A 不区分"真的死了"和"只是慢",原 worker 还活着时会出现两个 worker 同跑一个任务;也不计入 attempts,一个必崩的任务会无限重跑,也绕开了用户的取消请求。C 的任务会永远卡住。B 用租约区分死活,回收也走状态机,计数和取消都不会丢。
题 5(设计):任务处于 cancelling,worker 在收到取消信号前已经把 Bug 报告提交出去了,随后上报 succeed。本章的实现记成什么?为什么?
这是一个设计选择,本章选 A:报告已经提交了,记成 cancelled 会让下游以为什么都没发生。如果业务要求"取消后的结果要作废",那是另一套补偿逻辑(撤回报告),不该靠改状态名来掩盖。C 会让如实上报的 worker 报错。

6面试要点

一句话讲清楚

长任务用显式状态机管理:8 个状态、7 种事件、12 条合法转换写成一张表,表外的组合一律拒绝且不落库;状态和事件日志在同一个事务里写,领取任务带版本号,运行中靠租约证明"我还活着",进程崩溃后由巡检把过期租约按可重试失败回收。测试按 56 格逐格对照独立写的规格,再用随机游走查不变量、用变异测试证明测试真能抓 bug。

追问准备

  1. 取消一个正在跑的任务,为什么不直接改成 cancelled?worker 还在跑,改了状态它也不知道,可能继续产生副作用。先进 cancelling,worker 在检查点看到后停下并上报 cancel_ack。
  2. 为什么要事件日志,tasks 表不够吗?tasks 表只有"现在",排查"它是怎么变成 failed 的"要靠日志。日志还能重放,重放结果和 tasks 表一致是一条很好的不变量。
  3. 租约设多长?要比心跳间隔长几倍,留出网络抖动和 GC 停顿的余量;太短会把慢 worker 误判成死了(回收后它的迟到上报会被拒绝:任务还在 retry_wait / queued 时是非法转换,已被别的 worker 领走时是 StaleLease),太长则崩溃后要等很久才回收。具体倍数没有通用答案,要看心跳实测延迟。
  4. 换成 Redis 怎么做?同样要把"检查再写入"做成原子的:Redis 文档说明 WATCH 给事务提供检查再写入(CAS)语义,被 WATCH 的 key 在 EXEC 前被改过,整个事务就放弃;也可以用脚本。注意 Redis 事务不支持回滚。

常见错误说法

❌ "状态就是几个布尔字段":组合爆炸,语义没人写下来。
❌ "非法转换测几个典型的就够了":8 × 7 的表只有 56 格,全测成本很低,漏的往往是没想到的那几格。
❌ "取消就是把状态改成 cancelled":正在跑的 worker 不知道,要有 cancelling 和 worker 确认。
❌ "进程重启后把 running 全部改回 queued":分不清死和慢,会双跑,也不计重试次数。
❌ "有了状态校验就不需要事务":校验和写入不在一个原子操作里,并发时校验读到的就是旧数据。

配套代码:machine.py(纯函数状态机)、store.py(SQLite 持久化)、test_machine.py / test_store.py(77 个测试)、race_demo.py(三个交错场景)、mutation_check.py(5 个变异)。下一章:重试、指数退避和幂等键。