# 状态机、恢复阶梯与检查点语义

这是整个产品的核心。差异化不在"能跑命令"，在**跑挂了之后会发生什么**。

当前实现：`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` 里是硬编码的，不给配置覆盖）：**

1. **数据完整性失败直接跳到 FREEZE**，不走阶梯。
   哈希/读回比较不一致意味着现场有价值，任何重启都可能毁掉证据。
2. **带外控制器离线时，L3-L5 不可用**，直接降级到 FREEZE 并标记
   `INFRA_FAILURE`——不能因为带外没接就把 DUT 判成坏盘。
3. **每一级恢复都要留证**：恢复前抓画面/串口/温度，恢复后验证 DUT 重新枚举。
   `recovery_actions` 表记录每一级的 trigger / outcome / evidence_uri。
4. **恢复成功 ≠ 步骤成功**。恢复只是把系统救回可执行状态，
   原步骤按检查点语义决定是续跑还是重跑。

## 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. 怎么给状态机加东西（后续几波会反复做）

1. 在 `states.py` 加状态/事件枚举 + 改转移表
2. 在 `state_machine.py::decide()` 加分支，返回新的 `Effect`
3. 在 `runtime.py` 加该 `Effect` 的施加逻辑（唯一碰 I/O 的地方）
4. 在 `tests/test_state_machine.py` 加纯函数单测——不起库、不 sleep

如果第 2 步发现需要在 `decide()` 里做 I/O，说明抽象漏了，
应该把需要的数据加进 `Snapshot`，而不是在纯函数里开个口子。
