diff --git a/hermes_cli/local_runtime/bootstrap.py b/hermes_cli/local_runtime/bootstrap.py index fcc2cab169..9b78b7a073 100644 --- a/hermes_cli/local_runtime/bootstrap.py +++ b/hermes_cli/local_runtime/bootstrap.py @@ -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 ``/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(): diff --git a/hermes_cli/web_routers/local_models.py b/hermes_cli/web_routers/local_models.py index 1751b40988..b2ac709d4f 100644 --- a/hermes_cli/web_routers/local_models.py +++ b/hermes_cli/web_routers/local_models.py @@ -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 diff --git a/tests/hermes_cli/test_local_legacy_models_dir.py b/tests/hermes_cli/test_local_legacy_models_dir.py new file mode 100644 index 0000000000..825f54a5de --- /dev/null +++ b/tests/hermes_cli/test_local_legacy_models_dir.py @@ -0,0 +1,58 @@ +"""Models staged under the old per-profile ``/models`` layout are moved into the +machine-scoped ``/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"]}