# ADR-0003：自研状态机，不用 Temporal / Airflow

- 日期：2026-07-27
- 状态：已接受（方案 §6.1 把 Temporal 列为演进建议，这里给出不在首版用的理由）

## 背景

工作流编排有成熟方案：Temporal、Airflow、Prefect、OpenTAP。
自研状态机通常是坏味道。

## 决定

首版自研：`engine/states.py` 转移表 + `engine/state_machine.py` 纯函数
+ `engine/runtime.py` 异步循环。

## 理由

产品的差异化恰好落在通用引擎**不管**的那一层：

1. **恢复语义不是重试。** Temporal 的重试模型是"再调一次这个 activity"。
   我们需要的是"主机蓝屏了 → 带外触发主板 Reset → 等主机起来 → 验证 DUT
   重新枚举 → 从检查点续跑"。这是跨越进程、跨越主机生死的物理恢复，
   不是函数重试。硬套通用引擎会变成在 activity 里塞一堆状态判断，比自研更难维护。

2. **非幂等步骤默认不重跑。** 通用引擎的默认值是"重试是安全的"。
   我们这里最贵的一条规则是"刷写步骤挂了**不要**自动重跑"。
   跟框架默认值对着干，不如自己控制。

3. **冻结现场是一等状态。** 数据完整性失败要立刻停止一切覆盖性动作并保留现场。
   通用引擎里这是"失败"，我们这里它是一个需要保持很久、等人来看的活状态。

4. **可测试性。** 纯函数状态机能把"30 秒心跳超时后带外在线则走 L3"这种规则
   在毫秒内测完。挂在 Temporal 上要起测试环境。后面几波会疯狂往状态机加分支，
   测试成本是决定性因素。

5. 依赖成本：Temporal 需要自己的数据库和服务集群。客户是**内网私有化部署**，
   多一个必须运维的组件就多一个交付摩擦。

## 代价

- 跨节点调度、持久化定时器、版本化工作流这些 Temporal 白送的能力要自己做。
  这一波不需要（单工位），Phase 3 多工位时会痛。
- 自研状态机容易随时间腐化成 if 堆。缓解：转移表是纯数据、`decide()` 是纯函数、
  每加一个分支必须配单测，三条规矩写进了 `docs/state-machine.md §6`。

## 什么时候推翻

多工位跨节点调度成为瓶颈时（Phase 3，预计 20+ 工位）。
届时替换范围仅限 `runtime.py`——`states.py` / `state_machine.py` / `recovery.py`
是纯逻辑，可以原样搬进 Temporal 的 workflow 定义里。
这也是把它们做成纯函数的第二个理由。
