WWardendual-GPU control plane

Three programs · one system · built on my own hardware

Two GPUs, arbitrated by a lease-based control plane.

A Windows workstation holds an NVIDIA RTX 5090 (CUDA) and a Radeon Pro W7800 (ROCm), about 80 GB of VRAM across two vendors. This project makes that hardware serve several models and two applications at once: one control plane owns the memory as a budget and hands out time-bounded leases, so no workload silently starves another and every refusal names its cause.

Workstation under supervisionrat-desktop
cuda0 · NVIDIA RTX 509032768 MB
hip0 · Radeon Pro W780049152 MB
vulkan · span targetboth cards
Control planeFastAPI :8765
Runtimes supervisedllama.cpp · LM Studio · ComfyUI
Client machineMacBook · 24 GB unified
80 GiBWorkstation VRAM
32 GiBRTX 5090 · CUDA
48 GiBW7800 · ROCm
24 GiBMacBook · unified
3GPU targets routed

00 / Overview

Inference is requested as a lease,
not started as a process.

The three programs form one stack over an HTTP contract: Warden arbitrates GPU memory, DoubleHarness requests it through leases to build software, and AI-Home-Lab-Writer is a client that uses it without knowing how the workstation is configured.

Most local AI setups solve model loading and leave arbitration to the human: remembering which model is resident, on which card, and killing processes by hand before starting the next one. That holds until a second application exists. Then two workloads land on the same card, allocation fails deep inside a runtime, and the error that comes back has nothing to do with the actual cause.

Warden replaces that with a control plane that treats VRAM as a declared budget and hands out time-bounded capability leases instead of start and stop commands. A client says that it needs the chat model for five minutes, and Warden decides which card it runs on, starts the right binary for that card, returns the URL it chose, and stops the process once nobody is using it. A client that crashes cannot strand a model on a GPU, because its lease simply expires.

Once inference is a lease rather than a process, an application can build on top of it. DoubleHarness runs a planner, a worker and a verifier as three separately routed models with different privileges, so a local model can change a real repository under enforced limits. The planner reads only a frozen commit. The worker writes only the files its task listed, inside a disposable Git worktree. The verifier sees the diff and check output, never the tools.

AI-Home-Lab-Writer is the MacBook-side client. It never assumes a port, never launches a runtime, and treats a capacity refusal as a normal outcome with a stated reason rather than an error to retry blindly. It exists to keep the Warden interface small enough that a UI can sit on top of it without duplicating any GPU logic.

The result is that local hardware gets what a shared GPU host usually lacks: declared VRAM budgets, explicit per-task file contracts, refusals that name their cause, and an append-only evidence trail for every change that reaches a repository.

Everything below is running code on the hardware in the spec panel. The ports, budgets, model routes and failure rules on this page are the ones in the committed configuration.

Figure 1 · System topology
MacBook client 24 GB unified, hosts no models AI-Home-Lab-Writer CLI DoubleHarness API and UI launches no GPU runtime POST /v1/lease {profile, ttl_s, context} {base_url, accepted context} STATE: READY WINDOWS 11 WORKSTATION Warden control plane · :8765 supervisor · VRAM arbiter · leases · telemetry · tray icon · dashboard the only component that knows how to launch a GPU runtime START · STOP · MIGRATE · EVICT cuda0 · RTX 5090 budget 32256 MB of 32768 LM Studio, CUDA engine · :1234 ComfyUI NVIDIA portable · :8802 NVML TELEMETRY hip0 · Radeon Pro W7800 budget 48128 MB of 49152 llama.cpp ROCm build · :1235 ComfyUI AMD portable · :8803 AMDSMI OR PDH, BEST EFFORT vulkan · span target one LM Studio model spread across both cards, admitted against both budgets Idle profiles stop themselves. One button drains every lease and pauses admission, so the machine is yours again.
Two vendors, one interface. The MacBook never learns that the NVIDIA card is driven through LM Studio while the AMD card runs a ROCm build of llama.cpp, or that the two ComfyUI installations are separate portable folders. It asks for a profile and uses the base_url the lease returns, which is why a workload can migrate between cards mid-session without the client changing a line.

01 / The three programs

