From 19cff893000a6728ce525bd339b118d908f34968 Mon Sep 17 00:00:00 2001 From: Halldrix Date: Tue, 11 Aug 2026 09:29:52 -0500 Subject: [PATCH] fix(update): complete pending core install before any native import (self-lock loop fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer egilewski found the original defer was circular (#83590 comment): the self-lock preflight wrote .update-incomplete and exited, but the next launch only ran the full recovery AFTER main.py's third-party imports — so a healthy venv's probes made the early pass a no-op, main.py imported cryptography eagerly, the .pyd got mapped again, and the deferred install re-hit the exact self-lock it was meant to escape. Close the loop by making the marker guarantee the install runs BEFORE any native extension can be imported: - hermes_cli/_install_repair.py (new, stdlib-only): single source of truth for the core .[all] reinstall — ensurepip bootstrap, uv-pip/pip resolution with VIRTUAL_ENV, Termux env stripping, Windows hermes*.exe quarantine, per-extra fallback ladder, and fd1→fd2 routing for acp safety. Deliberately free of managed_uv/hermes_constants imports so it stays importable in the corrupted-venv state it exists to repair. - hermes_cli/_early_recovery.py: recover_if_needed now completes a pending .update-incomplete install BEFORE the import probes, on every launch that sees the marker (unless argv is update). Success clears the marker; failure bumps an attempts counter inside the marker body and keeps it. A 3-attempt ceiling stops a persistently-failing install from reinstall-hammering every launch (hermes acp included) — past the ceiling the late post-import recovery takes over with its manual recovery instructions. Single-flight lock shared with the late path. - hermes_cli/main.py: _recover_core_update_marker_locked delegates the install to the shared executor (no duplicated logic); ensure_uv stays in the late path so a venv whose uv vanished mid-update still bootstraps it. - tests: 7 new regressions — the reviewer's exact case (marker + healthy venv → install runs while sys.modules has no cryptography), failure keeps marker + increments attempts, retry ceiling, lazy marker does not trigger core install (#58004 invariant), argv-update skip, and corrupt/missing marker bodies. The key test was sabotage-verified: removing the pre-import branch makes it fail with zero install calls, while a lone-lazy-marker test still passes; restoring the branch makes it pass again. Refs #83569 --- hermes_cli/_early_recovery.py | 138 ++++++++ hermes_cli/_install_repair.py | 304 ++++++++++++++++++ hermes_cli/main.py | 39 +-- tests/hermes_cli/test_early_recovery.py | 161 ++++++++++ .../test_update_interrupted_recovery.py | 9 +- 5 files changed, 621 insertions(+), 30 deletions(-) create mode 100644 hermes_cli/_install_repair.py diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index f0d17ce3a9..910f8566c3 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -309,6 +309,20 @@ def recover_if_needed( if not (root / "pyproject.toml").is_file(): return + # Pending core install (.update-incomplete) — complete it NOW, before + # any native extension can be imported by this process. The lazy + # import-probe path below only proves main.py is importable; the core + # install here guarantees the WHOLE dependency set is replaced while + # nothing pins venv .pyd files yet (#83569 self-lock: deferring the + # install to main()'s post-import recovery re-locks it on Windows). + # Bounded retries: a persistently failing install must not hammer + # every launch, so attempts past the ceiling are left for main.py's + # post-import recovery path (which can safely probe-import after this + # process already holds whatever extensions it needs). + if core_marker.exists(): + _complete_pending_core_install(root, core_marker) + return + broken = _probe_broken_packages() if not broken: # Imports are fine — main.py will load and run full recovery. @@ -358,3 +372,127 @@ def recover_if_needed( except Exception: # Never block launch — the import of main.py will surface the truth. pass + + +# Cap on automatic early-pass install retries. A persistently failing +# install (e.g. network down, index unreachable) must not reinstall-hammer +# every `hermes` launch: past this many attempts the early pass hands the +# marker to main.py's post-import recovery, which presents the manual +# recovery command. The counter lives inside the marker file itself (JSON +# body) and is bumped on each failed attempt. +_EARLY_CORE_INSTALL_MAX_ATTEMPTS = 3 + + +def _claim_recovery_lock(root: Path) -> bool: + """Single-flight claim on the shared recovery lock. Never raises.""" + lock_path = root / ".update-incomplete.lock" + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, f"{os.getpid()}\n".encode()) + os.close(fd) + return True + except FileExistsError: + try: + if time.time() - lock_path.stat().st_mtime > 3600: + lock_path.unlink() + except OSError: + pass + return False + except OSError: + # Read-only fs / perms — proceed unlocked; the install itself + # surfaces the real problem. Recoverable. + return True + + +def _release_recovery_lock(root: Path) -> None: + """Best-effort release of the shared recovery lock.""" + try: + (root / ".update-incomplete.lock").unlink() + except OSError: + pass + + +def _complete_pending_core_install(root: Path, core_marker: Path) -> None: + """Run the pending core install BEFORE main.py can import native modules. + + ``recover_if_needed`` invokes this when ``.update-incomplete`` exists — + a prior ``hermes update`` (or the self-lock preflight, #83569) left the + dependency sync deliberately unfinished. Completing it here matters on + Windows: the deferral exists precisely because the process that wrote the + marker had a native venv extension mapped; this process, running before + ``hermes_cli.main``'s third-party imports, maps nothing yet, so the + installer can replace ``.pyd`` files without hitting the lock. + + Marker lifecycle: cleared on success; kept (attempts counter bumped) on + failure for the next launch or main.py's post-import recovery. An + attempts ceiling caps automatic retries so a persistent installer + failure does not block every launch (``hermes acp`` included). + + Never raises: any failure leaves the marker for the post-import path. + """ + try: + from hermes_cli import _install_repair as ir + + # Retry backoff: read current attempts before claiming the lock so a + # persistently-failing install stops hammering early. After the + # increment the counter reflects THIS attempt. + attempts = 0 + try: + raw = core_marker.read_text(encoding="utf-8", errors="replace").strip() + if raw: + import json as _json + + try: + attempts = int(_json.loads(raw).get("attempts", 0)) + except (ValueError, AttributeError): + attempts = 0 + except OSError: + attempts = 0 + + if attempts >= _EARLY_CORE_INSTALL_MAX_ATTEMPTS: + print( + "⚠ Pending interrupted-update install has already failed " + f"{attempts} times in the early pass — leaving it for the " + "post-import recovery path.", + file=sys.stderr, + ) + return + + if not _claim_recovery_lock(root): + return + + try: + print( + "⚠ A previous `hermes update` was interrupted mid-install — " + "finishing dependency installation now (before any native " + "extensions load)...", + file=sys.stderr, + ) + ir.run_core_install(root) + except Exception as exc: + new_attempts = ir.bump_marker_attempts(core_marker) + print( + f" ✗ Early interrupted-install completion failed (attempt " + f"{new_attempts}/{_EARLY_CORE_INSTALL_MAX_ATTEMPTS}): {exc}", + file=sys.stderr, + ) + print( + " The next launch will retry; hermes will keep working from " + "the current venv in the meantime.", + file=sys.stderr, + ) + return + finally: + _release_recovery_lock(root) + + try: + core_marker.unlink() + except OSError: + pass + print( + " ✓ Dependency installation completed in the early pass.", + file=sys.stderr, + ) + except Exception: + # Never block launch — the marker stays for the post-import path. + pass diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py new file mode 100644 index 0000000000..5e651effd9 --- /dev/null +++ b/hermes_cli/_install_repair.py @@ -0,0 +1,304 @@ +"""Dependency install execution shared between early recovery and full recovery. + +Both callers need to run the same core ``.[all]`` reinstall: + +- ``hermes_cli._early_recovery.recover_if_needed`` — stdlib-only, runs BEFORE + ``hermes_cli.main``'s third-party imports, so it can complete a pending + update while no native extension is mapped yet (#83569). +- ``hermes_cli.main._recover_core_update_marker_locked`` — the historical + post-import recovery path. Kept as a fallback for installs the early pass + could not complete (marker left in place on failure). + +This module is deliberately **stdlib-only** so importing it can never fail in +the corrupted-venv state it exists to repair. ``hermes_cli.main`` imports +``managed_uv``, ``hermes_constants``, and friends only in its late path; the +early path must not. Where the late path uses ``managed_uv.ensure_uv`` to +bootstrap uv if missing, the early path uses the stdlib +:func:`hermes_cli._early_recovery._find_uv_binary` lookup and falls back to +plain pip when uv is absent — a degraded but working installer (the late +recovery will bootstrap uv on the next launch if it ever matters). +""" + +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +# Single source of truth for the recovery-lock lifecycle and uv lookup — +# _early_recovery already owns both, and importing it is free (stdlib-only). +from hermes_cli import _early_recovery as _er + + +def _is_windows() -> bool: + return sys.platform == "win32" + + +def _is_termux_env(env: dict | None = None) -> bool: + """Stdlib Termux probe (hermes_cli.main's version lives behind imports).""" + env = env if env is not None else os.environ + try: + if env.get("TERMUX_VERSION"): + return True + prefix = env.get("PREFIX", "") + return "com.termux" in prefix + except Exception: + return False + + +@contextlib.contextmanager +def _stdout_to_stderr(): + """Route fd 1 (and sys.stdout) to stderr for the duration of an install. + + ``hermes acp`` speaks JSON-RPC on stdout; an inherited-fd install child + writing there would corrupt the protocol. Mirrors + ``main.py::_recover_from_interrupted_install``. + """ + saved_fd = None + saved_sys_stdout = sys.stdout + try: + saved_fd = os.dup(1) + os.dup2(2, 1) + except OSError: + saved_fd = None + sys.stdout = sys.stderr + try: + yield + finally: + sys.stdout = saved_sys_stdout + if saved_fd is not None: + try: + os.dup2(saved_fd, 1) + except OSError: + pass + try: + os.close(saved_fd) + except OSError: + pass + + +def _resolve_install_target(root: Path) -> tuple[list[str], dict | None]: + """(install_cmd_prefix, env) for the project venv — stdlib uv lookup. + + Mirrors ``main.py::_default_venv_install_target`` but without + ``managed_uv``. ``VIRTUAL_ENV`` steers ``uv pip`` at the project venv even + when invoked from the base interpreter (the early-recovery case). + Termux strips leaked interpreter-path env vars so uv resolves the venv + correctly. + """ + uv_bin = _er._find_uv_binary() + if uv_bin: + env = {**os.environ, "VIRTUAL_ENV": str(root / "venv")} + if _is_termux_env(env): + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) + return [uv_bin, "pip"], env + return [sys.executable, "-m", "pip"], None + + +def _venv_scripts_dir(root: Path) -> Path | None: + """Project venv Scripts/bin dir, when present. stdlib-only.""" + venv_dir = root / "venv" + if not venv_dir.is_dir(): + return None + scripts = venv_dir / ("Scripts" if _is_windows() else "bin") + return scripts if scripts.is_dir() else None + + +def _load_console_script_names(root: Path) -> list[str]: + """``[project.scripts]`` names from pyproject.toml (tomllib, 3.11+).""" + try: + import tomllib + except ImportError: # pragma: no cover + return [] + pyproject = root / "pyproject.toml" + if not pyproject.is_file(): + return [] + try: + with open(pyproject, "rb") as f: + data = tomllib.load(f) + scripts = data.get("project", {}).get("scripts", {}) or {} + return [str(name) for name in scripts if name] + except Exception: + return [] + + +def _quarantine_running_hermes_exe(scripts_dir: Path) -> list[tuple[Path, Path]]: + """Rename live hermes*.exe shims aside so the installer can rewrite them. + + Windows blocks REPLACE on a running .exe but allows RENAME. Best-effort: + silently skips anything that cannot be renamed. Returns (original, + quarantined) pairs. stdlib-only — the console-script set comes from + pyproject ``[project.scripts]`` (fallback: the well-known trio). + """ + if not _is_windows(): + return [] + names = set(_load_console_script_names(scripts_dir.parent.parent)) or { + "hermes", + "hermes-agent", + "hermes-acp", + } + names.add("hermes-gateway") + moved: list[tuple[Path, Path]] = [] + for name in sorted(names): + shim = scripts_dir / f"{name}.exe" + if not shim.exists(): + continue + quarantined = shim.with_name(f"{name}.exe.old.{int(time.time() * 1000)}") + try: + os.rename(shim, quarantined) + moved.append((shim, quarantined)) + except OSError: + pass + return moved + + +def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: + """Put quarantined shims back when the installer did not replace them.""" + for original, quarantined in moved: + if original.exists(): + continue # installer wrote a fresh shim — the .old one is garbage + try: + os.rename(quarantined, original) + except OSError: + pass + + +def _run_install_cmd(cmd: list[str], *, env: dict | None, root: Path) -> None: + """Run an install command with quarantine protection for venv shims. + + Raises CalledProcessError on install failure (callers implement the + per-extra fallback ladder). + """ + scripts_dir = _venv_scripts_dir(root) if _is_windows() else None + moved = _quarantine_running_hermes_exe(scripts_dir) if scripts_dir else [] + try: + subprocess.run(cmd, cwd=root, check=True, env=env) + except BaseException: + if scripts_dir is not None: + _restore_quarantined_exes(moved) + raise + + +def _load_installable_optional_extras(root: Path, group: str) -> list[str]: + """Optional extras referenced by a dependency group (all / termux-all).""" + try: + import tomllib + + with (root / "pyproject.toml").open("rb") as handle: + project = tomllib.load(handle).get("project", {}) + except Exception: + return [] + optional_deps = project.get("optional-dependencies", {}) + if not isinstance(optional_deps, dict): + return [] + refs = optional_deps.get(group, []) + referenced: list[str] = [] + for ref in refs: + if "[" in ref and "]" in ref: + name = ref.split("[", 1)[1].split("]", 1)[0] + if name in optional_deps: + referenced.append(name) + return referenced + + +def run_core_install(root: Path) -> None: + """Full core ``.[all]`` editable reinstall — the recovery install. + + Equal in behavior to the install half of + ``main.py::_recover_core_update_marker_locked``: + + - bootstrap pip via ensurepip (a killed install can leave the venv with no + pip module at all) + - prefer ``uv pip`` with VIRTUAL_ENV pointed at the project venv; fall back + to ``python -m pip`` when no uv binary is available + - target ``.[all]`` (or ``.[termux-all]`` on Termux) with the per-extra + fallback ladder when the combined extras resolve fails + - quarantine live ``hermes*.exe`` shims on Windows so they can be replaced + - route ALL install output to stderr (acp/JSON-RPC safety) + - Termux strips leaked PYTHONPATH/PYTHONHOME from the uv env + + Raises ``subprocess.CalledProcessError`` when even the base install fails; + callers own marker lifecycle (clear on success, keep on failure). + """ + prefix, env = _resolve_install_target(root) + group = "termux-all" if _is_termux_env(env) else "all" + + with _stdout_to_stderr(): + try: + subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + cwd=root, + capture_output=True, + ) + except Exception: + pass + + try: + _run_install_cmd( + prefix + ["install", "-e", f".[{group}]"], env=env, root=root + ) + return + except subprocess.CalledProcessError: + print( + " ⚠ Optional extras failed, reinstalling base dependencies " + "and retrying extras individually..." + ) + + _run_install_cmd(prefix + ["install", "-e", "."], env=env, root=root) + + failed_extras: list[str] = [] + installed_extras: list[str] = [] + for extra in _load_installable_optional_extras(root, group): + try: + _run_install_cmd( + prefix + ["install", "-e", f".[{extra}]"], env=env, root=root + ) + installed_extras.append(extra) + except subprocess.CalledProcessError: + failed_extras.append(extra) + if installed_extras: + print( + " ✓ Reinstalled optional extras individually: " + + ", ".join(installed_extras) + ) + if failed_extras: + print( + " ⚠ Skipped optional extras that still failed: " + + ", ".join(failed_extras) + ) + + +# --------------------------------------------------------------------------- +# Marker metadata (attempt counter for early-pass retry backoff) +# --------------------------------------------------------------------------- + + +def bump_marker_attempts(marker_path: Path) -> int: + """Increment an attempts counter stored inside the marker file. + + The marker's existence is the signal; opportunistic JSON body carries the + retry count so a persistently failing install can back off instead of + reinstall-hammering every launch. Corrupt/missing bodies restart at 1. + Returns the new attempt count. Never raises. + """ + attempts = 0 + try: + raw = marker_path.read_text(encoding="utf-8", errors="replace").strip() + if raw: + try: + attempts = int(json.loads(raw).get("attempts", 0)) + except (ValueError, AttributeError): + attempts = 0 + except OSError: + attempts = 0 + attempts += 1 + try: + marker_path.write_text(json.dumps({"attempts": attempts}), encoding="utf-8") + except OSError: + pass + return attempts diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a619fdc42c..e0371049fd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8188,36 +8188,21 @@ def _recover_core_update_marker_locked() -> None: _repair_venv_via_import_probes(install_prefix, env=install_env) try: + from hermes_cli import _install_repair as _ir + + # ensure_uv bootstraps the installer itself when missing (the early + # pass's stdlib-only lookup cannot); keeping it here means the late + # path still self-heals a venv whose uv vanished mid-update. from hermes_cli.managed_uv import ensure_uv - # Always bootstrap pip first: a killed install can leave the venv with - # no pip module at all, and uv may also be gone. ensurepip restores a - # known-good pip so at least the plain-pip path below can proceed. - try: - subprocess.run( - [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], - cwd=PROJECT_ROOT, - capture_output=True, - ) - except Exception as exc: - logger.debug("ensurepip during install recovery failed: %s", exc) + ensure_uv() - uv_bin = ensure_uv() - if uv_bin: - uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} - if _is_termux_env(uv_env): - uv_env.pop("PYTHONPATH", None) - uv_env.pop("PYTHONHOME", None) - _install_python_dependencies_with_optional_fallback( - [uv_bin, "pip"], - env=uv_env, - group="termux-all" if _is_termux_env(uv_env) else "all", - ) - else: - _install_python_dependencies_with_optional_fallback( - [sys.executable, "-m", "pip"], - group="termux-all" if _is_termux_env() else "all", - ) + # Delegate the install itself to the shared stdlib executor so both + # this late path and the pre-import early pass run exactly the same + # reinstall. Called inside the same stdout→stderr redirect already + # established by _recover_from_interrupted_install, so + # run_core_install's own redirect nests harmlessly. + _ir.run_core_install(PROJECT_ROOT) _clear_update_incomplete_marker() print("✓ Dependency installation recovered — your install is healthy again.") diff --git a/tests/hermes_cli/test_early_recovery.py b/tests/hermes_cli/test_early_recovery.py index a5aead9e4c..d7f6688254 100644 --- a/tests/hermes_cli/test_early_recovery.py +++ b/tests/hermes_cli/test_early_recovery.py @@ -327,6 +327,167 @@ def test_externally_managed_detection(tmp_path, monkeypatch): assert er._base_interpreter_is_externally_managed() is True +# --------------------------------------------------------------------------- +# Pending core install (.update-incomplete) — completed BEFORE native imports +# (#83569 review: a deferred update must not re-lock itself on the next launch) +# --------------------------------------------------------------------------- + +def test_core_marker_triggers_install_before_any_native_import( + tmp_path, monkeypatch +): + """The reviewer's exact case (comment 5254279935): ``.update-incomplete`` + present, venv HEALTHY (import probes would pass). The early pass must + STILL run the core install — crucially while no native extension module + is loaded in this process — because deferring to main()'s post-import + recovery lets a recurring eager import remap the .pyd first.""" + root = _project(tmp_path) + core_marker = root / ".update-incomplete" + core_marker.write_text('{"attempts": 0}', encoding="utf-8") + + from hermes_cli import _install_repair as ir + + calls: list[dict] = [] + + def fake_install(project_root): + calls.append( + { + "root": project_root, + "native_loaded_at_call": sorted( + m for m in sys.modules if m.startswith("cryptography") + ), + } + ) + + monkeypatch.setattr(ir, "run_core_install", fake_install) + # Early recovery imports _install_repair lazily inside the helper; make + # sure the lazy import resolves to the SAME monkeypatched module object. + import hermes_cli._install_repair # noqa: F401 (pre-import for patch) + + er.recover_if_needed(project_root=root, argv=[]) + + assert len(calls) == 1, "core install must run when the marker exists" + assert calls[0]["root"] == root + assert calls[0]["native_loaded_at_call"] == [], ( + "install must run BEFORE any cryptography module is loaded " + "(that is the whole point of the early pass)" + ) + assert not core_marker.exists(), "marker cleared on success" + # And the lazy import-probe repair path must NOT also fire: + # (no probe repair attempted — cryptography is irrelevant to this branch) + + +def test_core_marker_marks_attempts_and_keeps_marker_on_install_failure( + tmp_path, monkeypatch +): + root = _project(tmp_path) + core_marker = root / ".update-incomplete" + core_marker.write_text('{"attempts": 0}', encoding="utf-8") + + from hermes_cli import _install_repair as ir + + def boom(_project_root): + raise RuntimeError("simulated install failure") + + monkeypatch.setattr(ir, "run_core_install", boom) + import hermes_cli._install_repair # noqa: F401 + + er.recover_if_needed(project_root=root, argv=[]) + + assert core_marker.exists(), "failure keeps the marker for the next try" + import json + + body = json.loads(core_marker.read_text(encoding="utf-8")) + assert body["attempts"] == 1 + # Recovery lock released even on failure (next launch may retry). + assert not (root / ".update-incomplete.lock").exists() + + +def test_core_marker_retry_ceiling_hands_off_to_late_recovery( + tmp_path, monkeypatch +): + """A persistently failing install must not reinstall-hammer every launch.""" + root = _project(tmp_path) + core_marker = root / ".update-incomplete" + core_marker.write_text( + f'{{"attempts": {er._EARLY_CORE_INSTALL_MAX_ATTEMPTS}}}', encoding="utf-8" + ) + + from hermes_cli import _install_repair as ir + + monkeypatch.setattr( + ir, + "run_core_install", + lambda _r: (_ for _ in ()).throw( + AssertionError("install must NOT run past the attempts ceiling") + ), + ) + import hermes_cli._install_repair # noqa: F401 + + er.recover_if_needed(project_root=root, argv=[]) + + assert core_marker.exists(), "marker retained for main.py's late recovery" + # Counter not bumped further by the skipped attempt. + + +def test_lazy_marker_alone_does_not_trigger_core_install(tmp_path, monkeypatch): + """Invariant guard: a lone ``.lazy-refresh-incomplete`` must NOT trigger + the core-install branch (lazy repair has its own narrow probe path and + must NEVER clear the core marker per #58004).""" + root = _project(tmp_path) + (root / ".lazy-refresh-incomplete").write_text("x", encoding="utf-8") + + from hermes_cli import _install_repair as ir + + monkeypatch.setattr( + ir, + "run_core_install", + lambda _r: (_ for _ in ()).throw( + AssertionError("core install must not run for the lazy marker") + ), + ) + import hermes_cli._install_repair # noqa: F401 + + # Healthy probes → early pass does nothing (preserves existing behavior). + monkeypatch.setattr(er, "_probe_broken_packages", lambda: []) + + er.recover_if_needed(project_root=root, argv=[]) + + +def test_core_marker_skipped_when_user_is_running_update(tmp_path, monkeypatch): + """A launch of `hermes update` itself must not race its own markers.""" + root = _project(tmp_path) + core_marker = root / ".update-incomplete" + core_marker.write_text('{"attempts": 0}', encoding="utf-8") + + from hermes_cli import _install_repair as ir + + monkeypatch.setattr( + ir, + "run_core_install", + lambda _r: (_ for _ in ()).throw( + AssertionError("install must not run for `hermes update` argv") + ), + ) + import hermes_cli._install_repair # noqa: F401 + + er.recover_if_needed(project_root=root, argv=["update"]) + assert core_marker.exists() + + +def test_bump_marker_attempts_handles_missing_and_corrupt_bodies(tmp_path): + from hermes_cli import _install_repair as ir + + m = tmp_path / ".update-incomplete" + m.write_text("", encoding="utf-8") + assert ir.bump_marker_attempts(m) == 1 + + m.write_text("not json", encoding="utf-8") + assert ir.bump_marker_attempts(m) == 1 + + m.write_text('{"attempts": 2}', encoding="utf-8") + assert ir.bump_marker_attempts(m) == 3 + + diff --git a/tests/hermes_cli/test_update_interrupted_recovery.py b/tests/hermes_cli/test_update_interrupted_recovery.py index bd158dd6f2..aab18efacc 100644 --- a/tests/hermes_cli/test_update_interrupted_recovery.py +++ b/tests/hermes_cli/test_update_interrupted_recovery.py @@ -41,10 +41,13 @@ def _stub_install_env(monkeypatch, m, seen): monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R()) monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False) monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None) + # The install executor moved to hermes_cli._install_repair (shared between + # the pre-import early pass and this late recovery path) — stub WHERE it + # is executed, not the legacy main.py wrapper it replaced. + import hermes_cli._install_repair as ir + monkeypatch.setattr( - m, - "_install_python_dependencies_with_optional_fallback", - lambda *a, **k: seen.__setitem__("install", True), + ir, "run_core_install", lambda _root: seen.__setitem__("install", True) )