Files
hermes-agent/hermes_cli/main_install_repair.py
Teknium 2489694dae refactor(update): the startup half of a previous update runs from main_install_repair
On main, #132365 moved container-exec routing out of main.py, which
lowered main.py's code-health line cap to its new size (3643). This
branch's paused-gateway recovery added four lines to main() and pushed
the file to 3647, so the code-health check refused it.

The owed-fleet-restart warning and the paused-gateway recovery move
together into main_install_repair._recover_update_debts_on_startup, next
to the Windows quarantine sweep that already lives there; main() calls it
at the same point, so startup order and behaviour are unchanged. Both
steps stay late-imported, so tests patching
update_cmd_fleet._warn_pending_fleet_restart_on_startup or
update_pause_record.recover still intercept them. The fleet warning's
silent `except Exception: pass` now logs at debug with the traceback,
which is what the health check asks of a catch-all boundary in new code;
a failure there still never blocks the launch.
2026-10-06 06:18:39 -07:00

352 lines
15 KiB
Python

"""Update dependency diagnostics, historical markers and Windows launcher recovery."""
import logging
import os
import shutil
import sys
import time as _time
from pathlib import Path
from typing import NoReturn
from hermes_cli import _early_recovery as _early_recovery_mod
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.main")
def _pyproject_project(debug_fmt: str | None = None) -> dict | None:
"""``[project]`` table of pyproject.toml, or ``None`` when absent/unreadable."""
from hermes_cli.main import PROJECT_ROOT
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return None
try:
import tomllib
with pyproject.open("rb") as handle:
project = tomllib.load(handle).get("project", {})
except Exception as exc:
if debug_fmt:
logger.debug(debug_fmt, exc)
return None
return project if isinstance(project, dict) else None
# Install-scoped breadcrumbs live next to the venv (not under $HERMES_HOME)
# because the venv is shared across profiles.
# ``.update-incomplete`` — generic core ``.[all]`` install was interrupted;
# cleared only after a confirmed full dependency reinstall/recovery.
# ``.lazy-refresh-incomplete`` — lazy-backend refresh may have corrupted packages;
# cleared only after import-probe repair confirms healthy (never on indeterminate).
# Narrow lazy probes must NEVER clear the generic core marker.
# See #58004.
def _update_marker_path() -> Path:
from hermes_cli.main import PROJECT_ROOT
return PROJECT_ROOT / ".update-incomplete"
def _lazy_refresh_marker_path() -> Path:
from hermes_cli.main import PROJECT_ROOT
return PROJECT_ROOT / ".lazy-refresh-incomplete"
def _pytest_owns_live_checkout(root: Path) -> bool:
"""True under pytest when ``root`` is this checkout: unsandboxed update/recovery tests must
neither litter the live repo root with breadcrumbs (false-arming the developer's next launch)
nor run a real reinstall against the executing venv (cf. ``managed_scope._under_pytest``)."""
return "PYTEST_CURRENT_TEST" in os.environ and root == Path(__file__).resolve().parent.parent
def _clear_marker_file(path: Path, *, label: str) -> None:
"""Remove an update-recovery breadcrumb. Never raises."""
try:
path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not clear %s marker: %s", label, exc)
def _clear_update_incomplete_marker() -> None:
"""Remove the interrupted core-install breadcrumb. Never raises."""
_clear_marker_file(_update_marker_path(), label="update-incomplete")
def _clear_lazy_refresh_incomplete_marker() -> None:
"""Remove the interrupted lazy-refresh breadcrumb. Never raises."""
_clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete")
# Frozen old-updater import; current updates never detach dependency work.
_UPDATE_REEXEC_ENV = "HERMES_UPDATE_REEXEC"
def _reexec_dependency_sync_off_windows_shim() -> NoReturn:
"""Stop a mixed old-code/new-files updater at the retired sync boundary."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _is_windows() -> bool:
return sys.platform == "win32"
def _venv_scripts_dir() -> Path | None:
"""Return the venv Scripts directory if we're running inside the project venv."""
from hermes_cli.main import PROJECT_ROOT
from hermes_constants import project_venv_dir
from pm.environments import venv_bin_dir
venv_dir = project_venv_dir(PROJECT_ROOT)
if venv_dir is None:
return None
scripts = venv_bin_dir(venv_dir, windows=_is_windows())
return scripts if scripts.is_dir() else None
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
"""Entry-point shims uv may rewrite during ``pip install -e .`` — Windows .exe launchers
only; POSIX shims are plain scripts replaced atomically."""
if not _is_windows():
return []
names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"}
# Not a [project.scripts] entry point, but older update/install paths still
# rewrite and quarantine it.
names.add("hermes-gateway")
return [scripts_dir / f"{name}.exe" for name in sorted(names)]
_PENDING_RENAME_KEY = r"SYSTEM\CurrentControlSet\Control\Session Manager"
_PENDING_RENAME_VALUE = "PendingFileRenameOperations"
def _filter_pending_shim_renames(entries: list[str], shims: list[Path]) -> tuple[list[str], int]:
"""Drop our ``<shim>`` -> ``<shim>.old.<stamp>`` pairs from a PendingFileRenameOperations
value (a flat REG_MULTI_SZ of (source, target) pairs shared with other installers).
Returns the entries to keep and how many pairs were dropped."""
import ntpath
def _norm(value: str) -> str:
path = str(value).lstrip("!")
if path.startswith("\\??\\"):
path = path[4:]
return ntpath.normcase(ntpath.normpath(path))
shim_paths = {_norm(str(shim)) for shim in shims}
kept: list[str] = []
removed = 0
for index in range(0, len(entries) - 1, 2):
source, target = entries[index], entries[index + 1]
source_norm = _norm(source)
if source_norm in shim_paths and _norm(target).startswith(f"{source_norm}.old."):
removed += 1
else:
kept.extend((source, target))
if len(entries) % 2:
kept.append(entries[-1])
return kept, removed
def _cleanup_pending_shim_renames(scripts_dir: Path) -> int:
"""Drop reboot renames older Hermes versions queued for our shims: ``MOVEFILE_DELAY_UNTIL_REBOOT``
fallbacks outlive the update that queued them and move away whatever sits at the shim path
at next boot — even a shim a later repair just wrote. Needs elevation; a no-op otherwise."""
if not _is_windows():
return 0
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _PENDING_RENAME_KEY, 0,
winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE,
) as key:
entries, value_type = winreg.QueryValueEx(key, _PENDING_RENAME_VALUE)
if value_type != winreg.REG_MULTI_SZ or not isinstance(entries, list):
return 0
kept, removed = _filter_pending_shim_renames(entries, _hermes_exe_shims(scripts_dir))
if not removed:
return 0
if kept:
winreg.SetValueEx(key, _PENDING_RENAME_VALUE, 0, winreg.REG_MULTI_SZ, kept)
else:
winreg.DeleteValue(key, _PENDING_RENAME_VALUE)
return removed
except (OSError, ValueError):
return 0
# Fresh orphan files can belong to an updater still running.
_QUARANTINE_GRACE_SECONDS = 15 * 60
def _quarantine_stamp_ms(stale: Path) -> int | None:
"""The ``.old.<unix-ms>`` stamp in a quarantine filename; ``None`` (not ours — neither rescued
nor deleted) otherwise. Parsed from the NAME, not ``st_mtime``: ``rename`` preserves the
shim's mtime (when uv wrote it), not when it was quarantined."""
try:
return int(stale.name.rsplit(".old.", 1)[1])
except (IndexError, ValueError):
return None
def _recover_update_debts_on_startup() -> None:
"""The startup half of a previous update, run on every launch before dispatch."""
# Dependency recovery already ran before imports. Report any fleet restart
# still owed by a previous update without restarting services here.
if "update" not in sys.argv[1:]:
try:
from hermes_cli.update_cmd_fleet import _warn_pending_fleet_restart_on_startup
_warn_pending_fleet_restart_on_startup()
except Exception: # a startup warning never blocks the launch it warns about
logger.debug("pending fleet restart check failed", exc_info=True)
from hermes_cli.update_pause_record import recover as _recover_paused_gateways
# Unconditional: recover() itself skips the PARSED `update`/`gateway run` commands; a raw argv
# value equal to "update" (`--resume update`) is no reason to strand paused gateways.
_recover_paused_gateways() # a killed `hermes update` left gateways paused (Windows)
def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
"""Sweep — and where necessary RESCUE — ``hermes.exe.old.*`` from updates.
Called early on every invocation. Two cases an unconditional ``unlink()`` gets wrong:
(1) orphan rescue — ``hermes.exe`` missing while ``hermes.exe.old.*`` exists means the .old
file is the ONLY surviving copy (update died between rename and uv's write); put it back via
the same retry-and-report helper the update-time restore uses. (2) concurrency — a fresh
quarantine file may belong to an update in flight in another process; leave anything inside
the grace window alone. Silent no-op on non-Windows, nothing to do, or locked/permission errors.
Deleting it converts a one-rename recovery into a full reinstall. See #75584.
"""
if not _is_windows():
return
scripts_dir = scripts_dir if scripts_dir is not None else _venv_scripts_dir()
if scripts_dir is None:
return
from hermes_cli.main import PROJECT_ROOT
from hermes_cli.update_lock import update_in_progress
if update_in_progress(PROJECT_ROOT):
return # every quarantine may be that update's own, mid rename-back
_cleanup_pending_shim_renames(scripts_dir)
now = _time.time()
try:
candidates = [
(stamp, stale) for stale in scripts_dir.glob("*.exe.old.*")
if (stamp := _quarantine_stamp_ms(stale)) is not None]
except OSError:
return
# Newest first by PARSED stamp: lexicographic order breaks when a stray ``.old.999`` exists.
candidates.sort(key=lambda pair: pair[0], reverse=True)
for stamp, stale in candidates:
try:
original = stale.with_name(stale.name.rsplit(".old.", 1)[0])
if not original.exists():
# Orphan rescue: last copy of the shim — retry ladder + recovery message.
_early_recovery_mod.restore_quarantined_shims([(original, stale)])
continue
if now - stamp / 1000.0 < _QUARANTINE_GRACE_SECONDS:
continue # may be a live quarantine from a concurrent update
stale.unlink()
except OSError:
pass # still locked or in use — try again next run
def _configured_features_missing_deps() -> list[tuple[str, str, str]]:
"""Check configured platforms/MCP in the fresh, selected update-build child.
PM preserves recorded extras atomically, but configuration can reference an
unselected SDK. Never call this in the updater's stale import graph (#10651).
Rows are ``(label, hint, candidate extra)``: a platform's SDK is the extra named after it.
"""
missing: list[tuple[str, str, str]] = []
try:
from gateway.config import load_gateway_config
from gateway.platform_registry import platform_registry
for platform in load_gateway_config().get_connected_platforms():
entry = platform_registry.get(platform.value)
if entry is not None and not entry.check_fn():
missing.append((entry.label, entry.install_hint or "Run `hermes setup` to install support.",
entry.name))
except Exception as exc:
logger.debug("configured-platform dependency check skipped: %s", exc)
try:
import importlib.util
from hermes_cli.config import load_config_readonly
if (load_config_readonly().get("mcp_servers") or {}) and importlib.util.find_spec("mcp") is None:
missing.append(("MCP servers", "Run `hermes pm install` to install MCP support.", "mcp"))
except Exception as exc:
logger.debug("configured-MCP dependency check skipped: %s", exc)
return missing
def _install_configured_features_missing_deps(project_root: Path) -> None:
"""Add the extras configured features need to the dependency environment (#124228).
Recorded extras never follow config, so an enabled platform whose SDK is not
selected would come up without its adapter after the gateway restart.
"""
import pm
from pm.extras import extra_supported
from pm.features import declared_extras
missing = _configured_features_missing_deps()
if not missing:
return
declared = set(declared_extras(project_root))
extras = sorted({extra for *_, name in missing
if (extra := name.replace("_", "-")) in declared and extra_supported(extra)})
if extras:
try:
pm.sync_venv(extras, explicit=True, project_root=project_root, evict_incompatible_plugins=True)
except Exception as exc: # noqa: BLE001 — a feature install never fails the update; warn below
print(f" ⚠ Could not install {', '.join(extras)} for configured features: {exc}")
else:
missing = [row for row in missing if row[2].replace("_", "-") not in extras]
if missing:
print(" ⚠ Configured features whose dependencies are still missing — the gateway will fail to load them on restart:")
for feature, hint, _extra in missing:
print(f" - {feature}: {hint}")
def _load_console_script_names() -> list[str]:
"""Return ``[project.scripts]`` entry-point names from pyproject.toml."""
project = _pyproject_project("console script verification: failed to read pyproject.toml: %s")
scripts = (project or {}).get("scripts", {}) or {}
return [str(name) for name in scripts if name]
def _is_termux_env(env: dict[str, str] | None = None) -> bool:
from hermes_cli.main import _is_termux_startup_environment
return _is_termux_startup_environment(env)
def _is_windows_npm_path(npm_path: str) -> bool:
"""True if ``npm_path`` points at a Windows npm shim (WSL drive interop, ``.cmd``/``.exe``, UNC).
Callers use this only on a POSIX host — on native Windows ``npm.cmd`` is correct.
"""
low = npm_path.lower()
mount = low.split("/", 3)[2] if low.startswith("/mnt/") else ""
return (
low.endswith((".exe", ".cmd", ".bat"))
or (len(mount) == 1 and mount.isalpha())
or "\\" in npm_path
)
def _resolve_node_runtime_npm() -> str | None:
"""PM's npm, refused on a POSIX host when it is a Windows shim (EISDIR over WSL UNC paths, #30271).
Never re-scans the user's PATH for another npm: Hermes runs only its PM-managed toolchain.
"""
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
if _is_windows() or not npm:
return npm
return None if _is_windows_npm_path(npm) else npm
def _resolve_update_branch(args) -> str:
"""Normalize ``args.branch`` to a non-empty name (default ``main``; blank/whitespace = default)."""
return (getattr(args, "branch", None) or "main").strip() or "main"