From 91a38622dbc3df1616d7631d462516e870b5c321 Mon Sep 17 00:00:00 2001 From: KoNit-K <124019182+KoNit-K@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:34:46 +0800 Subject: [PATCH] fix: guard atomic writers after profile deletion --- hermes_cli/models.py | 3 +- .../test_deleted_profile_tombstone.py | 43 ++++++++++++++++++- utils.py | 15 +++++-- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 626e6daf41..9ac946d85c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -103,8 +103,9 @@ def _write_json_cache(path: Path, data: Any, **dump_kwargs: Any) -> None: """Atomically persist a cache file (creating parents). Raises on failure — callers decide whether a failed cache write is worth logging.""" from utils import atomic_json_write + from hermes_constants import mkdir_under_hermes_home - path.parent.mkdir(parents=True, exist_ok=True) + mkdir_under_hermes_home(path.parent) atomic_json_write(path, data, **dump_kwargs) diff --git a/tests/hermes_cli/test_deleted_profile_tombstone.py b/tests/hermes_cli/test_deleted_profile_tombstone.py index 608e078ad4..e2b00a26f7 100644 --- a/tests/hermes_cli/test_deleted_profile_tombstone.py +++ b/tests/hermes_cli/test_deleted_profile_tombstone.py @@ -24,7 +24,11 @@ from hermes_cli.profiles import ( resolve_profile_env, set_active_profile, ) -from hermes_constants import named_profile_home +from hermes_constants import ( + named_profile_home, + reset_hermes_home_override, + set_hermes_home_override, +) from hermes_logging import setup_logging @@ -67,6 +71,43 @@ class TestDeletedProfileTombstone: monkeypatch.setenv("HERMES_HOME", str(profile_env / ".hermes")) assert "worker" not in _named_homes(profile_env) + def test_late_reasoning_caps_save_does_not_recreate_deleted_home(self, profile_env): + """A daemon retaining a deleted profile context must not recreate its cache tree.""" + from hermes_cli import models_reasoning_caps + + profile_dir = create_profile("worker", no_alias=True, no_skills=True) + _delete("worker") + + token = set_hermes_home_override(profile_dir) + try: + models_reasoning_caps._save_reasoning_caps_disk( + "https://example.test/v1/models", + {"example/model": {"supports_reasoning": True}}, + ) + finally: + reset_hermes_home_override(token) + + assert not profile_dir.exists() + + def test_atomic_cache_write_allows_live_profile_home(self, profile_env): + from hermes_cli.models import _write_json_cache + + profile_dir = create_profile("worker", no_alias=True, no_skills=True) + cache_path = profile_dir / "cache" / "models.json" + + _write_json_cache(cache_path, {"cached": True}) + + assert cache_path.read_text(encoding="utf-8") == '{\n "cached": true\n}' + + def test_atomic_cache_write_allows_unrelated_profiles_path(self, tmp_path): + from hermes_cli.models import _write_json_cache + + cache_path = tmp_path / "custom" / "profiles" / "cache" / "models.json" + + _write_json_cache(cache_path, {"cached": True}) + + assert cache_path.read_text(encoding="utf-8") == '{\n "cached": true\n}' + def test_empty_shell_after_delete_is_not_listed_or_served(self, profile_env): profile_dir = create_profile("worker", no_alias=True, no_skills=True) with patch("hermes_cli.profiles._cleanup_gateway_service"), patch( diff --git a/utils.py b/utils.py index 5d14cfacec..36d9651e79 100644 --- a/utils.py +++ b/utils.py @@ -240,7 +240,12 @@ def _atomic_write(path: Path, write, *, prefix: str, encoding: str = "utf-8", mo also fsyncs the parent so the rename itself is durable. The temp file is removed on any failure — ``BaseException`` on purpose, so KeyboardInterrupt / SystemExit still clean up. """ - path.parent.mkdir(parents=True, exist_ok=True) + # A profile delete leaves a tombstone beside its removed home. Background + # writers may retain that home in a context variable, so a plain mkdir here + # would resurrect the profile before the write can fail. + from hermes_constants import mkdir_under_hermes_home + + mkdir_under_hermes_home(path.parent) if mode is None and not path.exists(): mode = default_new_file_mode() original_owner = _preserve_file_owner(path) if preserve_owner else None @@ -423,7 +428,9 @@ def atomic_roundtrip_yaml_update(path: Union[str, Path], key_path: str, value: A from hermes_cli.config import _greedy_literal_match, _split_key_path path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) + from hermes_constants import mkdir_under_hermes_home + + mkdir_under_hermes_home(path.parent) yaml_rt, config = _roundtrip_load(path) current = config keys = _split_key_path(key_path) @@ -467,7 +474,9 @@ def atomic_roundtrip_yaml_save(path: Union[str, Path], new_state: dict) -> None: from hermes_cli.config import require_readable_config_before_write path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) + from hermes_constants import mkdir_under_hermes_home + + mkdir_under_hermes_home(path.parent) require_readable_config_before_write(path) yaml_rt, existing = _roundtrip_load(path)