Plan: modeld Single Active Model Slot
Status: decision blueprint, drafted 2026-06-19. Sibling docs:
interface-boundary.md,provisioning-detection.md,multi-client-coordination.md,owner-coordination.md. Purpose: makemodeldthe owner of one resident local model at a time, while runtime can still discover, select, load, unload, and switch local models without restarting the process.
Problem Statement
The current modeld contract is already the right boundary for local compute:
runtime owns planning, messages, tools, model selection, and catalog state;
modeld owns resident local compute and the stateful
OpenSession -> EnsurePrefix -> PrefillSuffix -> Decode contract.
The problem is the cardinality behind that boundary.
Today modeld serves one backend mode at a time (llama, openvino, or
none), but it can open more than one resident session/model. The gRPC transport
keeps a map of handles to sessions, and the runtime-side warm cache limits
resident sessions only per runtime process. A second frontend or a direct client
can still cause extra local models to be resident. That weakens the whole reason
for the daemon: predictable local memory ownership.
There is also a UX gap:
model list
Correctly reports runtime/kernel availability: models available for use ASAP.
local model library / installed models
Missing surface: models present on disk or known to runtime, even when they
are not currently active in modeld.
Cloud providers and Ollama hid this distinction because “selectable” and
“runnable” were effectively the same thing. Local modeld makes the distinction
real: an installed artifact may be selectable, but not loaded. One loaded model
may be runnable, but switching to another model requires evicting the active
slot first.
Decision
One modeld owner means one active local model slot.
The invariant is:
At most one local chat/generation model is resident in one modeld owner.
At most one turn uses that resident slot at a time.
Runtime may load, unload, and switch the slot at runtime.
Runtime may not keep multiple local generation models resident through modeld.
This makes modeld a local accelerator owner, not a small multi-model serving
cluster. It also keeps the UX honest: a user can have many installed models, but
only one active local model.
Terms
library model
A model known to runtime and/or present on disk. It may be selectable in the UI
even when modeld is stopped or serving another model.
active model
The one model identity currently loaded in modeld's slot.
ready model
The active model is loaded, healthy, not currently busy, and compatible with
the requested model/config identity.
busy slot
The active model is in a protected operation: load, unload, switch, prefix
prefill, suffix prefill, decode, snapshot, or restore.
slot generation
A monotonically increasing epoch for the active slot. It changes on every
load, unload, switch, owner takeover, or fatal backend reset.
resident session
The backend session/context/KV state inside the active slot.
The active identity includes more than the model path:
backend type
model name
model digest
model path
runtime digest
device identity
NumCtx
NumBatch
NumGpuLayers
TensorSplit
FlashAttn
KVCacheType
prompt format/template digest
BOS policy
reasoning format
If any compatibility field changes, treat it as a different active identity and require a reload/switch.
Non-Goals
- Not turning
modeldinto a multi-model inference server. - Not moving cloud providers behind
modeld. - Not redefining
model listto mean “installed local model library”. - Not keeping two local generation models resident for faster switching.
- Not silently switching away from an active model in the middle of a decode.
- Not promising durable active-slot recovery across daemon crashes.
UX Surfaces
Keep the runtime state surface and add the missing inventory surface.
model list
Live runtime/kernel availability. This continues to mean "available ASAP from
the current runtime state." For local modeld, that usually means the active
loaded model plus any cloud/Ollama providers that are reachable.
model library / model installed / model local
Offline inventory. Lists local artifacts and metadata even when modeld is not
running, the daemon is serving a different backend, or the model is not loaded.
model status / modeld status
Daemon state. Shows owner instance, backend, active model, slot generation,
busy/ready state, memory estimate, effective context, last load error, and
whether the active slot is stale or unreachable.
Selection should be explicit:
installed but not active -> Activate
active and ready -> Use
active and busy -> Wait / Cancel if supported
different active model -> Switch
active model -> Unload
daemon missing/dead -> Start modeld / install modeld
This preserves the technical meaning of model list while giving users the
thing they expected: “show me the models I can choose from.”
Transport Contract Changes
The current runtime/transport.Service exposes:
OpenSession(ctx, OpenSessionRequest) (Session, error)
Describe(ctx, OpenSessionRequest) (ModelInfo, error)
Embed(ctx, EmbedRequest) (EmbedResult, error)
Add daemon-slot operations without moving high-level runtime planning into
modeld:
Status(ctx context.Context) (DaemonStatus, error)
LoadModel(ctx context.Context, req LoadModelRequest) (ActiveModel, error)
UnloadModel(ctx context.Context, req UnloadModelRequest) error
OpenSession remains the entry point for the session contract, but it must stop
meaning “create another resident model.” New behavior:
OpenSession(active identity)
If the slot is empty:
Either fail with ErrModelNotActive or load only when the request explicitly
allows compatibility-mode auto-load.
If the slot has the same identity:
Return a handle bound to the current slot generation.
If the slot has a different identity and is idle:
Fail with ErrModelSwitchRequired unless the request explicitly allows switch.
If switching is allowed, close the old slot before loading the new model.
If the slot has a different identity and is busy:
Fail with ErrModelBusy.
Prefer explicit LoadModel/UnloadModel from runtime. Compatibility auto-load
can exist for old call paths, but it should be visible in logs and tests so it
does not become hidden policy.
Add typed errors:
ErrModelBusy
ErrModelNotActive
ErrModelSwitchRequired
ErrModelLoadFailed
ErrInsufficientMemory
ErrSlotGenerationStale
ErrBackendMismatch
ErrSessionClosed
ErrStaleFence
Every session handle must include both owner instance and slot generation. A handle from an old generation fails after unload/switch/crash/takeover.
Slot State Machine
Empty
-> Loading
Loading
-> Ready
-> Failed
-> Empty
Ready
-> Busy
-> Unloading
-> Switching
Busy
-> Ready
-> Failed
Switching
-> Unloading old slot
-> Loading new slot
-> Ready
-> Failed
-> Empty
Unloading
-> Empty
Any state
-> ShuttingDown
-> LostOwner
-> Failed
Rules:
- Switch is never in-place. Close the current backend session, clear handles, bump the slot generation, then load the new model.
- A turn is serialized as one protected operation:
EnsurePrefix -> PrefillSuffix -> Decode. - Snapshot/restore, if supported, is also protected by the same slot lock.
Describecan remain non-resident and advisory, but memory must be checked again during actual load.
Safety Nets and Gotchas
Loaded Model Then Switching Model
Switching drops warm state by design.
Safety requirements:
- Require explicit switch intent from runtime/UI unless this is a documented compatibility path.
- Reject switch while the slot is busy, unless the caller explicitly asks to cancel and the backend can prove cancellation leaves the slot consistent.
- Close the old backend session before loading the new one so VRAM/RAM is actually released before the new allocation.
- Bump slot generation before exposing the new slot.
- Invalidate all old session handles.
- If new load fails after old unload, leave the daemon in
EmptyorFailed, not half-switched. - Do not try to silently restore the old model after a failed switch unless the implementation can prove the old model was never closed. Prefer memory safety over surprise reactivation.
User-visible consequence:
"Switching from qwen2.5 to codellama will unload qwen2.5 and drop its warm
context."
Sudden modeld Shutdown or Crash
The active slot is volatile.
Safety requirements:
- Runtime treats unreachable transport, expired lease, failed health probe, or owner mismatch as loss of active slot.
- Runtime drops all warm handles when the owner fence or slot generation is stale.
- On modeld restart,
StatusreportsEmptyunless a future explicit autoload feature is added. - Do not persist and trust “active model” as proof that the model is resident. Persisted state can be a hint for UI, never a compute guarantee.
- In-flight streams fail fast with a typed transport/owner error.
Lease Loss and Takeover
modeld already self-fences on owner loss. The single-slot rule makes that more
important:
- On
owner.Lost(), stop accepting new RPCs. - Cancel or let active RPCs drain only if they can finish before shutdown policy.
- Close the active slot.
- Release the lease when possible so a successor does not wait for TTL.
- New owner starts with a fresh owner instance and slot generation.
- Runtime must never reuse a session handle across owner instance changes.
Other VRAM/RAM Consumers
Memory availability is not stable. A browser, game, editor, display server,
training job, or another inference stack can consume memory after Describe.
Safety requirements:
- Treat
Describeas advisory capacity planning only. - Recheck free memory immediately before every
LoadModelor switch. - Keep a configurable reserve (
--mem-reserve/ policyMinFreeBytes) for the desktop and unrelated workloads. - Return
ErrInsufficientMemorywith required/free/reserve fields when load cannot fit. - If memory pressure appears while a model is already active, do not kill the slot mid-turn unless the backend has a safe cancellation/reset path.
- At turn boundaries,
Statusmay report pressure and runtime can warn or offer unload. - Effective context is part of active identity. If memory pressure changes the resolved context, a reload/switch is required.
Multiple Clients and Races
The slot manager must be the authority, not each runtime process.
Safety requirements:
- One mutex/state machine serializes load, unload, switch, snapshot, restore, and the full turn.
LoadModelandUnloadModelshould support compare-and-swap fields: expected active identity and expected slot generation.- A forced switch should be explicit and auditable.
- Status should expose enough detail for clients to explain why a request is blocked: busy operation, active model, owner instance, and generation.
- Runtime-side warm cache for local modeld must be slot-aware and capped at one resident local slot. The current per-process warm cache must not imply daemon residency beyond the single-slot invariant.
Same Model, Different Config
“Same file” is not necessarily “same active slot.”
Safety requirements:
- Treat config as part of identity.
- If
NumCtx, GPU layer count, KV cache type, prompt template digest, BOS policy, runtime digest, or device selection differs, require reload. - If a client asks for a compatible subset of the active config, define that explicitly. Do not rely on accidental reuse.
Embeddings
Embedding requests do not participate in chat KV reuse, but they still consume memory.
Decision for the first implementation:
Embedding may not cause a second resident generation model.
Allowed options:
- Run embedding as one-shot only when the backend can prove it does not create a second resident slot.
- Use the single active slot and evict/switch like any other local model.
- Return
ErrModelBusyorErrModelSwitchRequiredif embedding would violate the invariant.
Do not special-case embeddings into an unbounded second cache.
Backend Differences
Both llama.cpp and OpenVINO must implement the same slot semantics.
- llama.cpp slot contains the loaded model/context and KV state.
- OpenVINO slot contains the GenAI pipeline/session and its prefix reuse state.
- Backend-specific caches are allowed only inside the active slot.
- Backend-specific pool implementations must not keep another model alive after the slot switches.
Cancellation
Cancellation has to leave the daemon in a known state.
Safety requirements:
- If canceling decode leaves the backend session valid, return the slot to
Ready. - If validity cannot be proven, close the slot, bump generation, and report
EmptyorFailed. - Cancellation must unblock switch/unload after the backend reaches a safe boundary.
- Runtime should assume a canceled turn may have invalidated its session handle
unless
Statusconfirms the same generation is still ready.
External Process Kill
If the OS kills modeld or the user sends kill -9, cleanup code does not run.
Safety requirements:
- Lease TTL and health probe are the recovery path.
- Runtime must surface stale owner/unreachable daemon as “modeld stopped” rather than “model unavailable forever.”
- Successor modeld starts empty and publishes a new owner instance.
- Partially written status files, if any are added later, must be ignored unless they are atomically written and match the live owner.
Implementation Plan
P0: Blueprint and Tests First
- Land this blueprint.
- Add tests around the intended slot semantics before changing backend behavior.
- Name the invariant directly in tests: “one active model slot.”
P1: Slot Manager
Add a daemon-side slot manager that wraps the backend transport.Service.
Responsibilities:
- Hold active identity, active session, slot generation, status, and last error.
- Serialize load/unload/switch/turn operations.
- Close old sessions on switch/unload.
- Invalidate stale handles.
- Expose active status without requiring runtime to inspect backend internals.
Likely placement:
modeld/internal/slot
or
runtime/transport/grpc with a modeld-owned service wrapper
Prefer a modeld-owned wrapper so the generic gRPC transport remains mostly a wire adapter, not policy.
P2: Transport API
Extend runtime/transport and the gRPC adapter:
StatusLoadModelUnloadModel- active model/status structs
- typed slot errors
- handle format including owner instance and slot generation
Keep runtime as the owner of the transport interface. modeld implements it.
P3: Runtime Local Provider Wiring
Update local runtime providers to treat modeld as one active slot:
- Before using a local model, compare requested identity with
Status. - Call
LoadModelorOpenSessionwith explicit switch intent. - Drop runtime warm cache entries when owner or slot generation changes.
- Ensure local
WarmCacheMaxResidentcannot keep multiple daemon models alive. - Preserve cloud/Ollama behavior outside this path.
P4: Local Library Surface
Add the missing offline inventory surface separate from model list.
Rules:
- Scan installed model artifacts without requiring
SessionAvailable(). - Show backend type, digest/path, size, compatibility, and active status when modeld is reachable.
- Do not imply “loaded” when only “installed” is true.
- From the library view, offer activate/switch/unload actions.
P5: Status Surface
Extend modeld status and/or runtime status output:
owner instance
backend
endpoint
active model identity
slot generation
state: Empty / Loading / Ready / Busy / Switching / Unloading / Failed
busy operation
effective context
memory required/free/reserve
last load/switch error
This is needed for debugging “why can I not select this model?” without requiring logs.
P6: Backend Enforcement
Apply the slot manager to both local backends:
- llama.cpp service
- OpenVINO service
- no-backend service
The no-backend service should report running daemon status but fail load with a typed backend-unavailable error.
Test Plan
Unit tests:
- Loading first model transitions
Empty -> Loading -> Ready. - Loading the same identity reuses the active slot and generation.
- Loading a different identity while ready requires explicit switch intent.
- Switching closes the old session before opening the new one.
- Switching bumps generation and invalidates old handles.
- Switching while busy returns
ErrModelBusy. - Unload is idempotent.
- Load failure leaves the daemon in
EmptyorFailed, never with two sessions. - Cancellation either returns the same generation to
Readyor closes the slot and bumps generation. - Owner mismatch returns
ErrStaleFence. - Slot generation mismatch returns
ErrSlotGenerationStale. - Describe remains non-resident.
- Embed cannot create a second resident generation model.
Integration tests:
- Two runtime clients racing to load different models result in one winner and one typed busy/switch/stale error.
- Runtime drops warm handles after modeld restart.
- Runtime can list installed local models while modeld is stopped.
- Runtime can activate an installed model, use it, unload it, and activate a different installed model without process restart.
- Fake memory pressure between
DescribeandLoadModelfails withErrInsufficientMemory. - llama.cpp and OpenVINO both obey the same slot state machine.
Manual/e2e checks:
- Start modeld, load model A, run a turn, switch to model B, verify model A is no longer resident.
- Kill modeld during decode, restart, verify runtime reports empty/stale active slot and old handles fail.
- Start another VRAM-heavy process before load, verify the load failure is actionable.
- Open two editor/CLI clients and verify the status surface explains active and busy state consistently.
Acceptance Criteria
modeldnever has more than one resident local generation model.- Runtime can load, unload, and switch local models without restart.
- Switching never leaves stale session handles usable.
- A crashed or replaced modeld owner never appears as a still-loaded model.
model listkeeps its live runtime-state meaning.- A separate local library/inventory view lists installed models even when they are not loaded.
- Memory errors identify required/free/reserve values instead of failing deep in backend initialization.
- llama.cpp and OpenVINO share the same user-visible slot semantics.
Open Questions
- Should
OpenSessionauto-load an empty slot for compatibility, or should all new runtime code callLoadModelfirst? - When the slot is busy, should a switch request fail immediately, queue, or offer cancellation?
- Should embeddings evict the active chat model, run only when idle, or require a separate explicit embedding mode?
- Should modeld support optional autoload of the last active model on startup, or always restart empty?
- What should the final CLI name be for the offline inventory surface:
model library,model installed, ormodel local? - Should active slot state be persisted as UI hint metadata, and if so where?
- Should memory pressure warnings be polled or only computed on load/switch and turn boundaries?