Skip to content

Slot lifecycle reference

Every slot is a small state machine. The SlotManager drives transitions, the dashboard SSE stream renders them live, and each transition is persisted atomically to state.json. The dashboard shows real transitions — not systemd snapshots.

SlotState has nine values. Each value is also its JSON / SSE wire string.

StateWire valueMeaning
OFFLINEofflineSlot is not running. No systemd unit active.
PULLINGpullingModel files are downloading or being verified. Unit not yet started.
STARTINGstartingsystemd unit started; waiting for the container to come up.
WARMINGwarmingContainer up; health probe returns non-ready while the model loads into VRAM / GTT.
READYreadyPassed the full health probe (non-empty /v1/models + sentinel completion). Ready to serve.
SERVINGservingAn inference request is actively in-flight.
IDLEidleContainer up but cannot fulfil requests right now (see below).
UNLOADINGunloadingGraceful shutdown in progress; waiting for the container to exit.
ERRORerrorSlot has failed. Details in state.json and journald.

idle is reached two ways; both render identically on the wire but the state machine arrives via different edges:

  1. Process up, no model (warming → idle): the container is reachable but /v1/models is empty (e.g. launched with --model "" or the model file is missing). Routers must treat this distinctly from ready — never route an inference request to a slot that can’t fulfil it.
  2. Warm but quiet (ready → idle): a previously-ready slot received no request for longer than its idle timeout. A candidate for unloading.

Slot detail panel showing live state, model, and transition controls The slot detail panel in the dashboard, reflecting the current SlotState in real time.

Transitions are enforced by the SlotManager; an illegal attempt raises IllegalSlotTransition (HTTP 409).

FromAllowed next states
OFFLINEPULLING, STARTING
PULLINGSTARTING, ERROR, OFFLINE
STARTINGWARMING, ERROR, OFFLINE
WARMINGREADY, IDLE, ERROR, OFFLINE
READYSERVING, IDLE, UNLOADING, ERROR
SERVINGREADY, IDLE, ERROR
IDLESERVING, UNLOADING, READY
UNLOADINGOFFLINE, ERROR
ERROROFFLINE, PULLING, STARTING

The happy path is offline → pulling → starting → warming → ready, then ready ↔ serving under load, drifting to idle when quiet, and unloading → offline on teardown. error can recover back into offline, pulling, or starting.

While a slot sits in warming, ready, serving, or idle, a background per-slot task polls the container unit’s systemctl is-active every 2 seconds:

  • warming is judged purely on unit liveness — a big model load can legitimately hold /health down for minutes, so /health is never consulted here. Three consecutive inactive polls flip the slot to error with message "container unit died while warming".
  • ready / serving / idle are judged on unit liveness and /health: an active-but-unhealthy unit (two consecutive failed /health probes) flips to error with message "model server failed /health probe".
  • A unit that goes inactive cleanly — a GPU-arbiter handoff, a plain systemctl stop, an OOM-kill pending restart — is reflected as offline (message "container stopped (auto-reloads on next request)"), not error. The red error dot is reserved for genuine spawn/health/load failures; a clean stop is a normal condition the dispatcher lazily reloads on the next request.

Detection latency is therefore up to a few poll intervals (roughly 4-6 seconds), never instant.

An NPU FLM slot of type = "transcription" or type = "embedding" is a different case: a trio shadow. The NPU runs a single FLM process — the device = "npu", type = "llm" anchor — which also serves transcription/ embedding when the anchor’s [npu] toggles are on. A shadow slot is not independently loadable (a standalone load call on it 500s the single-tenant NPU), so load() skips both the spawn and the readiness probe for it and transitions it straight to READY with message "served by NPU FLM anchor (trio shadow)". Its live state is otherwise derived from the anchor, and inference requests are dispatched to the anchor’s FLM process, never to the shadow slot’s own (non-existent) port.

Beyond the idle-timeout demotion to IDLE, a slot can be unloaded (ready/idle → unloading → offline) two ways:

  • Hard TTL. A slot idle past its resolved idle_timeout_s (per-slot TOML override, else a 300-second default) is unloaded outright, freeing host RAM. An explicit idle_timeout_s = 0 pins a slot against this.
  • Host-memory-pressure LRU eviction. When host MemAvailable drops below a configured floor, hal0 evicts idle, lru = true-flagged slots oldest-last_used-first until free RAM recovers (or no eligible slots remain). A slot actively serving a request is never evicted, and agent, utility, and npu are never evicted by pressure regardless of their lru flag — they’re the anchors the dispatch fallback chains depend on.

Either kind of eviction leaves the slot in OFFLINE, not ERROR — it’s a resource-management transition, not a failure. A request that lands on an idle-evicted slot triggers a lazy reload rather than a 404: the dispatcher kicks off SlotManager.load() for the target slot and returns a structured slot.loading 503 with Retry-After, so the client’s retry lands once the slot is back up.

If a slot’s configured model.default isn’t locally servable — deleted, never pulled, or pulled-away — load() doesn’t just fail. It searches the local registry for the best match by the slot’s capability (chat, embed, rerank, asr, tts) and loads that instead, preferring name-similarity to the configured id. Diffusion / image / video artifacts are always excluded from this fallback search for a non-image slot, so a text slot can never silently come up serving an image-gen model it can’t actually run.

Each slot’s current state is stored as JSON at /var/lib/hal0/slots/<name>/state.json. The record is intentionally flat for human readability and written through an atomic same-directory tempfile + os.replace, so an SSE reader or the dashboard never observes a half-written transition.

FieldTypeNotes
namestrSlot name.
statestrOne of the wire values above.
model_idstr | nullCurrently-assigned model id.
portintHost port.
updated_atfloatEpoch seconds of the last transition.
messagestrHuman-readable detail (errors, notes).
extraobjectArbitrary extra metadata.

A malformed state.json (missing/invalid state, unreadable file, bad JSON) raises SlotConfigError rather than being silently swallowed.

Slot operations raise typed errors so the API renders a structured envelope with a stable slot.* code:

ErrorCodeHTTP
SlotNotFoundslot.not_found404
IllegalSlotTransitionslot.illegal_transition409
SlotNotReadyslot.not_ready503
SlotSpawnFailedslot.spawn_failed500
SlotHealthFailedslot.health_failed503
SlotConfigErrorslot.config_error400
NpuExclusivityViolationslot.npu_exclusivity_violation409

NpuExclusivityViolation guards the AMD XDNA hardware context, which admits exactly one NPU LLM at a time: two enabled device=npu, type=llm slots cannot coexist.

  • hal0 CLIhal0 slot list reports these states.
  • Config schema — the per-slot TOML that defines a slot.
  • Slots — the concept and dispatch model.