Three programs,
one HTTP contract.

Each program runs on its own and depends on the one below it only through Warden's HTTP API, never through shared code.

Layer 1 · Control plane

AIWarden

A GPU process supervisor for the Windows workstation. It owns VRAM as a per-card budget, launches and stops llama.cpp, LM Studio and ComfyUI on the correct card, arbitrates between competing workloads, and exposes all of it as a lease-oriented HTTP API with a live dashboard and a tray icon.

Stack
Python 3.11 · FastAPI · pydantic · pywin32 · NVML
Interface
26 HTTP endpoints, SSE stream, Prometheus
Runs on
Windows 11, the workstation with the GPUs
github.com/EliasHP/AIWarden →

Layer 2 · Application

DoubleHarness

A planner, worker and verifier harness that turns a task brief into reviewable Git commits. It routes the three agent roles onto the two GPUs through Warden leases, runs every change inside a disposable worktree behind a per-task file allowlist, and keeps an append-only event trail that streams to an operator console.

Stack
Python 3.12 · FastAPI · SQLite · Next.js · uv
Interface
29 HTTP endpoints, SSE stream, web console
Runs on
The MacBook, calling Warden over the LAN
github.com/EliasHP/DoubleHarness →

Layer 3 · Reference client

AI-Home-Lab-Writer

The MacBook-side client. A typed async Warden client with a lease context manager, a background renewal loop, and a CLI that runs a full leased chat completion using only the profile name and the lease response, with no workstation ports, paths or binary names in its configuration.

Stack
Python 3.11 · httpx · pytest-asyncio
Interface
4 CLI commands, 7 environment settings
Runs on
The MacBook, as the reference Warden client
github.com/EliasHP/AI-Home-Lab-Writer →

02 / Warden · the control plane

A profile is not
bound to a GPU.

A profile describes what a client wants; a variant describes where and how it runs. Separating the two is what lets the same request move between the CUDA and the ROCm card.

A profile is a role such as chat, worker or comfy. A target is a place it can run. A profile declares one variant per target, each with its own command line, environment and VRAM estimate, because the real unit of difference is not a config flag: the CUDA and ROCm builds of llama.cpp are different binaries, and the two ComfyUI portables are different folders on disk. Swapping a card is therefore not a special case, it is just picking a different variant.

The same idea carries a third kind of target. vulkan is not a card, it is a span across both of them, used when one model is too large for either. It owns no memory of its own, so the arbiter admits it against each physical card it touches, which means a span request can evict on two cards at once and is correctly refused if either one is full.

Admission runs on declared estimates rather than measurements, because the decision has to be made before the process exists, when there is nothing to measure yet. The estimate is a worst case footprint that includes model residency, the KV allocation for the full accepted context, the parallel slot multiplier, and an MTP reserve where the model supports it. Measured peaks are still recorded, but only so the dashboard can show you the gap between what a profile reserved and what it actually used. Telemetry never feeds an admission decision, so a card whose driver reports nothing useful still gets supervised correctly.

When a card is full, Warden tries eviction first, and only considers profiles that are unleased, unpinned and strictly lower priority. If that is not enough it returns 409 with the reason: which profiles are holding the memory, why each one could not be moved, and which other target could serve the request. It never queues silently, because a queue turns a capacity problem into a latency mystery, and the caller is the only party that knows whether waiting is acceptable.

# warden.yaml, abbreviated
profiles:
  chat:
    kind: llm
    priority: 100
    default_device: cuda0
    default_context: 32768
    mtp: true
    health: {type: http, path: /v1/models}

    # one entry per model, priced per target
    models:
      - label: Qwen3.6 35B-A3B
        supports_mtp: true
        hip0:   {arg: "...Q5_K_M.gguf", vram_mb: 26000}
        cuda0:  {arg: qwen_qwen3.6-35b-a3b, vram_mb: 27000}
        vulkan: {arg: qwen_qwen3.6-35b-a3b,
                 split: {cuda0: 14000, hip0: 14000}}

    # one launch recipe per target
    variants:
      cuda0:  # LM Studio shim, CUDA engine
        port: 1234
        cmd: [py, -3.11, -m, warden.lmstudio, ...]
      hip0:   # ROCm llama.cpp, its own binary
        port: 1235
        cwd: E:/llama-rocm-lemonade/bin
        cmd: [llama-server.exe, -m, "{model}", ...]

