fix(pm): align context-home publication and recovery scopes

Resolve dependency state and the plugin union from the active home without changing process environment. Use the existing home-root derivation for custom and named profiles. The journal validator checks the same root as the writer.

Verified 71 tests passed, 8 skipped across root resolution, union, recovery, and selection. The new context-only-home regression failed before the fix.
This commit is contained in:
ethernet
2026-09-06 13:31:24 -04:00
parent ce49cdbc59
commit acba441012
5 changed files with 74 additions and 19 deletions

View File

@@ -18,8 +18,16 @@ def install_key(project_root: Path) -> str:
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def dependency_home_root() -> Path:
"""Scope dependency state like a process launched in the active home."""
from hermes_constants import get_default_hermes_root, get_hermes_home_override
override = get_hermes_home_override()
return get_default_hermes_root(home=override) if override else get_default_hermes_root()
def installs_root() -> Path:
return get_default_hermes_root() / "installs"
return dependency_home_root() / "installs"
def install_state_dir(project_root: Path) -> Path:

View File

@@ -14,8 +14,7 @@ import tempfile
import time
import uuid
from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path
from hermes_constants import get_default_hermes_root
from hermes_cli.runtime_paths import dependency_home_root, install_state_dir, runtime_facts_path
def _lock(fd: int, *, wait: bool) -> bool:
@@ -93,7 +92,7 @@ def recover_publication(project: Path) -> None:
try:
row = json.loads(data)
config = Path(row["config"])
if config.name != "config.yaml" or not config.resolve().is_relative_to(get_default_hermes_root().resolve()):
if config.name != "config.yaml" or not config.resolve().is_relative_to(dependency_home_root().resolve()):
raise ValueError("config path is outside Hermes state")
previous = base64.b64decode(row["previous"], validate=True) if row["previous"] is not None else None
if _digest(runtime_facts_path(project)) == row["facts_before"]:

View File

@@ -143,11 +143,11 @@ def get_process_hermes_home() -> Path:
_default_hermes_root_memo: "tuple[str, str, Path] | None" = None
def get_default_hermes_root() -> Path:
"""Root Hermes dir for profile-level ops: ``<root>`` when ``HERMES_HOME=<root>/profiles/<name>``."""
def get_default_hermes_root(*, home: str | Path | None = None) -> Path:
"""Root of an explicit home, or the process home when none is supplied."""
global _default_hermes_root_memo
native_home = _get_platform_default_hermes_home()
env_home = os.environ.get("HERMES_HOME", "")
env_home = str(home) if home is not None else os.environ.get("HERMES_HOME", "")
memo = _default_hermes_root_memo
if memo is not None and memo[:2] == (str(native_home), env_home):
return memo[2]

View File

@@ -15,17 +15,10 @@ from typing import Any, Optional
def _profiles_root() -> Path:
# Profile roots are derived from get_default_hermes_root() — the ONE
# authority for "where do profiles live" (hermes_constants): it
# returns HERMES_HOME directly for custom roots (Docker /opt/data,
# non-default local roots) and <root> for profile-mode HERMES_HOME,
# on both standard and custom layouts. Hardcoding Path.home()/
# .hermes/profiles silently omits custom-root profiles — their
# enabled dep plugins never join the union and bisect disable
# decisions never write back to their config.
from hermes_constants import get_default_hermes_root
# Plugin discovery and dependency publication must use the same home root.
from hermes_cli.runtime_paths import dependency_home_root
return get_default_hermes_root() / "profiles"
return dependency_home_root() / "profiles"
def _read_home_config(home: Path) -> Optional[dict[str, Any]]:
@@ -69,9 +62,9 @@ def _all_homes() -> list[Path]:
"""The default home + every profile home (the union's scope)."""
homes: list[Path] = []
try:
from hermes_constants import get_default_hermes_root
from hermes_cli.runtime_paths import dependency_home_root
homes.append(get_default_hermes_root())
homes.append(dependency_home_root())
except Exception:
pass
try:

View File

@@ -0,0 +1,55 @@
"""Context-only homes use the same dependency state as their own process."""
from hermes_cli import runtime_paths
from hermes_cli.plugins_admission import _config_commit
from hermes_cli.runtime_state import recover_publication, runtime_lock
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from pm import paths, plugins_state
def test_context_home_publication_recovers_from_its_own_process(tmp_path, monkeypatch):
process_home = tmp_path / "process-home"
context_home = tmp_path / "context-home"
context_home.mkdir()
project = tmp_path / "repo"
project.mkdir()
config = context_home / "config.yaml"
original = b"plugins:\n enabled: [old]\n"
config.write_bytes(original)
monkeypatch.setenv("HERMES_HOME", str(process_home))
monkeypatch.setattr(paths, "repo_root", lambda: project)
process_state = runtime_paths.install_state_dir(project)
token = set_hermes_home_override(context_home)
try:
context_state = runtime_paths.install_state_dir(project)
assert context_state != process_state
assert plugins_state.enabled_plugins_ordered() == {
context_home / "plugins": ["old"],
}
with runtime_lock(project):
_config_commit({"new"}, set())
assert config.read_bytes() != original
finally:
reset_hermes_home_override(token)
assert not process_state.exists()
monkeypatch.setenv("HERMES_HOME", str(context_home))
assert runtime_paths.install_state_dir(project) == context_state
with runtime_lock(project):
recover_publication(project)
assert config.read_bytes() == original
assert not (context_state / "publication.json").exists()
def test_named_context_profile_shares_its_install_root(tmp_path, monkeypatch):
home = tmp_path / "home"
profile = home / "profiles" / "worker"
profile.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
project = tmp_path / "repo"
state = runtime_paths.install_state_dir(project)
token = set_hermes_home_override(profile)
try:
assert runtime_paths.install_state_dir(project) == state
finally:
reset_hermes_home_override(token)