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