Files
hermes-agent/tests/hermes_cli/test_venv_sync.py
ethernet ccf631701a fix(pm): disable plugins that no longer fit instead of failing the update
An update resolves the enabled plugin union against the NEW core. A plugin
admitted against the old core can stop fitting when core moves (managed
Python 3.13 -> 3.14 vs a member's requires-python <3.14, a requires_hermes
upper bound, a bumped pin), and the whole update then died after the
source swap with a non-resolver InstallError whose 'retry' hint failed the
same way every time.

Update syncs now pass evict_incompatible_plugins=True (update completion,
historical takeover, launch-time completion, venv_sync, post-update
drift). PM screens statically first (requires-python vs the target
interpreter, manifest/requires_hermes), then, if the rest still fails,
builds core alone to prove the plugins are the cause and re-adds members
in config order, disabling each one that breaks the build. Misfits land in
plugins.disabled (memory.provider cleared) in every home that enables
them, published through the existing journaled change hook (the journal
now carries several configs), and are reported on stderr + receipt
warnings. Admission and ordinary syncs still refuse; only a core that
cannot build on its own fails an update.
2026-09-25 00:00:09 -04:00

180 lines
6.1 KiB
Python

"""venv_sync must work on trees where the venv does not exist yet.
It is the stdlib-only-at-import pre-venv entry point: the installers call
it on a fresh clone before any dependency is importable, and post_update
calls it after a tree swap when the venv is not trustworthy. Its
behaviour is driven through PM's public client; package resolution and
publication belong to PM, not this CLI entry point.
"""
from __future__ import annotations
import json
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
from hermes_cli import venv_sync
REPO_ROOT = Path(__file__).resolve().parents[2]
def _run_bare(snippet: str) -> subprocess.CompletedProcess:
program = f"import sys\nsys.path.insert(0, {str(REPO_ROOT)!r})\n" + textwrap.dedent(snippet)
return subprocess.run([sys.executable, '-I', '-S', '-c', program],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=120)
def test_bare_import_and_passive_paths(tmp_path):
result = _run_bare(f"""
from pathlib import Path
from hermes_cli import venv_sync
assert 'pm' not in sys.modules
root = Path({str(tmp_path)!r})
assert venv_sync.sync(root, check=True)['state'] == 'failed'
(root / 'install-stamp.json').write_text('{{"updateMechanism":"external"}}')
assert venv_sync.sync(root) == {{'state': 'sealed', 'ok': True}}
assert venv_sync.prepare_launch(root, []) is None
assert 'pm.install' not in sys.modules
""")
assert result.returncode == 0, result.stderr
def test_bare_harness_rejects_third_party_import():
result = _run_bare('import requests')
assert result.returncode != 0 and 'ModuleNotFoundError' in result.stderr
def _checkout(tmp_path: Path, name: str = "co") -> Path:
root = tmp_path / name
(root / ".git").mkdir(parents=True)
(root / "pyproject.toml").write_text("[project]\nname='x'\n")
(root / "uv.lock").write_text("lock-v1\n")
return root
def _wire_pm(monkeypatch, *, current=False, error=None):
import pm
calls = []
monkeypatch.setattr(pm, "venv_is_current", lambda *, project_root: current)
def sync(*, explicit, project_root, evict_incompatible_plugins):
assert evict_incompatible_plugins, "an update sync must disable misfit plugins, not fail"
calls.append((project_root, explicit))
if error:
raise pm.InstallError("venv", error)
monkeypatch.setattr(pm, "sync_venv", sync)
return calls
class TestCheckoutSync:
@pytest.mark.parametrize("foreign", [False, True])
def test_each_root_uses_the_public_pm_transaction(self, tmp_path, monkeypatch, foreign):
root = _checkout(tmp_path)
monkeypatch.setattr(venv_sync, "_project_root", lambda: root)
calls = _wire_pm(monkeypatch)
assert venv_sync.sync(root if foreign else None) == {"state": "synced", "ok": True}
assert calls == [(root, True)]
def test_check_is_passive(self, tmp_path, monkeypatch):
root = _checkout(tmp_path)
calls = _wire_pm(monkeypatch)
before = set(tmp_path.rglob("*"))
assert venv_sync.sync(root, check=True) == {"state": "would-sync", "ok": True}
assert calls == []
assert set(tmp_path.rglob("*")) == before
def test_a_failed_sync_is_reported_and_retried(self, tmp_path, monkeypatch):
root = _checkout(tmp_path)
calls = _wire_pm(monkeypatch, error="resolution failed")
for _ in range(2):
out = venv_sync.sync(root)
assert out["state"] == "failed" and not out["ok"]
assert "resolution failed" in out["detail"]
assert calls == [(root, True), (root, True)]
class TestSealedTrees:
def test_a_sealed_tree_is_a_clean_noop(self, tmp_path, monkeypatch):
"""The desktop payload and nix bundle must not fail, must not sync."""
root = tmp_path / "sealed"
root.mkdir()
(root / "install-stamp.json").write_text(
json.dumps({"commit": "abc123", "payload": "full", "updateMechanism": "electron-updater"})
)
calls = _wire_pm(monkeypatch)
out = venv_sync.sync(root)
assert out == {"state": "sealed", "ok": True}
assert calls == []
def test_a_dev_tree_with_both_stamp_and_git_is_a_checkout(
self, tmp_path, monkeypatch
):
root = _checkout(tmp_path)
(root / "install-stamp.json").write_text(
json.dumps({"commit": "abc", "updateMechanism": "electron-updater"})
)
_wire_pm(monkeypatch)
assert venv_sync.sync(root)["state"] == "synced"
def test_a_stamp_without_update_mechanism_is_a_build_lane_bug(self, tmp_path):
root = tmp_path / "sealed"
root.mkdir()
(root / "install-stamp.json").write_text(json.dumps({"commit": "abc"}))
with pytest.raises(RuntimeError, match="updateMechanism"):
venv_sync.sync(root)
class TestCliContract:
def test_json_output_and_exit_codes(self, tmp_path):
"""post_update and the installers read exactly this."""
root = tmp_path / "sealed"
root.mkdir()
(root / "install-stamp.json").write_text(
json.dumps({"commit": "x", "updateMechanism": "electron-updater"})
)
proc = subprocess.run(
[
sys.executable,
"-m",
"hermes_cli.venv_sync",
"--project-root",
str(root),
"--json",
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout) == {"state": "sealed", "ok": True}
def test_failure_exits_nonzero(self, tmp_path):
proc = subprocess.run(
[
sys.executable,
"-m",
"hermes_cli.venv_sync",
"--project-root",
str(tmp_path), # empty dir: no pyproject, no stamp
"--json",
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
assert proc.returncode == 1
assert json.loads(proc.stdout)["state"] == "failed"