Files
hermes-agent/tests/pm/test_worker_publication.py
ethernet 9b4ed5f5ec refactor(pm): one plugin-input request for sync_venv and venv_is_current
sync_venv took four mutually exclusive plugin kwargs (plugin_dirs,
extra_plugin_dirs, selection, staged_plugin) and venv_is_current two, with
runtime ValueErrors guarding the combinations. Replace them with a single
`plugins=` argument typed as one of pm.plugin_inputs.Members, Candidates,
Selection or StagedUpdate, so a conflicting request cannot be expressed.
The module also owns the worker wire encoding that client.py and worker.py
each duplicated.

pm.install.sync_venv is split into cohesive helpers (feature policy,
install lock, publication snapshot, target selection, commit) with the
same ordering, receipts and recovery; its CC drops from 54 to 16.

All in-tree callers and tests move to the new argument.
2026-09-24 14:08:21 -04:00

432 lines
22 KiB
Python

"""Plugin publication runs in PM's independent worker, not application callbacks."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from pm.plugin_inputs import Candidates, Selection, StagedUpdate
from tests.pm.test_worker import client, isolated_python, _current_environment # noqa: F401
from tests.pm._fixtures import worker_toolchain
def test_worker_publishes_selection_even_when_dependencies_are_current(client, tmp_path, monkeypatch):
from pm.environments import install_state_dir
from pm import receipt
home = tmp_path / "home"
home.mkdir()
config = home / "config.yaml"
config.write_text('# keep my comment\nmodel: "untouched"\nplugins:\n enabled: [old]\n')
repo = _current_environment(tmp_path, monkeypatch, [])
facts = (install_state_dir(repo) / "facts.json").read_bytes()
with receipt.worker_context("selection-publication"):
client.sync_venv(explicit=True, plugins=Selection({
"home": str(home), "enabled": ["plain"], "disabled": ["old"], "extra_dirs": [],
}))
result = receipt.last_for_update("selection-publication", consume=True)
from utils import fast_safe_load
assert fast_safe_load(config.read_text())["plugins"] == {"enabled": ["plain"], "disabled": ["old"]}
assert '# keep my comment' in config.read_text()
assert 'model: "untouched"' in config.read_text()
assert (install_state_dir(repo) / "facts.json").read_bytes() == facts
assert not (install_state_dir(repo) / "publication.json").exists()
assert result is not None
assert result["outcome"] == "ok"
assert result["venv_rebuild"]["ok"] is False
assert json.loads((home / "logs/update_receipts/latest.json").read_text())["outcome"] == "ok"
@pytest.mark.parametrize("active", [False, True])
@pytest.mark.parametrize("missing", [False, True], ids=["existing", "missing"])
def test_staged_plugin_publication_uses_installed_identity_and_local_dependencies(
client, tmp_path, monkeypatch, isolated_python, active, missing,
):
from pm.environments import install_state_dir, selected_venv
from pm.store import tree_digest
from tests.pm.test_environment_build import _wheel
from pm import paths
worker_toolchain(client, monkeypatch, isolated_python)
project = tmp_path / "project"
project.mkdir()
monkeypatch.setattr(paths, "repo_root", lambda: project)
(project / "pyproject.toml").write_text(
'[project]\nname="publication-core"\nversion="1"\nrequires-python=">=3.11"\n'
'[tool.uv]\npackage=false\n')
client.lock_project(project, offline=True, explicit=True)
home = tmp_path / "home"
target = home / "plugins" / "example"
target.mkdir(parents=True)
(home / "config.yaml").write_text('plugins:\n enabled: ' + ('[example]' if active else '[]') + '\n')
(target / "plugin.yaml").write_text("name: example\n")
(target / "code.py").write_text('old code')
metadata = target.parent / ".install-metadata.json"
metadata.write_text('{"example":{"revision":"old"}}\n')
client.sync_venv(explicit=True)
previous = selected_venv(project)
if missing:
shutil.rmtree(target)
staged = tmp_path / "staged"
staged.mkdir()
wheel = _wheel(tmp_path, "publication_dep")
(staged / "plugin.yaml").write_text('name: example\npython_dependencies: ["publication-dep==1.0"]\n')
metadata_path = project / "pyproject.toml"
with metadata_path.open("a", encoding="utf-8") as stream:
stream.write(f'no-index=true\nfind-links=[{json.dumps(wheel.parent.as_posix())}]\n')
(staged / "code.py").write_text('new code')
client.sync_venv(explicit=True, plugins=StagedUpdate({
"staged": str(staged), "target": str(target), "target_digest": tree_digest(target) if target.exists() else None,
"old_metadata": {"example": {"revision": "old"}},
"new_metadata": {"example": {"revision": "new"}},
}))
assert (target / "code.py").read_text() == "new code"
assert not staged.exists()
assert json.loads(metadata.read_text())["example"]["revision"] == "new"
selected = selected_venv(project)
assert (selected != previous) is active
if active:
python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
probe = subprocess.run([str(python), "-I", "-c", "import publication_dep; print(publication_dep.__version__)"],
text=True, capture_output=True, timeout=30)
assert probe.returncode == 0, probe.stderr
assert probe.stdout.strip() == "1.0"
facts = (install_state_dir(project) / "facts.json").read_bytes()
client.sync_venv(explicit=True)
assert (install_state_dir(project) / "facts.json").read_bytes() == facts
assert not (install_state_dir(project) / "publication.json").exists()
assert not list(target.parent.glob(".previous-*"))
@pytest.mark.parametrize("mutation", ["sibling", "active"])
def test_selection_refuses_config_edits_during_preparation(client, tmp_path, monkeypatch, isolated_python, mutation):
from pm.environments import install_state_dir
home = tmp_path / "home"
home.mkdir()
config = home / "config.yaml"
config.write_text("plugins:\n enabled: [old]\n")
repo = _current_environment(tmp_path, monkeypatch, [])
(repo / "uv.lock").write_text("version = 2\n")
edited = config if mutation == "active" else home / "profiles/sibling/config.yaml"
expected = "plugins:\n enabled: [concurrent]\n"
facts = (install_state_dir(repo) / "facts.json").read_bytes()
injection = (
"from pm.packages import Venv\n"
"def apply(self, *args, **kwargs):\n"
f" p=Path({str(edited)!r}); p.parent.mkdir(parents=True, exist_ok=True); p.write_text({expected!r})\n"
" return {}\n"
"Venv.apply=apply\n"
)
worker_toolchain(client, monkeypatch, isolated_python, injection)
with pytest.raises(ValueError, match="changed"):
client.sync_venv(explicit=True, plugins=Selection({"home": str(home), "enabled": ["new"], "disabled": []}))
assert edited.read_text() == expected
assert (install_state_dir(repo) / "facts.json").read_bytes() == facts
assert not (install_state_dir(repo) / "publication.json").exists()
@pytest.mark.parametrize("invalid", ["name: [", "manifest_version: 999", "requires_hermes: '>=999'", "name: other"])
def test_worker_rejects_unloadable_staged_plugin_without_app_dependencies(client, tmp_path, monkeypatch, invalid):
from pm.store import tree_digest
repo = _current_environment(tmp_path, monkeypatch, [])
# The install stamp is the running version identity; without a release
# base (a tagless checkout) the requires_hermes gate is permissive.
monkeypatch.delenv("HERMES_INSTALL_ROOT", raising=False)
(repo / "install-stamp.json").write_text(json.dumps({
"commit": "1" * 40, "updateMechanism": "self", "baseVersion": "1.0.0", "source": "local",
}), encoding="utf-8")
target = tmp_path / "home/plugins/example"
target.mkdir(parents=True)
(target / "plugin.yaml").write_text("name: example\n")
staged = tmp_path / "staged"
staged.mkdir()
(staged / "plugin.yaml").write_text(invalid)
previous = tree_digest(target)
with pytest.raises(ValueError):
client.sync_venv(explicit=True, plugins=StagedUpdate({
"target": str(target), "staged": str(staged), "target_digest": previous,
"old_metadata": {}, "new_metadata": {"example": {"revision": "new"}},
}))
assert tree_digest(target) == previous
assert staged.exists()
def test_additional_candidates_are_discovered_by_sync_and_passive_probe(client, tmp_path, monkeypatch):
candidate = tmp_path / "candidate"
candidate.mkdir()
(candidate / "plugin.yaml").write_text("name: candidate\npython_dependencies: [fixture-dep==1]\n")
_current_environment(tmp_path, monkeypatch, [candidate])
assert client.venv_is_current(plugins=Candidates([candidate]))
assert not client.venv_is_current()
client.sync_venv(explicit=True, plugins=Candidates([candidate]))
assert client.venv_is_current(plugins=Candidates([candidate]))
def test_memory_setup_sends_candidate_paths_instead_of_discovery_callbacks(tmp_path, monkeypatch):
from hermes_cli.memory_setup import memory_provider_dependency_inputs
candidate = tmp_path / "provider"
candidate.mkdir()
(candidate / "plugin.yaml").write_text("name: provider\npython_dependencies: [fixture-dep==1]\n")
monkeypatch.setattr("plugins.memory.find_provider_dir", lambda name: candidate)
_, inputs = memory_provider_dependency_inputs("provider")
assert inputs == {"extras": [], "plugins": Candidates([candidate])}
@pytest.mark.parametrize(("kind", "rebuild", "phase"), [
(kind, rebuild, phase)
for kind in ("selection", "tree") for rebuild in (False, True) for phase in ("journal", "payload", "commit")
] + [("tree", rebuild, phase) for rebuild in (False, True) for phase in ("backup", "tree")])
def test_worker_death_recovers_at_each_durable_publication_boundary(
client, tmp_path, monkeypatch, isolated_python, kind, rebuild, phase,
):
from pm.environments import install_state_dir, selected_venv
from pm import paths
from pm.package import InstallError
from pm.store import tree_digest
worker_toolchain(client, monkeypatch, isolated_python)
project = tmp_path / "project"
project.mkdir()
monkeypatch.setattr(paths, "repo_root", lambda: project)
core = '[project]\nname="death-proof"\nversion="1"\nrequires-python=">=3.11"\n[tool.uv]\npackage=false\n'
(project / "pyproject.toml").write_text(core)
client.lock_project(project, offline=True, explicit=True)
home = tmp_path / "home"
target = home / "plugins/example"
target.mkdir(parents=True)
(target / "plugin.yaml").write_text("name: example\n")
(target / "code.py").write_text("old")
config = home / "config.yaml"
config.write_text("# exact bytes\nplugins:\n enabled: " + ("[example]" if kind == "tree" else "[]") + "\n")
metadata = target.parent / ".install-metadata.json"
metadata.write_text('{"example":{"revision":"old"}}\n')
client.sync_venv(explicit=True)
before_env = selected_venv(project)
state = install_state_dir(project)
facts = state / "facts.json"
before = {p: p.read_bytes() for p in (config, metadata, facts)}
before_tree = tree_digest(target)
if kind == "selection":
if rebuild:
(target / "pyproject.toml").write_text(core.replace("death-proof", "example"))
arguments = {"plugins": Selection({"home": str(home), "enabled": ["example"], "disabled": []})}
payload = config
else:
staged = tmp_path / "staged"
shutil.copytree(target, staged)
(staged / "code.py").write_text("new")
if rebuild:
(staged / "pyproject.toml").write_text(core.replace("death-proof", "example"))
arguments = {"plugins": StagedUpdate({
"target": str(target), "staged": str(staged), "target_digest": before_tree,
"old_metadata": {"example": {"revision": "old"}}, "new_metadata": {"example": {"revision": "new"}}})}
payload = metadata
# Exit immediately after the real durable write, not a simulated publication.
injection = (
"import json\nimport pm.publication as publication\nimport hermes_cli.runtime_state as state\n"
"original = state._atomic_bytes\n"
"def write(path, data):\n original(path, data)\n"
f" if {phase!r} == 'journal' and path.name == 'publication.json': os._exit(17)\n"
f" if {phase!r} == 'payload' and path == Path({str(payload)!r}): os._exit(17)\n"
f" if {phase!r} == 'commit' and path.name == 'publication.json' and json.loads(data).get('committed'): os._exit(17)\n"
"publication.durable_write_bytes = state._atomic_bytes = write\n"
"original_replace = os.replace\ndef replace(source, target):\n original_replace(source, target)\n"
f" if {phase!r} == 'backup' and Path(target).name.startswith('.previous-'): os._exit(17)\n"
f" if {phase!r} == 'tree' and Path(target) == Path({str(target)!r}): os._exit(17)\n"
"os.replace = replace\n"
)
if phase == "commit" and rebuild:
injection += ("from pm.lock import Facts\nrecord = Facts.record_state\n"
"def commit(self, *args, **kwargs):\n record(self, *args, **kwargs)\n os._exit(17)\n"
"Facts.record_state = commit\n")
worker_toolchain(client, monkeypatch, isolated_python, injection)
with pytest.raises(InstallError, match="worker exited without a result"):
client.sync_venv(explicit=True, **arguments)
assert (state / "publication.json").exists()
source = Path(client.__file__).resolve().parent.parent
program = (f"import sys; sys.path.insert(0, {str(source)!r}); from pathlib import Path; "
"from pm.environments import activate_dependencies; "
f"project = Path({str(project)!r})\n"
"activate_dependencies(project)\nactivate_dependencies(project)\n")
recovery = subprocess.run([sys.executable, "-I", "-S", "-c", program], capture_output=True, text=True,
env=dict(os.environ), timeout=30)
assert recovery.returncode == 0, recovery.stderr
committed = phase == "commit"
assert (selected_venv(project) != before_env) is (committed and rebuild)
if not committed:
assert {p: p.read_bytes() for p in before} == before
if kind == "tree":
assert (target / "code.py").read_text() == ("new" if committed else "old")
if not committed:
assert tree_digest(target) == before_tree
else:
from utils import fast_safe_load
assert fast_safe_load(config.read_text())["plugins"]["enabled"] == (["example"] if committed else [])
assert not (state / "publication.json").exists()
assert not list(target.parent.glob(".previous-*"))
@pytest.mark.parametrize("mutation", ["metadata", "target", "staged", "sibling-manifest"])
def test_staged_publication_refuses_concurrent_input_edits(client, tmp_path, monkeypatch, isolated_python, mutation):
from pm.environments import install_state_dir
from pm.store import tree_digest
repo = _current_environment(tmp_path, monkeypatch, [])
home = tmp_path / "home"
target = home / "plugins/example"
target.mkdir(parents=True)
(target / "plugin.yaml").write_text("name: example\n")
(target / "code.py").write_text("old code")
sibling = target.parent / "sibling"
sibling.mkdir()
(sibling / "plugin.yaml").write_text("name: sibling\npython_dependencies: [fixture-dep==1]\n")
(home / "config.yaml").write_text("plugins:\n enabled: [example, sibling]\n")
metadata = target.parent / ".install-metadata.json"
metadata.write_text("{}\n")
staged = tmp_path / "staged"
staged.mkdir()
(staged / "plugin.yaml").write_text("name: example\npython_dependencies: [fixture-dep==1]\n")
(staged / "code.py").write_text("new code")
edited = {"metadata": metadata, "target": target / "code.py", "staged": staged / "code.py",
"sibling-manifest": sibling / "plugin.yaml"}[mutation]
proposed = "name: sibling\nversion: changed\npython_dependencies: [fixture-dep==2]\n" if mutation == "sibling-manifest" else "concurrent edit"
worker_toolchain(client, monkeypatch, isolated_python,
"from pm.packages import Venv\n"
"def apply(*args, **kwargs):\n"
f" Path({str(edited)!r}).write_text({proposed!r})\n return {{}}\n"
"Venv.apply = apply\n")
facts = (install_state_dir(repo) / "facts.json").read_bytes()
with pytest.raises(ValueError, match="changed"):
client.sync_venv(explicit=True, plugins=StagedUpdate({
"target": str(target), "staged": str(staged), "target_digest": tree_digest(target),
"old_metadata": {}, "new_metadata": {"example": {"revision": "new"}},
}))
assert edited.read_text() == proposed
assert (target / "code.py").read_text() == (proposed if mutation == "target" else "old code")
assert (install_state_dir(repo) / "facts.json").read_bytes() == facts
assert not (install_state_dir(repo) / "publication.json").exists()
def test_staged_publication_preserves_a_concurrent_sibling_install_record(
client, tmp_path, monkeypatch, isolated_python,
):
from pm.store import tree_digest
_current_environment(tmp_path, monkeypatch, [])
home = tmp_path / "home"
target = home / "plugins/example"
target.mkdir(parents=True)
(target / "plugin.yaml").write_text("name: example\n")
(target / "code.py").write_text("old code")
(home / "config.yaml").write_text("plugins:\n enabled: [example]\n")
metadata = target.parent / ".install-metadata.json"
metadata.write_text('{"example":{"revision":"old"}}\n')
staged = tmp_path / "staged"
staged.mkdir()
(staged / "plugin.yaml").write_text("name: example\npython_dependencies: [fixture-dep==1]\n")
(staged / "code.py").write_text("new code")
concurrent = {"example": {"revision": "old"}, "sibling": {"revision": "sibling-new"}}
worker_toolchain(
client,
monkeypatch,
isolated_python,
"import json\nfrom pm.packages import Venv\n"
"def apply(*args, **kwargs):\n"
f" Path({str(metadata)!r}).write_text(json.dumps({concurrent!r}) + '\\n')\n"
" return {}\n"
"Venv.apply = apply\n",
)
client.sync_venv(explicit=True, plugins=StagedUpdate({
"target": str(target), "staged": str(staged), "target_digest": tree_digest(target),
"old_metadata": {"example": {"revision": "old"}},
"new_metadata": {"example": {"revision": "new"}},
}))
assert json.loads(metadata.read_text()) == {
"example": {"revision": "new"},
"sibling": {"revision": "sibling-new"},
}
assert (target / "code.py").read_text() == "new code"
def test_inactive_portable_publication_does_not_inspect_unrelated_dependency_manifests(client, tmp_path, monkeypatch):
from pm.store import tree_digest
_current_environment(tmp_path, monkeypatch, [])
home = tmp_path / "home"
target = home / "plugins/inactive"
target.mkdir(parents=True)
sibling = home / "plugins/sibling"
sibling.mkdir()
(sibling / "plugin.yaml").write_bytes(b"\xff")
(home / "config.yaml").write_text("plugins:\n enabled: [sibling]\n")
staged = tmp_path / "staged"
staged.mkdir()
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
(staged / "plugin.json").write_text(json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "inactive", "version": "1.0.0"}))
client.sync_venv(explicit=True, plugins=StagedUpdate({
"target": str(target), "staged": str(staged), "target_digest": tree_digest(target),
"old_metadata": {}, "new_metadata": {"inactive": {"revision": "new"}},
}))
assert json.loads((target / "plugin.json").read_text())["name"] == "inactive"
assert (sibling / "plugin.yaml").read_bytes() == b"\xff"
def test_selection_preserves_yaml11_values_and_quotes_plugin_names(client, tmp_path, monkeypatch):
import hermes_yaml
_current_environment(tmp_path, monkeypatch, [])
home = tmp_path / "home"
config = home / "config.yaml"
config.write_text('feature: yes\nother: no\nlabel: "on"\nplugins: {enabled: []}\n')
before = hermes_yaml.safe_load(config.read_bytes())
client.sync_venv(explicit=True, plugins=Selection({"home": str(home), "enabled": ["on", "yes", "no"], "disabled": []}))
after = hermes_yaml.safe_load(config.read_bytes())
assert {key: after[key] for key in ("feature", "other", "label")} == {key: before[key] for key in ("feature", "other", "label")}
assert set(after["plugins"]["enabled"]) == {"on", "yes", "no"}
assert 'label: "on"' in config.read_text()
def test_explicit_publication_keeps_its_intent_through_tool_acquisition(client, tmp_path, monkeypatch, isolated_python):
from pm import paths
from pm.environments import selected_venv
project = tmp_path / "project"
project.mkdir()
monkeypatch.setattr(paths, "repo_root", lambda: project)
(project / "pyproject.toml").write_text('[project]\nname="explicit-proof"\nversion="1"\nrequires-python=">=3.11"\n[tool.uv]\npackage=false\n')
worker_toolchain(client, monkeypatch, isolated_python)
client.lock_project(project, offline=True, explicit=True)
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
worker_toolchain(client, monkeypatch, isolated_python,
"from pm.package import InstallError\ntools = pm._uv._toolchain\n"
"def acquire(*, explicit=False, **kwargs):\n"
" if not explicit: raise InstallError('tools', 'explicit intent was lost')\n"
" return tools(explicit=explicit, **kwargs)\npm._uv._toolchain = acquire\n")
client.sync_venv(explicit=True, plugins=Selection({"home": str(tmp_path / "home"), "enabled": [], "disabled": []}))
assert (selected_venv(project) / "pyvenv.cfg").is_file()
def test_stale_enablement_cannot_replace_a_newer_selection(client, tmp_path, monkeypatch):
from hermes_cli import plugins_cmd as pc
from hermes_cli.plugins_admission import AdmissionRefused
from utils import fast_safe_load
_current_environment(tmp_path, monkeypatch, [])
config = tmp_path / "home/config.yaml"
config.write_text("plugins: {enabled: [], disabled: []}\n")
from pm import plugins_state
read = plugins_state.read_home_selection
def concurrent_commit(home):
stale = read(home)
config.write_text("plugins: {enabled: [first], disabled: []}\n")
return stale
monkeypatch.setattr(plugins_state, "read_home_selection", concurrent_commit)
with pytest.raises(AdmissionRefused, match="changed"):
pc._set_plugin_enabled("second", enable=True)
assert fast_safe_load(config.read_bytes())["plugins"]["enabled"] == ["first"]