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:
ethernet
2026-09-25 01:20:14 -04:00
committed by emozilla
parent 9fc7f17906
commit 3582c17cbc
3 changed files with 108 additions and 0 deletions

View File

@@ -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():

View File

@@ -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

View 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"]}