Skip to content
GantryCD

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)
  1. Poll — the runner group (or, for github-actions groups, an in-process dispatcher) claims the oldest pending run whose selector its labels satisfy, creates a stub runner, and marks the run assigned with an expiry.
  2. Accept — the ephemeral runner signals ready, then accepts. This is where the expensive work happens: credentials are minted, RunContext is built, and the run becomes running.
  3. Status — the runner polls to refresh liveness and detect cancellation.
  4. 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 structured tofu show -json summary 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.