feat(model-switch): self-healing control plane — watchdog, rollback, /pull - #1045
Open
titan550 wants to merge 2 commits into
Open
feat(model-switch): self-healing control plane — watchdog, rollback, /pull#1045titan550 wants to merge 2 commits into
titan550 wants to merge 2 commits into
Conversation
…/pull The service wrapped switch.sh for one-shot swaps. An unattended harness needs more than that: only one model fits in VRAM on a 1-2 GPU rig, much of the catalog is experimental, and a wedged model strands the rig until a human notices. Three additions, all on top of switch.sh rather than around it -- registry lookup, down->up and readiness stay switch.sh's job. 1. Self-healing. A watchdog re-launches a crashed or wedged desired model, and a failed switch rolls back to the previously-healthy one. Repeated failures spend a rolling budget (MAX_HEAL_FAILURES within HEAL_BUDGET_WINDOW_S, persisted across restarts); once spent the service tears the model down and goes `degraded` rather than thrashing. A docker-daemon outage reads as docker_state "unknown" and NEVER heals -- an unreachable daemon is not evidence that the model is down. 2. Consent, never inference. A slug is `requires_force` when its status is non-functional OR it needs more GPUs than the host has. FORCE=1 also bypasses switch.sh's VRAM/SM/hardware-fit preflights, not just the status gate, so it stays an explicit user act. The authorization persists, so healing re-launches a forced model the same way; a model switched in externally via the CLI is adopted but NOT auto-forced. 3. POST /pull. Weights presence -- the core variant AND every weights_companions the compose mounts -- is resolved from weights.py and checked in do_switch(), the single choke point both /switch and the watchdog heal pass through. A missing-weights model returns a structured 409 pointing at /pull instead of booting into a guaranteed failure, and the watchdog degrades with that reason rather than spending heal budget on a condition no retry can fix. ROUTES is the single runtime source for dispatch, the auth gate, the 404 set and the GET / manifest, so the served description of the API cannot drift from the code that serves it. Tests: unit coverage for the watchdog state machine (budget, degrade, adoption, restart persistence), weights presence and companions, /pull env and the disk walk-up, MODEL_DIR precedence and the route table; HTTP smoke against a live server. Both run green with no weights on disk.
…ated /health
Starting the rig needed tribal knowledge, and one of the two steps was broken
outright on a compose that sets VLLM_API_KEY.
READINESS. switch.sh probed /v1/models, which is auth-gated. With a key set it
answers 401 forever, so a healthy model never looks ready and the
CLI burns the whole READY_TIMEOUT before giving up on a model that
came up fine. The default is now the unauthenticated /health, which
returns 200 only once the engine has loaded, so readiness behaves the
same with or without a key. READY_URL still overrides it, and the
closing hint derives its origin from whatever path READY_URL carries
instead of assuming /v1/models.
WRAPPER. scripts/control-api.sh {start|stop|restart|status|logs} loads .env
and manages the daemon behind a pidfile + log, so nobody hand-types
the env incantation. The systemd unit stays the production path --
this is the equivalent for a rig without systemd.
PYTHONUTF8 is exported above the script's first python3 call, per noonghunna#779.
titan550
marked this pull request as ready for review
August 17, 2026 23:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
tools/model-switch(added in #549) wrapsswitch.shfor one-shot swaps. An unattended harness needs more: only one model fits in VRAM on a 1–2 GPU rig, much of the catalog is experimental, and a wedged model strands the rig until a human notices. This adds a watchdog that re-launches a crashed/wedged model, rollback to the previously-healthy model on a failed switch, a crash-loop budget that degrades instead of thrashing,POST /pullfor missing weights, and aROUTES-drivenGET /manifest so an agent can learn the API in one call. No TPS impact — the serving path, composes and KV config are untouched; this is host-side control plane only. Trade-off: ~1.6k lines of new host code and a background thread, in exchange for the rig staying up unattended. Compared against the existingtools/model-switchon master (the only prior art) andtools/serve-cockpit, which does the same job as a TUI.Type of change
scripts/,tools/)Verification
verify-full.shPASS — 8 checks pass, Genesis skipped (not in this compose).verify-stress.sh— boundary 5/5, recall ladder 3/3, all 6 ceiling rungs to 240,633 tok (91% of n_ctx=262144).test-model-switch.sh(live HTTP) +test-model-switch-unit.py, green with and without weights on disk.test-locale-utf8.sh— 106 scripts (this PR addscontrol-api.sh, which carries thePYTHONUTF8export per [bug] half the script layer breaks on non-UTF-8 locales — 42/84 tests fail under LC_ALL=C #779).report.sh --full— see N/A below.verify-full.sh(click)verify-stress.sh(click)1 stress check(s) faileddespite 5/5 and 3/3. That counter is theVRAM-margin advisory at
verify-stress.sh:1515— 588 MB free at 91% fill, under the1024 MB threshold — which prints a
⚠but incrementsFAILED. It is a property of thevllm/dualcompose that was already running, not a functional failure, and this PRtouches no compose, serving path or KV config. Flagging rather than hiding it.
Note: CONTRIBUTING says "verify-stress 7/7"; since #1018 the script reports split
boundary/recall verdicts instead. The above is the equivalent full pass.
N/A justifications
report.sh --full(~35 min: soak-continuous + bench) — N/A. That pass exists to validate a serving config. This PR adds a host-side control plane and changes no compose, engine flag, or KV setting, so soak-continuous andbench.shwould measure the unmodifiedvllm/dualconfig.verify-full+verify-stresswere still run (above) to prove the tool doesn't perturb the running stack.models/<model>/CHANGELOG.mdis the per-model gate and no model is touched; consistent with recentscripts/+tools/changes on master, none of which carry a CHANGELOG entry.Cross-links
_weights_present()verifies the subdir exists and that some file matchesverify_glob, so it catches "weights never downloaded" but not Launch should catch a missing weight shard before the engine does #1042's case. Tested explicitly: with 65 of 66 shards on disk it returnsTrue. A shard-levelindex.jsoncheck is the separate fix Launch should catch a missing weight shard before the engine does #1042 describes.control-api.shcarries thePYTHONUTF8export required by [bug] half the script layer breaks on non-UTF-8 locales — 42/84 tests fail under LC_ALL=C #779 / fix(scripts): guarantee Python UTF-8 mode + gate it (#779) #782.