Files
hermes-agent/tests/hermes_cli/test_old_updater_takeover.py
ethernet 427d4936c2 test: retarget merge-fallout tests at pm-clean's seams; drop tests of retired code
The merges from main kept or added about 45 test files written against the
legacy updater and installer seams that pm-clean replaced. Each failing test
was checked against the current code:

- Deleted, because every test targets code that pm-clean removed or that is
  only reachable from dead or frozen old-updater code:
  update_orphan/handoff_backend_reap, handoff_desktop_rebuild,
  interrupted_recovery (it also launched a real completion against the live
  checkout), update_stale_virtualenv, update_venv_health,
  verify_core_dependencies, lazy_refresh_venv_repair,
  certifi_repair (already dropped in 2f20ceb6f7; the merge resurrected it),
  banner_git_state (covered by test_source_check).
- Retargeted where production reads now:
  - version identity via version_info.get_code_identity
  - update receipts via a ContextVar scope
  - pm.extras / pm.installed_package / pm.ensure_import for matrix,
    computer_use, fal and tirith
  - install hints via pm.install_hint
  - the lock pins in pm/lock.json
  - probe_root for the critical-module probe
- Fixture repairs:
  - git-committed trees for source stamps
  - activate's real `--runtime-only` argument
  - a semver install stamp for requires_hermes gates
  - the Windows gateway restart after a verified update
  - snowflake-length ids that cannot collide with the runner uid
- Collection fix: one platforms() marker per test in gateway_proc_fallback.
2026-09-23 16:55:09 -04:00

438 lines
23 KiB
Python

