Execution model
Two subsystems turn a triggered deployment into applied infrastructure: the deployment state machine (backend-side orchestration) and the runner protocol (how work gets to and from a runner). This page is the high-level map; the canonical references are Deployment State Machine and Runner Polling. Read those before changing queueing, cancellation, or completion logic.
Three state-carrying objects
- Deployment — the user-visible workflow record.
- Deployment stage — the current step (plan run, confirmation, apply run…). Stages are created just in time: a plan deployment doesn’t pre-create the apply run; the confirmation stage appears after a successful plan, and the apply stage only after confirmation.
- Run — the unit a runner executes.
The one queue primitive
A stack has at most one active state-locking deployment. The single function
that maintains this is RunRepository.PromoteNextOnStack: it releases a stale
lock, acquires the lock for the oldest queued deployment, and promotes it.
The invariant every contributor must respect:
Any write path that may free stack occupancy must end with
PromoteNextOnStack, in the same write transaction.
plan_unlocked (PR preview) deployments are the exception — they never hold the
lock, so they never call it. FIFO ordering is maintained in SQL, not in memory.
The two-phase runner protocol
Work assignment is split so credentials aren’t wasted on assignments that never start:
Phase 1 — POLL (tentative) Phase 2 — ACCEPT (authoritative)
group claims oldest matching run ephemeral runner accepts the run
→ stub runner created (not-ready) → credentials + RunContext generated
→ run: pending → assigned → run: assigned → running
→ assignment expiry set → deployment enters planning/applying
(no deployment transition yet)
- Poll — the runner group (or, for github-actions groups, an in-process
dispatcher) claims the oldest
pendingrun whose selector its labels satisfy, creates a stub runner, and marks the runassignedwith an expiry. - Accept — the ephemeral runner signals ready, then accepts. This is where
the expensive work happens: credentials are minted,
RunContextis built, and the run becomesrunning. - Status — the runner polls to refresh liveness and detect cancellation.
- Done / deregister — it reports completion and removes its runner record.
Every launcher (local, docker, kubernetes, github-actions) converges on this same HTTP surface; they differ only in how the runner process is brought online.
Completion drives the workflow
ReportRunCompletion is the authoritative completion path. In one write
transaction it sets the run’s terminal status, advances the workflow
(internal/backend/services/workflow/), updates the stage and deployment, creates
any next stage/run, and calls PromoteNextOnStack if occupancy was freed.
The interesting cases:
- Plan with no changes short-circuits straight to
finished— no confirmation stage. The signal is OpenTofu’s-detailed-exitcode; a structuredtofu show -jsonsummary refines the outcome and feeds the plan-analysis UI. - Plan with changes creates the confirmation stage and the deployment waits.
- Replan at confirmation creates a new targeted plan stage and loops back.
Recovery is designed in
Things fail; the queue stays healthy regardless:
- A runner that dies without acknowledging is reaped by
RunnerCleanupJob, which fails its runs and promotes the next deployment. - An unsupported cloud provider fails the deployment cleanly and bulk-fails the stack’s queued state-locked runs to avoid a promote-fail loop.
- A dispatch failure (workflow_dispatch rejected, worker spawn failed) fails just that run and moves the queue on — re-dispatching a broken run would only loop.
- A hard run-duration cap (
RUN_TTL) interrupts and kills runaway runs while their credentials are still valid.