# 状态机、恢复阶梯与检查点语义 这是整个产品的核心。差异化不在"能跑命令",在**跑挂了之后会发生什么**。 当前实现:`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`,而不是在纯函数里开个口子。