Files
storage-labos/flashops/docs/state-machine.md
T
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

7.2 KiB
Raw Blame History

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

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

当前实现:engine/states.pyRun 转移表)、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,而不是在纯函数里开个口子。