Phases
Last updated on May 21, 2026
A phase is one step inside a Procedure, and phases group under three stages: setup, main, and teardown. The CLI runs them in order and schedules them against the execution policy, so every phase produces one outcome (PASS, FAIL, SKIP, ERROR) and contributes Measurements, Logs, and Attachments to the run.
Fields
The example below sets the fields you reach for most often, so you can see the shape of a phase declaration before diving into each one.
main:
- name: Power On Test
key: power_on
description: Verify 5V power rail is within specification
python: phases.power_on
timeout: 30s
retry:
limit: 3| Field | Purpose |
|---|---|
name | Display name. 1-100 characters. Required. |
key | Identifier for depends_on and previous-result access. Defaults to snake_case(name). |
description | Optional, up to 1,000 characters. |
python | module:callable spec. Mutually exclusive with executable. |
executable | Shell command. Mutually exclusive with python. |
measurements | List of measurement specs. See Measurements. |
ui | Operator UI components for this phase. See Operator UI. |
enabled | When false, the phase is skipped without recording. Default true. |
depends_on | Phase keys that must complete first. Up to 100 entries. |
timeout | Max execution time. Human-readable duration (ms, s, m, h). |
retry | Retry policy: limit (1-10), optional delay. |
then | Per-outcome next action: pass/fail/error/timeout → continue/stop/retry. |
result | Force a static outcome without executing. |
scope | In multi-slot runs: all runs once across slots, each runs per slot. Default each. |
Python runtime
You set python to a module:callable spec, and the module path resolves relative to procedure.yaml. The callable name defaults to the last path component, so phases.measure_voltage looks up measure_voltage inside phases/measure_voltage.py.
main:
- name: Measure Voltage
python: phases.measure_voltagedef measure_voltage(measurements, multimeter):
measurements.voltage = multimeter.read_voltage()Function parameters
TofuPilot injects framework objects by parameter name, so you declare what you need on the function signature and the runtime fills the rest.
| Parameter | Purpose |
|---|---|
phase | Phase control: phase.fail(), phase.skip(), phase.retry(), phase.stop(). |
measurements | Assign measurement values: measurements.voltage = 3.3. |
log | Structured logging: log.info(...), log.warning(...). |
ui | Push values to operator UI displays: ui.progress = 50. |
unit | Read or set unit fields: unit.serial_number. |
attach | Attach files: attach.file(path), attach.data(bytes, name). |
run | Access the full run context. |
<phase_key> | Result object from a completed dependency phase. |
<plug_key> | Plug instance bound to the procedure. |
Environment
TofuPilot manages a per-procedure venv through uv, so you declare deps in pyproject.toml and the CLI installs them before the first run.
[project]
requires-python = ">=3.11"
dependencies = ["numpy>=1.24.0"]Executable runtime
When the test is already a CLI tool (firmware flashers, sample utilities, vendor SDKs), you set executable to run a shell command instead of Python, and the phase passes when the command exits 0.
main:
- name: Flash Firmware
executable:
command: "esptool write_flash 0x0 firmware.bin"
shell: bash
working_directory: ./firmware| Field | Purpose |
|---|---|
command | Shell command. 1-10,000 characters. Required. |
shell | Shell. Defaults to sh on Unix, powershell on Windows. Supported: bash, sh, zsh, powershell, pwsh, cmd. |
working_directory | Run directory. Defaults to the procedure directory. |
Conditional execution
You toggle a phase off with enabled, and you order phases or build a DAG with depends_on. Phases without depends_on in the same stage run in parallel against the worker pool, so you only declare dependencies when one phase truly needs another's results.
main:
- name: Power On
key: power_on
python: phases.power_on
- name: Test WiFi
python: phases.test_wifi
depends_on: [power_on]
- name: Slow Diagnostic
python: phases.diagnostic
enabled: falseCycles are rejected at validation, and references to non-existent keys are rejected too. The CLI reports which phase fails to parse and why, so the error always points to the line you need to fix.
Timeout
You bound the wall-clock duration with timeout, so when the timeout fires the worker is terminated and the phase outcome becomes ERROR.
main:
- name: Burn-in
python: phases.burn_in
timeout: 1h30mThe supported range is 1ms to 24h, and you can combine units in one string (1h30m, 2m500ms) so the value reads the way you would say it out loud.
Retry
A phase retries only when phase code calls phase.retry() or a matching then.<outcome>: retry rule fires, and the retry block bounds the extra attempts.
main:
- name: Flaky Sensor
python: phases.sensor_read
retry:
limit: 3
delay: 500ms
then:
fail: retrylimit counts retries on top of the initial attempt, so limit: 3 runs the phase up to 4 times. Each attempt uploads as a separate phase record with an incremented retry_count, so analytics can tell flaky phases from stable ones.
Setup, main, teardown
The three stages map directly to top-level YAML keys, so the layout reads like a checklist for the operator and the runtime.
setup:
- name: Connect Instruments
python: phases.connect
main:
- name: Run Tests
python: phases.test
teardown:
- name: Disconnect
python: phases.disconnectSetup runs first, and when any setup phase fails main is skipped because the preconditions are not met. Teardown always runs even after setup or main fail, so resources get released no matter how the run ended.
Outcomes
Every phase resolves to one of four outcomes, which the runtime uses to pick the next action.
| Outcome | When |
|---|---|
PASS | All measurements valid, no failure signaled. |
FAIL | phase.fail() called, retry limit exceeded, or measurement validator failed. |
SKIP | phase.skip() called or enabled: false. |
ERROR | Unhandled exception, timeout, or phase.stop(). |
After the outcome resolves, the runtime picks the next action. The defaults are pass → continue and fail/error → stop, and you override that per phase with then or globally with execution.on_first_failure.
Phase control from Python
When you need to drive the outcome from inside the phase body, the phase object gives you four control methods.
| Method | Effect |
|---|---|
phase.fail() | Mark fail and exit immediately. |
phase.skip() | Skip without recording measurements. |
phase.retry() | Retry if retry.limit allows. |
phase.stop() | Stop the entire run with ERROR. |
Previous results
Name a function parameter after a completed phase's key to read its results, and the runtime injects the result object so you can branch on what came before.
main:
- name: Measure Voltage
key: measure_voltage
python: phases.measure_voltage
measurements:
- name: voltage
- name: Verify Voltage
python: phases.verify_voltage
depends_on: [measure_voltage]def verify_voltage(phase, measure_voltage):
if measure_voltage.voltage < 4.9:
phase.fail()The result object exposes <key>.<measurement>, <key>.outcome, <key>.duration_ms, and <key>.retry_count, so you have everything the dashboard sees.
In multi-slot runs, a phase only sees results from phases within the same slot, so cross-slot reads return None.
How is this guide?
TofuPilot Framework
Learn how to author a TofuPilot procedure in a single YAML file and run it with the CLI, with native support for phases, measurements, plugs, and operator UI.
Measurements
Learn how to declare typed measurements with validators and aggregations so TofuPilot scores each value and computes stats on numeric arrays.