fix: guard atomic writers after profile deletion

This commit is contained in:
KoNit-K
2026-09-16 12:34:46 +08:00
committed by Teknium
parent 81140e4546
commit 91a38622db
3 changed files with 56 additions and 5 deletions
+2 -1
View File
@@ -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 """Atomically persist a cache file (creating parents). Raises on failure — callers decide
whether a failed cache write is worth logging.""" whether a failed cache write is worth logging."""
from utils import atomic_json_write 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) atomic_json_write(path, data, **dump_kwargs)
@@ -24,7 +24,11 @@ from hermes_cli.profiles import (
resolve_profile_env, resolve_profile_env,
set_active_profile, 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 from hermes_logging import setup_logging
@@ -67,6 +71,43 @@ class TestDeletedProfileTombstone:
monkeypatch.setenv("HERMES_HOME", str(profile_env / ".hermes")) monkeypatch.setenv("HERMES_HOME", str(profile_env / ".hermes"))
assert "worker" not in _named_homes(profile_env) 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): 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) profile_dir = create_profile("worker", no_alias=True, no_skills=True)
with patch("hermes_cli.profiles._cleanup_gateway_service"), patch( with patch("hermes_cli.profiles._cleanup_gateway_service"), patch(
+12 -3
View File
@@ -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 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. 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(): if mode is None and not path.exists():
mode = default_new_file_mode() mode = default_new_file_mode()
original_owner = _preserve_file_owner(path) if preserve_owner else None 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 from hermes_cli.config import _greedy_literal_match, _split_key_path
path = Path(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) yaml_rt, config = _roundtrip_load(path)
current = config current = config
keys = _split_key_path(key_path) 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 from hermes_cli.config import require_readable_config_before_write
path = Path(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)
require_readable_config_before_write(path) require_readable_config_before_write(path)
yaml_rt, existing = _roundtrip_load(path) yaml_rt, existing = _roundtrip_load(path)