7.2 KiB
状态机、恢复阶梯与检查点语义
这是整个产品的核心。差异化不在"能跑命令",在跑挂了之后会发生什么。
当前实现:engine/states.py(Run 转移表)、engine/recovery.py(恢复纯函数)、
events/recorder.py(事件与投影写入口)以及 services/runs.py(内置模拟编排)。
Step 租约状态机与独立 runtime 属于下一阶段。
1. Run 状态机
┌──────────┐
│ QUEUED │ 等资源(DUT/主机独占锁)
└────┬─────┘
│ 锁到手
┌────▼─────┐
│ PREFLIGHT│ 安全门禁:序列号绑定、系统盘保护、
└──┬────┬──┘ 破坏性白名单、环境指纹冻结
门禁拒绝 │ │ 全过
┌────▼┐ │
│REJEC│ │
│ TED │ │
└─────┘ │
┌───────▼──────┐
┌─────────▶│ RUNNING │◀────────┐
│ └──┬───┬───┬───┘ │ 恢复成功,
│ 人工继续 │ │ │ │ 从检查点续跑
┌────┴───┐ │ │ │ ┌────┴──────┐
│ PAUSED │◀───────┘ │ └───────▶│ RECOVERING│
└────────┘ 人工暂停 │ 心跳超时/ └────┬──────┘
│ 蓝屏/掉盘 │ 阶梯耗尽
全部循环完成 │ 或数据完整性失败
┌─────▼─────┐ ┌────▼────┐
│ COMPLETED │ │ FROZEN │ 冻结现场,
│ PASS/FAIL │ └─────────┘ 停止一切覆盖性动作
└───────────┘
任何非终态 → ABORTED(紧急停止:Web / 物理按钮 / 带外均可触发)。
终态:COMPLETED / ABORTED / FROZEN / REJECTED。终态不可再转移,
assert_transition() 会抛 IllegalTransition——这个异常在生产里意味着有代码
绕过了事件溯源,属于必须修的 bug,不是可以吞掉的告警。
转移表是 states.py 里的一份纯数据 RUN_TRANSITIONS: Dict[RunState, FrozenSet[RunState]]。
加新状态时只改这张表 + 补一条单测,不改 runtime.py。
2. Step 状态机
PENDING ──▶ DISPATCHED ──▶ RUNNING ──┬──▶ SUCCEEDED
▲ ├──▶ FAILED ──┐
│ ├──▶ TIMED_OUT┤
└──── 重试(attempt+1)◀───────────┴─────────────┘
└──▶ CANCELLED / SKIPPED
DISPATCHED:控制平面已把步骤租给某个 Agent,等待 Agent 确认接手。 租约有超时——Agent 领了活就死了,租约到期步骤回到PENDING重新派发。- 重试次数由工作流步骤的
retry声明,默认 0。 危险步骤(刷写、Format)默认不重试:重试一次刷写可能把盘刷成砖。
3. 五级恢复阶梯
对应方案 §7.2。触发条件是双通道观测的判定结果,不是单一信号:
| 级别 | 动作 | 前提 | 典型触发 |
|---|---|---|---|
| L1 | AGENT_SOFT Agent 优雅停止/软重启进程 |
Agent 心跳还在 | 测试脚本卡死、进程僵死 |
| L2 | OS_REBOOT 通过 OS 远程通道重启 |
主机网络还通 | Agent 进程崩溃、驱动异常 |
| L3 | OOB_RESET 带外触发主板 Reset |
带外控制器在线 | 心跳超时 + 带外仍在线(蓝屏典型) |
| L4 | OOB_ATX_POWER 带外模拟 ATX 长按关机再开机 |
带外控制器在线 | Reset 无效(挂在 BIOS/固件态) |
| L5 | OOB_AC_CYCLE 整机 AC 断电 → 安全间隔 → 上电 |
带外控制器在线 | ATX 无效;也是 DUT 掉盘的最后手段 |
| — | FREEZE 冻结现场,停止自动恢复 |
— | 超过限定次数;或数据完整性失败立即触发 |
关键规则(recovery.py 里是硬编码的,不给配置覆盖):
- 数据完整性失败直接跳到 FREEZE,不走阶梯。 哈希/读回比较不一致意味着现场有价值,任何重启都可能毁掉证据。
- 带外控制器离线时,L3-L5 不可用,直接降级到 FREEZE 并标记
INFRA_FAILURE——不能因为带外没接就把 DUT 判成坏盘。 - 每一级恢复都要留证:恢复前抓画面/串口/温度,恢复后验证 DUT 重新枚举。
recovery_actions表记录每一级的 trigger / outcome / evidence_uri。 - 恢复成功 ≠ 步骤成功。恢复只是把系统救回可执行状态, 原步骤按检查点语义决定是续跑还是重跑。
4. 检查点与断点续跑
检查点在步骤边界创建,不在步骤中间——中间态无法保证幂等。
loop_index=37, step=fio-workload, state=SUCCEEDED
└─▶ checkpoint 写入:{loop_index: 37, next_step: verify-enumeration, dut_fw: "A"}
恢复后的续跑规则:
| 挂在哪 | 恢复后 |
|---|---|
步骤已 SUCCEEDED,检查点已落 |
从下一步继续 |
步骤 RUNNING 时挂了,步骤幂等(idempotent: true) |
重跑该步骤 |
步骤 RUNNING 时挂了,步骤非幂等(默认,如刷写) |
不重跑,标记 INCONCLUSIVE,进入人工确认 |
| 步骤是循环体中的一环 | 回到该 loop_index 的起点重跑整轮 |
非幂等步骤不自动重跑,是这一层最保守也最重要的默认值。 "自动重试把盘刷坏"是这个产品最容易砸招牌的失败模式。
5. 心跳与失联判定
Agent 心跳间隔 5s
├─ 15s 无心跳 → 主机 DEGRADED,记事件,不动作
├─ 30s 无心跳 → 判定失联,查带外:
│ ├─ 带外在线 + 主机有电 → 走恢复阶梯 L3
│ ├─ 带外在线 + 主机无电 → INFRA_FAILURE(供电问题,不是 DUT)
│ └─ 带外也离线 → INFRA_FAILURE + FREEZE(不能瞎判)
└─ 心跳恢复 → 校验 Run 状态一致性,续跑
阈值在 config.py,但判定逻辑在 state_machine.py 里是纯函数:
输入 (last_heartbeat_age, oob_online, oob_power_state, now),输出判定。
所以"30 秒超时"这种行为可以不等 30 秒就测出来。
6. 怎么给状态机加东西(后续几波会反复做)
- 在
states.py加状态/事件枚举 + 改转移表 - 在
state_machine.py::decide()加分支,返回新的Effect - 在
runtime.py加该Effect的施加逻辑(唯一碰 I/O 的地方) - 在
tests/test_state_machine.py加纯函数单测——不起库、不 sleep
如果第 2 步发现需要在 decide() 里做 I/O,说明抽象漏了,
应该把需要的数据加进 Snapshot,而不是在纯函数里开个口子。