"""Historical frames stay old; completion runs once in a clean child."""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
@pytest.mark.parametrize("desktop", [False, True])
@pytest.mark.parametrize("resume", [None, {"resume_needed": True, "profiles": {"work": "old-pid"}}])
def test_historical_payload_maps_to_takeover_request_schema(tmp_path, desktop, resume):
"""Regression for the 0.21.3 rehearsal failure: the old post-swap payload has no
``desktop``/``assume_yes``/``windows_resume`` keys, and update_finish reads them
with bare subscriptions — the completion died on KeyError('desktop') with the
checkout already swapped (post_swap_7006.json on the rehearsal host)."""
source = Path(__file__).resolve().parents[2]
root = tmp_path / "updated checkout"
package = root / "hermes_cli"
package.mkdir(parents=True)
shutil.copy2(source / "hermes_cli/_old_updater.py", package / "_old_updater.py")
(package / "_update_takeover.py").write_text(
"import json, os, sys\nfrom pathlib import Path\n"
"request = json.loads(Path(sys.argv[1]).read_text())\n"
"assert request['desktop'] == " + repr(desktop) + "\n"
"assert request['assume_yes'] == True\n"
"assert request['restart_update'] == False\n"
"assert request['gateway_mode'] == True\n"
"assert request['had_desktop_app_before_update'] == " + repr(desktop) + "\n"
"assert request['windows_resume'] == " + repr(resume) + "\n"
"assert request['receipt']['update_id'] == 'old-correlation'\n"
"assert request['plan']['install_method'] == 'git'\n"
f"Path(sys.argv[2]).write_text(json.dumps({{'resume_handled': True}}), encoding='utf-8')\n"
"raise SystemExit(0)\n", encoding="utf-8",
)
home = tmp_path / "isolated home 日本 café"
home.mkdir()
payload = {
"swap": "git", "branch": "main", "pre_pull_sha": "10433003", "is_fork": True,
"gateway_mode": True, "had_desktop_app_before_update": desktop,
"pre_update_snapshot_id": "20260918-185530-pre-update", "pre_update_version": "0.21.3",
"active_lazy_features": ["tool.dashboard"], "active_tool_dependencies": [],
"plan": {"install_method": "git", "expected_sha": "old-sha"},
"sibling_snapshots": {},
"receipt": {"update_id": "old-correlation", "outcome": "running"},
}
if resume is not None:
payload["windows_gateway_resume"] = resume
# The retired updater reaches the takeover through the NEW tree's hand-off module; this
# checkout is the whole tree the child sees (CI has no editable finder for the source).
shutil.copy2(source / "hermes_cli/update_handoff.py", package / "update_handoff.py")
# write_handoff resolves the home through hermes_constants; the child tree is the whole
# sys.path (CI has no editable finder), so give it the one name the hand-off reads.
(root / "hermes_constants.py").write_text(
"import os\nfrom pathlib import Path\n"
"def get_hermes_home():\n return Path(os.environ['HERMES_HOME'])\n", encoding="utf-8",
)
program = root / "historical.py"
program.write_text(
"import os\n"
"from hermes_cli.update_handoff import continue_update_in_fresh_interpreter\n"
"code = continue_update_in_fresh_interpreter(\n"
f" {payload!r}, argv_tail=['update', '--yes', '--gateway', '--branch', 'main'])\n"
"raise SystemExit(code)\n", encoding="utf-8",
)
env = {key: value for key, value in os.environ.items()
if not key.startswith(('HERMES_', 'PYTHON', 'UV_'))}
env.update(HOME=str(home), HERMES_HOME=str(home))
result = subprocess.run([sys.executable, "-B", str(program)], env=env,
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
handoff = json.loads((home / "logs/update_receipts").glob("post_swap_*.json").__next__().read_text())
assert handoff["had_desktop_app_before_update"] is desktop
assert "desktop" not in handoff and "assume_yes" not in handoff
@pytest.mark.live_system_guard_bypass
def test_shipped_post_swap_argv_enters_takeover_before_current_cli(tmp_path):
"""The 2026.9.21 updater starts HEAD as ``hermes update <flags> --post-swap FILE``.
That command must reach the historical takeover before current launch preparation or
argparse: both belong to the replacement updater and may require dependencies the old
environment has not installed yet.
"""
source = Path(__file__).resolve().parents[2]
root = tmp_path / "updated checkout"
package = root / "hermes_cli"
package.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
(package / "main.py").write_text(
"import hermes_bootstrap\nraise AssertionError('current CLI parsed a legacy continuation')\n",
encoding="utf-8",
)
for relative in ("hermes_bootstrap.py", "hermes_cli/update_handoff.py", "hermes_cli/_old_updater.py"):
shutil.copy2(source / relative, root / relative)
(package / "_update_takeover.py").write_text(
"import json, sys\nfrom pathlib import Path\n"
"request = json.loads(Path(sys.argv[1]).read_text())\n"
"assert request['desktop'] is True\n"
"assert request['assume_yes'] is True\n"
"assert request['argv'][1:] == ['update', '--yes', '--no-gateway-restart', "
"'--branch', 'main', '--post-swap', request['legacy_handoff']]\n"
"Path(sys.argv[2]).write_text(json.dumps({'resume_handled': True}), encoding='utf-8')\n",
encoding="utf-8",
)
home = tmp_path / "home"
home.mkdir()
handoff = home / "post_swap_3185.json"
payload = {
"legacy_handoff": str(handoff),
"gateway_mode": False,
"had_desktop_app_before_update": True,
"windows_gateway_resume": None,
"plan": {"install_method": "git"},
"receipt": {"update_id": "old-correlation", "outcome": "running"},
}
handoff.write_text(json.dumps(payload), encoding="utf-8")
env = {key: value for key, value in os.environ.items()
if not key.startswith(("HERMES_", "PYTHON", "UV_"))}
env.update(HOME=str(home), HERMES_HOME=str(home))
result = subprocess.run(
[sys.executable, "-B", "-m", "hermes_cli.main", "update", "--yes",
"--no-gateway-restart", "--branch", "main", "--post-swap", str(handoff)],
cwd=root, env=env, capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
assert not handoff.exists()
@pytest.mark.parametrize("status", [0, 7])
@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"])
@pytest.mark.parametrize("desktop", [None, False, True])
def test_takeover_waits_propagates_status_and_never_reenters_old_code(tmp_path, status, encoding, desktop):
source = Path(__file__).resolve().parents[2]
root = tmp_path / "updated checkout"
package = root / "hermes_cli"
package.mkdir(parents=True)
shutil.copy2(source / "hermes_cli/_old_updater.py", package / "_old_updater.py")
# This is the process seam, not a counterfeit installer. Actual PM and
# product construction are exercised separately against local packages.
(package / "_update_takeover.py").write_text(
"import json, os, sys\nfrom pathlib import Path\n"
"request = json.loads(Path(sys.argv[1]).read_text())\n"
"assert 'old_only' not in sys.modules\n"
"assert sys.flags.utf8_mode == 1\n"
"assert 'PYTHONPATH' not in os.environ\n"
f"assert request['desktop'] is {desktop!r}, request['desktop']\n"
"assert request['windows_resume']['profiles'] == {'work': 'old-pid'}\n"
"assert request['pre_update_snapshot_id'] == 'preserve-snapshot'\n"
"assert request['gateway_mode'] == True\n"
"assert request['pre_update_version'] == 'old-version'\n"
"assert request['home'] == os.environ['HERMES_HOME']\n"
"with Path(request['home'], 'runs').open('a') as stream: stream.write('child\\n')\n"
f"Path(sys.argv[2]).write_text(json.dumps({{'resume_handled': True}}), encoding={encoding!r})\n"
f"raise SystemExit({status})\n", encoding="utf-8",
)
home = tmp_path / "isolated home 日本 café"
home.mkdir()
program = root / "historical.py"
program.write_text(
"import atexit, json, os, sys, types\nfrom pathlib import Path\n"
"sys.modules['old_only'] = types.ModuleType('old_only')\n"
"from hermes_cli._old_updater import stop_for_relaunch\n"
"_windows_gateway_resume = {'resume_needed': True, 'profiles': {'work': 'old-pid'}}\n"
# The June updater reaches the takeover before it declares this local.
f"if {desktop!r} is not None:\n had_desktop_app_before_update = {desktop!r}\n"
"pre_update_snapshot_id = 'preserve-snapshot'\n"
"gateway_mode = True\npre_update_version = 'old-version'\n"
"def cleanup():\n"
" assert not _windows_gateway_resume['resume_needed']\n"
" assert 'old_only' in sys.modules\n"
" Path(os.environ['HERMES_HOME'], 'cleanup').write_text('ran')\n"
"atexit.register(cleanup)\n"
"try:\n stop_for_relaunch()\n"
"finally:\n stop_for_relaunch()\n"
"raise AssertionError('old updater resumed')\n", encoding="utf-8",
)
env = {key: value for key, value in os.environ.items()
if not key.startswith(('HERMES_', 'PYTHON', 'UV_'))}
env.update(HOME=str(home), HERMES_HOME=str(home), PYTHONPATH="/not/the/new/source")
result = subprocess.run([sys.executable, "-B", str(program)], env=env,
capture_output=True, text=True, timeout=30)
assert result.returncode == status, result.stdout + result.stderr
assert (home / "runs").read_text() == "child\n"
assert (home / "cleanup").read_text() == "ran"
assert "run `hermes` again" not in result.stderr
# Only the copied shim and JSON probe run; this temp checkout has no real updater.
@pytest.mark.live_system_guard_bypass
@pytest.mark.parametrize("post_pull", [False, True])
@pytest.mark.parametrize("module_name", ["update_cmd", "unrelated"])
def test_only_known_early_updater_restarts_with_original_arguments(tmp_path, post_pull, module_name):
source = Path(__file__).resolve().parents[2]
root = tmp_path / "updated checkout"
package = root / "hermes_cli"
package.mkdir(parents=True)
for name in ("_old_updater.py", "old_updater_deps.py"):
shutil.copy2(source / "hermes_cli" / name, package / name)
(package / "_update_takeover.py").write_text(
"import json, sys\nfrom pathlib import Path\n"
"request = json.loads(Path(sys.argv[1]).read_text())\n"
"Path(request['home'], 'request.json').write_text(json.dumps(request))\n"
"Path(sys.argv[2]).write_text('{}')\n", encoding="utf-8",
)
# Model the historical frame at 096826bf: capture precedes the assignment;
# after pulling, the *same* frame reaches a dependency hook. The wrapper
# must not make that post-pull invocation look like another early update.
(package / f"{module_name}.py").write_text(
"from hermes_cli.old_updater_deps import _capture_active_lazy_features, _update_node_dependencies\n"
"def _cmd_update_impl(post_pull):\n"
" if not post_pull:\n _capture_active_lazy_features()\n"
" pre_update_version = None\n"
" _update_node_dependencies()\n"
"def cmd_update(post_pull):\n _cmd_update_impl(post_pull)\n", encoding="utf-8",
)
home = tmp_path / "isolated home 日本 café"
home.mkdir()
argv = [str(root / "historical.py"), "--profile", "work profile", "update", "--yes",
"--keep-stash", "--switch-branch", "--force", "--gateway"]
Path(argv[0]).write_text(
f"from hermes_cli.{module_name} import cmd_update\n"
f"cmd_update({post_pull!r})\n"
"raise AssertionError('old updater resumed')\n", encoding="utf-8",
)
env = {key: value for key, value in os.environ.items()
if not key.startswith(("HERMES_", "PYTHON", "UV_"))}
env.update(HOME=str(home), HERMES_HOME=str(home))
result = subprocess.run([sys.executable, "-B", *argv], env=env,
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
request = json.loads((home / "request.json").read_text())
assert request.get("restart_update", False) is (not post_pull and module_name == "update_cmd")
assert request["argv"] == argv
@pytest.mark.parametrize("acknowledged", [False, True])
@pytest.mark.parametrize("cleanup_status", [0, 9])
def test_atexit_recovers_only_stopped_serves_after_cached_update(tmp_path, acknowledged, cleanup_status):
source = Path(__file__).resolve().parents[2]
root = tmp_path / "updated checkout"
package = root / "hermes_cli"
package.mkdir(parents=True)
for name in ("_old_updater.py", "old_updater_deps.py"):
shutil.copy2(source / "hermes_cli" / name, package / name)
(package / "_update_takeover.py").write_text(
"import json, sys\nfrom pathlib import Path\n"
"request = json.loads(Path(sys.argv[1]).read_text())\n"
"home = Path(request['home'])\n"
"cleanup = 'stopped_serves' in request\n"
"if cleanup:\n"
" assert set(request) == {'root', 'home', 'argv', 'stopped_serves'}\n"
" assert request['stopped_serves']['pending'] is True\n"
" assert request['stopped_serves']['entries'][0]['profile'] == 'work profile'\n"
"with (home / 'runs').open('a') as stream:\n"
" stream.write('cleanup\\n' if cleanup else 'update\\n')\n"
f"Path(sys.argv[2]).write_text(json.dumps({{'serves_handled': {acknowledged!r}}} if cleanup else {{}}))\n"
f"raise SystemExit({cleanup_status} if cleanup else 7)\n", encoding="utf-8",
)
home = tmp_path / "isolated home 日本 café"
home.mkdir()
program = root / "historical.py"
program.write_text(
"import atexit, json, os\nfrom pathlib import Path\n"
"from hermes_cli._old_updater import stop_for_relaunch\n"
"from hermes_cli.old_updater_deps import _relaunch_stopped_serves\n"
"token = {'pending': True, 'entries': [{'purpose': 'serve', 'profile': 'work profile',"
" 'host': '127.0.0.1', 'port': 8119}]}\n"
"def cleanup():\n"
" try:\n"
" _relaunch_stopped_serves(token)\n"
" if not token['pending']:\n _relaunch_stopped_serves(token)\n"
" finally:\n"
" Path(os.environ['HERMES_HOME'], 'token.json').write_text(json.dumps(token))\n"
"atexit.register(cleanup)\n"
"stop_for_relaunch()\n"
"raise AssertionError('old updater resumed')\n", encoding="utf-8",
)
env = {key: value for key, value in os.environ.items()
if not key.startswith(("HERMES_", "PYTHON", "UV_"))}
env.update(HOME=str(home), HERMES_HOME=str(home))
result = subprocess.run([sys.executable, "-B", str(program)], env=env,
capture_output=True, text=True, timeout=30)
assert result.returncode == 7, result.stdout + result.stderr
assert (home / "runs").read_text() == "update\ncleanup\n"
token = json.loads((home / "token.json").read_text())
assert token["pending"] is (not acknowledged)
assert "Exception ignored" not in result.stderr
assert ("restart the affected" in result.stderr) is (not acknowledged or cleanup_status != 0)
@pytest.mark.parametrize("spawn_fails", [False, True])
@pytest.mark.parametrize("discarded", [False, True])
def test_serve_resume_child_reuses_respawn_without_updater_or_supervisors(tmp_path, spawn_fails, discarded):
root = Path(__file__).resolve().parents[2]
context, result_path = tmp_path / "request.json", tmp_path / "result.json"
home = tmp_path / "home"
home.mkdir()
entries = [
{"purpose": "serve", "profile": "default", "host": "127.0.0.1", "port": 8119},
{"purpose": "dashboard", "profile": "ops team 日本", "host": "::1", "port": 8120},
]
if discarded:
entries += [
dict(entries[0]),
{"purpose": "serve", "profile": "desktop", "port": 0},
{"purpose": "serve", "profile": "foreign", "port": 8121, "hermes_home": str(tmp_path / "foreign")},
{"purpose": "gateway", "profile": "not-serve", "port": 8122},
{"purpose": "serve", "profile": "boolean-port", "port": True},
{"purpose": "serve", "profile": "invalid-port", "port": 65536},
]
context.write_text(json.dumps({"root": str(root), "home": str(home),
"stopped_serves": {"pending": True, "entries": entries}}))
program = tmp_path / "probe.py"
program.write_text(
"import json, os, runpy, subprocess, sys\nfrom pathlib import Path\n"
"calls = []\n"
"def forbidden(*args, **kwargs):\n raise AssertionError('updater/process control ran')\n"
"def spawn(command, **kwargs):\n"
" assert kwargs['start_new_session'] is True\n"
" assert kwargs['stdin'] == subprocess.DEVNULL\n"
" calls.append(command)\n"
f" if {spawn_fails!r}:\n raise OSError('probe spawn failed')\n"
"subprocess.Popen = spawn\nsubprocess.run = os.system = os.kill = forbidden\n"
f"sys.argv = [{str(root / 'hermes_cli/update_serve_resume.py')!r}, {str(context)!r}, {str(result_path)!r}]\n"
"try:\n runpy.run_path(sys.argv[0], run_name='__main__')\n"
"finally:\n"
" assert not any(name in sys.modules for name in ('hermes_cli.update_cmd', 'hermes_cli.update_receipt', 'hermes_cli.main'))\n"
" Path(os.environ['HERMES_HOME'], 'commands.json').write_text(json.dumps(calls))\n",
encoding="utf-8",
)
env = {key: value for key, value in os.environ.items()
if not key.startswith(("HERMES_", "PYTHON", "UV_"))}
env.update(HOME=str(home), HERMES_HOME=str(home))
result = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", str(program)],
env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == int(spawn_fails or discarded), result.stdout + result.stderr
assert json.loads(result_path.read_text()) == {"serves_handled": True}
commands = json.loads((home / "commands.json").read_text())
assert commands == [
[sys.executable, str(root / "hermes"), "serve", "--host", "127.0.0.1", "--port", "8119"],
[sys.executable, str(root / "hermes"), "--profile", "ops team 日本", "dashboard",
"--host", "::1", "--port", "8120", "--no-open"],
]
assert ("probe spawn failed" in result.stdout) is spawn_fails
def test_serve_resume_child_leaves_token_unhandled_when_imports_fail(tmp_path):
root = Path(__file__).resolve().parents[2]
context, result_path = tmp_path / "request.json", tmp_path / "result.json"
context.write_text(json.dumps({"root": str(tmp_path), "home": str(tmp_path),
"stopped_serves": {"pending": True, "entries": []}}))
env = {key: value for key, value in os.environ.items()
if not key.startswith(("HERMES_", "PYTHON", "UV_"))}
env.update(HOME=str(tmp_path), HERMES_HOME=str(tmp_path))
# -S and an empty checkout make application imports genuinely unavailable;
# this is not a mocked exception or a path into the real update machinery.
result = subprocess.run(
[sys.executable, "-I", "-S", "-B", "-X", "utf8", str(root / "hermes_cli/update_serve_resume.py"),
str(context), str(result_path)], env=env, capture_output=True, text=True, timeout=30,
)
assert result.returncode == 1
assert json.loads(result_path.read_text()) == {"serves_handled": False}
assert "Stopped serve recovery failed" in result.stderr
@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"])
@pytest.mark.parametrize("entrypoint", ["_update_takeover", "update_serve_resume"])
def test_completed_serve_token_is_acknowledged_without_preparation(tmp_path, encoding, entrypoint):
source = Path(__file__).resolve().parents[2]
root = tmp_path / "checkout 日本 café"
package = root / "hermes_cli"
package.mkdir(parents=True)
for name in ("_update_takeover.py", "update_serve_resume.py"):
shutil.copy2(source / "hermes_cli" / name, package / name)
# Only selection is supplied. The two real entrypoints must carry the
# request without importing PM or launching any completed backend twice.
(package / "_launchers.py").write_text(
"import sys\nresolve_store_python = lambda root: sys.executable\n", encoding="utf-8")
pm_package = root / "pm"
pm_package.mkdir()
(pm_package / "__init__.py").write_text("", encoding="utf-8")
(pm_package / "environments.py").write_text(
"import os\nactivation_environment = lambda root: dict(os.environ)\n", encoding="utf-8")
context, result_path = tmp_path / "request.json", tmp_path / "result.json"
context.write_text(json.dumps({"root": str(root), "stopped_serves": {"pending": False}},
ensure_ascii=False), encoding=encoding)
before = context.read_bytes()
child = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(package / f"{entrypoint}.py"),
str(context), str(result_path)], cwd=tmp_path, capture_output=True,
text=True, encoding="utf-8", timeout=30,
)
assert child.returncode == 0, child.stdout + child.stderr
assert json.loads(result_path.read_text(encoding="utf-8-sig")) == {"serves_handled": True}
assert not result_path.read_bytes().startswith(b"\xef\xbb\xbf")
assert context.read_bytes() == before
def test_bootstrap_lock_remains_live_without_application_dependencies(tmp_path):
"""A dependency-free (-I -S) takeover child still sees another live updater's claim.
The holder is a separate, unrelated process: a claim naming the caller's own pid is
adopted as a fresh attempt, so only a foreign live holder can prove liveness detection.
"""
root = Path(__file__).resolve().parents[2]
lock = tmp_path / "lock"
prelude = (
"import os, sys\nfrom pathlib import Path\n"
f"sys.path.insert(0, {str(root)!r})\n"
"from hermes_cli.update_lock import UpdateLock\n"
f"lock = UpdateLock(path=Path({str(lock)!r}))\n"
)
holder = subprocess.Popen(
[sys.executable, "-I", "-S", "-B", "-c",
prelude + "assert lock.acquire()\nprint(os.getpid(), flush=True)\n"
"sys.stdin.readline()\nlock.release()\n"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, encoding="utf-8",
)
assert holder.stdout is not None
try:
holder_pid = int(holder.stdout.readline())
result = subprocess.run(
[sys.executable, "-I", "-S", "-B", "-c",
prelude + "assert not lock.acquire(), 'live lock was stolen'\n"
f"assert lock.holder.pid == {holder_pid}, lock.holder\n"],
stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", timeout=30,
)
finally:
holder.communicate("\n", timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
assert holder.returncode == 0
assert not lock.exists()