The same profile, two vendors, one price list. footprint() reads this and returns megabytes per physical card, which is the only number the arbiter trusts.

02b / Lease admission in Warden

How a lease request
is admitted or refused.

A lease request runs through seven steps in Warden, with one place where the answer can be no. Each step below maps to a specific module in the AIWarden codebase, named on the right.

Figure 2 · Lease admission and lifecycle
01 The client asks for a capability profile, ttl_s, optional context and MTP LEASES 02 The worst case footprint is computed residency + KV at accepted context CONFIG.FOOTPRINT 03 Admission against the declared budget checked per physical card, span on both ARBITER.PLAN OVER BUDGET FITS AS DECLARED 04 Eviction pass, only when required unleased, unpinned, lower priority only SUPERVISOR.STOP STILL FULL No capacity: 409 with the reason names every profile holding the memory and why it could not be moved: pinned, leased, or of equal priority, plus the targets that could run it NEVER A SILENT QUEUE the caller decides: wait, or use the other card 05 Start, then a real health gate ready only when /v1/models answers 200 PROCESS.PY 06 Lease granted with the chosen URL base_url, accepted context, lease id LEASES.CREATE 07 Renewal, release, then idle stop no leases starts the idle countdown SUPERVISOR WHY LEASES INSTEAD OF START AND STOP A crashed client cannot strand a model on a card: the lease expires and the profile idles itself out. Eviction ordering becomes possible at all, because Warden knows which profiles are genuinely in use. TTL is clamped between 10 and 3600 seconds, and a long job simply renews at half of its own TTL. Measured peaks are recorded beside every estimate so a wrong number becomes visible, but they never feed an admission decision.
Steps one and two are arithmetic and steps five to seven are process supervision. Step four is the only branch that can refuse. By the time Warden returns a 409 it has already tried eviction and can state exactly which profiles it could not move and why.

Four more behaviours in Warden

Warden · supervisor

Sticky failure state

A profile that dies during startup keeps its exit code and last 500 log lines and is not retried. A health failure while ready allows exactly one restart, then stays failed. Warden avoids silent restart loops, which hide the real problem and keep reserving VRAM.

Warden · process launch

Windows Job Objects

Every runtime Warden starts is attached to a Windows Job Object, so ComfyUI cannot leave orphaned embedded-Python workers holding VRAM after a stop, and killing Warden itself kills the whole process tree it started.

Warden · telemetry

Per-vendor GPU telemetry

The RTX 5090 reports through NVML, which is reliable. The W7800 on Windows is not, so Warden tries amdsmi, falls back to Windows PDH performance counters, and then reports "unavailable" rather than showing zeros. Telemetry is display-only and never feeds admission.

Warden · dashboard

Release all GPUs

The dashboard's "Release all GPUs" button drains every lease, stops every profile and pauses admission. The dashboard keeps serving, new lease requests get a 503, and "Resume" returns the workstation to normal, so the operator can reclaim both cards for a game or a manual render.

03 / DoubleHarness · the application

Three agent roles,
three sets of privileges.

DoubleHarness lets a local model change a real repository under enforced limits: each change is planned, isolated in a worktree, checked, verified by a separate model, and left as a reviewable Git commit that the operator merges or discards.

The planner runs on the W7800 at Q8, the worker on the RTX 5090 at Q6, and the verifier follows the planner route so that it never shares a model instance with the code it is judging. Those are configured defaults rather than embedded rules: the console offers only the models Warden reports as admissible for each role's card, validates the choice against the live catalog, and refuses routing changes while an execution is running.

Splitting the roles across two cards keeps the verifier independent as well as parallel. A verifier sharing the worker's model instance can only report that a model agrees with itself. The router in DoubleHarness warns explicitly when a configuration would collapse that separation, and again when planner and worker land on the same physical card and lose their concurrency.

