Server-side job system
The job engine runs client-posted sequences of server operations on lemond: each job is an ordered list of steps that pass data forward through a shared context, branch on results, and have a pause / interrupt / resume / delete / query lifecycle that survives client disconnect and server restart.
It exists so multi-step server work — the motivating case is the AutoOpt benchmark methodology (repeatedly load a model with a config, time a completion, unload) — runs on the server, coordinated with model management, instead of being driven from a browser that a page reload would kill. The engine is generic and domain-agnostic: the client owns the recipe and any synthesis of the results; the server just executes the recipe durably.
Concepts
- Job — a named list of steps plus an
inputsobject, an accumulatingcontext, astatus, and acursor(the id of the current/next step). - Step — one operation (
op) withparams, an optionalwhenguard, optionalextractmappings, and optional forward branches. Steps have their own status (pending/running/completed/failed/skipped). - Context — a JSON object, the data bus. After a step runs, its raw output
is stored under
context[<step id>], and anyextractmappings copy fields to top-level keys. Jobinputsare available undercontext.inputs. - Op — a named server operation.
paramsare reference-resolved against the context before the op runs.
Operations
| op | exclusive | output |
|---|---|---|
system_info |
no | the system-info snapshot |
system_stats |
no | cpu/gpu/vram/npu/memory sample |
models |
no | model list, or {id} metadata when params.id is set |
sleep |
no | {} after params.ms (cancellable) |
load |
yes | {loaded, model, backend, ctx_size}; loads with params = {model, llamacpp_backend, ctx_size, llamacpp_args, merge_args, save_options, pinned} (same shape as POST /load) |
unload |
yes | {}; unloads params.model if set and loaded, else all |
chat |
yes | the backend chat response verbatim (timings/usage included); params is a chat/completions request |
"Exclusive" ops require the model slot — see Exclusivity. This is the current set, not a fixed one; ops for other modalities are additive — see Extending with new ops.
Recipe: steps
{
"id": "run_a", // unique within the job
"op": "chat",
"params": { "...": "${refs allowed}" },
"when": "boolean expression", // optional; skip the step if false
"extract": { "a_tps": "timings.predicted_per_second" }, // output path -> context key
"branch": [ { "when": "expr", "goto": "later_step_id" } ],
"on_done": "later_step_id", // where to go on success (default: next in list)
"on_fail": "abort" // abort (default) | continue | <later step id>
}
References (${path}) and the when/branch expression grammar are documented
in job-expression-language.md.
Control flow (forward-only, no loops)
On success the next step is: the first branch case whose when is true, else
on_done, else the next step in list order (end when none). On failure on_fail
decides: abort fails the job, continue proceeds to the next step, or a step
id jumps there — failure is a first-class branch, which is how a load that
OOMs can jump to a smaller-config load (test-by-failure). Every branch /
on_done / on_fail target must reference a later step; the graph is
validated at creation, so execution is acyclic and always terminates.
Lifecycle
States: queued → running → { paused | interrupted | completed | failed }.
A single worker runs one job at a time.
- pause — stop after the current step; the job goes
pausedat the next step boundary and (if exclusive) releases the slot. Pausing a still-queuedjob takes effect immediately: it is removed from the queue and persisted aspausedbefore the call returns. Pause does not commit the job's model side-effects: models the job loaded stay resident for the resume, and the job keeps owning them (see below). - interrupt — cancel the current step now (kills an in-flight
load, and aborts an in-flightchatat the HTTP layer rather than waiting for the backend to reply); the step returns topending, the job goesinterrupted. Interrupting a still-queuedjob likewise dequeues and persists it asinterruptedimmediately. An interrupted exclusive job unloads only the model(s) it owns: ownership is tracked explicitly per job and is scoped to the specific residency the job created (the backend instance its ownloadop produced), not to the model name — a model the job'sunloadop released, or one whose job-created instance was replaced by an external client's later load of the same name, is no longer job-owned. Pre-job residents are protected separately: the first slot acquisition snapshots them with their pin state, and a pause/resume cycle re-baselines neither ownership nor the snapshot. Models loaded by external clients — including while the job is paused or interrupted and the gate is open — are never touched by the job's cleanup. One caveat: an externalloadthat merely re-confirms the job's own still-resident instance (same model, matching options — the router deduplicates instead of creating a new instance) does not transfer ownership; that instance remains job-owned and is still cleaned up. Clients that need a model to outlive a job should load it before the job starts or after it ends. Reconcile unloads the owned instances (recording their configuration for resume, see below), unpins any the job pinned, and restores the recorded pin state of surviving pre-job models. A job that endscompletedorfailedcommits its model side-effects instead (ownership and snapshot are discarded, resident models stay). Guarantee scope: a model that was resident before the job is preserved only if it is still resident at reconcile time. If a jobloadevicted it (loaded-model cap) or replaced it with different options, the reconcile does not reload it or restore its previous options — clients that need the exact prior state must reload it themselves. - resume —
pausedcontinues at the next step;interruptedre-runs the pending step. Steps must be idempotent on re-run. Before an interrupted exclusive job re-runs, the engine restores the job-owned models that the interrupt cleanup unloaded (reloading each with its recorded options and pin state), so a step that depends on an earlier completedload— e.g. a chat interrupted mid-generation — finds its model again instead of failing. If an external client loaded the same model while the job was interrupted, the restore adopts that residency instead of replacing it: the external instance is left untouched (options and pin included) and stops being job-owned. Restore also survives a server restart: the in-memory captured state is gone after a crash, so the engine reconstructs the job's model state from its persisted record — the completedload/unloadsteps (parameters resolved against the persisted context, in execution order) describe exactly what should be resident, and resuming a recovered job reloads that before re-running the pending step. If a restore fails, the job still resumes and the dependent step fails with its normalon_failsemantics. - delete — removes the job. Deleting an active job persists a deletion
tombstone before the call returns, then interrupts it and defers the actual
removal until the worker has finished cleanup (reconcile unload), so a deleted
exclusive job never leaks a resident model. Deleting a
pausedorinterruptedjob takes the same tombstone-then-worker-cleanup path, so a model introduced before a pause is still unloaded. The tombstone makes the deletion durable: a crash between the acknowledgement and the final removal does not resurrect the job on restart, and a tombstoned job is already invisible toGET/list. - query — the full job record (status, per-step state, context).
Chat interrupts are genuine aborts: the chat op passes its cancel flag through
Router::chat_completion to the backend WrappedServer, and forward_request
hands it to the HTTP client, which tears down the in-flight connection (curl
XFERINFO abort, fired at least once per second even when no bytes move). The
exclusive slot is therefore released promptly instead of being held until a slow
or stuck backend responds.
There is no rollback: an op commits only on completion, so an interrupted step never took effect and "before the step" is automatic.
Exclusivity and queuing
load / unload / chat require the model slot. While a job containing any
exclusive step runs, it holds a Router-level exclusive gate: all normal
inference and load traffic queues behind it until the job finishes or is
paused (pause is the escape hatch — it releases the slot so queued traffic
drains, and resume re-acquires). Acquiring the gate first drains in-flight
work: it waits for any active load and for every in-flight request on loaded
backends to finish, so an external chat that started just before the job cannot
overlap the exclusive session. The drain is cancellable — interrupting or
deleting the job while it waits behind a long-running request aborts the
acquisition and marks the job interrupted instead of blocking indefinitely.
Every model-touching Router path — inference, streaming, tokenize, slots,
pinning, load/unload — checks the same gate, and the idle-eviction/downsize
engine suspends itself (and abandons any already-marked eviction) while an
exclusive session is active, so background eviction cannot pull a model out
from under a job step. The gate is keyed by the worker thread, so the job's
own ops pass through while every other request waits. A job with only
read-only ops (e.g. system_info, sleep) never takes the gate. Read-only
status queries (model list, health, telemetry) are not gated.
Persistence
Jobs persist to <cache_dir>/jobs.json (atomic write, cap 50, oldest terminal
evicted first). The cap is enforced at creation: when all 50 retained jobs are
still active or resumable (nothing completed/failed to evict), POST jobs
is rejected with 429 until a job is deleted or finishes. On startup a job left
running/queued by a crash is marked interrupted ("server restarted while
the job was active") but keeps its cursor, so it can be resumed from where it
stopped; tombstoned (deleted-while-active) jobs are dropped.
A job summary's progress.completed counts steps that no longer need work:
completed, skipped, and failed steps whose failure was handled by
on_fail: continue or a recovery branch — so a recovery job that completes
reports full progress even though one of its steps is marked failed.
API
Registered under all four prefixes (/api/v0, /api/v1, /v0, /v1).
| method | path | purpose |
|---|---|---|
| POST | jobs |
create {name, definition:{steps} \| steps, inputs} → 202 {id}; 400 on an invalid graph; 429 when the job store is full of non-evictable jobs |
| GET | jobs |
{jobs:[summaries]} |
| GET | jobs/{id} |
full record, or 404 |
| POST | jobs/{id}/pause |
200 / 404 |
| POST | jobs/{id}/interrupt |
200 / 404 |
| POST | jobs/{id}/resume |
200 / 404 |
| DELETE | jobs/{id} |
200 / 404 |
Example: a bench sweep
Two configs, each timed, then a branch on the measured throughput — the shape a
benchmark/AutoOpt client posts (it reads context afterward and synthesizes the
recommendation itself):
{ "name": "bench",
"inputs": { "model": "Qwen3-0.6B", "backend": "vulkan" },
"steps": [
{ "id": "u0", "op": "unload" },
{ "id": "load_a", "op": "load",
"params": { "model": "${inputs.model}", "llamacpp_backend": "${inputs.backend}",
"ctx_size": 4096, "llamacpp_args": "" },
"on_fail": "load_a_lo" },
{ "id": "run_a", "op": "chat",
"params": { "model": "${inputs.model}", "messages": [{"role":"user","content":"hi"}],
"temperature": 0, "max_completion_tokens": 32 },
"extract": { "a_tps": "timings.predicted_per_second" } },
{ "id": "u_a", "op": "unload" },
{ "id": "load_a_lo", "op": "load",
"params": { "model": "${inputs.model}", "llamacpp_backend": "${inputs.backend}",
"ctx_size": 2048 }, "on_fail": "abort" },
/* …second config, then a `decide` step branching on ${a_tps} >= ${b_tps}… */
] }
Extending with new ops (any modality)
The engine is modality-agnostic — chat is only the first inference op wired
up. Ops are a pluggable registry, not a fixed set, so image generation, TTS,
transcription, or a 3D-mesh op are additive: no changes to the engine, graph,
context, or expression language.
An op is a handler in the op registry (OpRegistry):
struct OpHandler {
std::function<json(const json& params, const json& context, CancelFlag& cancel)> run;
bool exclusive = false; // true if it holds the model slot (see Exclusivity)
};
The signature assumes nothing about text or LLMs: params and the return value
are arbitrary JSON, and any op that loads a model sets exclusive = true (like
load/unload/chat). Because every step's output lands in the shared context,
later steps consume it via ${step_id.field} references — that is the data-flow
a pipeline needs.
To add one, register a provider in OpProviders that forwards to the relevant
already-existing Router backend (SdServer, KokoroServer, WhisperServer, …)
and register_op it. A text → LLM enhancer → image → 3D pipeline is then just
three registered ops passing data through the context:
{ "steps": [
{ "id": "enhance", "op": "chat", "params": { "...": "..." },
"extract": { "prompt": "choices.0.message.content" } },
{ "id": "image", "op": "generate_image",
"params": { "prompt": "${enhance.prompt}" },
"extract": { "path": "data.0.path" } },
{ "id": "model3d", "op": "generate_3d",
"params": { "image": "${image.path}" } }
] }