Files
hermes-agent/tests/hermes_cli/test_venv_sync_currency.py
Matt Healey 24e8c341c1 fix(pm): prune recorded extras the tree no longer declares
The venv ledger unions its recorded extras into every sync. When a source
update removes an extra (hindsight, 73c598e319/285768fdfb), installs that
recorded it pass `--extra hindsight` to `uv sync` forever and fail with
"Extra `hindsight` is not defined in any project's optional-dependencies
table". Every CLI launch then retries the failed source-update completion,
and gateways keep booting the previous generation's code.

Drop recorded extras the checkout's pyproject no longer declares, in both
the sync target and the currency probe. An explicitly requested unknown
extra still fails loudly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:48:40 -04:00

134 lines
5.9 KiB
Python

"""Checkout freshness follows the selected PM generation, not a second stamp."""
import importlib
import json
import os
from pathlib import Path
import subprocess
import hermes_yaml as yaml
from hermes_cli import venv_sync
from pm.environments import install_state_dir, selected_venv
from pm import paths
from pm.lock import Lockfile
from tests.pm.test_plugin_survival_contract import admission_env # noqa: F401
def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env, monkeypatch, capsys):
monkeypatch.setattr("pm.client.is_runtime", lambda: True)
root, home = admission_env
core = root / 'core'
ensure = importlib.import_module('pm.install')
pin_path = root / 'pins.json'
pins = Lockfile(pin_path)
pins.set_pin('python', '1.0', {'any': {'url': 'https://example.invalid/python', 'sha256': 'a' * 64}})
pins.save()
monkeypatch.setattr(paths, 'lockfile_path', lambda: pin_path)
# A historical foreign-root stamp must not certify a PM environment.
old_stamp = core / ".hermes-runtime" / "cache" / "venv-sync.json"
old_stamp.parent.mkdir(parents=True)
old_stamp.write_text('{"lockDigest": "old-bootstrap-stamp"}')
cached = old_stamp.read_bytes()
def check(expected, code=0):
capsys.readouterr()
result = venv_sync.main(['--project-root', str(core), '--check', '--json'])
output = json.loads(capsys.readouterr().out)
assert result == code, output
assert output['state'] == expected, output
assert old_stamp.read_bytes() == cached
return output
check('would-sync')
ensure.sync_venv(explicit=True)
facts_path = paths.runtime_facts_path()
pristine = facts_path.read_bytes()
selected = selected_venv(core)
assert selected.is_relative_to(install_state_dir(core) / 'environments')
check('current')
assert not any(problem.startswith('venv:') for problem in ensure.check())
config = home / 'config.yaml'
old_config = config.read_bytes()
member = home / 'plugins' / 'extra-member'
member.mkdir(parents=True)
(member / 'plugin.yaml').write_text('name: extra-member\npython_dependencies: ["example-dep==1"]\n', encoding='utf-8')
config.write_text(yaml.safe_dump({'plugins': {'enabled': ['extra-member']}}), encoding='utf-8')
check('would-sync')
assert any(problem.startswith('venv:') for problem in ensure.check())
config.write_bytes(old_config)
check('current')
# A recorded extra only counts while the tree still declares it.
pyproject = core / 'pyproject.toml'
pyproject.write_text(pyproject.read_text(encoding='utf-8')
+ '[project.optional-dependencies]\nchanged-extra = []\n', encoding='utf-8')
altered = json.loads(pristine)
altered['packages']['venv']['extras'] = ['changed-extra']
facts_path.write_text(json.dumps(altered), encoding='utf-8')
check('would-sync')
assert any(problem.startswith('venv:') for problem in ensure.check())
facts_path.write_bytes(pristine)
pins.set_pin('python', '1.0', {'any': {'url': 'https://example.invalid/python', 'sha256': 'b' * 64}})
pins.save()
check('would-sync')
assert any(problem.startswith('venv:') for problem in ensure.check())
pins.set_pin('python', '1.0', {'any': {'url': 'https://example.invalid/python', 'sha256': 'a' * 64}})
pins.save()
marker = selected / 'pyvenv.cfg'
marker_bytes = marker.read_bytes()
marker.unlink()
check('would-sync')
assert any(problem.startswith('venv:') for problem in ensure.check())
marker.write_bytes(marker_bytes)
check('current')
outside = root / 'foreign-environment'
outside.mkdir()
(outside / 'pyvenv.cfg').write_bytes(marker_bytes)
altered = json.loads(pristine)
altered['packages']['venv']['environment'] = str(outside)
facts_path.write_text(json.dumps(altered), encoding='utf-8')
check('would-sync')
assert any(problem.startswith('venv:') for problem in ensure.check())
for invalid in (b'not JSON', b'{"schema":1,"packages":{"venv":[]}}'):
facts_path.write_bytes(invalid)
output = check('failed', 1)
assert output.get('detail')
assert any(problem.startswith('venv:') for problem in ensure.check())
assert facts_path.read_bytes() == invalid
assert not facts_path.with_suffix('.corrupt').exists()
facts_path.unlink()
check('would-sync')
assert not facts_path.exists()
def test_own_tree_sync_reuses_pm_without_writing_an_extra_stamp(admission_env, monkeypatch):
# Keep the public client seam; run its in-process path against admission's real engine.
monkeypatch.setattr("pm.client.is_runtime", lambda: True)
root, home = admission_env
core = root / 'core'
assert not (core / ".hermes-runtime" / "cache" / "venv-sync.json").exists()
assert venv_sync.sync(core) == {'state': 'synced', 'ok': True}
environment = selected_venv(core)
saved = paths.runtime_facts_path().read_bytes()
assert not (core / ".hermes-runtime" / "cache" / "venv-sync.json").exists()
assert venv_sync.sync(core) == {'state': 'current', 'ok': True}
assert paths.runtime_facts_path().read_bytes() == saved
python = environment / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python')
result = subprocess.run([str(python), '-I', '-c', 'import sys; print(sys.prefix)'],
cwd=home, capture_output=True, text=True, check=True, timeout=30)
assert Path(result.stdout.strip()).resolve() == environment.resolve()
marker = environment / 'pyvenv.cfg'
marker_bytes = marker.read_bytes()
marker.unlink()
failure = venv_sync.sync(core)
assert failure['state'] == 'failed'
assert 'dependency environment is missing' in failure['detail']
marker.write_bytes(marker_bytes)
assert venv_sync.sync(core) == {'state': 'current', 'ok': True}
assert not (core / ".hermes-runtime" / "cache" / "venv-sync.json").exists()