From 529050eab72d5181467db05941ef65ae6bde0da5 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 11 Sep 2026 19:45:30 -0400 Subject: [PATCH] fix(update): stop retired installers and finish on fresh launch Old updaters keep running after the checkout changes. Returning None from their removed uv helpers enables a pip fallback against the new tree. Keep the historical imports as inert shims and stop dependency entrypoints with a relaunch message instead. Do not call PM or write recovery markers from that mixed-version process. Self-managed source launches use PM's successful input stamp to decide when dependencies need a sync. Restart on the managed interpreter before activating the selected generation. Preserve launcher forms and options, and do not sync while a live updater owns the installation. Targeted runtime batch: 225 passed, 8 platform skips. Real PM worker tests build and publish disposable dependency generations, retain prior state on failure, and exercise fresh-process relaunch before dependency activation. The final launch guard test also passes. Full suite and native Windows execution were not run locally. --- hermes_bootstrap.py | 18 ++ hermes_cli/_install_repair.py | 6 + hermes_cli/_old_updater.py | 14 + hermes_cli/backup.py | 7 + hermes_cli/banner.py | 10 + hermes_cli/config.py | 16 ++ hermes_cli/main.py | 8 +- hermes_cli/managed_uv.py | 45 +++ hermes_cli/post_update.py | 4 +- hermes_cli/psutil_android.py | 24 ++ hermes_cli/update_cmd.py | 31 +++ hermes_cli/update_cmd_windows.py | 7 +- hermes_cli/venv_sync.py | 90 ++++++ tests/compat/old_updater_dependencies.py | 199 ++++++++++++++ tests/hermes_cli/conftest.py | 2 +- tests/hermes_cli/test_cmd_update.py | 4 +- .../test_desktop_lifecycle_windows_live.py | 4 +- .../test_update_concurrent_quarantine.py | 4 +- .../hermes_cli/test_update_head_moved_gate.py | 2 +- .../test_update_launch_completion.py | 168 ++++++++++++ .../test_update_orphan_backend_reap.py | 4 +- tests/hermes_cli/test_update_venv_health.py | 13 +- tests/hermes_cli/test_update_yes_flag.py | 4 +- ...ws_gateway_cold_start_desktop_lifecycle.py | 4 +- tests/pm/test_source_update_launch.py | 256 ++++++++++++++++++ tests/test_old_updater_shims.py | 185 +++++++++++++ tools/browser_tool.py | 5 + website/docs/reference/package-management.md | 12 + 28 files changed, 1116 insertions(+), 30 deletions(-) create mode 100644 hermes_cli/_old_updater.py create mode 100644 hermes_cli/managed_uv.py create mode 100644 hermes_cli/psutil_android.py create mode 100644 tests/compat/old_updater_dependencies.py create mode 100644 tests/hermes_cli/test_update_launch_completion.py create mode 100644 tests/pm/test_source_update_launch.py create mode 100644 tests/test_old_updater_shims.py diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index e5e931121b..ad09bd4a0c 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -114,6 +114,24 @@ _root = Path(__file__).resolve().parent # Repair needs only stdlib. Do not activate the damaged tree to reach it. _pm_repair = command_argv(sys.argv[1:])[:2] == ["pm", "repair"] if not _pm_repair: + from hermes_cli.venv_sync import prepare_launch, relaunch_command + + try: + _launch_python = prepare_launch(_root, sys.argv[1:]) + if _launch_python is not None: + _main_spec = getattr(sys.modules.get("__main__"), "__spec__", None) + _command = relaunch_command( + _launch_python, _root, sys.argv, sys.orig_argv, + getattr(_main_spec, "name", None), + ) + if os.name == "nt": + import subprocess + + raise SystemExit(subprocess.call(_command)) + os.execv(str(_launch_python), _command) + except Exception as exc: + print(f"hermes: source-update completion failed: {exc}", file=sys.stderr) + raise SystemExit(1) from None recover_if_needed(_root) try: activate_dependencies(_root) diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 5c5958abc2..3738bf068b 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -7,6 +7,12 @@ import os import sys from pathlib import Path + +def _sync_windows_cli_launchers(root: Path) -> list[Path]: + # Shim to stop the old updater doing work until relaunch. Copy no launchers. + return [] + + def _is_windows() -> bool: return sys.platform == "win32" diff --git a/hermes_cli/_old_updater.py b/hermes_cli/_old_updater.py new file mode 100644 index 0000000000..09b9b77726 --- /dev/null +++ b/hermes_cli/_old_updater.py @@ -0,0 +1,14 @@ +"""Shims to stop the old updater doing work until relaunch.""" + +import sys +from typing import NoReturn + + +def stop_for_relaunch() -> NoReturn: + """Do not return: old callers would fall back to pip or claim completion.""" + print( + "This updater is running code from before the checkout changed. " + "Stopping without installing dependencies; relaunch Hermes to continue.", + file=sys.stderr, + ) + raise SystemExit(0) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 4eaadb7797..0fc4e29bf4 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -40,6 +40,13 @@ from hermes_cli.backup_restore import ( logger = logging.getLogger(__name__) + +def _foreign_db_holder_pids(db_path: Path) -> Optional[List[int]]: + # Shim to stop the old updater doing work until relaunch. None means unknown, + # not permission to restore over a database whose holders we did not scan. + return None + + # --- Exclusion rules --- # Where ``hermes backup --quick`` / ``/snapshot`` / the pre-update safety net write state diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 19d12da311..c3ecac175c 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -24,6 +24,16 @@ _DIM = "\033[2m" _RST = "\033[0m" +def _check_via_pypi() -> Optional[int]: + # Shim to stop the old updater doing work until relaunch. no registry query. + return None + + +def check_via_pypi() -> Optional[int]: + # Shim to stop the old updater doing work until relaunch. status is unknown. + return None + + def _quiet(fn, default=None): """``fn()``, or ``default`` on any exception — for best-effort display inputs.""" try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 86f656108f..6d51af5e96 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -34,6 +34,22 @@ from utils import atomic_replace, atomic_yaml_write, fast_safe_load logger = logging.getLogger(__name__) + +def is_uv_tool_install() -> bool: + # Shim to stop the old updater doing work until relaunch, not select uv tool. + return False + + +def is_unsupported_install_method(method: str) -> bool: + # Shim to stop the old updater doing work until relaunch. no legacy detection. + return False + + +def format_unsupported_install_warning(method: str) -> str: + # Shim to stop the old updater doing work until relaunch. no obsolete advice. + return "" + + # (config_path, mtime_ns, size) tuples already warned about, so concurrent CLI/gateway # loads of a broken config.yaml don't spam stderr. A changed file (new mtime) warns again. _CONFIG_PARSE_WARNED: set = set() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f1a5dbd0a4..d93cd72687 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2021,6 +2021,12 @@ def select_provider_and_model(args=None): _clear_stale_openai_base_url() +def _detect_venv_python_processes(*, exclude_pids: set[int] | None = None) -> list[tuple[int, str, str]]: + # Shim to stop the old updater doing work until relaunch. Do not scan or kill. + # Current Windows checks use the real detector in update_cmd_windows instead. + return [] + + # Frozen updater surface (PEP 562 ``__getattr__`` below): the frozen # ``hermes_cli/update_cmd*.py`` files resolve these names via ``_m().`` # on hermes_cli.main; importing update_cmd eagerly would cost every ``hermes`` @@ -2030,7 +2036,7 @@ _FROZEN_UPDATER_SURFACE: dict[str, tuple[str, ...]] = { "hermes_cli.update_cmd": ( "_assess_parked_branch_switch", "_capture_active_lazy_features", - "_cold_start_windows_gateway_after_update", "_detect_venv_python_processes", "_discard_stashed_changes", + "_cold_start_windows_gateway_after_update", "_discard_stashed_changes", "_filter_non_gateway_concurrent_instances", "_fleet_probe_expected_runtimes", "_get_origin_url", "_handoff_reapable_backend_pids", "_ledger_manual_serve_holders", "_ledger_reapable_backend_pids", "_leftover_pausable_gateway_pids", "_npm_lockfile_changed", diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py new file mode 100644 index 0000000000..c8225d175e --- /dev/null +++ b/hermes_cli/managed_uv.py @@ -0,0 +1,45 @@ +"""Shims to stop the old updater doing work until relaunch. + +An updater already in memory imports these names after replacing its checkout. +They must not install anything, delegate to PM, or return a falsy value that +would send that old process down its pip fallback. New code must not use them. +""" + +from pathlib import Path +from typing import NoReturn + +from hermes_cli._old_updater import stop_for_relaunch + + +def ensure_uv(*args, **kwargs) -> NoReturn: + # Shim to stop the old updater doing work until relaunch. Older releases + # expect a tuple, newer ones a path. Exit before either can consume it. + stop_for_relaunch() + + +def update_managed_uv(*args, **kwargs) -> NoReturn: + # Shim to stop the old updater doing work until relaunch. + stop_for_relaunch() + + +def resolve_uv(*args, **kwargs) -> NoReturn: + # Shim to stop the old updater doing work until relaunch, not enable pip. + stop_for_relaunch() + + +def managed_python_env( + project_root: Path | None = None, + *, + install_dir: Path | None = None, + base_env: dict[str, str] | None = None, + **kwargs, +) -> NoReturn: + # Shim to stop the old updater doing work until relaunch, not prepare a child. + stop_for_relaunch() + + +def rebuild_venv( + uv_bin: str, venv_dir: Path, python_version: str = "3.11", **kwargs +) -> NoReturn: + # Shim to stop the old updater doing work until relaunch, not claim a rebuild. + stop_for_relaunch() diff --git a/hermes_cli/post_update.py b/hermes_cli/post_update.py index cef870251f..3eac173365 100644 --- a/hermes_cli/post_update.py +++ b/hermes_cli/post_update.py @@ -165,7 +165,7 @@ def step_state_db_guard() -> dict: return {"ok": False, "error": message} -def step_adopt_blessed_checkout() -> dict: +def step_adopt_blessed_checkout(project_root: Path | None = None) -> dict: """One-time adoption of shipped stampless installs (birth certificate). Main-era curl|sh / Setup installs created a ``.git`` checkout at a @@ -192,7 +192,7 @@ def step_adopt_blessed_checkout() -> dict: from hermes_constants import get_hermes_home - root = _install_root() + root = _install_root() if project_root is None else Path(project_root) # The blessed roots: the canonical locations installers create. blessed = ( diff --git a/hermes_cli/psutil_android.py b/hermes_cli/psutil_android.py new file mode 100644 index 0000000000..4a85c73a72 --- /dev/null +++ b/hermes_cli/psutil_android.py @@ -0,0 +1,24 @@ +"""Shims to stop the old updater doing work until relaunch, not Android support.""" + +from pathlib import Path +from typing import NoReturn + +from hermes_cli._old_updater import stop_for_relaunch + +# Frozen data for old imports, not a download performed by this module. +PSUTIL_URL = ( + "https://files.pythonhosted.org/packages/aa/c6/" + "d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/" + "psutil-7.2.2.tar.gz" +) + + +def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> NoReturn: + # Shim to stop the old updater doing work until relaunch. Extract nothing. + stop_for_relaunch() + + +# Shim to stop the old updater doing work until relaunch. Historical callers +# download PSUTIL_URL BEFORE calling prepare_patched_psutil_sdist, so waiting +# for that call is too late. Stop the import without offering a fake URL. +stop_for_relaunch() diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 5bcae4fc67..3ef5154fb7 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -16,9 +16,11 @@ import sys import time as _time from dataclasses import dataclass from pathlib import Path +from typing import NoReturn from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd) from hermes_cli.update_cmd_common import _best_effort +from hermes_cli._old_updater import stop_for_relaunch from hermes_constants import venv_python_path # Re-exports: every split-module name stays reachable (and monkeypatchable) as update_cmd.. @@ -113,6 +115,35 @@ from hermes_cli.update_cmd_maint import ( # noqa: F401 logger = logging.getLogger(__name__) +def _ensure_uv_for_termux(pip_cmd: list[str]) -> NoReturn: + # Shim to stop the old updater doing work until relaunch, not bootstrap uv. + stop_for_relaunch() + + +def _ensure_venv_pip(pip_cmd: list, python_exe: str) -> NoReturn: + # Shim to stop the old updater doing work until relaunch, not bootstrap pip. + stop_for_relaunch() + + +def _pip_install_prefix(uv_bin) -> NoReturn: + # Shim to stop the old updater doing work until relaunch, not form an install. + stop_for_relaunch() + + +def _refuse_update_for_contended_shims(exc: BaseException) -> NoReturn: + # Shim to stop the old updater doing work until relaunch. Write no markers. + stop_for_relaunch() + + +def _shim_quarantine_error_type() -> type[Exception]: + # Shim to stop the old updater doing work until relaunch. Its old except + # clause needs an exception type, but must not catch real failures. + class _NeverRaised(Exception): + pass + + return _NeverRaised + + def _m(): """Lazy ``hermes_cli.main`` reference. diff --git a/hermes_cli/update_cmd_windows.py b/hermes_cli/update_cmd_windows.py index 2c7832efba..45624f1612 100644 --- a/hermes_cli/update_cmd_windows.py +++ b/hermes_cli/update_cmd_windows.py @@ -603,13 +603,12 @@ def _desktop_owns_gateway_lifecycle() -> bool: See #76129, #92091. """ - from hermes_cli.update_cmd import _m with _best_effort('Desktop-lifecycle ledger probe failed: %s'): from hermes_cli.process_identity import ledger_entries, spawner_is_dead if any(e.get("purpose") in _BACKEND_PURPOSES and spawner_is_dead(e) is False for e in ledger_entries()): return True psutil = _psutil() - for pid, _name, cmdline in _try_call(_m()._detect_venv_python_processes, "Desktop-lifecycle holder scan failed: %s") or []: + for pid, _name, cmdline in _try_call(_detect_venv_python_processes, "Desktop-lifecycle holder scan failed: %s") or []: if not _looks_like_desktop_control_plane(cmdline): continue if psutil is None: @@ -1176,7 +1175,7 @@ def _reap_and_rescan(message: str, pids, stop=None) -> list[tuple[int, str, str] print(message) (stop or _m()._stop_process_trees)(pids) _time.sleep(1.0) - return _m()._detect_venv_python_processes() + return _detect_venv_python_processes() def _terminate_leftover_gateways(pids) -> None: @@ -1214,7 +1213,7 @@ def _clear_windows_venv_holders_or_exit(args, gateway_mode: bool, _windows_gatew _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(2) - holders = _m()._detect_venv_python_processes() + holders = _detect_venv_python_processes() # Gateways the pause machinery owns (respawned in the pause->guard window or unmapped # spawn path): stop and re-check; post-update resume brings them back. if holders and (gateway_holders := _m()._leftover_pausable_gateway_pids(holders)) is not None: diff --git a/hermes_cli/venv_sync.py b/hermes_cli/venv_sync.py index 192f920f1c..8c44d2879c 100644 --- a/hermes_cli/venv_sync.py +++ b/hermes_cli/venv_sync.py @@ -69,6 +69,96 @@ def sync(project_root: Path | None = None, *, check: bool = False) -> dict: return {"state": "failed", "ok": False, "detail": str(exc)} +def prepare_launch(project_root: Path, argv: list[str]) -> Path | None: + """Finish a self-managed source update before importing app dependencies. + + PM's successful input stamp is the only completion signal. Old updaters + need not write a marker (and cannot accidentally clear this obligation). + Return the store interpreter when this process must restart cleanly. + """ + import os + import sys + from hermes_cli._parser import command_argv + from hermes_cli.steward import read_install_stamp + + root = Path(project_root).resolve() + if (command_argv(argv)[:1] == ["pm"] + or os.environ.get("HERMES_DISABLE_LAZY_INSTALLS", "").lower() in ("1", "true", "yes") + or not (root / ".git").exists() + or not (root / "pyproject.toml").is_file()): + return None + stamp = read_install_stamp(root) + if not stamp: + from hermes_cli.post_update import step_adopt_blessed_checkout + + step_adopt_blessed_checkout(root) + stamp = read_install_stamp(root) + if stamp.get("updateMechanism") != "self": + return None # Developer checkouts and packaged runtimes retain their owner. + + from hermes_cli._early_recovery import _marker_owner_is_live + from hermes_cli.update_lock import read_live_update + + import pm + from hermes_cli._launchers import resolve_store_python + from hermes_cli.runtime_paths import runtime_facts_path + + current = pm.venv_is_current(project_root=root) + if not current: + # Existing markers guard liveness, never create the completion obligation. + # Current post-sync verification children can boot under a live updater. + legacy_markers = (root / ".update-incomplete", root / ".lazy-refresh-incomplete") + if any(_marker_owner_is_live(marker) for marker in legacy_markers) or read_live_update(): + raise RuntimeError("an update is still running; wait for it to exit, then relaunch Hermes") + print("hermes: completing source-update dependencies...", file=sys.stderr, flush=True) + # Main-era installers selected [all] but had no PM ledger. Established + # PM installs retain their recorded extras and plugin union instead. + extras = ["all"] if not runtime_facts_path(root).is_file() else None + pm.sync_venv(extras, explicit=True, project_root=root) + # These can predate the swap. Once PM commits the replacement they + # must not make early recovery immediately rebuild it a second time. + for name in (".update-incomplete", ".lazy-refresh-incomplete"): + (root / name).unlink(missing_ok=True) + python = resolve_store_python(root) + if python is None: + raise RuntimeError("source update has no managed Python; run `hermes pm install`") + if not current or python.absolute() != Path(sys.executable).absolute(): + return python + return None + + +def relaunch_command( + python: Path, root: Path, argv: list[str], original: list[str], module: str | None, +) -> list[str]: + """Re-enter the same script/module/launcher with the managed interpreter. + + An old venv may use a different Python ABI. Do not add the new generation + to that interpreter, and do not depend on its obsolete editable finder. + """ + # Preserve interpreter options, not application flags with the same names. + options: list[str] = [] + index = 1 + while index < len(original): + option = original[index] + if option in ("-c", "-m", "--", "-") or not option.startswith("-"): + break + options.append(option) + index += 1 + if option in ("-W", "-X") and index < len(original): + options.append(original[index]) + index += 1 + prefix = f"import sys, runpy; sys.path.insert(0, {str(root)!r}); sys.argv = {argv!r}; " + if argv[0] == "-c": + body = f"exec({original[index + 1]!r})" + elif module and module != "__main__": + body = f"runpy.run_module({module!r}, run_name='__main__', alter_sys=True)" + else: + # distlib .exe launchers are executable zip files with __main__, not + # importable modules named '__main__'. run_path handles both shapes. + body = f"runpy.run_path({str(Path(argv[0]).absolute())!r}, run_name='__main__')" + return [str(python), *options, "-I", "-c", prefix + body] + + def main(argv: list | None = None) -> int: parser = argparse.ArgumentParser(prog="hermes_cli.venv_sync") parser.add_argument("--project-root", default=None) diff --git a/tests/compat/old_updater_dependencies.py b/tests/compat/old_updater_dependencies.py new file mode 100644 index 0000000000..5af4229aa6 --- /dev/null +++ b/tests/compat/old_updater_dependencies.py @@ -0,0 +1,199 @@ +"""Frozen historical updater function, executed by test_old_updater_shims. + +Verbatim extraction from hermes_cli/update_cmd.py at +096826bf7ded2170eafe6a0781af22c807fba6c2 (_sync_python_dependencies_after_pull +and _install_psutil_android_compat). +Only the module scaffolding is supplied by the test. Lazy imports stay intact: +they must resolve to the real NEW tree, not test doubles or the old module. +This fixture avoids depending on a deep Git history in CI/shallow checkouts. +""" + +# ruff: noqa: F821 -- globals came from the old process; the test supplies them. +import sys +from pathlib import Path + + +def _install_psutil_android_compat( + install_cmd_prefix: list[str], + *, + env: dict[str, str] | None = None, +) -> None: + """Install psutil on Android by patching upstream platform detection. + + psutil's setup gates Linux sources behind ``sys.platform.startswith('linux')``; + Termux reports ``'android'``, so setup aborts although the Linux source path + compiles fine. Only the extracted build tree for this attempt is patched. + + Stopgap: remove (together with the standalone installer's use of the same + helper) once https://github.com/giampaolo/psutil/pull/2762 ships. + """ + import tempfile + import urllib.request + from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + archive = tmp_path / "psutil.tar.gz" + urllib.request.urlretrieve(PSUTIL_URL, archive) + src_root = prepare_patched_psutil_sdist(archive, tmp_path) + + _m()._run_install_with_heartbeat( + install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], + env=env, + ) + + +def _sync_python_dependencies_after_pull( + git_cmd, + branch, + pre_pull_sha, + *, + active_lazy_features, + active_tool_dependencies, + _windows_gateway_resume, +): + """Reinstall Python dependencies for the freshly pulled checkout. + + Order matters: ownership preflight -> self-lock deferral -> core-install + marker -> ``.[all]`` (uv or pip) -> bytecode sweep -> lazy-feature and + tool-dependency refresh (own marker) -> memory-provider bridge deps -> + critical-import probe (warn only; stale-bytecode self-heals next launch). + """ + _refuse_update_if_venv_foreign_owned(_m().PROJECT_ROOT) + # + # Self-lock deferral (relocated preflight — #86735): if THIS process + # holds a native extension the sync must rewrite, defer NOW — after + # the code swap, so only the dependency install is pending and the + # next fresh launch completes it via the marker. + _m()._abort_dependency_sync_if_self_locked(_windows_gateway_resume) + # + # Drop the core-install breadcrumb BEFORE touching the venv. If the + # install is killed mid-flight (Ctrl-C, terminal close, WSL OOM), the + # marker survives and the next ``hermes`` launch finishes the install + # via ``_recover_from_interrupted_install``. Cleared after the core + # ``.[all]`` install completes — lazy refresh uses a separate marker. + _write_update_incomplete_marker() + deps_current = _editable_install_is_current( + git_cmd, _m().PROJECT_ROOT, pre_pull_sha + ) + if deps_current: + print("→ Python dependencies unchanged — skipping reinstall") + else: + print("→ Updating Python dependencies...") + from hermes_cli.managed_uv import ensure_uv, update_managed_uv + + # Keep managed uv current — runs `uv self update` if we already have one. + update_managed_uv() + + uv_bin = ensure_uv() + + pip_cmd = [sys.executable, "-m", "pip"] + if not uv_bin: + uv_bin = _ensure_uv_for_termux(pip_cmd) + install_group = "all" + + if uv_bin: + # Use official managed_python_env() isolation so third-party + # UV_PYTHON_INSTALL_DIR (e.g. WorkBuddy) cannot hijack uv; then + # point VIRTUAL_ENV at this install's venv. + from hermes_cli.managed_uv import managed_python_env + + uv_env = managed_python_env() + uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") + if _m()._is_termux_env(uv_env): + uv_env.pop("PYTHONPATH", None) + uv_env.pop("PYTHONHOME", None) + install_group = "termux-all" + print(" → Termux detected: using uv + curated termux-all optional profile...") + if not deps_current: + if _m()._is_termux_env(uv_env) and _is_android_python(): + print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") + _install_psutil_android_compat([uv_bin, "pip"], env=uv_env) + _m()._install_python_dependencies_with_optional_fallback( + [uv_bin, "pip"], env=uv_env, group=install_group + ) + else: + # sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors. + pip_cmd = [sys.executable, "-m", "pip"] + _ensure_venv_pip(pip_cmd, sys.executable) + if _m()._is_termux_env(): + install_group = "termux-all" + print(" → Termux detected: using curated termux-all optional profile...") + if not deps_current: + if _m()._is_termux_env() and _is_android_python(): + print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") + _install_psutil_android_compat(pip_cmd) + _m()._install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group) + + install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd + lazy_env = uv_env if uv_bin else None + + if deps_current: + # The verification normally runs inside the install we just + # skipped. Run it here so a wrong skip self-heals into a real + # install (both verifiers reinstall what they find missing) + # instead of leaving a venv nobody checked. + _m()._verify_core_dependencies_installed( + install_prefix, env=lazy_env, group=install_group + ) + _m()._verify_console_scripts_installed(install_prefix, env=lazy_env) + + # Core ``.[all]`` install finished. Clear the generic core breadcrumb + # before the lazy-refresh phase — that phase uses its own marker so a + # later lazy failure cannot be "healed" by clearing the core marker + # based on a narrow 7-package import probe (#58004 review). + _m()._clear_update_incomplete_marker() + + # The update process is still the old Python interpreter process. Run + # one final cache/module refresh immediately before lazy backend + # refresh, which imports newly-pulled modules that may depend on fresh + # symbols in hermes_constants or lazy_deps. The dependency install + # above may also have regenerated bytecode from build-cache copies — + # this second sweep catches those stragglers (#60242, #65240). + _sweep_bytecode_after_update(branch) + _m()._reload_updated_runtime_modules() + + # Upgrade pip before lazy refreshes — stale pip can fail source builds + # and leave partially-written packages (#57828). + _write_lazy_refresh_incomplete_marker() + _m()._upgrade_pip_before_lazy_refresh(install_prefix, env=lazy_env) + + # Lazy refresh can corrupt the venv when a backend install fails. + # Clear the lazy marker only when refresh/repair is confirmed healthy. + lazy_ok = _m()._refresh_active_lazy_features( + install_prefix, + env=lazy_env, + features=active_lazy_features, + ) + if lazy_ok: + _m()._clear_lazy_refresh_incomplete_marker() + else: + print( + " ⚠ Lazy-refresh recovery incomplete — run `hermes` again " + "to finish import-based venv repair." + ) + + _m()._restore_active_tool_dependencies( + active_tool_dependencies, + install_prefix, + env=lazy_env, + ) + + # Heal the active memory provider's bridge packages last — the core + # reinstall + lazy refresh above may have stripped or downgraded + # plugin.yaml-declared deps that aren't in extras (#53272, #70636). + _m()._refresh_active_memory_provider_dependencies() + + # All transient-ImportError sources have run, so a module that still + # won't import is real breakage. Warn only — never roll back: `cannot + # import name X` is also the stale-bytecode signature (#6207, #60242), + # which _sweep_stale_bytecode_if_checkout_changed() self-heals next launch. + import_ok, failing_module, import_error = _validate_critical_modules_import( + _m().PROJECT_ROOT + ) + if not import_ok: + print() + print(f" ⚠ {failing_module} still fails to import after updating:") + print(f" {import_error}") + print(" Run `hermes update` again — if it persists, reinstall:") + print(" https://hermes-agent.nousresearch.com") diff --git a/tests/hermes_cli/conftest.py b/tests/hermes_cli/conftest.py index 2fd01e555d..3d2f907462 100644 --- a/tests/hermes_cli/conftest.py +++ b/tests/hermes_cli/conftest.py @@ -75,7 +75,7 @@ def isolated_update_processes(): with patch("hermes_cli.gateway.find_gateway_pids", return_value=[]), \ patch("hermes_cli.gateway.supports_systemd_services", return_value=False), \ patch("hermes_cli.gateway.find_profile_gateway_processes", return_value=[]), \ - patch("hermes_cli.main._detect_venv_python_processes", return_value=[]), \ + patch("hermes_cli.update_cmd_windows._detect_venv_python_processes", return_value=[]), \ patch("hermes_cli.main._fleet_probe_expected_runtimes", return_value=False), \ patch("os.kill"), \ patch("pm.sync_venv"), \ diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 36123e3f4d..97245801c3 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -19,9 +19,7 @@ def _isolate_venv_holders(monkeypatch): """The update flow's venv-holder guard sees the live gateway processes on a dev machine and aborts with SystemExit 2 before reaching the branch logic under test. Isolate it so the test exercises the intended path.""" - import hermes_cli.main as cli_main - - monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: []) def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"): diff --git a/tests/hermes_cli/test_desktop_lifecycle_windows_live.py b/tests/hermes_cli/test_desktop_lifecycle_windows_live.py index 4a462ea03d..3630197605 100644 --- a/tests/hermes_cli/test_desktop_lifecycle_windows_live.py +++ b/tests/hermes_cli/test_desktop_lifecycle_windows_live.py @@ -128,7 +128,7 @@ def test_holder_scan_fallback_respects_token_classifier(sleeper, monkeypatch, tm return out monkeypatch.setattr( - "hermes_cli.main._detect_venv_python_processes", fake_holders + "hermes_cli.update_cmd_windows._detect_venv_python_processes", fake_holders ) # serve-shaped holder with a live parent (us) → owns @@ -138,7 +138,7 @@ def test_holder_scan_fallback_respects_token_classifier(sleeper, monkeypatch, tm serve_like.kill() serve_like.wait() monkeypatch.setattr( - "hermes_cli.main._detect_venv_python_processes", + "hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: [fake_holders()[1]], ) assert update_cmd._desktop_owns_gateway_lifecycle() is False diff --git a/tests/hermes_cli/test_update_concurrent_quarantine.py b/tests/hermes_cli/test_update_concurrent_quarantine.py index b8169ab07b..377887fad3 100644 --- a/tests/hermes_cli/test_update_concurrent_quarantine.py +++ b/tests/hermes_cli/test_update_concurrent_quarantine.py @@ -1081,8 +1081,8 @@ def test_update_impl_refuses_before_terminating_gateway_ancestor( cli_main, "_run_pre_update_backup", return_value=None ), patch.object( cli_main, "_pause_windows_gateways_for_update", return_value=None - ), patch.object( - cli_main, "_detect_venv_python_processes", return_value=[holder] + ), patch( + "hermes_cli.update_cmd_windows._detect_venv_python_processes", return_value=[holder] ), patch.object( cli_main, "_leftover_pausable_gateway_pids", return_value=[300] ), patch.object( diff --git a/tests/hermes_cli/test_update_head_moved_gate.py b/tests/hermes_cli/test_update_head_moved_gate.py index c014b074b3..02fcd7c17b 100644 --- a/tests/hermes_cli/test_update_head_moved_gate.py +++ b/tests/hermes_cli/test_update_head_moved_gate.py @@ -26,7 +26,7 @@ def _isolate_venv_holders(monkeypatch): """The update flow's venv-holder guard sees the live gateway processes on a dev machine and aborts with SystemExit 2 before reaching the HEAD-move gate under test. Isolate it so the test exercises the intended path.""" - monkeypatch.setattr(hermes_main, "_detect_venv_python_processes", lambda: []) + monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: []) def _make_head_moved_side_effect(pre_sha="abc123", post_sha="def456"): diff --git a/tests/hermes_cli/test_update_launch_completion.py b/tests/hermes_cli/test_update_launch_completion.py new file mode 100644 index 0000000000..90a1ab3e6d --- /dev/null +++ b/tests/hermes_cli/test_update_launch_completion.py @@ -0,0 +1,168 @@ +"""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 hermes_cli.runtime_paths import runtime_facts_path + + +def test_first_launch_syncs_without_marker_then_uses_completion_fact(tmp_path, monkeypatch): + 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) + 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})] + assert not (root / ".update-incomplete").exists() + 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","dev"]}}}' + 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): + 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() diff --git a/tests/hermes_cli/test_update_orphan_backend_reap.py b/tests/hermes_cli/test_update_orphan_backend_reap.py index c121d0fb0d..3b63d53efa 100644 --- a/tests/hermes_cli/test_update_orphan_backend_reap.py +++ b/tests/hermes_cli/test_update_orphan_backend_reap.py @@ -265,8 +265,8 @@ def _run_guard(detect_side_effect, orphan_return): cli_main, "_pause_windows_gateways_for_update", return_value=None ), patch.object( cli_main, "_resume_windows_gateways_after_update" - ), patch.object( - cli_main, "_detect_venv_python_processes", side_effect=detect_side_effect + ), patch( + "hermes_cli.update_cmd_windows._detect_venv_python_processes", side_effect=detect_side_effect ), patch.object( cli_main, "_leftover_pausable_gateway_pids", return_value=None ), patch.object( diff --git a/tests/hermes_cli/test_update_venv_health.py b/tests/hermes_cli/test_update_venv_health.py index 77f0dc4ac1..62cf5171a4 100644 --- a/tests/hermes_cli/test_update_venv_health.py +++ b/tests/hermes_cli/test_update_venv_health.py @@ -24,7 +24,7 @@ from unittest.mock import MagicMock, patch import pytest from hermes_cli import main as cli_main -from hermes_cli import update_cmd +from hermes_cli import update_cmd, update_cmd_windows # --------------------------------------------------------------------------- @@ -84,7 +84,7 @@ def test_detect_venv_python_excludes_self_and_ancestors(_winp, tmp_path): with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict( sys.modules, {"psutil": fake_psutil} ): - assert cli_main._detect_venv_python_processes() == [] + assert update_cmd_windows._detect_venv_python_processes() == [] @patch.object(cli_main, "_is_windows", return_value=True) @@ -109,7 +109,7 @@ def test_detect_venv_python_prefetches_only_cheap_process_fields(_winp, tmp_path with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict( sys.modules, {"psutil": fake_psutil} ): - matches = cli_main._detect_venv_python_processes() + matches = update_cmd_windows._detect_venv_python_processes() assert attrs_seen == [["pid", "exe", "name"]] assert [match[0] for match in matches] == [101] @@ -137,7 +137,7 @@ def test_detect_venv_python_keeps_external_interpreter_fallback(_winp, tmp_path) with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict( sys.modules, {"psutil": fake_psutil} ): - matches = cli_main._detect_venv_python_processes() + matches = update_cmd_windows._detect_venv_python_processes() assert [match[0] for match in matches] == [103] external.cmdline.assert_called_once_with() @@ -187,9 +187,8 @@ def _run_update_until_guard(args): cli_main, "_pause_windows_gateways_for_update", return_value=None ), patch.object( cli_main, "_resume_windows_gateways_after_update" - ), patch.object( - cli_main, - "_detect_venv_python_processes", + ), patch( + "hermes_cli.update_cmd_windows._detect_venv_python_processes", return_value=[(101, "python.exe", "python.exe -m hermes_cli.main serve")], ), patch.object( # Pin the orphan classifier: this test exercises --force/--force-venv diff --git a/tests/hermes_cli/test_update_yes_flag.py b/tests/hermes_cli/test_update_yes_flag.py index 140c60eb81..8c7b7e0d9c 100644 --- a/tests/hermes_cli/test_update_yes_flag.py +++ b/tests/hermes_cli/test_update_yes_flag.py @@ -26,9 +26,7 @@ def _isolate_venv_holders(monkeypatch): """The update flow's venv-holder guard sees the live gateway processes on a dev machine and aborts with SystemExit 2 before reaching the branch logic under test. Isolate it so the test exercises the intended path.""" - import hermes_cli.main as cli_main - - monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: []) monkeypatch.setattr("pm.sync_venv", lambda *a, **k: None) diff --git a/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py index e1454f0fcc..e28738637e 100644 --- a/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py +++ b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py @@ -71,7 +71,7 @@ def test_ledger_live_serve_with_live_spawner_owns_lifecycle(monkeypatch): process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()] ) monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: False) - monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: []) assert update_cmd._desktop_owns_gateway_lifecycle() is True @@ -81,7 +81,7 @@ def test_orphaned_control_plane_does_not_own_lifecycle(monkeypatch): process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()] ) monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: True) - monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: []) assert update_cmd._desktop_owns_gateway_lifecycle() is False diff --git a/tests/pm/test_source_update_launch.py b/tests/pm/test_source_update_launch.py new file mode 100644 index 0000000000..52517f2c5f --- /dev/null +++ b/tests/pm/test_source_update_launch.py @@ -0,0 +1,256 @@ +"""Source-update launch completion must publish a real PM generation. + +Only tool acquisition is substituted: the isolated worker uses the test host's +uv/Python. Currency checks, resolution, installation, validation, facts and +selection all run through production PM. The shared worker fixture stages PM's +small locked dependency graph; the application project needs no downloads. +""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +import pm +from hermes_cli import venv_sync +from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, selected_venv, site_packages +from pm import paths +from pm.lock import Facts +from pm.package import InstallError +from tests.pm.test_worker import isolated_python # noqa: F401 + + +@pytest.fixture +def source_launch(tmp_path, monkeypatch, isolated_python): + client = importlib.import_module("pm.client") + engine = importlib.import_module("pm.ensure") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "tool-lock.json") + + uv = shutil.which("uv") + assert uv, "source launch integration requires real uv" + worker = Path(client.__file__).with_name("worker.py") + worker_code = ( + "import runpy, sys\n" + "from pathlib import Path\n" + f"sys.path.insert(0, {str(worker.parent.parent)!r})\n" + "import pm._uv\n" + f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r}))\n" + f"runpy.run_path({str(worker)!r}, run_name='__main__')\n" + ) + command = [str(isolated_python), "-I", "-B", "-c", worker_code] + monkeypatch.setattr(client, "runtime_command", lambda *args, **kwargs: command) + monkeypatch.setattr(engine, "sync_venv", lambda *args, **kwargs: pytest.fail("sync escaped worker isolation")) + + root = tmp_path / "source with spaces" + root.mkdir() + (root / ".git").mkdir() + (root / "install-stamp.json").write_text( + json.dumps({"updateMechanism": "self"}), encoding="utf-8", + ) + (root / "pyproject.toml").write_text( + '[project]\nname = "launch-proof"\nversion = "1"\nrequires-python = ">=3.11"\n' + '[project.optional-dependencies]\nall = []\nlaunch-extra = []\n' + '[tool.uv]\npackage = false\nno-index = true\noffline = true\n', encoding="utf-8", + ) + pm.lock_project(root, offline=True, explicit=True) + + # Exercise the launcher's real store lookup without fabricating tool facts. + # This is a real executable, not a fake installer or successful shell stub. + store_python = tmp_path / "store" / "python-test" / "bin" / "python3" + store_python.parent.mkdir(parents=True) + # A Nix Python wrapper resets sys.executable to its own path. PM installs + # an actual interpreter, so use the underlying binary rather than a wrapper. + store_python.symlink_to(sys._base_executable) + return root, store_python, command + + +def _fact(root): + fact = Facts(runtime_facts_path(root), strict=True).get("venv") + assert fact is not None + selected = selected_venv(root) + assert Path(fact["environment"]) == selected + assert selected.is_relative_to(install_state_dir(root) / "environments") + assert (selected / "pyvenv.cfg").is_file() + assert Path(fact["resolved_lock"]).is_file() + probe = subprocess.run( + [str(selected / "bin" / "python"), "-I", "-c", "import json,sys; print(json.dumps(sys.prefix))"], + capture_output=True, text=True, timeout=30, + ) + assert probe.returncode == 0, probe.stderr + assert Path(json.loads(probe.stdout)) == selected + return fact + + +def _receipts(tmp_path): + return set((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json")) + + +@pytest.mark.platforms("posix") +def test_launch_without_marker_publishes_then_skips_and_rebuilds_on_lock_change(source_launch, tmp_path): + root, store_python, _ = source_launch + assert not runtime_facts_path(root).exists() + assert not (root / ".update-incomplete").exists() + assert not pm.venv_is_current(project_root=root) + + assert venv_sync.prepare_launch(root, []) == store_python + first = _fact(root) + assert first["extras"] == ["all"] + assert pm.venv_is_current(project_root=root) + facts_bytes = runtime_facts_path(root).read_bytes() + generations = set((install_state_dir(root) / "environments").iterdir()) + receipts = _receipts(tmp_path) + assert receipts + + # A caller still in its old interpreter must re-exec, but must not sync again. + assert venv_sync.prepare_launch(root, []) == store_python + assert runtime_facts_path(root).read_bytes() == facts_bytes + assert set((install_state_dir(root) / "environments").iterdir()) == generations + assert _receipts(tmp_path) == receipts, "current launch performed another sync" + + lock = root / "uv.lock" + lock.write_bytes(lock.read_bytes() + b"\n# source update changes the committed lock\n") + assert not pm.venv_is_current(project_root=root) + assert venv_sync.prepare_launch(root, []) == store_python + rebuilt = _fact(root) + assert rebuilt["stamp"] != first["stamp"] + assert rebuilt["environment"] != first["environment"] + assert rebuilt["extras"] == first["extras"] + assert Path(first["environment"]).is_dir() + assert pm.venv_is_current(project_root=root) + assert not (root / ".update-incomplete").exists() + + +@pytest.mark.platforms("posix") +def test_failed_real_sync_preserves_previous_selection_and_retries(source_launch, tmp_path): + root, store_python, _ = source_launch + # Established PM installs must retain their selection, not gain legacy [all]. + pm.sync_venv(["launch-extra"], explicit=True, project_root=root) + previous = _fact(root) + facts_bytes = runtime_facts_path(root).read_bytes() + generations = set((install_state_dir(root) / "environments").iterdir()) + lock = root / "uv.lock" + valid_lock = lock.read_bytes() + lock.write_text("version = 1\nnot valid TOML\n", encoding="utf-8") + markers = [root / name for name in (".update-incomplete", ".lazy-refresh-incomplete")] + for marker in markers: + marker.write_text("legacy pending install", encoding="utf-8") + + for _ in range(2): + receipts = _receipts(tmp_path) + with pytest.raises(InstallError, match="uv sync exited"): + venv_sync.prepare_launch(root, []) + failed, = _receipts(tmp_path) - receipts + assert json.loads(failed.read_text(encoding="utf-8"))["outcome"] == "failed" + assert runtime_facts_path(root).read_bytes() == facts_bytes + assert _fact(root) == previous + assert set((install_state_dir(root) / "environments").iterdir()) == generations + assert not pm.venv_is_current(project_root=root) + assert all(marker.read_text(encoding="utf-8") == "legacy pending install" for marker in markers) + + lock.write_bytes(valid_lock + b"\n# corrected source update\n") + assert venv_sync.prepare_launch(root, []) == store_python + rebuilt = _fact(root) + assert rebuilt["environment"] != previous["environment"] + assert rebuilt["extras"] == ["launch-extra"] + assert pm.venv_is_current(project_root=root) + assert not any(marker.exists() for marker in markers) + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("mode", ["script", "module", "command"]) +def test_real_bootstrap_reexecs_before_app_imports(source_launch, tmp_path, isolated_python, mode): + root, store_python, worker_command = source_launch + repository = Path(__file__).resolve().parents[2] + # Copy the real bootstrap so it owns this disposable source install. The + # other modules remain real checkout imports; only acquisition is injected. + shutil.copy2(repository / "hermes_bootstrap.py", root / "hermes_bootstrap.py") + (root / "launch_test_tools.py").write_text( + "import sys\n" + "from pathlib import Path\n" + f"sys.path.insert(1, {str(repository)!r})\n" + "import pm.client\n" + "from pm import paths\n" + f"paths.lockfile_path = lambda: Path({str(tmp_path / 'tool-lock.json')!r})\n" + f"pm.client.runtime_command = lambda *args, **kwargs: {worker_command!r}\n", + encoding="utf-8", + ) + entry = root / "launch_probe.py" + entry.write_text( + "import launch_test_tools\n" + "import hermes_bootstrap\n" + "import json, sys\n" + "from pathlib import Path\n" + "from hermes_cli.runtime_paths import selected_venv, site_packages\n" + "from hermes_cli.venv_sync import prepare_launch\n" + "root = Path(__file__).parent\n" + "selected = selected_venv(root)\n" + "print(json.dumps({'executable': sys.executable, 'args': sys.argv[1:],\n" + " 'site': str(site_packages(selected)), 'path': sys.path,\n" + " 'module': getattr(__spec__, 'name', None),\n" + " 'launch_current': prepare_launch(root, sys.argv[1:]) is None}))\n", + encoding="utf-8", + ) + args = ["--profile", "name with spaces", "-c", "session"] + invocation = { + "script": [str(entry)], + "module": ["-m", "launch_probe"], + "command": ["-c", "import launch_probe"], + }[mode] + assert not runtime_facts_path(root).exists() + activated_old = tmp_path / "activated-old-dependencies" + if mode == "script": + pm.sync_venv(["all"], explicit=True, project_root=root) + old_site = site_packages(selected_venv(root)) + # Executable .pth files are real activation hooks. If bootstrap selects + # the old generation before completing the update, this leaves evidence. + (old_site / "old_dependency.pth").write_text( + f"import pathlib; pathlib.Path({str(activated_old)!r}).touch()\n", encoding="utf-8", + ) + activation_probe = subprocess.run( + [str(isolated_python), "-I", "-c", + f"import sys; sys.path.insert(0, {str(repository)!r}); " + "from pathlib import Path; from hermes_cli.runtime_paths import activate_dependencies; " + f"activate_dependencies(Path({str(root)!r}))"], + capture_output=True, text=True, timeout=30, + ) + assert activation_probe.returncode == 0, activation_probe.stderr + assert activated_old.is_file(), "positive control did not execute the old activation hook" + activated_old.unlink() + lock = root / "uv.lock" + lock.write_bytes(lock.read_bytes() + b"\n# update before activation\n") + if mode == "module": + # These leftovers must not trigger an immediate second (repair) sync + # after launch completion, which would validate the full app graph. + for name in (".update-incomplete", ".lazy-refresh-incomplete"): + (root / name).write_text("legacy pending install", encoding="utf-8") + before_receipts = _receipts(tmp_path) + result = subprocess.run( + [str(isolated_python), *invocation, *args], cwd=root, + env=dict(os.environ), capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stderr + output = json.loads(result.stdout) + assert output["executable"] == str(store_python) + assert output["args"] == args + assert output["launch_current"] is True + assert output["module"] == (None if mode == "script" else "launch_probe") + selected = selected_venv(root) + assert Path(output["site"]).is_relative_to(selected) + assert output["site"] in output["path"] + assert not activated_old.exists(), "old dependencies activated before source-update completion" + assert _fact(root)["extras"] == ["all"] + receipt, = _receipts(tmp_path) - before_receipts + assert json.loads(receipt.read_text(encoding="utf-8"))["outcome"] == "ok" + assert not (root / ".update-incomplete").exists() + assert not (root / ".lazy-refresh-incomplete").exists() diff --git a/tests/test_old_updater_shims.py b/tests/test_old_updater_shims.py new file mode 100644 index 0000000000..444837701a --- /dev/null +++ b/tests/test_old_updater_shims.py @@ -0,0 +1,185 @@ +"""The post-swap import boundary must never revive retired installers.""" + +import importlib +import importlib.util +import os +from pathlib import Path +import socket +import subprocess +import sys +import urllib.request + +import pytest + + +@pytest.fixture +def no_external_work(monkeypatch): + """Fail at real I/O and PM boundaries, not at the shim under test.""" + import pm + + def forbidden(*args, **kwargs): + pytest.fail("old-updater shim attempted external work") + + for name in ("sync_venv", "ensure_environment", "build_environment"): + monkeypatch.setattr(pm, name, forbidden) + monkeypatch.setattr(subprocess, "Popen", forbidden) + monkeypatch.setattr(os, "system", forbidden) + monkeypatch.setattr(os, "kill", forbidden) + monkeypatch.setattr(socket, "create_connection", forbidden) + monkeypatch.setattr(urllib.request, "urlopen", forbidden) + monkeypatch.setattr(urllib.request, "urlretrieve", forbidden) + return forbidden + + +@pytest.mark.parametrize( + "name,args,kwargs", + [ + ("ensure_uv", (), {}), + ("ensure_uv", (), {"repair_observer": lambda result: pytest.fail("repair observer ran")}), + ("update_managed_uv", (), {}), + ("update_managed_uv", (), {"force": True}), + ("resolve_uv", (), {}), + ("managed_python_env", (), {}), + ("managed_python_env", (Path("checkout"),), {"install_dir": Path("python"), "base_env": {}}), + ("rebuild_venv", ("uv", Path("venv")), {}), + ("rebuild_venv", ("uv", Path("venv"), "3.11"), {}), + ], +) +def test_retired_managed_uv_stops_before_fallback(name, args, kwargs, no_external_work, capsys): + module = importlib.import_module("hermes_cli.managed_uv") + before_env = dict(os.environ) + before_home = set(Path(os.environ["HERMES_HOME"]).rglob("*")) + with pytest.raises(SystemExit) as exc: + getattr(module, name)(*args, **kwargs) + assert exc.value.code == 0 + output = capsys.readouterr() + assert "relaunch" in (output.out + output.err).lower() + assert dict(os.environ) == before_env + assert set(Path(os.environ["HERMES_HOME"]).rglob("*")) == before_home + + +@pytest.mark.parametrize("unpack", [False, True], ids=["path-era", "tuple-era"]) +def test_ensure_uv_stops_both_historical_return_contracts(unpack, no_external_work, capsys): + from hermes_cli.managed_uv import ensure_uv + + with pytest.raises(SystemExit) as exc: + if unpack: + uv, fresh_bootstrap = ensure_uv() + else: + uv = ensure_uv() + # A falsy result is NOT inert: old callers install through pip instead. + subprocess.run([uv, "pip", "install"] if uv else [sys.executable, "-m", "pip", "install"]) + assert exc.value.code == 0 + assert "relaunch" in capsys.readouterr().err.lower() + + +@pytest.mark.parametrize( + "module,name,args,kwargs", + [ + ("hermes_cli.psutil_android", "prepare_patched_psutil_sdist", (Path("psutil.tar.gz"), Path("src")), {}), + ("hermes_cli.update_cmd", "_ensure_uv_for_termux", (["python", "-m", "pip"],), {}), + ("hermes_cli.update_cmd", "_ensure_venv_pip", (["python", "-m", "pip"], "python"), {}), + ("hermes_cli.update_cmd", "_pip_install_prefix", (None,), {}), + ("hermes_cli.update_cmd", "_pip_install_prefix", ("uv",), {}), + ("hermes_cli.update_cmd", "_refuse_update_for_contended_shims", (RuntimeError("locked"),), {}), + ], +) +def test_other_dependency_entrypoints_stop_cleanly(module, name, args, kwargs, no_external_work, capsys): + before_home = set(Path(os.environ["HERMES_HOME"]).rglob("*")) + with pytest.raises(SystemExit) as exc: + shim = getattr(importlib.import_module(module), name) + shim(*args, **kwargs) + assert exc.value.code == 0 + assert "relaunch" in capsys.readouterr().err.lower() + assert set(Path(os.environ["HERMES_HOME"]).rglob("*")) == before_home + + +def test_retired_probes_and_refreshes_do_no_work(no_external_work, tmp_path): + from hermes_cli import _install_repair, backup, banner, config, main, update_cmd + from tools import browser_tool + + assert _install_repair._sync_windows_cli_launchers(tmp_path) == [] + # Unknown, not an invented "no live holders" result. + assert backup._foreign_db_holder_pids(tmp_path / "state.db") is None + assert banner._check_via_pypi() is None + assert banner.check_via_pypi() is None + assert config.is_uv_tool_install() is False + assert config.is_unsupported_install_method("pip") is False + assert config.format_unsupported_install_warning("pip") == "" + assert main._detect_venv_python_processes() == [] + assert main._detect_venv_python_processes(exclude_pids={123}) == [] + assert browser_tool.warm_agent_browser_npx_cache() is False + assert browser_tool.warm_agent_browser_npx_cache(timeout=0.1) is False + # A private, never-raised type keeps historical `except helper():` valid + # without swallowing real errors or resolving the removed quarantine code. + error_type = update_cmd._shim_quarantine_error_type() + assert issubclass(error_type, Exception) + with pytest.raises(RuntimeError, match="not a quarantine"): + try: + raise RuntimeError("not a quarantine") + except error_type: + pytest.fail("retired quarantine caught an unrelated exception") + + +@pytest.fixture +def old_updater(): + path = Path(__file__).parent / "compat" / "old_updater_dependencies.py" + spec = importlib.util.spec_from_file_location("old_updater_dependencies", path) + assert spec is not None and spec.loader is not None + old = importlib.util.module_from_spec(spec) + spec.loader.exec_module(old) + return old + + +def test_old_android_updater_stops_before_download(old_updater, no_external_work, capsys): + with pytest.raises(SystemExit) as exc: + old_updater._install_psutil_android_compat(["uv", "pip"]) + assert exc.value.code == 0 + assert "relaunch" in capsys.readouterr().err.lower() + + +def test_old_updater_retains_its_code_but_loads_new_managed_uv(old_updater, no_external_work, capsys, tmp_path): + """The real pre-PM post-pull function must stop at its new lazy import.""" + from types import SimpleNamespace + + old = old_updater + # These are the already-imported pre-swap collaborators. Record the prefix + # without touching an installation; do not replace any lazy imports in the + # frozen function (the managed_uv import is the boundary being exercised). + prefix = [] + old._refuse_update_if_venv_foreign_owned = lambda root: prefix.append("ownership") + old_main = SimpleNamespace( + PROJECT_ROOT=tmp_path, + _abort_dependency_sync_if_self_locked=lambda token: prefix.append("self-lock"), + ) + old._m = lambda: old_main + old._write_update_incomplete_marker = lambda: prefix.append("old-marker") + old._editable_install_is_current = lambda *args: False + old._ensure_venv_pip = no_external_work + old._ensure_uv_for_termux = no_external_work + before = set(tmp_path.rglob("*")) + + with pytest.raises(SystemExit) as exc: + old._sync_python_dependencies_after_pull( + ["git"], "main", "before-pull", active_lazy_features=[], + active_tool_dependencies=[], _windows_gateway_resume=None, + ) + assert exc.value.code == 0 + assert prefix == ["ownership", "self-lock", "old-marker"] + assert "relaunch" in capsys.readouterr().err.lower() + assert set(tmp_path.rglob("*")) == before + + +def test_live_windows_scan_does_not_use_the_retired_main_alias(monkeypatch, no_external_work): + from hermes_cli import main, process_identity, update_cmd_windows + + # This is routing, not OS emulation: the lifecycle fallback accepts holder + # rows on any host. The real scanner remains owned by update_cmd_windows. + monkeypatch.setattr(main, "_detect_venv_python_processes", no_external_work) + monkeypatch.setattr(process_identity, "ledger_entries", lambda: []) + monkeypatch.setattr(update_cmd_windows, "_psutil", lambda: None) + monkeypatch.setattr( + update_cmd_windows, "_detect_venv_python_processes", + lambda: [(123, "python", "python -m hermes_cli.main serve")], + ) + assert update_cmd_windows._desktop_owns_gateway_lifecycle() is True diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 6a6843eb77..0b94d12854 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -35,6 +35,11 @@ _BROWSER_PASSTHROUGH_KEYS: tuple[str, ...] = ( ) +def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool: + # Shim to stop the old updater doing work until relaunch. Nothing was warmed. + return False + + def _build_browser_env() -> dict: """Credential-scrubbed env for an agent-browser subprocess (deferred import: test harnesses stub the ``tools`` package).""" diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 031a061eb1..29bc7e6068 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -26,6 +26,18 @@ Each file has a separate role: A lockfile entry does not prove that a package is installed. `hermes pm doctor` compares the installed state with the lock and checks the realized bytes. Startup uses a cheaper check. It does not query upstream versions on every launch. +For self-managed source installs, the pre-import launcher compares PM's recorded +successful dependency stamp with the current inputs. Missing or stale completion +state triggers a sync, then the same command restarts on the managed Python before +loading application dependencies. Failed syncs keep the previous selection and +retry on the next launch; no pending-update marker is required. Developer checkouts +and packaged installations retain their existing owner. + +Historical updaters can still be executing old Python code after swapping in this +source tree. Their retired helper names are inert compatibility shims; dependency +entry shims stop the old updater cleanly and ask for a relaunch instead of invoking +PM or falling back to pip. Completion belongs to the new launcher, not that mixed +old-code/new-files process. ## Source installs and packaged builds