Files
hermes-agent/tests/pm/test_plugin_survival_contract.py
ethernet 0978aca962 fix(pm): retry a plugin fetch failure once, then disable it
Sitting a plugin out after a fetch failure left the recorded stamp stale on
purpose, so every launch resynced, and the spawned-process guard in
prepare_launch raised "dependency sync left this install out of date".
One retry covers a blip; after that the plugin is disabled with the reason,
and hermes plugins enable restores it. Only requires_hermes still sits out,
which boot skips the same way.
2026-09-25 00:00:09 -04:00

538 lines
25 KiB
Python

"""Plugin survival + upgrade contracts (mnemosyne-oss/mnemosyne#859).
Hermes-side contracts an external memory-provider wrapper can hold us to,
exercised through PUBLIC paths (no source inspection, no network — uv
resolves offline local path-source fixtures):
1. Sidecar isolation. A wrapper plugin exposes NO dependency surface at
the scanned plugin root (no pyproject.toml, no legacy dep keys); a
pyproject belonging to a nested or external sidecar dir must NOT
join the pm workspace union, because pm scans only plugin roots.
2. Conflict admission. Through the PUBLIC admission authority
(hermes_cli.plugins_admission.admit_plugin_set_change — the one path
`hermes plugins enable/install` use), a candidate union with no
valid solution is REFUSED before anything is published: the
candidate stays unenabled (so the loader never imports it), the
plugin trees and every home's config.yaml survive untouched, the
refusal message carries the plugin identity + the resolver's
reason, and a machine-readable pm receipt records the failure.
The retry path — re-admitting only the resolvable candidate —
commits through the same public function.
3. Active-home propagation. The active CONTEXT home
(hermes_constants.set_hermes_home_override) is what wrapper/sidecar
subprocess launches must inherit through build_subprocess_env.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import hermes_yaml as yaml
import pm.workspace as ws
from pm.plugin_inputs import Members
def _write_enabled(home: Path, enabled: list, provider: str | None = None) -> None:
home.mkdir(parents=True, exist_ok=True)
cfg: dict = {"plugins": {"enabled": enabled}}
if provider:
cfg["memory"] = {"provider": provider}
with (home / "config.yaml").open("w", encoding="utf-8") as f:
yaml.safe_dump(cfg, f)
def _uv_available() -> bool:
return shutil.which("uv") is not None
# 1. Sidecar with no root dependency surface never joins the union
def test_sidecar_no_root_pyproject_excludes_nested_and_external(tmp_path, monkeypatch):
"""The mnemosyne-wrapper shape: plugin root has ONLY plugin.yaml +
marker; its runtime lives in sidecar dirs with their own pyprojects.
Neither the nested subdir pyproject nor the external one may join
the workspace union."""
home = tmp_path / "home"
_write_enabled(home, ["mnemosyne-wrapper"])
plugins_dir = home / "plugins"
wrapper = plugins_dir / "mnemosyne-wrapper"
wrapper.mkdir(parents=True)
(wrapper / "plugin.yaml").write_text("name: mnemosyne-wrapper\n", encoding="utf-8")
(wrapper / "mnemosyne-wrapper.json").write_text('{"wrapper": true}\n', encoding="utf-8")
# a nested pyproject INSIDE the plugin dir (below the scanned root)
nested = wrapper / "runtime"
nested.mkdir()
(nested / "pyproject.toml").write_text(
'[project]\nname = "mnemosyne-runtime"\nversion = "1.0.0"\n', encoding="utf-8"
)
# an EXTERNAL sidecar next to the plugin (the wrapper's own venv project)
external = plugins_dir / ".mnemosyne-sidecar"
external.mkdir()
(external / "pyproject.toml").write_text(
'[project]\nname = "mnemosyne-sidecar"\nversion = "1.0.0"\n', encoding="utf-8"
)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
assert ws._is_member_candidate(wrapper) is False, (
"a wrapper root without pyproject/dep keys must never be a member candidate"
)
members = ws.enabled_member_dirs()
member_names = [p.name for p in members]
assert "mnemosyne-wrapper" not in member_names
assert "mnemosyne-sidecar" not in member_names, (
"an external sidecar pyproject must not join the union — pm scans "
"only plugin roots, and the wrapper owns its runtime"
)
assert all("runtime" not in str(p) for p in members)
# 2. Conflict through the PUBLIC admission path: refused, preserved, retry
def _local_conflict_members(home: Path) -> tuple[Path, Path, Path, Path]:
"""plug-a and plug-b both need a local project named sharedlib, but
map it to DIFFERENT path sources (v1 vs v2) — a union with NO valid
solution, resolvable by uv fully OFFLINE."""
shared1 = home / "sidecars" / "sharedlib-v1"
shared2 = home / "sidecars" / "sharedlib-v2"
for path, version in ((shared1, "1.0.0"), (shared2, "2.0.0")):
path.mkdir(parents=True)
(path / "pyproject.toml").write_text(
"[project]\nname = \"sharedlib\"\n"
f'version = "{version}"\nrequires-python = ">=3.11"\n',
encoding="utf-8",
)
plugins_dir = home / "plugins"
members = []
for name, pin, shared in (("plug-a", "1.0.0", shared1), ("plug-b", "2.0.0", shared2)):
plug = plugins_dir / name
plug.mkdir(parents=True)
(plug / "plugin.yaml").write_text(f"name: {name}\n", encoding="utf-8")
(plug / "pyproject.toml").write_text(
"[project]\n"
f'name = "{name}"\nversion = "0.1.0"\n'
'requires-python = ">=3.11"\n'
f'dependencies = ["sharedlib=={pin}"]\n'
"\n[tool.uv.sources]\n"
f'sharedlib = {{ path = "{shared.as_posix()}" }}\n',
encoding="utf-8",
)
members.append(plug)
return members[0], members[1], shared1, shared2
@pytest.fixture
def admission_env(tmp_path, monkeypatch):
"""Fake core repo + temp HERMES_HOME so the REAL pm.install.sync_venv
transaction (lock, receipts, config publication) runs entirely under
tmp — the production path, temp homes."""
core = tmp_path / "core"
core.mkdir()
(core / "pyproject.toml").write_text(
"[project]\n"
'name = "fake-core"\nversion = "0.1.0"\n'
'requires-python = ">=3.11"\ndependencies = []\n',
encoding="utf-8",
)
# the venv package's stamp digest + lock seed read core/uv.lock —
# produce a real one (no deps: uv lock resolves offline)
subprocess.run(
[shutil.which("uv"), "lock"], cwd=core, check=True,
capture_output=True, text=True, timeout=120,
)
home = tmp_path / "home"
_write_enabled(home, []) # nothing enabled yet — the candidate must move it
import importlib
ensure = importlib.import_module("pm.install")
import pm.paths
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(pm.paths, "repo_root", lambda: core)
monkeypatch.setattr(ws.paths, "repo_root", lambda: core)
monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: True)
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools"))
# Exercise the real dependency transaction in-process so the local uv
# fixture owns provisioning; worker transport is covered separately. The
# facade's project_root selects a foreign checkout for the worker; this
# fixture's repo_root already IS the core under test.
def in_process_sync(*args, project_root=None, **kwargs):
assert project_root is None or Path(project_root).resolve() == core.resolve()
return ensure.sync_venv(*args, **kwargs)
monkeypatch.setattr("pm.client.sync_venv", in_process_sync)
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(shutil.which("uv")), Path(sys.executable)))
return tmp_path, home
def _latest_receipt(home: Path) -> dict:
from pm.receipt import latest
receipt = latest()
assert receipt is not None, f"no pm receipt written for {home}"
return receipt
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_conflicting_candidate_refused_unenabled_and_unimported(admission_env):
"""Public admission of an unsatisfiable union: AdmissionRefused with
plugin identity + resolver reason; the candidate is NOT published to
config (so the plugin loader never imports it); plugin trees and
configs survive; the receipt records the failure."""
from hermes_cli import plugins_admission as admission
tmp_path, home = admission_env
plug_a, plug_b, *_ = _local_conflict_members(home)
_write_enabled(home, [], provider="plug-a")
admission.admit_plugin_set_change(set(), set(), active_plugins_dir=home / "plugins")
from pm.environments import selected_venv
working = selected_venv(tmp_path / "core")
config_before = (home / "config.yaml").read_bytes()
tree_before = {p: sorted(str(f) for f in p.rglob("*")) for p in (plug_a, plug_b)}
wrapper = home / "plugins/mnemosyne-wrapper"
wrapper.mkdir()
marker = wrapper / "mnemosyne-wrapper.json"
marker.write_bytes(b'{"wrapper":true}\n')
sidecar = tmp_path / "external-sidecar"
subprocess.run([shutil.which("uv"), "venv", "--python", sys.executable, str(sidecar)], check=True, capture_output=True, timeout=60)
sidecar_python = sidecar / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
before = marker.read_bytes()
with pytest.raises(admission.AdmissionRefused) as excinfo:
admission.admit_plugin_set_change(
{"plug-a", "plug-b"}, set(), active_plugins_dir=home / "plugins"
)
message = str(excinfo.value)
assert "plug-b" in message or "plug-a" in message, (
f"refusal must carry the plugin identity, got: {message}"
)
assert "sharedlib" in message.lower() or "conflict" in message.lower(), (
f"refusal must carry the resolver's reason, got: {message}"
)
# unenabled → unimported: the candidate never reached the enabled list
with (home / "config.yaml").open(encoding="utf-8-sig") as f:
cfg = yaml.safe_load(f)
assert cfg["plugins"]["enabled"] == [], (
"a refused candidate must stay unenabled — config published a set that never resolved"
)
assert (home / "config.yaml").read_bytes() == config_before
assert cfg["memory"]["provider"] == "plug-a"
assert selected_venv(tmp_path / "core") == working
# no plugin tree was deleted or mutated by the failed resolution
for plug, listing in tree_before.items():
assert plug.is_dir(), f"failed resolution deleted plugin tree {plug}"
assert sorted(str(f) for f in plug.rglob("*")) == listing
# the machine-readable receipt records the failed sync
receipt = _latest_receipt(home)
assert receipt.get("outcome") == "failed"
flattened = json.dumps(receipt)
assert "plug-b" in flattened or "sharedlib" in flattened, (
"receipt must carry the conflict identity/reason"
)
# retry: drop the conflicting candidate, keep the good one
admission.admit_plugin_set_change(
{"plug-a"}, set(), active_plugins_dir=home / "plugins"
)
with (home / "config.yaml").open(encoding="utf-8-sig") as f:
cfg = yaml.safe_load(f)
assert cfg["plugins"]["enabled"] == ["plug-a"]
assert "plug-b" not in cfg["plugins"]["enabled"], (
"the conflicting candidate must remain unenabled after the retry"
)
assert marker.read_bytes() == before
child = subprocess.run([str(sidecar_python), "-c", "import sys; print(sys.prefix)"], check=True, capture_output=True, text=True, timeout=30)
assert Path(child.stdout.strip()) == sidecar
from pm.environments import selected_venv
selected = selected_venv(tmp_path / "core")
assert selected.is_dir() and selected != sidecar
# A declared version range remains a member across the next managed rebuild.
project = plug_a / "pyproject.toml"
project.write_text(project.read_text(encoding="utf-8").replace("sharedlib==1.0.0", "sharedlib>=1,<2"), encoding="utf-8")
admission.admit_plugin_set_change({"plug-a"}, set(), active_plugins_dir=home / "plugins")
assert selected_venv(tmp_path / "core") != selected
assert marker.read_bytes() == before
subprocess.run([str(sidecar_python), "-c", "import sys; assert sys.prefix != sys.base_prefix"], check=True, timeout=30)
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_malformed_secondary_cannot_evict_recorded_member(admission_env, monkeypatch, caplog):
"""A→B→A: passive inspection survives bad config; A's recorded graph does not shrink."""
from pm.environments import runtime_facts_path, selected_venv
from pm.install import sync_venv, venv_is_current
from pm.lock import Facts
tmp_path, home_a = admission_env
core = tmp_path / "core"
profile = home_a / "profiles" / "work"
profile.mkdir(parents=True)
_write_enabled(profile, ["profile-dep"])
member = profile / "plugins" / "profile-dep"
member.mkdir(parents=True)
(member / "pyproject.toml").write_text(
'[project]\nname="profile-dep"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
sync_venv(explicit=True)
recorded = Facts(runtime_facts_path(core), strict=True).get("venv")
selected = selected_venv(core)
assert recorded["stamp"] and selected.is_dir()
assert "profile-dep" in (Path(recorded["resolved_lock"]).parent / "pyproject.toml").read_text()
bad = profile / "config.yaml"
bad.write_text("plugins: [broken]\n", encoding="utf-8")
candidate = home_a / "plugins" / "new-dep"
candidate.mkdir(parents=True)
(candidate / "pyproject.toml").write_text(
'[project]\nname="new-dep"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
_write_enabled(home_a, ["new-dep"])
assert venv_is_current(project_root=core) is False
assert str(bad) in caplog.text
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True)
assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded
assert selected_venv(core) == selected
# Even a precomputed member list cannot bypass a newly broken profile.
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True, plugins=Members([]))
assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded
home_b = tmp_path / "home-b"
_write_enabled(home_b, [])
monkeypatch.setenv("HERMES_HOME", str(home_b))
sync_venv(explicit=True)
assert selected_venv(core).is_dir()
assert Facts(runtime_facts_path(core), strict=True).get("venv")["stamp"] != recorded["stamp"]
monkeypatch.setenv("HERMES_HOME", str(home_a))
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True)
assert selected_venv(core) == selected
_write_enabled(profile, ["profile-dep"])
sync_venv(explicit=True)
restored = Facts(runtime_facts_path(core), strict=True).get("venv")
assert selected_venv(core).is_dir()
assert restored["stamp"] != recorded["stamp"]
assert "profile-dep" in (Path(restored["resolved_lock"]).parent / "pyproject.toml").read_text()
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_update_sync_disables_plugin_excluded_by_requires_python(admission_env):
"""An update never fails because of a plugin: a member whose requires-python excludes the
interpreter core now runs on is disabled in every home that enables it (plugins.enabled in
one, memory.provider in another), the rest build, and the next boot sees a current venv."""
from pm.environments import runtime_facts_path, selected_venv
from pm.install import sync_venv, venv_is_current
from pm.lock import Facts
from pm.package import InstallError
tmp_path, home = admission_env
core = tmp_path / "core"
for name, requires in (("fits", ">=3.11"), ("too-old", f"<{sys.version_info[0]}.{sys.version_info[1]}")):
plugin = home / "plugins" / name
plugin.mkdir(parents=True)
(plugin / "pyproject.toml").write_text(
f'[project]\nname="{name}"\nversion="1"\nrequires-python="{requires}"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
(home / "config.yaml").write_text("# operator note\nplugins:\n enabled: [fits, too-old]\n", encoding="utf-8")
profile = home / "profiles" / "work"
_write_enabled(profile, [], provider="too-old")
(profile / "plugins" / "too-old").mkdir(parents=True)
(profile / "plugins" / "too-old" / "pyproject.toml").write_bytes(
(home / "plugins" / "too-old" / "pyproject.toml").read_bytes().replace(b'name="too-old"', b'name="too-old-work"'))
with pytest.raises(InstallError, match="Python requirement"):
sync_venv(explicit=True) # admission semantics stay: an ordinary sync refuses
sync_venv(explicit=True, evict_incompatible_plugins=True)
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
assert selected_venv(core).is_dir()
assert "fits" in (workspace / "pyproject.toml").read_text()
assert "too-old" not in (workspace / "pyproject.toml").read_text()
text = (home / "config.yaml").read_text(encoding="utf-8")
assert "# operator note" in text
assert "too-old" in yaml.safe_load(text)["plugins"]["disabled"]
assert yaml.safe_load((profile / "config.yaml").read_text(encoding="utf-8"))["memory"]["provider"] == ""
assert "too-old" in json.dumps(_latest_receipt(home).get("warnings"))
assert venv_is_current(project_root=core) is True
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_update_sync_disables_later_plugin_of_unresolvable_union(admission_env):
"""Core moved under two admitted plugins that no longer co-resolve: the update keeps the
first in config order, disables the one that breaks the build, and completes."""
from pm.install import sync_venv, venv_is_current
tmp_path, home = admission_env
_local_conflict_members(home)
_write_enabled(home, ["plug-a", "plug-b"])
sync_venv(explicit=True, evict_incompatible_plugins=True)
cfg = yaml.safe_load((home / "config.yaml").read_text(encoding="utf-8"))
assert cfg["plugins"]["disabled"] == ["plug-b"]
assert venv_is_current(project_root=tmp_path / "core") is True
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_update_sync_survives_unreadable_secondary_profile(admission_env):
"""A secondary profile's broken config.yaml cannot fail an update: its plugins sit out
(reported), the rest build, and the next boot sees a current venv."""
from pm.environments import runtime_facts_path
from pm.install import sync_venv, venv_is_current
from pm.lock import Facts
tmp_path, home = admission_env
core = tmp_path / "core"
member = home / "plugins" / "primary-dep"
member.mkdir(parents=True)
(member / "pyproject.toml").write_text(
'[project]\nname="primary-dep"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
_write_enabled(home, ["primary-dep"])
broken = home / "profiles" / "work" / "config.yaml"
broken.parent.mkdir(parents=True)
broken.write_text("plugins: [broken]\n", encoding="utf-8")
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True) # an ordinary sync still refuses to shrink the graph
sync_venv(explicit=True, evict_incompatible_plugins=True)
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
assert "primary-dep" in (workspace / "pyproject.toml").read_text()
assert broken.read_text(encoding="utf-8") == "plugins: [broken]\n"
assert str(broken.parent) in json.dumps(_latest_receipt(home).get("warnings"))
assert venv_is_current(project_root=core) is True
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_plugin_our_version_rejects_sits_out_without_being_disabled(admission_env, monkeypatch):
"""requires_hermes is judged against our version identity, which can lag (an untagged
source checkout reads as an older release). Such a plugin sits out: config untouched,
boot's currency check neither raises nor loops, and it rejoins once the verdict flips."""
from pm.environments import runtime_facts_path
from pm.install import sync_venv, venv_is_current
from pm.lock import Facts
import hermes_cli.plugins_manifest as plugins_manifest
tmp_path, home = admission_env
core = tmp_path / "core"
# A tagless checkout (CI's) has no parseable version, which makes requires_hermes permissive.
monkeypatch.setattr(plugins_manifest, "running_hermes_version", lambda: "1.0.0")
for name in ("fits", "needs-newer"):
plugin = home / "plugins" / name
plugin.mkdir(parents=True)
(plugin / "pyproject.toml").write_text(
f'[project]\nname="{name}"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
manifest = home / "plugins" / "needs-newer" / "plugin.yaml"
manifest.write_text("name: needs-newer\nrequires_hermes: '>=999'\n", encoding="utf-8")
_write_enabled(home, ["fits", "needs-newer"])
before = (home / "config.yaml").read_bytes()
assert venv_is_current(project_root=core) is False # boot probes it before any sync
sync_venv(explicit=True, evict_incompatible_plugins=True)
assert (home / "config.yaml").read_bytes() == before
assert "Left plugin 'needs-newer'" in json.dumps(_latest_receipt(home).get("warnings"))
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
assert "needs-newer" not in (workspace / "pyproject.toml").read_text()
assert venv_is_current(project_root=core) is True
manifest.write_text("name: needs-newer\nrequires_hermes: '>=0'\n", encoding="utf-8")
assert venv_is_current(project_root=core) is False
sync_venv(explicit=True, evict_incompatible_plugins=True)
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
assert "needs-newer" in (workspace / "pyproject.toml").read_text()
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_update_sync_retries_a_fetch_failure_once_before_disabling(admission_env, monkeypatch):
"""Build evidence disables a plugin at once. A fetch failure could be the moment, so it
gets one more try first. Either way the recorded state is the one boot expects."""
from pm.install import sync_venv, venv_is_current
from pm.packages import Venv
trials: list[str] = []
real_apply = Venv.apply
def counting_apply(self, extras, *, plugin_dirs=None, **kwargs):
if plugin_dirs:
trials.append(Path(plugin_dirs[-1]).name)
return real_apply(self, extras, plugin_dirs=plugin_dirs, **kwargs)
monkeypatch.setattr(Venv, "apply", counting_apply)
tmp_path, home = admission_env
monkeypatch.setenv("UV_HTTP_RETRIES", "0")
broken = tmp_path / "broken-lib"
broken.mkdir()
(broken / "pyproject.toml").write_text(
'[project]\nname="broken-lib"\ndynamic=["version"]\n'
'[build-system]\nrequires=[]\nbuild-backend="backend"\nbackend-path=["."]\n', encoding="utf-8")
(broken / "backend.py").write_text(
"def get_requires_for_build_wheel(config=None): return []\n"
"def prepare_metadata_for_build_wheel(directory, config=None): raise SystemExit('no build')\n"
"def build_wheel(directory, config=None, metadata=None): raise SystemExit('no build')\n", encoding="utf-8")
# Plugin requirements are index-only; local and remote inputs come through tool.uv.sources.
for name, dependency, source in (("wont-build", "broken-lib", f'{{ path = "{broken.as_posix()}" }}'),
("offline-dep", "gone", '{ url = "https://127.0.0.1:9/gone-1.0-py3-none-any.whl" }')):
plugin = home / "plugins" / name
plugin.mkdir(parents=True)
(plugin / "pyproject.toml").write_text(
f'[project]\nname="{name}"\nversion="1"\nrequires-python=">=3.11"\n'
f'dependencies=["{dependency}"]\n[tool.uv]\npackage=false\n'
f'[tool.uv.sources]\n{dependency} = {source}\n', encoding="utf-8",
)
_write_enabled(home, ["wont-build", "offline-dep"])
sync_venv(explicit=True, evict_incompatible_plugins=True)
cfg = yaml.safe_load((home / "config.yaml").read_text(encoding="utf-8"))
assert cfg["plugins"]["disabled"] == ["wont-build", "offline-dep"]
# After the full selection's own attempt: build evidence once, the fetch failure twice.
assert trials[1:] == ["wont-build", "offline-dep", "offline-dep"]
assert "could not be prepared, twice" in json.dumps(_latest_receipt(home).get("warnings"))
assert venv_is_current(project_root=tmp_path / "core") is True
def test_active_context_home_exported_to_wrapper_subprocess(monkeypatch, tmp_path):
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tools.environments.local import build_subprocess_env
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "ambient"))
active = tmp_path / "custom-root/profiles/worker"
active.mkdir(parents=True)
token = set_hermes_home_override(active)
try:
child_env = build_subprocess_env()
child = subprocess.run(
[sys.executable, "-c", "import os; print(os.environ['HERMES_HOME'], end='')"],
env=child_env, capture_output=True, text=True, check=True, timeout=60,
)
assert child.stdout == str(active)
finally:
reset_hermes_home_override(token)