Skip to content

States & transitions

The run lifecycle FSM is defined in backend/runs/domain/lifecycle/. Every run moves through exactly one state at a time; every state change requires a matching event, a passing set of guards, and the execution of a set of actions.

State name correction

The existing placeholder diagram in docs/content/processes/run-lifecycle.md uses CANCELED. The code uses "Cancelled" (British spelling, mixed case). Always use the value from RunLifecycleStates — never the enum member name.

State set

Defined in backend/runs/domain/lifecycle/states.py as RunLifecycleStates(str, Enum):

Enum member Value (str) Meaning
REQUESTED "Requested" API call received; run record created
VALIDATED "Validated" GTFS consistency and resource availability confirmed
INITIALIZED "Initialized" Redis state populated; resources claimed
CONFIRMED "Confirmed" Operator (driver/dispatcher) has acknowledged
TRACKING "Tracking" First telemetry received; GPS tracking active
IN_PROGRESS "In Progress" Vehicle is moving along the route
NO_SIGNAL "No Signal" Telemetry went silent; grace window running
COMPLETED "Completed" Vehicle reached the terminal stop
INTERRUPTED "Interrupted" Run manually or automatically aborted mid-route
SHORT_TURNED "Short Turned" Run terminated at a non-terminal stop
CANCELLED "Cancelled" Rejected, cancelled before start, or expired

State diagram

stateDiagram-v2
    [*] --> Requested : POST /create-run

    Requested --> Validated : VALIDATE_RUN\n[is_gtfs_valid, is_trip_available,\nis_vehicle_available, is_operator_available]
    Requested --> Cancelled : RUN_REJECTED

    Validated --> Initialized : INITIALIZE_RUN\n[is_run_validated]\n/ update_system_state
    Validated --> Cancelled : RUN_REJECTED\n/ release_resources

    Initialized --> Confirmed : RUN_CONFIRMED_BY_OPERATOR\n/ sync_lifecycle_state
    Initialized --> Cancelled : RUN_REJECTED\n[is_cancellation_authorized]\n/ remove_from_system_state, release_resources

    Confirmed --> Tracking : RUN_TRACKING_STARTED\n[is_vehicle_tracked]\n/ sync_lifecycle_state, add_to_tracking_set
    Confirmed --> Cancelled : CANCEL_RUN\n[is_cancellation_authorized]\n/ remove_from_system_state, release_resources

    Tracking --> InProgress : RUN_STARTED\n[is_vehicle_moving]\n/ sync_lifecycle_state, add_to_in_progress_set
    Tracking --> Cancelled : CANCEL_RUN\n[is_cancellation_authorized]\n/ remove_from_tracking_set, remove_from_system_state, release_resources

    InProgress --> NoSignal : RUN_TRACKING_LOST\n[is_telemetry_stale]\n/ sync_lifecycle_state
    InProgress --> Interrupted : RUN_INTERRUPTED\n[is_interruption_authorized]\n/ sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources
    InProgress --> ShortTurned : RUN_SHORT_TURNED\n[is_short_turn_authorized, is_short_turn_geometrically_valid]\n/ sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources
    InProgress --> Completed : RUN_COMPLETED\n[is_at_terminal_stop]\n/ sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources

    NoSignal --> InProgress : RUN_TRACKING_RESTORED\n[is_telemetry_fresh, is_vehicle_tracked]\n/ sync_lifecycle_state, add_to_tracking_set, add_to_in_progress_set
    NoSignal --> Cancelled : RUN_TRACKING_EXPIRED\n[is_telemetry_grace_period_exceeded]\n/ sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources

    Completed --> [*]
    Interrupted --> [*]
    ShortTurned --> [*]
    Cancelled --> [*]

    InProgress : In Progress
    NoSignal : No Signal
    ShortTurned : Short Turned

Transition table

Full table from backend/runs/domain/lifecycle/transitions.py:

From Event To Guards Actions
Requested VALIDATE_RUN Validated is_gtfs_valid, is_trip_available, is_vehicle_available, is_operator_available
Requested RUN_REJECTED Cancelled
Validated INITIALIZE_RUN Initialized is_run_validated update_system_state
Validated RUN_REJECTED Cancelled release_resources
Initialized RUN_CONFIRMED_BY_OPERATOR Confirmed sync_lifecycle_state
Initialized RUN_REJECTED Cancelled is_cancellation_authorized remove_from_system_state, release_resources
Confirmed RUN_TRACKING_STARTED Tracking is_vehicle_tracked sync_lifecycle_state, add_to_tracking_set
Confirmed CANCEL_RUN Cancelled is_cancellation_authorized remove_from_system_state, release_resources
Tracking RUN_STARTED In Progress is_vehicle_moving sync_lifecycle_state, add_to_in_progress_set
Tracking CANCEL_RUN Cancelled is_cancellation_authorized remove_from_tracking_set, remove_from_system_state, release_resources
In Progress RUN_TRACKING_LOST No Signal is_telemetry_stale sync_lifecycle_state
In Progress RUN_INTERRUPTED Interrupted is_interruption_authorized sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources
In Progress RUN_SHORT_TURNED Short Turned is_short_turn_authorized, is_short_turn_geometrically_valid sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources
In Progress RUN_COMPLETED Completed is_at_terminal_stop sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources
No Signal RUN_TRACKING_RESTORED In Progress is_telemetry_fresh, is_vehicle_tracked sync_lifecycle_state, add_to_tracking_set, add_to_in_progress_set
No Signal RUN_TRACKING_EXPIRED Cancelled is_telemetry_grace_period_exceeded sync_lifecycle_state, remove_from_tracking_set, remove_from_in_progress_set, release_resources

