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.

procedure.yaml
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
FieldPurpose
nameDisplay name. 1-100 characters. Required.
keyIdentifier for depends_on and previous-result access. Defaults to snake_case(name).
descriptionOptional, up to 1,000 characters.
pythonmodule:callable spec. Mutually exclusive with executable.
executableShell command. Mutually exclusive with python.
measurementsList of measurement specs. See Measurements.
uiOperator UI components for this phase. See Operator UI.
enabledWhen false, the phase is skipped without recording. Default true.
depends_onPhase keys that must complete first. Up to 100 entries.
timeoutMax execution time. Human-readable duration (ms, s, m, h).
retryRetry policy: limit (1-10), optional delay.
thenPer-outcome next action: pass/fail/error/timeoutcontinue/stop/retry.
resultForce a static outcome without executing.
scopeIn 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.

procedure.yaml
main:
  - name: Measure Voltage
    python: phases.measure_voltage
phases/measure_voltage.py
def 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.

ParameterPurpose
phasePhase control: phase.fail(), phase.skip(), phase.retry(), phase.stop().
measurementsAssign measurement values: measurements.voltage = 3.3.
logStructured logging: log.info(...), log.warning(...).
uiPush values to operator UI displays: ui.progress = 50.
unitRead or set unit fields: unit.serial_number.
attachAttach files: attach.file(path), attach.data(bytes, name).
runAccess 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.

pyproject.toml
[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.

procedure.yaml
main:
  - name: Flash Firmware
    executable:
      command: "esptool write_flash 0x0 firmware.bin"
      shell: bash
      working_directory: ./firmware
FieldPurpose
commandShell command. 1-10,000 characters. Required.
shellShell. Defaults to sh on Unix, powershell on Windows. Supported: bash, sh, zsh, powershell, pwsh, cmd.
working_directoryRun 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.

procedure.yaml
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: false

Cycles 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.

procedure.yaml
main:
  - name: Burn-in
    python: phases.burn_in
    timeout: 1h30m

The 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.

procedure.yaml
main:
  - name: Flaky Sensor
    python: phases.sensor_read
    retry:
      limit: 3
      delay: 500ms
    then:
      fail: retry

limit 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.

procedure.yaml
setup:
  - name: Connect Instruments
    python: phases.connect

main:
  - name: Run Tests
    python: phases.test

teardown:
  - name: Disconnect
    python: phases.disconnect

Setup 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.

OutcomeWhen
PASSAll measurements valid, no failure signaled.
FAILphase.fail() called, retry limit exceeded, or measurement validator failed.
SKIPphase.skip() called or enabled: false.
ERRORUnhandled 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.

MethodEffect
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.

procedure.yaml
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]
phases/verify_voltage.py
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?

On this page