Files
hermes-agent/tests/hermes_cli/test_plugin_worker_lifecycle.py

281 lines
15 KiB
Python

"""Command → real PM worker → offline wheels → persisted state → fresh imports."""
from __future__ import annotations
import pytest
from tests.hermes_cli.plugin_worker_support import (
plugin_world as plugin_world,
isolated_python as isolated_python,
)
@pytest.mark.parametrize("surface,suffix", [
("pyproject", "yaml"), ("python_dependencies", "yaml"), ("pip_dependencies", "yaml"),
("python_dependencies", "yml"), ("pip_dependencies", "yml"),
])
def test_declaration_consent_admission_and_resync(plugin_world, monkeypatch, capsys, surface, suffix):
from hermes_cli import plugins_cmd
from pm import client, receipt
world = plugin_world
origin, revision = world.origin(surface=surface, suffix=suffix)
before = (world.home / "config.yaml").read_bytes()
if (surface, suffix) == ("pyproject", "yaml"):
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: False)
world.command("install", identifier=origin.as_uri(), ref=revision, enable=True, allow_removed=True)
assert world.enabled() == [], "non-interactive Python admission bypassed consent"
assert (world.home / "config.yaml").read_bytes() == before
assert "skipped (non-interactive)" in " ".join(capsys.readouterr().out.split())
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda prompt: "no")
world.command("install", identifier=origin.as_uri(), force=True, enable=True, allow_removed=True)
assert world.enabled() == []
assert (world.home / "config.yaml").read_bytes() == before
assert "declined" in capsys.readouterr().out
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
world.command("install", identifier=origin.as_uri(), force=True, enable=True, allow_removed=True)
assert world.enabled() == ["plugin-worker-proof"]
assert receipt.latest()["outcome"] == "ok"
world.imports()
selected = world.selected()
client.sync_venv(explicit=True)
assert world.selected() == selected
world.imports()
declaration = world.home / "plugins/plugin-worker-proof" / ("pyproject.toml" if surface == "pyproject" else f"plugin.{suffix}")
declaration.write_text(declaration.read_text(encoding="utf-8").replace("==1.0", "==2.0"), encoding="utf-8")
client.sync_venv(explicit=True)
assert world.selected() != selected, "changed declaration was not stamped"
# Code has not changed: inspect the new dependency independently.
import os
import subprocess
python = world.selected() / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
result = subprocess.run([str(python), "-I", "-c", "import plugin_proof_dep; assert plugin_proof_dep.__version__ == '2.0'"],
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
def test_active_force_reinstall_decline_preserves_entire_selection(plugin_world, monkeypatch):
from hermes_cli import plugins_cmd
from pm import paths
from tests.hermes_cli.plugin_worker_support import git, version
world = plugin_world
origin, first = world.origin()
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
answers = []
monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "yes")
world.command("install", identifier=origin.as_uri(), ref=first, enable=True, allow_removed=True)
world.imports()
target = world.home / "plugins/plugin-worker-proof"
watched = [world.home / "config.yaml", target / "__init__.py", target / "plugin.yaml",
target.parent / ".install-metadata.json", paths.runtime_facts_path()]
before = {path: path.read_bytes() for path in watched}
selected = world.selected()
second = version(origin, pin="2.0")
monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "no")
try:
world.command("install", identifier=origin.as_uri(), ref=second, force=True, enable=True, allow_removed=True)
except SystemExit as exc:
assert exc.code == 1
assert {path: path.read_bytes() for path in watched} == before
assert git(target, "rev-parse", "HEAD") == first
assert world.selected() == selected
world.imports()
answers.clear()
monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "yes")
world.command("install", identifier=origin.as_uri(), ref=second, force=True, enable=True, allow_removed=True)
assert len(answers) == 1, "active replacement asked for the same consent twice"
assert git(target, "rev-parse", "HEAD") == second
assert world.selected() != selected
world.imports(pin="2.0")
@pytest.mark.parametrize("flag", ["no_enable", "enable"])
def test_active_reinstall_does_not_overwrite_a_later_disable(plugin_world, monkeypatch, flag):
from hermes_cli import plugins_cmd
from pm import paths
from tests.hermes_cli.plugin_worker_support import version
world = plugin_world
origin, first = world.origin()
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
world.command("install", identifier=origin.as_uri(), ref=first, enable=True, allow_removed=True)
second = version(origin, pin="2.0")
watched = [world.home / "config.yaml", paths.runtime_facts_path()]
acknowledged = {}
def disable_after_publication(*args):
world.command("disable", name="plugin-worker-proof")
acknowledged.update({path: path.read_bytes() for path in watched})
monkeypatch.setattr(plugins_cmd, "_display_after_install", disable_after_publication)
world.command("install", identifier=origin.as_uri(), ref=second, force=True, allow_removed=True, **{flag: True})
assert acknowledged
assert {path: path.read_bytes() for path in watched} == acknowledged
assert world.enabled() == []
def test_malformed_portable_member_cannot_join_a_new_generation(plugin_world, monkeypatch):
import json
from hermes_cli import plugins_cmd
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
from pm import client, paths
from tests.hermes_cli.plugin_worker_support import git
world = plugin_world
origin, _ = world.origin(surface="pyproject")
(origin / "plugin.yaml").unlink()
(origin / "plugin.json").write_text(json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "plugin-worker-proof"}), encoding="utf-8")
git(origin, "add", "--all")
git(origin, "commit", "-qm", "portable declaration")
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
world.command("install", identifier=origin.as_uri(), enable=True, allow_removed=True)
world.imports()
watched = [world.home / "config.yaml", paths.runtime_facts_path()]
before = {path: path.read_bytes() for path in watched}
(world.home / "plugins/plugin-worker-proof/plugin.json").write_text("{malformed", encoding="utf-8")
with pytest.raises((ValueError, RuntimeError), match="plugin.json"):
client.sync_venv(explicit=True)
assert {path: path.read_bytes() for path in watched} == before
def test_pack_admits_compatible_members_and_reports_conflict(plugin_world, monkeypatch, capsys):
import json
import hermes_yaml as yaml
from hermes_cli import plugins_cmd
from tests.hermes_cli.plugin_worker_support import git, version
world = plugin_world
incumbent, first = world.origin()
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
world.command("install", identifier=incumbent.as_uri(), ref=first, enable=True, allow_removed=True)
world.imports()
good, good_sha = world.origin(name="pack-good", capabilities=["tools.override"])
bad, bad_sha = world.origin(name="pack-conflict", pin="2.0")
last, last_sha = world.origin(name="pack-last", capabilities=["tools.override"])
# Moving HEAD cannot move a pack's pinned selection.
version(good, name="pack-good", pin="2.0")
pack = world.root / "pack.yaml"
pack.write_text(yaml.safe_dump({"name": "proof-pack", "plugins": [
{"repo": repo.as_uri(), "ref": sha} for repo, sha in [(good, good_sha), (bad, bad_sha), (last, last_sha)]
], "config": {"pack-good": {"voice": "fixture"}}}), encoding="utf-8")
answers = iter(["yes", "yes", "no"])
monkeypatch.setattr("rich.console.Console.input", lambda self, prompt: next(answers))
with pytest.raises(SystemExit) as exc:
world.command("pack", pack_action="install", source=str(pack))
assert exc.value.code == 1
from rich.text import Text
assert "2 installed, 1 failed" in " ".join(Text.from_ansi(capsys.readouterr().out).plain.split())
assert set(world.enabled()) == {"plugin-worker-proof", "pack-good", "pack-last"}
for name, sha in [("pack-good", good_sha), ("pack-conflict", bad_sha), ("pack-last", last_sha)]:
assert git(world.home / "plugins" / name, "rev-parse", "HEAD") == sha
for name in ("plugin-worker-proof", "pack-good", "pack-last"):
world.imports(name)
config = yaml.safe_load((world.home / "config.yaml").read_text(encoding="utf-8"))
entries = config["plugins"]["entries"]
assert entries["pack-good"]["voice"] == "fixture"
assert "tools.override" in entries["pack-good"]["granted_capabilities"]
assert "tools.override" not in entries.get("pack-last", {}).get("granted_capabilities", [])
receipts = [json.loads(path.read_text()) for path in (world.home / "logs/update_receipts").glob("pm_*.json")]
assert any(row["outcome"] == "failed" and "plugin-proof-dep" in json.dumps(row) for row in receipts)
@pytest.mark.parametrize("sibling_profiles", [False, True], ids=["same-profile", "sibling-profiles"])
def test_concurrent_commands_keep_acknowledged_enables(plugin_world, sibling_profiles):
import queue
import threading
import os
from pathlib import Path
import shutil
import subprocess
import sys
import hermes_yaml as yaml
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
from tests.hermes_cli.plugin_worker_support import worker_command
world = plugin_world
homes = [world.home, world.home]
if sibling_profiles:
homes = [world.home / "profiles" / name for name in ("left", "right")]
for home in homes:
home.mkdir(parents=True)
(home / "config.yaml").write_text("plugins:\n enabled: []\n disabled: []\n", encoding="utf-8")
for name, home in zip(("race-left", "race-right"), homes):
origin, revision = world.origin(name=name, dependency="plugin-proof-dep" if name == "race-left" else "plugin-proof-other")
token = set_hermes_home_override(home)
try:
world.command("install", identifier=origin.as_uri(), ref=revision, no_enable=True, allow_removed=True)
finally:
reset_hermes_home_override(token)
untouched = (world.home / "config.yaml").read_bytes()
checkout = Path(__file__).resolve().parents[2]
command = worker_command(checkout / "pm/worker.py", shutil.which("uv"), sys.executable,
runtime_python=world.runtime_python)
processes = []
try:
for name, home in zip(("race-left", "race-right"), homes):
script = (
"import sys; from pathlib import Path; "
f"sys.path.insert(0, {str(checkout)!r}); from pm import client, paths; "
f"paths.repo_root = lambda: Path({str(world.core)!r}); "
f"paths.lockfile_path = lambda: Path({str(world.root / 'lock.json')!r})\n"
"def ready(worker, **kwargs):\n"
" print('READY', flush=True)\n"
" assert sys.stdin.readline().strip() == 'go'\n"
f" return {command!r}\n"
"client.runtime_command = ready\n"
"from hermes_cli.plugins_cmd import cmd_enable\n"
"from hermes_cli.plugins_admission import AdmissionRefused\n"
"try:\n"
f" cmd_enable({name!r}, allow_tool_override=False)\n"
"except AdmissionRefused as exc:\n"
" assert 'configuration changed since this selection was read' in str(exc)\n"
" raise SystemExit(75)\n"
)
processes.append(subprocess.Popen([sys.executable, "-I", "-B", "-c", script],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
env={**os.environ, "HERMES_HOME": str(home)}))
# Both commands have reached the worker launch (after the old stale
# read). Release one all the way through publication, then the other.
ready = queue.Queue()
for process in processes:
threading.Thread(target=lambda proc=process: ready.put(proc.stdout.readline().strip()), daemon=True).start()
assert [ready.get(timeout=30) for _ in processes] == ["READY", "READY"]
for index, process in enumerate(processes):
out, err = process.communicate("go\n", timeout=60)
expected = 75 if index == 1 and not sibling_profiles else 0
assert process.returncode == expected, out + err
if not sibling_profiles:
# The stale second request is refused, not acknowledged. An explicit
# retry reads the first successful enable and extends it.
assert world.enabled() == ["race-left"]
world.command("enable", name="race-right", no_allow_tool_override=True)
if sibling_profiles:
assert (world.home / "config.yaml").read_bytes() == untouched
for name, home in zip(("race-left", "race-right"), homes):
assert yaml.safe_load((home / "config.yaml").read_text())["plugins"]["enabled"] == [name]
else:
assert set(world.enabled()) == {"race-left", "race-right"}, "acknowledged enable was lost"
for name, home in zip(("race-left", "race-right"), homes):
original = world.home
world.home = home
try:
world.imports(name)
finally:
world.home = original
finally:
for process in processes:
if process.poll() is None:
process.kill()
process.communicate(timeout=10)