Guards

Defined in backend/runs/domain/lifecycle/guards.py.

Registration guards:

  • is_gtfs_valid — checks that route_id, trip_id, direction_id, shape_id, and schedule_relationship are present and consistent with the current GTFS feed in PostgreSQL.
  • is_trip_available — checks Redis trip:<trip_id>:current_run is not already assigned to another run.
  • is_vehicle_available — checks Redis vehicle:<vehicle_id>:current_run is not assigned elsewhere.
  • is_operator_available — checks Redis operator:<operator_id>:current_run is not assigned elsewhere.
  • is_run_validated — always returns True; placeholder for future validation checks.

Authorization guards:

  • is_cancellation_authorized — allows actor_role in {"system", "dispatcher", "operator"}.
  • is_interruption_authorized — allows actor_role in {"system", "dispatcher", "operator"}.
  • is_short_turn_authorized — allows actor_role in {"dispatcher", "system"} only (not plain operator).
  • is_short_turn_geometrically_valid — checks that the proposed short-turn stop is on the trip and is not the terminal stop.

Telemetry guards:

  • is_vehicle_tracked — checks SISMEMBER runs:tracking <run_id> in Redis.
  • is_vehicle_moving — checks speed > 0.5 m/s in the payload.
  • is_telemetry_stale — checks staleness > TELEMETRY_GRACE_S (60 s).
  • is_telemetry_fresh — checks staleness <= TELEMETRY_GRACE_S (60 s).
  • is_telemetry_grace_period_exceeded — checks staleness > TELEMETRY_EXPIRY_S (600 s).
  • is_at_terminal_stop — checks that the stop_id in the payload matches the last stop of the run's trip in the current GTFS feed.

Actions

Defined in backend/runs/domain/lifecycle/actions.py. All actions execute synchronously inside the run_lifecycle_event Celery task.

  • update_system_state — writes the full run hash to run:<id> in Redis; writes the GTFS-RT-shaped TripDescriptor to run:<id>:trip; claims vehicle:<id>:current_run, operator:<id>:current_run, and trip:<trip_id>:current_run; writes vehicle:<id>:metadata.
  • sync_lifecycle_state — updates only the run_lifecycle_state field in run:<id>.
  • add_to_tracking_setSADD runs:tracking <run_id>.
  • remove_from_tracking_setSREM runs:tracking <run_id>.
  • add_to_in_progress_setSADD runs:in_progress <run_id>.
  • remove_from_in_progress_setSREM runs:in_progress <run_id>.
  • remove_from_system_stateDEL run:<id>, SREM runs:tracking, SREM runs:in_progress.
  • release_resourcesDEL vehicle:<id>:current_run, DEL operator:<id>:current_run, DEL trip:<trip_id>:current_run.

runs:tracking is a work queue, not a status flag

A comment in transitions.py makes this explicit: when IN_PROGRESS transitions to NO_SIGNAL, the run is not removed from runs:tracking. The set is the work queue for scan_stale_runs — it must include NO_SIGNAL runs so the periodic scan can later fire RUN_TRACKING_EXPIRED. Only fully-terminal transitions (Completed, Interrupted, Short Turned, Cancelled) remove from the tracking set.

Events reference

All events from backend/runs/domain/lifecycle/events.py:

class RunLifecycleEvents(str, Enum):
    RUN_REQUESTED = "run_requested"
    VALIDATE_RUN = "validate_run"
    INITIALIZE_RUN = "initialize_run"
    RUN_CONFIRMED_BY_OPERATOR = "run_confirmed_by_operator"
    RUN_TRACKING_STARTED = "run_tracking_started"
    RUN_STARTED = "run_started"
    RUN_COMPLETED = "run_completed"
    RUN_REJECTED = "run_rejected"
    CANCEL_RUN = "cancel_run"
    RUN_INTERRUPTED = "run_interrupted"
    RUN_SHORT_TURNED = "run_short_turned"
    RUN_TRACKING_LOST = "run_tracking_lost"
    RUN_TRACKING_RESTORED = "run_tracking_restored"
    RUN_TRACKING_EXPIRED = "run_tracking_expired"

For which of these are commands vs detected facts, see commands-vs-detections.md.