Files
hermes-agent/tests/hermes_cli/test_update_launch_completion.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

294 lines
13 KiB
Python

"""A fresh launch finishes a source update using PM's success record, not a marker."""
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import pytest
from hermes_cli import venv_sync
from pm.environments import runtime_facts_path
@pytest.fixture(autouse=True)
def _no_tool_downloads(monkeypatch):
"""The launch sync publishes lockfile tools first; these tests cover the sync decision."""
import pm.client
monkeypatch.setattr(pm.client, "ensure_tools_for_sync", lambda: None)
@pytest.fixture
def completion_tail(monkeypatch):
"""Record the source-completion child prepare_launch spawns after a sync instead of running it.
The real child is ``hermes_cli/source_completion.py`` from the checkout under test — a
scratch tree here — building products with the selected interpreter; the tests below
cover the sync decision, not the build.
"""
class Spawned(list):
exit_code = 0
kwargs: dict = {}
spawned = Spawned()
def call(command, **kwargs):
spawned.append(command)
spawned.kwargs = kwargs
return spawned.exit_code
monkeypatch.setattr(venv_sync.subprocess, "call", call)
return spawned
def _self_checkout(tmp_path, monkeypatch):
root = tmp_path / "checkout"
root.mkdir()
(root / ".git").mkdir()
(root / "pyproject.toml").write_text("[project]\nname='example'\n")
(root / "install-stamp.json").write_text(json.dumps({"updateMechanism": "self"}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
return root
@pytest.mark.parametrize("argv", [["--version"], ["-V"], ["--help"], ["-p", "work", "-h"]])
def test_metadata_query_never_waits_on_source_completion(tmp_path, monkeypatch, argv):
"""`hermes --version` offline must answer from the tree, not run a network-bound sync."""
import pm
root = _self_checkout(tmp_path, monkeypatch)
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: pytest.fail("metadata query reached PM"))
assert venv_sync.prepare_launch(root, argv) is None
def test_failed_completion_tail_is_retried_without_rebuilding_dependencies(tmp_path, monkeypatch, completion_tail):
"""Dependencies committed, tail failed: the next launch owes the tail only."""
import pm
from hermes_cli import _launchers
root = _self_checkout(tmp_path, monkeypatch)
fact = runtime_facts_path(root)
syncs = []
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: fact.is_file())
monkeypatch.setattr(_launchers, "resolve_store_python", lambda _: Path(sys.executable))
def sync(extras=None, **kwargs):
syncs.append(extras)
fact.parent.mkdir(parents=True, exist_ok=True)
fact.write_text(json.dumps({"packages": {"venv": {"stamp": "complete", "extras": ["all"]}}}))
monkeypatch.setattr(pm, "sync_venv", sync)
completion_tail.exit_code = 1
with pytest.raises(RuntimeError, match="run `hermes update`"):
venv_sync.prepare_launch(root, [])
assert len(syncs) == 1 and len(completion_tail) == 1
assert pm.venv_is_current()
with pytest.raises(RuntimeError, match="run `hermes update`"):
venv_sync.prepare_launch(root, [])
assert len(syncs) == 1, "current dependencies were rebuilt for a tail retry"
assert len(completion_tail) == 2
completion_tail.exit_code = 0
# Dependencies are already this interpreter's: the tail alone owes no re-exec.
assert venv_sync.prepare_launch(root, []) is None
assert len(syncs) == 1 and len(completion_tail) == 3
assert venv_sync.prepare_launch(root, []) is None
assert len(completion_tail) == 3, "a finished tail was run again"
def test_completion_tail_output_stays_off_stdout(tmp_path, monkeypatch, completion_tail):
"""The automatic tail runs in front of the user's command, which may be piping JSON."""
import pm
from hermes_cli import _launchers
root = _self_checkout(tmp_path, monkeypatch)
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: False)
monkeypatch.setattr(pm, "sync_venv", lambda *a, **kw: None)
monkeypatch.setattr(_launchers, "resolve_store_python", lambda _: Path(sys.executable))
venv_sync.prepare_launch(root, [])
assert completion_tail.kwargs["stdout"] is sys.__stderr__
def test_first_launch_syncs_without_marker_then_uses_completion_fact(tmp_path, monkeypatch, completion_tail):
import pm
from hermes_cli import _launchers
root = tmp_path / "checkout"
root.mkdir()
(root / ".git").mkdir()
(root / "pyproject.toml").write_text("[project]\nname='example'\n")
(root / "uv.lock").write_text("lock\n")
(root / "install-stamp.json").write_text(json.dumps({"updateMechanism": "self"}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
fact = runtime_facts_path(root)
calls = []
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: fact.is_file())
monkeypatch.setattr(_launchers, "resolve_store_python", lambda _: Path(sys.executable))
def sync(extras=None, **kwargs):
calls.append((extras, kwargs))
fact.parent.mkdir(parents=True, exist_ok=True)
fact.write_text(json.dumps({"packages": {"venv": {"stamp": "complete", "extras": ["all"]}}}))
monkeypatch.setattr(pm, "sync_venv", sync)
# A shipped updater may have written this before it reaches an inert shim.
# It is obsolete after successful sync, not the trigger for that sync.
(root / ".update-incomplete").write_text("pid=-1\n")
assert venv_sync.prepare_launch(root, []) == Path(sys.executable)
assert calls == [(["all"], {"explicit": True, "project_root": root, "evict_incompatible_plugins": True})]
assert not (root / ".update-incomplete").exists()
assert any("source_completion.py" in str(part) for cmd in completion_tail for part in cmd)
assert venv_sync.prepare_launch(root, []) is None
assert len(calls) == 1
@pytest.mark.parametrize("mode", ["script", "module", "command"])
def test_relaunch_keeps_invocation_and_checkout_imports(tmp_path, mode):
root = tmp_path / "source"
root.mkdir()
(root / "checkout_only.py").write_text("value = 'from checkout'\n")
script = root / "entry.py"
script.write_text("import checkout_only, json, sys\nprint(json.dumps([checkout_only.value, sys.argv[1:]]))\n")
argv = [str(script), "--profile", "name with spaces", "-c", "session"]
orig = [sys.executable, *argv]
module = None
if mode == "module":
module = "entry"
orig = [sys.executable, "-m", module, *argv[1:]]
elif mode == "command":
argv[0] = "-c"
orig = [sys.executable, "-c", "import entry", *argv[1:]]
command = venv_sync.relaunch_command(Path(sys.executable), root, argv, orig, module)
result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == ["from checkout", argv[1:]]
@pytest.mark.parametrize("owner,argv", [(None, []), ("external", []), ("electron-updater", []), ("self", ["-p", "coder", "pm", "repair"])])
def test_non_self_or_pm_launch_cannot_trigger_update(tmp_path, monkeypatch, owner, argv):
import pm
root = tmp_path / "checkout"
root.mkdir()
(root / ".git").mkdir()
(root / "pyproject.toml").write_text("[project]\n")
if owner:
(root / "install-stamp.json").write_text(json.dumps({"updateMechanism": owner}))
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: pytest.fail("unowned launch reached PM"))
assert venv_sync.prepare_launch(root, argv) is None
def test_failed_launch_keeps_previous_completion_and_retries(tmp_path, monkeypatch):
import pm
root = tmp_path / "checkout"
root.mkdir()
(root / ".git").mkdir()
(root / "pyproject.toml").write_text("[project]\n")
(root / "install-stamp.json").write_text(json.dumps({"updateMechanism": "self"}))
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
fact = runtime_facts_path(root)
fact.parent.mkdir(parents=True)
previous = '{"packages":{"venv":{"stamp":"previous","extras":["all","anthropic"]}}}'
fact.write_text(previous)
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: False)
calls = []
def fail(extras, **kwargs):
calls.append(extras)
raise RuntimeError("network unavailable")
monkeypatch.setattr(pm, "sync_venv", fail)
for _ in range(2):
with pytest.raises(RuntimeError, match="network unavailable"):
venv_sync.prepare_launch(root, [])
assert fact.read_text() == previous
assert calls == [None, None]
assert not (root / ".update-incomplete").exists()
def test_blessed_legacy_install_is_adopted_before_sync(tmp_path, monkeypatch, completion_tail):
import pm
from hermes_cli import _launchers
monkeypatch.setattr(Path, "home", lambda: tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
root = home / "hermes-agent"
root.mkdir(parents=True)
(root / ".git").mkdir()
(root / "pyproject.toml").write_text("[project]\n")
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: False)
calls = []
monkeypatch.setattr(pm, "sync_venv", lambda *args, **kw: calls.append(args))
monkeypatch.setattr(_launchers, "resolve_store_python", lambda _: Path(sys.executable))
assert venv_sync.prepare_launch(root, []) == Path(sys.executable)
assert json.loads((root / "install-stamp.json").read_text())["source"] == "adoption"
assert calls == [(["all"],)]
def test_relaunch_runs_zip_launchers_and_preserves_interpreter_options(tmp_path):
import zipfile
launcher = tmp_path / "hermes.exe"
with zipfile.ZipFile(launcher, "w") as archive:
archive.writestr("__main__.py", "import json,sys; print(json.dumps([sys.argv[1:], sys.stdout.write_through, sys.flags.utf8_mode]))")
original = [sys.executable, "-u", "-X", "utf8", str(launcher), "arg with spaces"]
command = venv_sync.relaunch_command(Path(sys.executable), tmp_path, [str(launcher), "arg with spaces"], original, "__main__")
result = subprocess.run(command, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [["arg with spaces"], True, 1]
def test_live_old_update_blocks_launch_sync(tmp_path, monkeypatch):
import pm
root = tmp_path / "checkout"
root.mkdir()
(root / ".git").mkdir()
(root / "pyproject.toml").write_text("[project]\n")
(root / "install-stamp.json").write_text(json.dumps({"updateMechanism": "self"}))
marker = root / ".update-incomplete"
marker.write_text(f"pid={os.getpid()}\n")
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: False)
monkeypatch.setattr(pm, "sync_venv", lambda *a, **kw: pytest.fail("raced old updater"))
with pytest.raises(RuntimeError, match="still running"):
venv_sync.prepare_launch(root, [])
assert marker.is_file()
# Fresh post-sync verification children may boot under a live updater.
from hermes_cli import _launchers
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: True)
monkeypatch.setattr(_launchers, "resolve_store_python", lambda _: Path(sys.executable))
assert venv_sync.prepare_launch(root, []) is None
assert marker.is_file()
def test_launch_under_the_owning_update_does_not_run_the_tail_again(tmp_path, monkeypatch, completion_tail):
"""The tail imports the application, whose entry point runs prepare_launch: inside the
process tree of the update that owns the pending tail it must be a no-op, not recurse."""
import time
import pm
from hermes_cli.update_lock import update_marker_path
root = _self_checkout(tmp_path, monkeypatch)
pending = venv_sync.completion_pending_path(root)
pending.parent.mkdir(parents=True)
pending.write_text("owed\n")
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: True)
marker = update_marker_path()
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(f"{os.getppid()}\n{int(time.time())}\n") # an ancestor holds the update
assert venv_sync.prepare_launch(root, []) is None
assert completion_tail == []
assert pending.is_file(), "the owning update's obligation was discharged by its own tail"