Files
yuanshuai c91a64fddb
CI / Python 3.12 (push) Waiting to run
CI / Python 3.9 (push) Waiting to run
chore(repo): initialize team collaboration repository
2026-07-27 20:40:12 +08:00

135 lines
7.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 状态机、恢复阶梯与检查点语义
这是整个产品的核心。差异化不在"能跑命令",在**跑挂了之后会发生什么**。
当前实现:`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`,而不是在纯函数里开个口子。