The core of the design is the transaction boundary. Planning is read-only against a commit SHA recorded once at session start, so the plan cannot drift while it is written. Execution happens in a disposable Git worktree, never the operator's checkout. Deterministic checks (tests, lint, build) run before any model is asked for a judgement. Integration into the session branch is the last step and stays an operator decision.

The key gate is the reconciliation in step three of the figure below. Neither the worker's own report nor Git alone is trusted: the set of files Git reports as changed must equal the set the worker claims it wrote, and both must sit inside the allowlist the plan issued for that task. Disagreement in either direction fails the task, so a model that quietly edits an undeclared file and a model that claims work it did not do are caught by the same check.

# src/codeharness/worker.py
def _reconcile(self, task, result, actual):
    claimed = set(result.files_written)
    allowed = set(task.writable_files)

    # the worker does not get the benefit
    # of the doubt in either direction
    if claimed != set(actual):
        raise TaskTransactionError(
            f"worker claim mismatch: "
            f"changed={sorted(actual)}, "
            f"claimed={sorted(claimed)}")

    outside = set(actual) - allowed
    if outside:
        raise TaskTransactionError(
            f"worker changed files outside "
            f"allowlist: {sorted(outside)}")

Run twice: once after the worker finishes, and again after the deterministic checks, so a formatter or a test run cannot smuggle a file past the contract.

Live baseline routevalidated at boot
Plannerchat · hip0 · qwen3.8-27b@q8_0
Workerworker · cuda0 · qwen3.8-27b@q6_k
Verifierfollows planner · serial
Revision budget2 per task
Parallel tasks2 max, disjoint files
Figure 3 · The task transaction
PLANNER · HIP0 · Q8 WORKER · CUDA0 · Q6 NO MODEL INVOLVED VERIFIER · HIP0 · Q8 GIT · OPERATOR 01 Plan ENTRY: HEAD PINNED ONCE Reads one frozen commit through git show, ls-tree and git grep only No write, shell, test or network tool exists Emits a task DAG: repeated ids, missing deps and cycles are rejected Plan revisions are kept as restorable versions 02 Build ENTRY: DEPENDENCIES DONE Disposable Git worktree per task and per attempt Writes only the files the task listed, with fsync and atomic replace Reads limited to the same allowlist Independent tasks run concurrently, up to two 03 Check ENTRY: CLAIM MATCHES GIT The changed set must equal the worker claim and sit inside the allowlist Checks are chosen by name and run as argv arrays A model can never supply a shell string Any non zero exit code fails the task 04 Verify ENTRY: EVERY CHECK EXIT 0 Three narrow closed questions, run serially Spec conformance, cross file consistency and regression risk Verdicts are JSON and must cite concrete evidence Sees the diff and checks, never the tools 05 Integrate ENTRY: 3 OF 3 VERDICTS PASS The commit records which model produced it The session integration ref advances only after a full pass Parallel results merge as Git trees, in plan order Operator merges locally or opens a pull request REJECTION RETURNS THE VERDICT EVIDENCE · BUDGET OF TWO REVISIONS · A FRESH WORKTREE EACH ATTEMPT Only verifier and contract rejections spend that budget. A Warden or infrastructure failure stops the task immediately, because it is a different class of problem. EVERY TRANSITION ABOVE IS AN APPEND ONLY SQLITE EVENT The operator console replays them over server sent events: active agent, model, device, task state, check output, verdict evidence, per file diff and preview screenshot.
Each stage refuses to start until the previous one produced something checkable, which is what keeps the model inside a deterministic process rather than in control of it. The deterministic checks are cheap and run before the model verifier, so most failing attempts are rejected before any verifier model is loaded.

04 / AI-Home-Lab-Writer · the client

A client that needs no knowledge
of the workstation.

The client is the test of whether the Warden interface is small enough to build on. It runs a full leased chat completion knowing only a profile name and the lease response.

The writer client runs on the MacBook, which has its own Apple Silicon GPU and 24 GB of unified memory but hosts no models; the workstation's two cards do that. The client does not know that the chat profile might be on the NVIDIA card behind LM Studio today and on the AMD card behind llama.cpp tomorrow. It asks for a profile, uses the base_url the lease returned, and renews in the background while long work continues. When the lease closes, the VRAM is released without the client issuing a stop.

