Files
VoiceStudio/.planning/quick/260613-mm2-clean-model-management-v2/260613-mm2-PLAN.md
T
4cc55ab852 Fast model downloads: Xet fast path + accurate progress (FDL W0–W2 + W4) (#424)
* feat(downloads): Xet fast path + accurate progress (FDL W0–W2)

Make model downloads fast and show accurate downloaded/remaining/speed.
Research confirmed hf-xet already implements the IDM/uGet technique
(content-defined chunking, parallel byte-range gets, dedup, resume), and
the spike found all 25 catalog repos are Xet-backed — so the win is
driving Xet well + accurate progress, not a custom downloader.

W1 — maximize + guarantee Xet:
- pin huggingface_hub>=1.7 + hf-xet>=1.1 (was transitive); no hf_transfer
- drive snapshot_download with explicit tqdm_class + max_workers + endpoint
- opt-in HF_XET_HIGH_PERFORMANCE / HDD sequential-write knobs (default off)
- /system/info reports fast_download {xet_enabled, xet_version, high_perf}

W2 — accurate progress:
- dry_run preflight -> install_plan event (exact total/cached/remaining)
- utils/download_aggregator.py: one overall bar; byte bars (by id) vs the
  "Fetching N files" count bar; windowed rate; emits one 'aggregate' event
- frontend overall bar (speed/remaining/ETA), cached-skip,  fast badge

Known limit (verified live): under Xet+hf_hub 1.7.2 per-file byte bars
never advance/close via tqdm, so mid-download the bar is file-granular and
bytes flush to the exact total on completion. Classic-LFS/mirror repos get
true byte progress (W4).

Drive-by: download.py used os.walk without importing os (latent NameError
in _validate_snapshot_has_weights on every install) — fixed.

Tests: tests/backend/setup/test_download_preflight.py (10). Spike + plan
under .planning/quick/260613-fdl-fast-model-downloads/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(downloads): opt-in mirror + cancel + docs (FDL W4)

- mirror (FDL-10): snapshot_download(endpoint=) honours prefs hf_endpoint /
  env HF_ENDPOINT on preflight + download (per-call, no process-wide env).
  Documented as the classic-LFS path (no Xet) for restricted networks.
- cancel (FDL-11): POST /models/install/cancel {repo_id} stops further
  retries at the next boundary, emits install_cancelled, clears the cooldown
  (cancel is intent, not failure). Frontend treats it as a terminator.
- docs (FDL-12): docs/downloading-models.md (Xet fast path, progress
  semantics + byte-speed limitation, opt-in tuning, mirror, cancel,
  troubleshooting) + README pointer. Docs-sync rule satisfied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(planning): model-management v2 cleanup plan (mm2)

GSD plan for cleaning the model-management subsystem: registry unload-on-
switch + per-engine unload() (fixes VRAM leak), model_lifecycle facade,
unified idle/timeout config, bounded cooldowns, sidecar VRAM self-report,
cache-fallback logging. Planning artifact only — no code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(downloads): reconcile with main's HF_HUB_DISABLE_XET; honest status

Rebasing onto main surfaced that main forces HF_HUB_DISABLE_XET=1 (classic
LFS) because Xet progress bypasses the tqdm hook — the same limitation found
here. Reconcile instead of fight:

- /system/info fast_download now reports runtime truth: xet_installed +
  xet_active (installed AND not HF_HUB_DISABLE_XET) + xet_enabled alias. The
   badge only shows when Xet actually runs; startup log says
  "downloads: Xet disabled → legacy LFS".
- complete(): clear the rate window before the final flush so crediting the
  full size in one step can't emit an absurd instantaneous rate.
- docs/downloading-models.md rewritten: default is legacy LFS for accurate
  progress; Xet is opt-in via HF_HUB_DISABLE_XET=0. hf-xet pin stays (ready
  for a future Xet progress hook).

W2 (preflight total/remaining + aggregate bar + exact completion) is the
value on either path; W1's "maximize Xet" is dormant by main's design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(downloads): opt-in segmented multi-connection accelerator (FDL W3)

Since main forces Xet off (HF_HUB_DISABLE_XET=1), the default path is
single-stream legacy LFS — so a segmented downloader is the way to get BOTH
parallel speed and live byte progress.

- services/segmented_download.py: async multi-connection Range downloader for
  one file — parallel byte-ranges, resume (.part + manifest), per-segment
  short-read truncation guard, optional sha256/etag verify, cancel, and a
  single-stream fallback when the server won't range. Auth-safe: the HF
  Authorization header is sent only to huggingface.co/hf.co and never
  forwarded to a CDN host on redirect (unit-tested).
- dispatch (download.py): opt-in via prefs segmented_downloader / env
  OMNIVOICE_SEGMENTED_DOWNLOAD (default off). When on and Xet inactive,
  fetches each file into the HF cache mirroring hf_hub_download (blobs +
  snapshot symlinks + refs/main), feeding real bytes to the aggregator. Any
  failure falls back to snapshot_download — never breaks a correct install.
- fix: complete() was adding a full total on top of accumulated segmented
  bytes (2x); now replaces byte bars so the sum is exactly total.

Verified live (accelerator on): real byte progress to ~16.6 MB/s, final
bytes==total, /models installed=True, delete frees correctly.

Tests: test_segmented_download.py (7) + aggregator double-count regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(downloads): relocate FDL tests to top-level; loop-isolate segmented test

CI runs the full suite, which exposed a pre-existing test-isolation leak:
several tests/backend/** fixtures purge core.*/services.* from sys.modules
under a temp OMNIVOICE_DATA_DIR and never restore, leaving core.config/core.db
bound to a dead temp dir. It only bites when collection order puts a purging
test ahead of a real-DB reader (test_longform_jobs). Adding tests under
tests/backend/setup/ reordered collection and tripped it.

Fix without touching the shared (fragile) fixtures or risking class-identity
breakage from a blanket sys.modules restore:
- move the two FDL test files to top-level tests/ (tests/test_fdl_*.py) so
  tests/backend/** collection order is identical to main — longform passes.
- rewrite the segmented test to run each case under asyncio.run() (fresh loop)
  instead of asyncio.get_event_loop(), which an earlier async test can leave
  closed in the full suite.

Full suite green locally: 1364 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:15:15 +05:30

29 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
260613-mm2 01 execute 1
backend/services/tts_backend.py
backend/services/model_manager.py
backend/services/subprocess_backend.py
backend/services/model_lifecycle.py
backend/api/routers/system.py
backend/api/routers/setup/download.py
backend/api/routers/setup/models.py
tests/test_engines.py
tests/backend/services/test_model_lifecycle.py
tests/backend/services/test_subprocess_reaper.py
true
MM2-01
MM2-02
MM2-03
MM2-04
MM2-05
MM2-06
MM2-07
MM2-08
MM2-09
truths artifacts key_links
Switching the active TTS backend in Settings releases the outgoing engine's VRAM before the new one loads — verified by asserting the outgoing instance's unload() was called exactly once on switch.
TTSBackend.unload() is overridden by OmniVoiceBackend (drops model ref + free_vram) and by every SubprocessBackend subclass (routes to unload_sidecar); all overrides are idempotent and safe before first generate().
/model/loaded never reports a model as loaded with a misleading unloadable flag: the ASR row's unloadable reflects whether it can actually be released independently of the TTS lifecycle.
services.model_lifecycle is the single import surface for list_loaded()/unload(id)/unload_all()/free_vram(); system.py routers call it instead of re-enumerating models inline.
Idle timeouts for the in-process model and subprocess sidecars resolve through core.prefs.resolve(... env=...) so an env var still wins and the Settings store can override; no module duplicates IDLE_TIMEOUT_SECONDS by hand.
_install_cooldowns cannot grow without bound: entries are removed on successful install and stale entries are evicted by TTL.
A live subprocess sidecar reports a non-zero vram_mb in /model/loaded when it actually holds GPU memory (pong carries the figure); CPU-only sidecars report 0 truthfully.
When scan_cache_dir() raises and the code falls back to the on-disk walk, the reason is logged at WARNING with the exception type (the #117/#118 WinError-448 path is no longer silent).
uv run pytest tests/test_engines.py tests/backend/services/test_model_lifecycle.py tests/backend/services/test_subprocess_reaper.py tests/test_model_load_timeout.py passes.
No on-disk model state changes; no new runtime dependency added; behavior degrades gracefully (not errors) on MPS/CPU where VRAM APIs are sparse.
path provides contains
backend/services/tts_backend.py Active-instance reuse + unload-on-switch in get_active_tts_backend(); per-engine unload() overrides _active_instance AND (def unload)
path provides contains
backend/services/model_lifecycle.py Facade owning list_loaded/unload/unload_all/free_vram across in-process + subprocess models def list_loaded AND def unload_all
path provides contains
backend/api/routers/system.py Thin /model/loaded + /model/unload routers delegating to model_lifecycle model_lifecycle
from to via pattern
get_active_tts_backend() (tts_backend.py:1235) outgoing backend.unload() module-level _active_instance compared against newly-resolved active_backend_id() _active_instance
from to via pattern
system.py /model/loaded + /model/unload (system.py:129, 210) model_lifecycle.list_loaded() / model_lifecycle.unload() import services.model_lifecycle model_lifecycle.(list_loaded|unload)
from to via pattern
subprocess sidecar pong reply (subprocess_backend.py:435-438) list_live_sidecars() vram_mb field ping reply carries allocated VRAM measured inside the sidecar process vram_mb
Clean up OmniVoice's model-management subsystem ("v2"). Today load / unload / list / free-VRAM each behave differently across three worlds — the in-process model (`model_manager.py`), the TTS backend registry (`tts_backend.py`), and subprocess sidecars (`subprocess_backend.py`) — with no single lifecycle owner. This produces one real user-facing bug (VRAM leak on engine switch), inaccurate VRAM/unloadable reporting, an unbounded cooldown dict, and a silent cache fallback.

This is cleanup + correctness, not a rewrite. The Wave 13 idle-reaper and the SubprocessBackend primitive are sound and stay. The TTSBackend.unload() contract already exists as a documented default no-op (tts_backend.py:149) explicitly deferred to "Phase 2"; this plan is that Phase-2 follow-through — wire the registry to call it, override it per engine, and unify the surrounding surface.

Three tiers, executed in order (each independently shippable, continuous-to-main per the v0.3.0 cadence):

  • Wave 1 / Tier 1 — Correctness: MM2-01..03. The VRAM leak on switch + honest unload reporting. Highest value; ship first.
  • Wave 2 / Tier 2 — Single lifecycle surface: MM2-04..05. Extract model_lifecycle facade + unify idle/timeout config.
  • Wave 3 / Tier 3 — Robustness & observability: MM2-06..09. Bounded cooldowns, per-role weight validation, sidecar VRAM self-report, cache-fallback logging.

Output: PRs on branches off main (one per wave is fine), each green on the listed pytest selection. No push until the orchestrator merges; tests added with each wave.

Out of scope (call out, do not touch): GPU-pool per-engine sizing (model_manager.py:42, _GPU_VRAM_PER_JOB_GB) and torch.compile tuning — those are performance, not cleanup, and carry regression risk against #278/#315.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/STATE.md @./CLAUDE.md

Files under edit (read before editing)

@backend/services/tts_backend.py @backend/services/model_manager.py @backend/services/subprocess_backend.py @backend/api/routers/system.py @backend/api/routers/setup/download.py @backend/api/routers/setup/models.py

Reference only — establish patterns, do NOT modify

@backend/core/prefs.py @tests/test_engines.py @tests/backend/services/test_subprocess_reaper.py

backend/services/tts_backend.py

  • class TTSBackend(ABC) (line 59); unload() default no-op (line 149) — contract already documented: idempotent, synchronous, safe before first generate().
  • OmniVoiceBackend.init(self, model=None) (line 174); self._model reuses model_manager singleton.
  • _REGISTRY: dict[str, type[TTSBackend]] (line 1109, a _LazyRegistry).
  • active_backend_id() (line 1228) -> prefs.resolve("tts_backend", env="OMNIVOICE_TTS_BACKEND", default="omnivoice").
  • get_active_tts_backend(*, model=None) (line 1235) — builds a FRESH instance every call, no teardown. THE leak.

backend/core/prefs.py

  • resolve(key: str, *, env: Optional[str] = None, default: Any = None) -> Any (line 75) — env wins, then store, then default.

backend/services/model_manager.py

  • module global model (line 111); _last_used; free_vram() (line 678); idle_worker() (line 667).
  • IDLE_TIMEOUT_SECONDS imported from core.config (line 33); duplicated as _IDLE_TIMEOUT_SECONDS (line 114). Collapse.
  • offload_tts_for_asr() (line 701) / restore_tts_after_asr() — ad-hoc ASR<->TTS VRAM juggling; _diar_pipeline global.

backend/services/subprocess_backend.py

  • protocol op set (line 74); SIDECAR_IDLE_TIMEOUT_S = env-only float (line 107) — move to prefs.resolve.
  • list_live_sidecars() -> list[dict] (line 181); unload_sidecar(engine_id) (line 199); unload_all_sidecars() (line 205).
  • health/ping: _send({"op":"ping"}) then expect {"op":"pong"} (lines 435-438). Add vram_mb to the pong here AND in the sidecar entry-point that answers ping (search the sidecar worker for the "ping"->"pong" handler).

backend/api/routers/system.py

  • GET /model/loaded (line 129) — ~80 lines of inline enumeration of TTS/ASR/diar/sidecars. Replace body with model_lifecycle.list_loaded().
  • POST /model/unload/{model_id} (line 210) — handles "tts" | "diarization" | "sidecar:" | "sidecars". Replace body with model_lifecycle.unload(model_id).

backend/api/routers/setup/download.py

  • _install_cooldowns dict (line 27) — unbounded. _validate_snapshot_has_weights (line 55) + _MIN_WEIGHT_BYTES 5 MB (line 45) — single magic number across roles.

backend/api/routers/setup/models.py

  • scan_cache_dir() with silent disk-walk fallback (~line 268-280) + _scan_cache_on_disk (line 177).
Task 1 (MM2-02): Per-engine unload() overrides backend/services/tts_backend.py The base-class `unload()` no-op already exists (tts_backend.py:149) with a documented contract. Override it where it matters. Do this BEFORE Task 2 — the registry switch (Task 2) calls these.
  • OmniVoiceBackend (line 162): override unload(self). Drop the local model ref (self._model = None) and, because OmniVoice shares the singleton owned by model_manager, also release that: import services.model_manager as mm; mm.model = None; mm.free_vram(). Idempotent — guard on mm.model is not None before free_vram(). Safe before first generate() (no-op when nothing loaded).
  • Every SubprocessBackend subclass: implement unload(self) on the SubprocessBackend base (subprocess_backend.py — the duck-typed _is_subprocess_isolated class) so all subclasses inherit it. It must call unload_sidecar(self.id) (force-shut this engine's sidecar; busy sidecars are skipped, never interrupted — existing semantics). Idempotent: unload_sidecar on a non-running engine returns 0, no raise.
  • In-process non-OmniVoice engines that hold their own model (e.g. KittenTTS/VoxCPM2 keep refs in init): override unload() to drop the ref + best-effort empty_cache via the existing free_vram() helper if they used GPU. Where an engine genuinely holds nothing resident, leave the base no-op (and note it in the SUMMARY so the future CI gate knows it's intentional, not missed).

Honor the contract comment verbatim: idempotent, synchronous, safe pre-load. grep -n "def unload" backend/services/tts_backend.py backend/services/subprocess_backend.py uv run python -c "from services.tts_backend import OmniVoiceBackend; b=OmniVoiceBackend(); b.unload(); b.unload(); print('idempotent ok')"

  • OmniVoiceBackend.unload() drops both self._model and mm.model and calls free_vram(), guarded for idempotency.
  • SubprocessBackend.unload() routes to unload_sidecar(self.id); inherited by all subprocess engines.
  • Calling unload() twice, and before any generate(), never raises.
Task 2 (MM2-01): Registry reuses one active instance + unloads on switch backend/services/tts_backend.py Fix the leak at get_active_tts_backend() (line 1235). Today it builds a fresh instance every call with no teardown of the prior engine — switching engines (or repeated synth) leaks VRAM until GC. This is the root cause behind the #278 comment thread.
  • Add a module-level cache: _active_instance: TTSBackend | None = None and _active_instance_id: str | None = None.
  • In get_active_tts_backend(): resolve bid = active_backend_id(). If _active_instance is not None and _active_instance_id != bid, call _active_instance.unload() (best-effort, wrap in try/except so a bad unload can't block the switch — log on failure) before discarding it.
  • Build the new instance, store it as _active_instance + _active_instance_id = bid, return it.
  • IMPORTANT subtlety: OmniVoiceBackend takes model=. When model= is passed (the caller already has a loaded model), do NOT cache that instance as the shared _active_instance blindly — it's a per-call view over the shared singleton. Keep current behavior for the model= path (return a fresh OmniVoiceBackend(model=model)) but still trigger unload() of a different outgoing engine first. Pick the simplest correct rule: the cache tracks the configured backend id; passing model= for the SAME id reuses, switching id always unloads the previous. Document the rule in a comment.
  • Add a module-level reset_active_backend() helper that unloads + clears the cache, for app shutdown and tests. grep -n "_active_instance|def reset_active_backend|def get_active_tts_backend" backend/services/tts_backend.py
  • Switching backend id calls the outgoing instance's unload() exactly once before the new instance is built.
  • A bad/raising unload() is caught + logged, never blocks the switch.
  • reset_active_backend() exists and is idempotent.
  • The model= fast-path for OmniVoice still works (no double-load).
Task 3 (MM2-03): Honest /model/loaded + /model/unload for ASR backend/api/routers/system.py The ASR row (system.py:166-175) is reported as unloadable:False, vram_mb:0 even when loaded on GPU, and /model/unload doesn't expose the offload-to-CPU path. Make reporting truthful WITHOUT changing the ASR<->TTS lifecycle coupling (that coupling is intentional — offload_tts_for_asr/restore_tts_after_asr).
  • ASR row: keep unloadable reflecting reality. If ASR truly cannot be released independently of TTS, keep unloadable:False but add a note field ("released with TTS") so the UI explains it rather than showing a dead button. Do not invent a separate ASR unload that breaks the WhisperX large-v3 offload path.
  • vram_mb: if ASR currently runs on CPU (device "cpu" in the row), 0 is correct — leave it but make the device value derive from where the pipe actually is, not a hardcoded "cpu".
  • This task is intentionally small; the bigger restructure is Task 4 (facade). Land MM2-03 as the honest-reporting fix, then Task 4 moves the enumeration into the facade. uv run pytest tests/test_engines.py -q 2>&1 | tail -15
  • No row reports loaded-but-with-a-misleading-unloadable flag; ASR carries an explanatory note when unloadable:False.
  • Device field reflects the actual device of the ASR pipe.
Task 4 (MM2-01..03 tests): Wave 1 regression tests tests/test_engines.py Add tests proving the leak fix and the unload contract: - test_switching_backend_unloads_previous: monkeypatch two fake backends into _REGISTRY, set active to A (get_active_tts_backend), switch prefs to B, assert A.unload() was called exactly once before B is returned. - test_unload_is_idempotent_and_preload_safe: OmniVoiceBackend().unload() twice + before generate() never raises. - test_reset_active_backend_clears_cache: after reset_active_backend(), the next get_active_tts_backend() builds fresh. Reuse the existing fixture style in tests/test_engines.py (it already monkeypatches the registry / availability). Keep tests CPU-only (no real model load). uv run pytest tests/test_engines.py -q 2>&1 | tail -20 All three new tests pass; existing test_engines.py tests still green. Task 5 (MM2-04): Extract services/model_lifecycle.py facade backend/services/model_lifecycle.py Create backend/services/model_lifecycle.py as the single owner of cross-world model lifecycle. It composes the existing pieces — it does NOT reimplement loading.

Public surface:

  • list_loaded() -> list[dict]: returns the unified rows currently assembled inline in system.py:129-207 (TTS, ASR, diarization, subprocess sidecars). Move that logic here verbatim first, then improve (MM2-03 note field, MM2-08 sidecar vram once Task 8 lands).
  • unload(model_id: str) -> dict: the dispatch currently inline in system.py:210-242 ("tts" | "diarization" | "sidecar:" | "sidecars"). Move here; keep async-lock semantics for the in-process model (mm._model_lock).
  • unload_all() -> dict: unload every releasable model (in-process TTS + diar + all sidecars). New convenience used by app shutdown.
  • free_vram(): thin re-export of model_manager.free_vram() so callers have one import. Keep the "never let sidecar enumeration break the panel" try/except guard. uv run python -c "import services.model_lifecycle as ml; print([f for f in ('list_loaded','unload','unload_all','free_vram') if hasattr(ml,f)])" model_lifecycle exposes list_loaded/unload/unload_all/free_vram; logic moved out of system.py (not duplicated).
Task 6 (MM2-04): Thin system.py routers + facade tests backend/api/routers/system.py, tests/backend/services/test_model_lifecycle.py - Replace the bodies of GET /model/loaded (line 129) and POST /model/unload/{model_id} (line 210) with calls to model_lifecycle.list_loaded() / model_lifecycle.unload(model_id). Preserve the exact response shapes (frontend hooks.ts useModelStatus/useFlushMemory + the flush dropdown depend on {models, count} and {unloaded, success, ...}). The 400 on unknown model_id stays. - New tests/backend/services/test_model_lifecycle.py: list_loaded with nothing loaded returns {models:[], count:0}; unload("tts") when not loaded returns success:False reason:"not loaded"; unload("sidecars") with no sidecars returns count:0; unknown id raises/400 path. Mock model_manager + subprocess_backend so no real models load. uv run pytest tests/backend/services/test_model_lifecycle.py -q 2>&1 | tail -20 grep -n "model_lifecycle" backend/api/routers/system.py system.py routers are thin delegations; response shapes unchanged; new facade tests pass. Task 7 (MM2-05): Unify idle/timeout config through prefs.resolve backend/services/model_manager.py, backend/services/subprocess_backend.py - model_manager.py: remove the duplicated `_IDLE_TIMEOUT_SECONDS = IDLE_TIMEOUT_SECONDS` (line 114). Resolve at use-site in idle_worker() via prefs: `prefs.resolve("idle_timeout_seconds", env="OMNIVOICE_IDLE_TIMEOUT_S", default=IDLE_TIMEOUT_SECONDS)`. Keep core.config.IDLE_TIMEOUT_SECONDS as the default source. - subprocess_backend.py: replace the env-only `SIDECAR_IDLE_TIMEOUT_S` (line 107) read with prefs.resolve("sidecar_idle_timeout_seconds", env="OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S", default=300.0). Preserve "<=0 disables reaping" semantics and the existing reaper-start guard (line 222). Resolve lazily (function call), not at import, so a test/setting change takes effect — but keep a sensible cached default for the hot reaper loop. - Both must keep env precedence (env wins over store) — that's exactly what prefs.resolve already does. grep -n "_IDLE_TIMEOUT_SECONDS\|prefs.resolve\|SIDECAR_IDLE_TIMEOUT" backend/services/model_manager.py backend/services/subprocess_backend.py uv run pytest tests/backend/services/test_subprocess_reaper.py -q 2>&1 | tail -20 - No hand-duplicated IDLE_TIMEOUT constant; both timeouts resolve via prefs with env precedence. - Reaper "<=0 disables" + busy-skip behavior unchanged; all 10+ reaper tests still pass. Task 8 (MM2-08): Subprocess sidecars self-report VRAM in pong backend/services/subprocess_backend.py Sidecar VRAM is reported as 0 (system.py:192-203 / list_live_sidecars) because the parent can't measure a child's GPU memory. Have the child measure itself.
  • In the sidecar worker's ping handler (the code that answers {"op":"ping"} with {"op":"pong"} — find it in the sidecar entry-point module), include vram_mb: measure inside the child via torch.cuda.memory_allocated() (CUDA) or torch.mps.driver_allocated_memory() (MPS, guarded), else 0. Same degrade-gracefully pattern as system.py:147-156.
  • Parent: in the health-check ping/pong path (subprocess_backend.py:435-438), capture reply["vram_mb"] and stash it on the sidecar record so list_live_sidecars() (line 181) can surface it. Refresh opportunistically on each successful ping; default to last-known or 0 if never measured.
  • Keep the contract that enumeration never breaks the panel. This is CUDA/MPS-aware and degrades to 0 on CPU — honoring cross-platform parity (default behavior identical; the number is just more accurate where the API exists). grep -n "vram_mb" backend/services/subprocess_backend.py uv run pytest tests/backend/services/test_subprocess_reaper.py -q 2>&1 | tail -15 list_live_sidecars() exposes a vram_mb sourced from the child's own measurement; 0 only when truly CPU/unmeasured; reaper tests still green.
Task 9 (MM2-06, MM2-07): Bounded cooldowns + per-role weight validation backend/api/routers/setup/download.py - MM2-06: _install_cooldowns (line 27) grows unbounded. On a successful install, delete the repo's cooldown entry. Add a TTL sweep: when reading/writing the dict, evict entries older than a fixed window (reuse the existing cooldown window constant; pick the larger of cooldown-window and e.g. 1h). Keep it simple — a dict + timestamps, swept on access. No new dep. - MM2-07: _validate_snapshot_has_weights (line 55) + _MIN_WEIGHT_BYTES 5 MB (line 45) is one magic number for all roles. Make the threshold per-role/per-extension: safetensors/bin/ckpt expect the existing floor; .onnx models (kittentts, supertonic, sherpa) can be legitimately smaller — set a lower, role-aware floor so a valid small ONNX model isn't flagged as truncated. Keep the #352 truncation-catch intent (catch a 0-byte / KB-sized partial), just stop false-positiving small-but-complete models. grep -n "_install_cooldowns\|_MIN_WEIGHT_BYTES\|def _validate_snapshot_has_weights" backend/api/routers/setup/download.py uv run pytest tests/ -k "download or install or model" -q 2>&1 | tail -20 Cooldown dict is bounded (evict-on-success + TTL sweep); weight validation floor varies by role/extension; #352 truncation still caught. Task 10 (MM2-09): Log why scan_cache_dir() fell back to disk walk backend/api/routers/setup/models.py The scan_cache_dir() -> _scan_cache_on_disk() fallback (~line 268-280, helper at line 177) silently swallows the exception — this is the #117/#118 Windows WinError-448 path. Wrap the fallback so it logs at WARNING with the exception type and a one-line reason ("scan_cache_dir failed (%s); falling back to on-disk walk of %s") before walking. Do not change the fallback behavior itself — just stop it being invisible in logs. Keep it from ever raising out (the panel must still render). grep -n "falling back\|logger.warning\|_scan_cache_on_disk\|scan_cache_dir" backend/api/routers/setup/models.py | head The disk-walk fallback logs a WARNING naming the exception type; behavior otherwise unchanged; never raises out. Full-suite gate after each wave (run the relevant subset per wave, full set before the last PR):
  1. uv run pytest tests/test_engines.py tests/backend/services/test_model_lifecycle.py tests/backend/services/test_subprocess_reaper.py tests/test_model_load_timeout.py tests/test_model_manager_preload.py -q — all green.
  2. uv run pytest tests/ -k "download or install or model or engine" -q — green (Tier 3 touch points).
  3. Response-shape guard: GET /model/loaded still returns {models, count}; POST /model/unload returns {unloaded, success, ...}; 400 on unknown id. (Covered by test_model_lifecycle.py.)
  4. No new runtime dependency: git diff pyproject.toml uv.lock is empty.
  5. Localization/CJK + redaction gates unaffected: uv run pytest tests/test_no_hardcoded_cjk.py -q.

<success_criteria>

  • Tier 1: switching the active backend releases the previous engine's VRAM (unload() called once on switch); contract overridden for OmniVoice + all subprocess engines; ASR reporting is honest. (MM2-01..03)
  • Tier 2: services.model_lifecycle is the single lifecycle surface; system.py routers are thin delegations with unchanged response shapes; idle/timeout config flows through prefs.resolve with env precedence and no duplicated constants. (MM2-04..05)
  • Tier 3: cooldown dict bounded; weight validation is per-role; sidecars self-report real VRAM; cache-fallback logs its reason. (MM2-06..09)
  • All listed pytest selections pass; no on-disk model-state change; no new dep; cross-platform default behavior identical (VRAM numbers degrade gracefully on MPS/CPU). </success_criteria>
- **unload() correctness for the shared OmniVoice singleton (MM2-01/02):** OmniVoiceBackend shares model_manager's `model` global. unload() must release the shared singleton, but the idle_worker() + offload_tts_for_asr() paths also touch it. Risk: a switch during an in-flight ASR offload double-frees or races. Mitigation: take mm._model_lock around the shared release in unload(); guard on `mm.model is not None`; keep unload best-effort (try/except) so it can never wedge a switch. Add the idempotency test (Task 4). - **Response-shape drift (MM2-04):** Moving /model/loaded + /model/unload bodies into the facade risks changing the JSON the frontend depends on (hooks.ts, flush dropdown). Mitigation: move verbatim first, assert shapes in test_model_lifecycle.py, only then layer MM2-03/08 improvements. - **Sidecar protocol change (MM2-08):** Adding vram_mb to pong touches the parent/child wire format. Older sidecars (a long-running session mid-upgrade) won't send it. Mitigation: treat vram_mb as optional in the parent (`reply.get("vram_mb", )`); never require it; never break the existing pong==success check. - **prefs.resolve at import time (MM2-05):** Resolving timeouts at import freezes them; the reaper loop reads SIDECAR_IDLE_TIMEOUT_S. Mitigation: resolve lazily inside the reaper tick / idle_worker tick (cheap) so a settings change takes effect, while keeping the import-time default for the start-guard. - **Per-role weight floor (MM2-07):** Lowering the ONNX floor could let a genuinely-truncated ONNX through (#352 regression). Mitigation: keep a non-zero floor for every role (e.g. ONNX floor still >> a partial KB), key on extension, and keep the "largest file" heuristic — only the threshold becomes role-aware. - **Scope creep into perf:** GPU-pool sizing and torch.compile are explicitly out of scope. If an executor is tempted, stop — those regress #278/#315. Write `.planning/quick/260613-mm2-clean-model-management-v2/260613-mm2-SUMMARY.md` when done (per wave or once at the end), documenting: which engines got real unload() overrides vs intentional no-ops (for the future CI gate), the exact response shapes preserved on the two endpoints, the per-role weight-validation thresholds chosen, and the pytest output for the verification selection. Note any decision an executor made where the plan said "use judgment."

Docs-sync check (CLAUDE.md hard rule): this is internal lifecycle cleanup with no user-facing install/Docker/versioning change, so no README/docs edit is expected. If MM2-05 surfaces the new idle-timeout settings keys in the Settings UI, add them to the relevant settings doc in the same PR.