fix(local-runtime): move models from the old per-profile models dir into the machine dir
Models used to live at <profile home>/models (adb2fdbf5c) before the managed runtime moved them to the machine-scoped <root>/models (43e67d872f). GGUFs staged under a named profile's old dir silently stopped being served. adopt_legacy_models() renames them (and their assets/) into the current dirs, so everything downstream keeps reading one directory: listing, presets, delete, the --models-dir fallback. It runs at boot, before the "anything staged?" check, and in the Local Models status route so the pane lists them while the runtime is off. os.rename only: instant within a filesystem, while shutil.move would silently copy tens of GB across devices at session start; a cross-device dir stays put with a warning. An existing destination name is never replaced.
This commit is contained in:
@@ -76,6 +76,50 @@ def staged_in(models_dir: Path, *, require_complete: bool = True) -> "list[Path]
|
||||
return out
|
||||
|
||||
|
||||
def adopt_legacy_models() -> "list[Path]":
|
||||
"""Move GGUFs left in the old per-profile ``<profile home>/models`` layout (and its assets/)
|
||||
into the machine-scoped dirs, so everything downstream keeps reading one directory.
|
||||
|
||||
``os.rename`` only: within one filesystem it is instant even for a 20 GB model, while
|
||||
``shutil.move`` silently degrades to a copy across devices. A cross-device profile dir is left
|
||||
in place with a warning rather than copying tens of GB at session start. A name that already
|
||||
exists in the destination is left alone (check-then-rename: the only window is two processes
|
||||
adopting two profiles' same-named file at once, and a same name is the same catalog variant).
|
||||
Two processes racing on one file are harmless: the loser's rename finds the source gone and
|
||||
skips it. Returns the new paths of the moved files."""
|
||||
from hermes_constants import get_default_hermes_root, named_profile_has_identity
|
||||
|
||||
profiles_root = get_default_hermes_root() / "profiles"
|
||||
if not profiles_root.is_dir():
|
||||
return []
|
||||
moved: list[Path] = []
|
||||
for home in sorted(profiles_root.iterdir()):
|
||||
old = home / "models"
|
||||
if home.name.startswith(".") or not old.is_dir() or not named_profile_has_identity(home):
|
||||
continue
|
||||
for src_dir, dest_dir in ((old, models_dir()), (old / "assets", assets_dir())):
|
||||
for src in sorted(src_dir.glob("*.gguf")):
|
||||
dest = dest_dir / src.name
|
||||
if dest.exists():
|
||||
logger.warning("legacy model %s not moved: %s already exists", src, dest)
|
||||
continue
|
||||
try:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.rename(src, dest)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
logger.warning("legacy model %s not moved to %s: %s", src, dest_dir, exc)
|
||||
continue
|
||||
moved.append(dest)
|
||||
for emptied in (old / "assets", old):
|
||||
with suppress(OSError):
|
||||
emptied.rmdir()
|
||||
if moved:
|
||||
logger.info("moved %d legacy model file(s) into %s", len(moved), models_dir())
|
||||
return moved
|
||||
|
||||
|
||||
def staged_models() -> "list[Path]":
|
||||
"""Servable staged models (continuation parts, incomplete splits and assets/ never count)."""
|
||||
return staged_in(models_dir())
|
||||
@@ -275,6 +319,10 @@ def ensure_local_runtime(config: dict, force: bool = False) -> "object | None":
|
||||
if _SUPERVISOR is not None:
|
||||
return _SUPERVISOR
|
||||
|
||||
try:
|
||||
adopt_legacy_models()
|
||||
except OSError as exc: # an unreadable profiles dir must not block serving what's staged
|
||||
logger.warning("legacy model adoption failed: %s", exc)
|
||||
# Residency: no staged models means nothing to serve — don't boot an empty server (delete
|
||||
# your last model and boots stop). force boots as ever.
|
||||
if not force and not staged_models():
|
||||
|
||||
@@ -500,6 +500,8 @@ def local_models_status():
|
||||
import pm
|
||||
|
||||
current = pm.installed_package(binaries.BACKEND_PACKAGES[runtime_backend]) if runtime_backend else None
|
||||
# The pane must list models from the old per-profile layout even while the runtime is off.
|
||||
_quiet(bootstrap.adopt_legacy_models, [], warn="legacy model adoption failed: %r")
|
||||
mdir = bootstrap.models_dir()
|
||||
running = _state_endpoint()
|
||||
# Resident models from the live router ({} when down): Loaded pills + eject. A failed read is never
|
||||
|
||||
58
tests/hermes_cli/test_local_legacy_models_dir.py
Normal file
58
tests/hermes_cli/test_local_legacy_models_dir.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Models staged under the old per-profile ``<profile home>/models`` layout are moved into the
|
||||
machine-scoped ``<root>/models`` so they keep loading."""
|
||||
from hermes_cli.local_runtime import bootstrap
|
||||
|
||||
|
||||
def _profile(root, name, *, identity=True):
|
||||
home = root / "profiles" / name
|
||||
(home / "models").mkdir(parents=True)
|
||||
if identity:
|
||||
(home / "config.yaml").write_text("{}\n")
|
||||
return home
|
||||
|
||||
|
||||
def test_legacy_models_move_into_the_machine_dir_without_clobbering(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
new_dir = bootstrap.models_dir()
|
||||
new_dir.mkdir(parents=True)
|
||||
(new_dir / "shared.gguf").write_text("current")
|
||||
work = _profile(tmp_path, "work")
|
||||
split = ["big-00001-of-00002.gguf", "big-00002-of-00002.gguf"]
|
||||
for name in (*split, "shared.gguf"):
|
||||
(work / "models" / name).write_text(f"legacy {name}")
|
||||
(work / "models" / "assets").mkdir()
|
||||
(work / "models" / "assets" / "mmproj.gguf").write_text("proj")
|
||||
play = _profile(tmp_path, "play")
|
||||
(play / "models" / "small.gguf").write_text("small")
|
||||
ghost = _profile(tmp_path, "ghost", identity=False) # marker-less shell: not a profile
|
||||
(ghost / "models" / "ghost.gguf").touch()
|
||||
|
||||
bootstrap.adopt_legacy_models()
|
||||
|
||||
assert {p.name for p in bootstrap.staged_models()} == {"shared.gguf", "big-00001-of-00002.gguf", "small.gguf"}
|
||||
assert all((new_dir / name).read_text() == f"legacy {name}" for name in split)
|
||||
assert (bootstrap.assets_dir() / "mmproj.gguf").read_text() == "proj"
|
||||
assert (new_dir / "shared.gguf").read_text() == "current" # never clobbered...
|
||||
assert (work / "models" / "shared.gguf").read_text() == "legacy shared.gguf" # ...and not lost
|
||||
assert not (play / "models").exists() # emptied legacy dir is cleaned up
|
||||
assert (ghost / "models" / "ghost.gguf").exists()
|
||||
assert bootstrap.adopt_legacy_models() == [] # idempotent
|
||||
|
||||
|
||||
def test_boot_and_status_both_adopt_legacy_models(tmp_path, monkeypatch):
|
||||
from hermes_cli.local_runtime import binaries
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(binaries, "installed_engine", lambda backend="auto": None)
|
||||
work = _profile(tmp_path, "work")
|
||||
|
||||
(work / "models" / "booted.gguf").touch()
|
||||
bootstrap.ensure_local_runtime({"local_runtime": {"enabled": True}})
|
||||
assert (bootstrap.models_dir() / "booted.gguf").exists()
|
||||
|
||||
(work / "models").mkdir() # the boot's adoption emptied and removed it
|
||||
(work / "models" / "listed.gguf").touch()
|
||||
monkeypatch.setattr(lm, "_runtime_section", lambda: {"enabled": False})
|
||||
monkeypatch.setattr(lm, "_state_endpoint", lambda: None)
|
||||
assert "listed" in {row["id"] for row in lm.local_models_status()["models"]}
|
||||
Reference in New Issue
Block a user