It also handles Warden's error contract precisely. A 409 is surfaced immediately as a capacity condition with the reason Warden gave, not retried in a loop that will fail identically. A 503 means the workstation is paused because the operator pressed Release all GPUs, which is a human decision, not a transient fault. The two are reported differently instead of both becoming a hang.

Everything the client needs is seven environment variables and one context manager. That constraint is the point: if the client had needed a path, a port or a binary name, the Warden abstraction would have been leaking workstation detail it is meant to hide.

# the entire client contract
async with warden.lease(
        "chat", ttl_s=300) as lease:
    reply = await chat(lease.base_url, messages)

# renewal runs in the background at ttl/2,
# release happens on the way out, and a
# crash needs no cleanup path at all

# failures are named, not swallowed
class NoCapacityError(WardenError): ...
class WardenPausedError(WardenError): ...

The same shape is reused inside DoubleHarness, where a lease handle is held per agent role and lease transitions are serialised per Warden profile.

05 / Design decisions

Six rules,
and why they hold.

The design rules that shaped Warden and DoubleHarness, each with the concrete reason it is enforced in code rather than left to the operator.

Warden

Leases, never start and stop

Clients request a capability for a bounded time and never command a process.

Why

One indirection buys idle shutdown, crash safety and eviction ordering at the same time. A client that dies mid-request cannot leave 27 GB of a model resident on a card, because nothing about its survival was required for cleanup.

Warden

Admission on declared estimates

The arbiter uses configured worst case footprints, never live telemetry.

Why

The decision must be made before the process exists, so there is nothing to measure yet. Estimates also stay correct on hardware where telemetry is unreliable, which on Windows plus AMD is the normal case rather than the exception.

Warden

Refuse with a reason, never queue

A full card returns 409 naming the blockers and the alternative targets.

Why

Silent queueing converts a capacity problem into a latency mystery. Only the caller knows whether waiting is acceptable or whether the other card would do, so the refusal hands back the facts needed to decide.

Warden

Failed is sticky

A profile that fails to start is not retried, and health failures allow one restart.

Why

An automatic restart loop is a way of not finding out what is wrong while paying for it in VRAM. Keeping the exit code and the last 500 log lines turns a failure into something diagnosable at a glance.

DoubleHarness

Models never produce executable strings

Checks are selected by configured name and run as argv arrays, never as shell text.

Why

The moment a model can name a command it can also compose one. Selecting from a fixed set keeps the dangerous surface in the configuration file, where a human wrote it and a reviewer can read it.

DoubleHarness

The verifier is not the worker

Separate route, separate card, separate quantisation, tools withheld.

Why

A verifier sharing the worker's instance can only confirm that a model agrees with itself. The router warns explicitly when a configuration collapses that separation, because the failure is silent and the output still looks like verification.

06 / Capabilities by program

What each program
implements.

A concrete summary of the mechanisms described above, grouped by the program that provides them, for a reader skimming the stack rather than reading it top to bottom.

Warden

VRAM arbitration

An admission controller that decides before a process starts whether a model fits, using declared worst-case footprints.

  • Pre-start admission on declared VRAM
  • Priority-based eviction of unleased profiles
  • TTL leases with background renewal
  • Vulkan span admitted against both cards
Warden

Process supervision

Starting, health-gating and stopping llama.cpp, LM Studio and ComfyUI across two GPU vendors on Windows 11.

  • Windows Job Object process trees
  • NVML, amdsmi and PDH telemetry fallbacks
  • A shim that makes LM Studio supervisable
  • HTTP health gates before a lease is ready
DoubleHarness

Bounded agent execution

Capability separation is done with enforceable boundaries instead of basic instructions in a system prompt.

  • Read-only planning on a pinned commit SHA
  • Per-task write allowlists, reconciled with Git
  • A disposable Git worktree per task attempt
  • A verifier on a separate model and card
DoubleHarness

Operator visibility

Every state transition is recorded and streamed, so an operator can follow and control a run without reading raw logs.

  • Append-only SQLite event log
  • Server-sent events to the web console
  • Config validation before the service boots
  • Parallel DAG with in-order Git integration