From cf94a29319d46a446d57230e04ef39f7adf9b8fd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:05:29 -0700 Subject: [PATCH] fix(profiles): share the runtime-tree trio with backup via hermes_constants backup.py imports hermes_cli.profiles only lazily and profiles.py never imports backup, so there was no cycle to justify two literals. LOCAL_RUNTIME_ROOT_DIRS now feeds both backup._EXCLUDED_ROOT_DIRS and the clone-all root gate; one invariant test pins the identity. --- hermes_cli/backup.py | 11 ++++++----- hermes_cli/profiles.py | 11 ++++++----- hermes_constants.py | 6 ++++++ tests/hermes_cli/test_profiles.py | 9 +++++++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index d05d3c592d..2b3fe56013 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -17,7 +17,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from hermes_constants import ( - _get_platform_default_hermes_home, get_default_hermes_root, get_hermes_home, display_hermes_home, + LOCAL_RUNTIME_ROOT_DIRS, _get_platform_default_hermes_home, get_default_hermes_root, get_hermes_home, + display_hermes_home, ) from hermes_state_dbfile import RETIRED_GENERATION_DIR_SUFFIX from utils import ( @@ -68,10 +69,10 @@ _EXCLUDED_DIRS = { ".cache", ".tox", ".nox", ".pytest_cache", ".mypy_cache", ".ruff_cache", } -# Hermes-managed runtime downloads (GGUF models, llama.cpp runtimes, managed Node): re-downloaded -# on demand and routinely tens to hundreds of GB. Matched ONLY at the root of HERMES_HOME and at -# ``profiles//`` — a deeper dir of the same name (a skill's ``models/``) is user data. -_EXCLUDED_ROOT_DIRS = {"models", "runtimes", "node"} +# Hermes-managed runtime downloads (see ``LOCAL_RUNTIME_ROOT_DIRS``). Matched ONLY at the root of +# HERMES_HOME and at ``profiles//`` — a deeper dir of the same name (a skill's ``models/``) +# is user data. +_EXCLUDED_ROOT_DIRS = LOCAL_RUNTIME_ROOT_DIRS # ``cache/`` at those same roots mixes regenerable state (model/plugin catalogs, stamps, browser # profiles with locked SQLite, tool-output spill) with durable artifacts nothing can rebuild: media diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 9e6730d4cd..aa7cf4feaf 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -17,7 +17,9 @@ from typing import Dict, List, Optional, Tuple from agent.skill_utils import is_excluded_skill_path from hermes_cli.archive_safe import archive_root_dirs, make_targz, normalize_archive_parts, safe_extract_targz -from hermes_constants import clear_named_profile_deleted, mark_named_profile_deleted, named_profile_is_deleted +from hermes_constants import ( + LOCAL_RUNTIME_ROOT_DIRS, clear_named_profile_deleted, mark_named_profile_deleted, named_profile_is_deleted, +) logger = logging.getLogger(__name__) @@ -45,12 +47,11 @@ _CLONE_ALL_STRIP: list[str] = ["gateway.pid", "gateway_state.json", "processes.j # and resolved from the default root only. Named profiles never hold these at root, so the # gate avoids silently dropping user data from a named-profile source. Export uses a root # allow-list instead (``_DEFAULT_EXPORT_INCLUDE_ROOT``): an archive is a portable snapshot, -# a clone must run. The last three mirror ``hermes_cli.backup._EXCLUDED_ROOT_DIRS`` — kept -# as separate literals because importing it here would be circular; change both together. +# a clone must run. The runtime trio is ``LOCAL_RUNTIME_ROOT_DIRS``, shared with +# ``hermes_cli.backup._EXCLUDED_ROOT_DIRS`` so the two lists cannot drift. _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ "hermes-agent", ".worktrees", "profiles", "bin", "node_modules", - "models", "runtimes", "node", -}) +}) | LOCAL_RUNTIME_ROOT_DIRS # Per-profile history excluded from --clone-all for ANY source: SQLite session store # (+wal/shm, can reach many GB), session dirs, `hermes backup` archives, quick-backup diff --git a/hermes_constants.py b/hermes_constants.py index dced36c03e..b903638a2d 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -157,6 +157,12 @@ def get_process_hermes_home() -> Path: return Path(val) if val else _get_platform_default_hermes_home() +# Hermes-managed runtime downloads at the root of a home (GGUF models, llama.cpp runtimes, +# managed Node): re-downloadable on demand and routinely tens to hundreds of GB. Shared by +# ``hermes backup`` (excludes them) and ``profile create --clone-all`` (skips them from the +# default profile) so the two lists cannot drift apart. +LOCAL_RUNTIME_ROOT_DIRS: frozenset[str] = frozenset({"models", "runtimes", "node"}) + # get_default_hermes_root() memo keyed on (native home, HERMES_HOME) so it stays # fresh when a test or plugin mutates HERMES_HOME; saves ~80us/call at 31+ sites. _default_hermes_root_memo: "tuple[str, str, Path] | None" = None diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 663af0fce5..010d32d776 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -1308,6 +1308,15 @@ class TestCloneAllExcludesRuntimeTrees: (source / tree).mkdir() assert not _clone_all_copytree_ignore(source)(str(source), [*self.RUNTIME_TREES, "SOUL.md"]) + def test_runtime_trio_is_one_constant_shared_with_backup(self): + """backup's exclusion list and the clone-all root gate must be built from the same + constant; two literals drifting apart is how the models/ copy of #111718 crept in.""" + from hermes_cli import backup, profiles + from hermes_constants import LOCAL_RUNTIME_ROOT_DIRS + assert LOCAL_RUNTIME_ROOT_DIRS == frozenset(self.RUNTIME_TREES) + assert backup._EXCLUDED_ROOT_DIRS is LOCAL_RUNTIME_ROOT_DIRS + assert LOCAL_RUNTIME_ROOT_DIRS <= profiles._CLONE_ALL_DEFAULT_EXCLUDE_ROOT + def test_clone_all_from_default_skips_runtime_trees_but_keeps_the_rest(self, profile_env): default_home = profile_env / ".hermes" self._seed(default_home)