1819e50081
The current windows.ps1 hand-off drains both pipes; the deadlock the merge guards against lives in hand-offs shipped before it. The twin-fix cross-reference follows the heartbeat helper to main_install_repair.py.
1192 lines
52 KiB
Python
1192 lines
52 KiB
Python
"""Install/update recovery: interrupted-install markers, lazy-refresh repair, Windows shim quarantine, dependency verification.
|
|
|
|
Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...)
|
|
are imported lazily inside the functions that use them (avoids an import cycle).
|
|
"""
|
|
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time as _time
|
|
|
|
from pathlib import Path
|
|
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
|
|
|
|
|
|
def _naive_requirement(spec: str) -> tuple[str, str]:
|
|
"""``(name, head)`` of a ``name OP version ; marker`` spec without ``packaging``."""
|
|
head = spec.split(";", 1)[0].strip()
|
|
bare = head
|
|
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
|
|
if op in bare:
|
|
bare = bare.split(op, 1)[0]
|
|
break
|
|
return bare.strip().split("[", 1)[0].strip(), head
|
|
|
|
|
|
def _parse_requirements(raw_deps: list[str]) -> list[tuple[str, "object | None", str]]:
|
|
"""``(name, marker, head)`` per dep spec — ``packaging`` when importable, else a naive split."""
|
|
parsed: list[tuple[str, "object | None", str]] = []
|
|
try:
|
|
from packaging.requirements import Requirement # type: ignore
|
|
for spec in raw_deps:
|
|
try:
|
|
req = Requirement(spec)
|
|
except Exception:
|
|
continue
|
|
parsed.append((req.name, req.marker, spec.split(";", 1)[0].strip()))
|
|
except Exception:
|
|
for spec in raw_deps:
|
|
name, head = _naive_requirement(spec)
|
|
if name:
|
|
parsed.append((name, None, head))
|
|
return parsed
|
|
|
|
|
|
def _load_installable_optional_extras(group: str = "all") -> list[str]:
|
|
"""Return optional extras referenced by a dependency group (``all`` or ``termux-all``)."""
|
|
optional_deps = (_pyproject_project() or {}).get("optional-dependencies", {})
|
|
if not isinstance(optional_deps, dict):
|
|
return []
|
|
referenced: list[str] = []
|
|
for ref in optional_deps.get(group, []):
|
|
if "[" in ref and "]" in ref:
|
|
name = ref.split("[", 1)[1].split("]", 1)[0]
|
|
if name in optional_deps:
|
|
referenced.append(name)
|
|
return referenced
|
|
|
|
|
|
# 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")
|
|
|
|
|
|
def _claim_recovery_lock(lock_path: Path) -> bool:
|
|
"""Atomically claim the single-flight recovery lock; False when another process holds it.
|
|
|
|
A crashed holder's stale lock is broken after an hour (well past any realistic install).
|
|
Failing to CREATE the lock (read-only fs, perms) proceeds unlocked — the install itself
|
|
will surface the real problem."""
|
|
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)
|
|
except FileExistsError:
|
|
try:
|
|
if _time.time() - lock_path.stat().st_mtime > 3600:
|
|
lock_path.unlink()
|
|
except OSError:
|
|
pass
|
|
return False
|
|
except OSError as exc:
|
|
logger.debug("Could not create install-recovery lock: %s", exc)
|
|
return True
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _stdout_to_stderr():
|
|
"""Route Python prints AND the fd 1 that pip/uv inherit to stderr: launches whose stdout is
|
|
a protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get install noise."""
|
|
saved_stdout_fd = None
|
|
saved_sys_stdout = sys.stdout
|
|
try:
|
|
saved_stdout_fd = os.dup(1)
|
|
os.dup2(2, 1)
|
|
except OSError:
|
|
saved_stdout_fd = None
|
|
sys.stdout = sys.stderr
|
|
try:
|
|
yield
|
|
finally:
|
|
sys.stdout = saved_sys_stdout
|
|
if saved_stdout_fd is not None:
|
|
try:
|
|
os.dup2(saved_stdout_fd, 1)
|
|
os.close(saved_stdout_fd)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _recover_from_interrupted_install() -> None:
|
|
"""Finish update work left half-done by a prior ``hermes update``.
|
|
|
|
``.update-incomplete`` recovers via full quarantined reinstall; ``.lazy-refresh-incomplete``
|
|
via package-only import probes (cleared only when probes confirm healthy/repaired). Never
|
|
raises: on failure it prints the manual command and leaves the marker for the next launch.
|
|
Concurrent launches race on the shared venv, so an ``O_EXCL`` lockfile lets one process
|
|
recover while the others skip.
|
|
"""
|
|
from hermes_cli.main import PROJECT_ROOT
|
|
if _pytest_owns_live_checkout(PROJECT_ROOT):
|
|
return
|
|
lazy_marker = _lazy_refresh_marker_path().exists()
|
|
if not lazy_marker and not _update_marker_path().exists():
|
|
return
|
|
# Managed/Docker installs and git-less PyPI installs never run the source-tree
|
|
# update path, so a stray marker is not ours to act on. Just clear it.
|
|
if not (PROJECT_ROOT / "pyproject.toml").is_file():
|
|
_clear_update_incomplete_marker()
|
|
_clear_lazy_refresh_incomplete_marker()
|
|
return
|
|
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
|
|
if not _claim_recovery_lock(lock_path):
|
|
return
|
|
try:
|
|
with _stdout_to_stderr():
|
|
if lazy_marker:
|
|
_recover_lazy_refresh_marker_locked()
|
|
if _update_marker_path().exists():
|
|
_recover_core_update_marker_locked()
|
|
finally:
|
|
try:
|
|
lock_path.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _recover_lazy_refresh_marker_locked() -> None:
|
|
"""Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair."""
|
|
print(
|
|
"⚠ A previous lazy-backend refresh may have left the venv unhealthy — "
|
|
"running import-based package repair...")
|
|
install_prefix, install_env = _default_venv_install_target()
|
|
status = _repair_venv_via_import_probes(install_prefix, env=install_env)
|
|
if status in ("healthy", "repaired"):
|
|
_clear_lazy_refresh_incomplete_marker()
|
|
print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.")
|
|
return
|
|
indeterminate = status == "indeterminate"
|
|
problem = (
|
|
"Import probes unavailable — cannot confirm venv health." if indeterminate
|
|
else "Lazy-refresh package repair incomplete.")
|
|
print(f" ⚠ {problem} Leaving `.lazy-refresh-incomplete` for the next launch.")
|
|
if indeterminate:
|
|
return
|
|
print(" Recover manually with:")
|
|
all_specs = _lazy_refresh_repair_specs(sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values())))
|
|
print(
|
|
f" {' '.join(install_prefix)} install --force-reinstall "
|
|
+ " ".join(shlex.quote(s) for s in all_specs))
|
|
|
|
|
|
def _recover_core_update_marker_locked() -> None:
|
|
"""Heal ``.update-incomplete`` via full ``.[all]`` reinstall only.
|
|
|
|
Narrow lazy-refresh import probes are not proof that a generic interrupted core
|
|
install finished — a missing dep outside that probe set would look healthy and
|
|
clear the breadcrumb too early.
|
|
"""
|
|
from hermes_cli.main import PROJECT_ROOT
|
|
print(
|
|
"⚠ A previous `hermes update` was interrupted mid-install — "
|
|
"finishing dependency installation now...")
|
|
|
|
# Windows: a ``hermes.exe`` launch has the launcher as an ancestor; the quarantined full
|
|
# reinstall can still replace it. Package-only repair is first aid and NEVER clears the marker.
|
|
# Full editable reinstall uses quarantine so the live shim can still be replaced. Package-only import
|
|
# repair may help as first aid but must NEVER clear this core marker on its own (#58004 review).
|
|
self_locked = _windows_running_hermes_launcher_locked()
|
|
if self_locked:
|
|
install_prefix, install_env = _default_venv_install_target()
|
|
print(
|
|
" → Running from hermes.exe; applying package-only first aid, "
|
|
"then quarantined full reinstall (core marker stays until that "
|
|
"succeeds)...")
|
|
_repair_venv_via_import_probes(install_prefix, env=install_env)
|
|
try:
|
|
from hermes_cli import _install_repair as _ir
|
|
|
|
# ensure_uv bootstraps uv itself when missing (the early pass's stdlib-only lookup
|
|
# cannot), so a venv whose uv vanished mid-update still heals.
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
ensure_uv()
|
|
# Shared stdlib executor: late path and pre-import early pass run exactly the same
|
|
# reinstall. Its own stdout→stderr redirect nests harmlessly inside ours.
|
|
_ir.run_core_install(PROJECT_ROOT)
|
|
_clear_update_incomplete_marker()
|
|
print("✓ Dependency installation recovered — your install is healthy again.")
|
|
except Exception as exc:
|
|
# Leave the marker so the next launch retries; give the exact manual command.
|
|
logger.debug("Interrupted-install recovery failed: %s", exc)
|
|
print("✗ Could not auto-recover the interrupted install.")
|
|
manual = (
|
|
" Hermes is still running from the launcher that needs "
|
|
"replacing. Close other Hermes windows, restart from a "
|
|
"different terminal, then run:",
|
|
f' cd /d "{PROJECT_ROOT}"',
|
|
f' "{sys.executable}" -m pip install -e ".[all]"',
|
|
) if self_locked else (
|
|
" Recover manually with:",
|
|
f" cd {PROJECT_ROOT}",
|
|
f" {sys.executable} -m ensurepip --upgrade",
|
|
f" {sys.executable} -m pip install -e '.[all]'",
|
|
)
|
|
for line in manual:
|
|
print(line)
|
|
|
|
|
|
def _norm_exe_path(path) -> str:
|
|
"""Case-folded resolved path, for comparing executables on Windows."""
|
|
try:
|
|
return str(Path(path).resolve()).lower()
|
|
except OSError:
|
|
return str(path).lower()
|
|
|
|
|
|
def _windows_shim_in_process_chain() -> Path | None:
|
|
"""The venv console shim this process runs from or under, if any.
|
|
|
|
``venv\\Scripts\\hermes.exe`` holds itself open (no ``FILE_SHARE_DELETE``) for the whole
|
|
process lifetime, so an editable install run from one can never rewrite it. Two probes, since
|
|
either can come up empty: own launch paths (argv[0], ``__main__`` file/spec origin — runpy/
|
|
zipapp puts ``<shim>\\__main__.py`` there) and psutil ancestry. Candidates are intersected
|
|
with the project venv's own shims so a foreign ``hermes.exe`` never matches.
|
|
|
|
See #88838, #89599.
|
|
"""
|
|
if not _is_windows():
|
|
return None
|
|
scripts_dir = _venv_scripts_dir()
|
|
if scripts_dir is None:
|
|
return None
|
|
shims = {_norm_exe_path(shim): shim for shim in _hermes_exe_shims(scripts_dir)}
|
|
if not shims:
|
|
return None
|
|
|
|
def _match(candidate) -> Path | None:
|
|
path = Path(candidate)
|
|
if path.name.lower() == "__main__.py":
|
|
path = path.parent
|
|
return shims.get(_norm_exe_path(path))
|
|
|
|
main_mod = sys.modules.get("__main__")
|
|
candidates = [*sys.argv[:1], *filter(None, (
|
|
getattr(main_mod, "__file__", None),
|
|
getattr(getattr(main_mod, "__spec__", None), "origin", None)))]
|
|
for candidate in candidates:
|
|
matched = _match(candidate)
|
|
if matched is not None:
|
|
return matched
|
|
|
|
with contextlib.suppress(Exception):
|
|
import psutil
|
|
me = psutil.Process()
|
|
for proc in [me] + list(me.parents()):
|
|
try:
|
|
matched = _match(proc.exe())
|
|
except Exception:
|
|
continue
|
|
if matched is not None:
|
|
return matched
|
|
return None
|
|
|
|
|
|
def _windows_running_hermes_launcher_locked() -> bool:
|
|
"""True when a venv ``hermes*.exe`` shim is this process or an ancestor (best-effort)."""
|
|
return _windows_shim_in_process_chain() is not None
|
|
|
|
|
|
# Set on the re-exec'd child so it can never spawn another one.
|
|
_UPDATE_REEXEC_ENV = "HERMES_UPDATE_REEXEC"
|
|
|
|
|
|
def _reexec_dependency_sync_off_windows_shim() -> bool:
|
|
"""Hand the dependency sync to the venv interpreter, off the console shim.
|
|
|
|
Returns True when a child was spawned and the caller must exit at once (releasing the
|
|
shim before the child reaches ``pip install -e .``); False to continue in-process.
|
|
|
|
Called at the dependency-sync boundary, NOT at the top of the command: by then the code swap
|
|
is done and every interactive question has been answered; only the venv rewrite — the one
|
|
step that cannot run inside the shim — remains. Earlier would detach every run (even the
|
|
``Already up to date!`` no-op) and take the prompts along. Waiting on the child deadlocks
|
|
(we hold the handle it needs) and Windows has no exec, so the shell returns; the child keeps
|
|
the console, prints its own result, and ``--gateway`` writes the true exit code to
|
|
``.update_exit_code``. The child re-runs ``hermes update`` so the sync and its tail happen
|
|
exactly once; ``_UPDATE_REEXEC_ENV`` stops it spawning again and stops the "already up to
|
|
date" early return from swallowing the sync. ``.update-incomplete`` is already written, so
|
|
a child that dies mid-install is finished by the next launch's recovery.
|
|
|
|
Called at the dependency-sync boundary, NOT at the top of the command — the same placement rule as the
|
|
native-module deferral beside it, and for the same reason (#86735): a hand-off that fires before the
|
|
fetch detaches every run, including the ``Already up to date!`` no-op that never touches the venv at
|
|
all, and it takes the interactive prompts with it. By the time we reach here the code swap is done and
|
|
every question — stash, branch switch, config migration — has already been asked and answered in the
|
|
user's own console.
|
|
``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with the shim as its script and
|
|
holds it open without ``FILE_SHARE_DELETE`` for the whole command, so the quarantine rename is refused
|
|
and uv fails to replace it with os error 32 (#88838, #89599).
|
|
"""
|
|
if os.environ.get(_UPDATE_REEXEC_ENV) == "1":
|
|
return False
|
|
shim = _windows_shim_in_process_chain()
|
|
if shim is None:
|
|
return False
|
|
from hermes_constants import venv_python_path
|
|
python_exe = venv_python_path(shim.parent.parent, windows=True)
|
|
cmd = [str(python_exe), "-m", "hermes_cli.main", *sys.argv[1:]]
|
|
if python_exe.is_file():
|
|
try:
|
|
subprocess.Popen(
|
|
cmd, env={**os.environ, _UPDATE_REEXEC_ENV: "1"}, stdin=subprocess.DEVNULL)
|
|
print(
|
|
f"→ Windows: {shim.name} cannot replace itself while it runs; "
|
|
"finishing the dependency install under the venv Python.")
|
|
print(
|
|
" The code update is already applied. The install continues "
|
|
"below and this shell returns right away.")
|
|
return True
|
|
except OSError as exc:
|
|
logger.debug("Dependency-sync hand-off via %s failed: %s", python_exe, exc)
|
|
print(f" ⚠ Could not hand the dependency install off {shim.name}.")
|
|
print(" Continuing in-process; if it cannot replace the shim, run:")
|
|
print(f" {subprocess.list2cmdline(cmd)}")
|
|
return False
|
|
|
|
|
|
def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]:
|
|
"""Return ``(install_cmd_prefix, env)`` for the project venv when possible."""
|
|
from hermes_cli.main import PROJECT_ROOT
|
|
try:
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
uv_bin = ensure_uv()
|
|
except Exception:
|
|
uv_bin = None
|
|
if uv_bin:
|
|
from hermes_constants import project_venv_dir
|
|
venv_dir = project_venv_dir(PROJECT_ROOT) or PROJECT_ROOT / "venv"
|
|
env = {**os.environ, "VIRTUAL_ENV": str(venv_dir)}
|
|
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 _run_install_with_heartbeat(
|
|
cmd: list[str], *, env: dict[str, str] | None = None, heartbeat_interval_seconds: int = 30
|
|
) -> None:
|
|
"""Run a dependency install, printing an elapsed-time heartbeat while pip/uv is silent.
|
|
|
|
Resolvers/build backends compiling Rust/C extensions can stay quiet for minutes.
|
|
"""
|
|
from hermes_cli.main import PROJECT_ROOT
|
|
done = threading.Event()
|
|
start = _time.time()
|
|
|
|
def _heartbeat() -> None:
|
|
# Wait first, then print, so short installs don't emit noise.
|
|
while not done.wait(heartbeat_interval_seconds):
|
|
elapsed = int(_time.time() - start)
|
|
print(
|
|
f" … still installing dependencies ({elapsed}s elapsed)"
|
|
" — compiling Rust/C extensions can take several minutes",
|
|
flush=True)
|
|
|
|
t = threading.Thread(target=_heartbeat, daemon=True)
|
|
t.start()
|
|
try:
|
|
# stderr=STDOUT: uv/pip write progress to stderr. Legacy desktop
|
|
# hand-offs (pre scripts/desktop-update/windows.ps1, which drains
|
|
# both pipes) only drain the child's stdout, so a full stderr
|
|
# pipe (~64KB) blocks the installer forever. Merged into stdout,
|
|
# the output rides the pipe old hand-offs DO drain. This module
|
|
# is imported when `hermes update` starts, so an update running
|
|
# from an old base executes the old copy regardless of the git
|
|
# reset — this protects updates initiated from bases that ship
|
|
# it. (managed_uv.py gets the same fix AND is imported lazily
|
|
# after the reset, so its sync is protected even on old bases.)
|
|
subprocess.run(cmd, cwd=PROJECT_ROOT, check=True, env=env, stderr=subprocess.STDOUT)
|
|
finally:
|
|
done.set()
|
|
t.join(timeout=0.2)
|
|
|
|
|
|
def _run_repair_step(run, cmd: list[str], *, log_msg: str, fail_msg: str | None, **kwargs) -> bool:
|
|
"""``run(cmd, **kwargs)``; on ``CalledProcessError`` log + print the failure and return False."""
|
|
try:
|
|
run(cmd, **kwargs)
|
|
except subprocess.CalledProcessError as e:
|
|
logger.warning(log_msg, e)
|
|
if fail_msg is not None:
|
|
print(fail_msg)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _report_still_missing(missing: list[str], hint: str, *, ok: str) -> None:
|
|
if missing:
|
|
print(f" ⚠ Still missing after repair: {', '.join(missing)}. {hint}")
|
|
else:
|
|
print(ok)
|
|
|
|
|
|
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, 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)]
|
|
|
|
|
|
_QUARANTINE_BACKOFF_MS = (0, 100, 250, 500, 1000)
|
|
|
|
|
|
def _rename_with_backoff(source: Path, target: Path, attempts: int) -> OSError | None:
|
|
"""Rename with the quarantine backoff ladder; returns the last ``OSError`` or ``None``."""
|
|
for delay_ms in _QUARANTINE_BACKOFF_MS[:attempts]:
|
|
if delay_ms:
|
|
_time.sleep(delay_ms / 1000.0)
|
|
try:
|
|
source.rename(target)
|
|
return None
|
|
except OSError as e:
|
|
last_exc = e
|
|
return last_exc
|
|
|
|
|
|
def _quarantine_running_hermes_exe(
|
|
scripts_dir: Path, *, max_attempts: int = 4, failed_out: list[str] | None = None
|
|
) -> list[tuple[Path, Path]]:
|
|
"""Pre-empt the Windows file lock on the running ``hermes.exe``.
|
|
|
|
Windows allows RENAMING a running executable but blocks DELETE/REPLACE (uv fails with
|
|
``Access is denied. (os error 5)``), so live shims are renamed to ``<shim>.old.<unix-ms>``
|
|
first; ``_cleanup_quarantined_exes`` sweeps the ``.old`` files next invocation. Rename can
|
|
still fail when another process holds the .exe without ``FILE_SHARE_DELETE`` (AV scanner:
|
|
transient; Hermes Desktop backend child: until closed) — retry with backoff, then warn
|
|
naming the likely culprit. Returns ``(original, quarantined)`` pairs for rollback;
|
|
``failed_out`` collects shims whose rename failed every attempt so the update dependency
|
|
sync can refuse instead of stranding a half-broken venv.
|
|
|
|
See #87331.
|
|
"""
|
|
moved: list[tuple[Path, Path]] = []
|
|
if not _is_windows():
|
|
return moved
|
|
stamp = int(_time.time() * 1000)
|
|
# First attempt immediate; 100/250/500ms covers the typical AV re-scan window.
|
|
attempts = max(1, min(max_attempts, len(_QUARANTINE_BACKOFF_MS)))
|
|
for shim in _hermes_exe_shims(scripts_dir):
|
|
if not shim.exists():
|
|
continue
|
|
target = shim.with_suffix(shim.suffix + f".old.{stamp}")
|
|
last_exc = _rename_with_backoff(shim, target, attempts)
|
|
if last_exc is None:
|
|
moved.append((shim, target))
|
|
continue
|
|
|
|
# Every rename failed. MOVEFILE_DELAY_UNTIL_REBOOT is no fallback (needs elevation, frees
|
|
# nothing now, moves a later repaired shim aside at boot). Report; let uv try its luck.
|
|
print(
|
|
f" ⚠ Could not quarantine {shim.name} ({last_exc.__class__.__name__}: "
|
|
f"another process is holding it open).")
|
|
print(
|
|
" Close Hermes Desktop, exit other `hermes` REPLs, stop the "
|
|
"gateway, or pause AV scanning, then re-run `hermes update`.")
|
|
if failed_out is not None:
|
|
failed_out.append(shim.name)
|
|
|
|
return moved
|
|
|
|
|
|
_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
|
|
|
|
|
|
def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
|
|
"""Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements. Safety-
|
|
critical: a failed quarantine only aborts an update; a failed restore leaves no ``hermes``
|
|
on PATH. Delegates to the stdlib-only retrying helper shared with ``_install_repair``.
|
|
|
|
The outbound rename already retries a lock, so this one must too rather than swallow the first
|
|
``OSError`` in silence. See #75584.
|
|
"""
|
|
_early_recovery_mod.restore_quarantined_shims(moved)
|
|
|
|
|
|
class ShimQuarantineError(RuntimeError):
|
|
"""A live ``hermes*.exe`` shim could not be renamed aside. Raised by
|
|
:func:`_run_quarantined_install` in ``strict_quarantine`` mode BEFORE the install runs: a
|
|
process holds the venv hard enough that the sync would die partway — refuse, don't warn.
|
|
|
|
See #87331.
|
|
"""
|
|
|
|
def __init__(self, failed_shims: list[str]):
|
|
self.failed_shims = list(failed_shims)
|
|
super().__init__("could not quarantine live shim(s): " + ", ".join(self.failed_shims))
|
|
|
|
|
|
def _run_quarantined_install(
|
|
cmd: list[str], *, env: dict[str, str] | None = None, scripts_dir: Path | None = None,
|
|
strict_quarantine: bool = False,
|
|
) -> None:
|
|
"""Run an editable install, quarantining the running ``hermes.exe`` first.
|
|
|
|
Every editable install rewrites the entry-point shims; on Windows the live ``hermes.exe``
|
|
can be neither deleted nor overwritten, so without quarantine ``hermes`` drops off PATH.
|
|
``strict_quarantine=True`` (the update dependency sync): a shim whose rename failed every
|
|
retry proves a hard venv hold — the install WILL hit the same lock on .pyd files — so roll
|
|
back and raise :class:`ShimQuarantineError` without installing. Non-strict callers already
|
|
mutated the venv, so refusing buys nothing. ``scripts_dir is None`` is a pass-through.
|
|
|
|
See #87331.
|
|
"""
|
|
moved: list[tuple[Path, Path]] = []
|
|
failed: list[str] = []
|
|
if scripts_dir is not None:
|
|
moved = _quarantine_running_hermes_exe(scripts_dir, failed_out=failed)
|
|
if strict_quarantine and failed:
|
|
_restore_quarantined_exes(moved)
|
|
raise ShimQuarantineError(failed)
|
|
try:
|
|
_run_install_with_heartbeat(cmd, env=env)
|
|
finally:
|
|
# Restore on FAILURE and SUCCESS: an already-satisfied editable install is a uv no-op
|
|
# that rewrites no entry points. Skips shims the installer replaced; finally re-raises.
|
|
if scripts_dir is not None:
|
|
_restore_quarantined_exes(moved)
|
|
|
|
|
|
# A quarantine file younger than this may belong to an update running RIGHT NOW in
|
|
# another process, whose restore step still needs it — the only copy of that shim.
|
|
# Restore shims when the installer didn't write replacements — on FAILURE (install died before the
|
|
# entry-points step) and on SUCCESS too: uv audits an already-satisfied editable install as a no-op and
|
|
# rewrites no entry points, which would otherwise leave the shims quarantined aside and `hermes` missing
|
|
# from PATH after a green install (#75584). _restore_quarantined_exes skips any shim the installer actually
|
|
# replaced, so this never clobbers fresh output. Errors are not swallowed — the finally re-raises whatever
|
|
# escaped.
|
|
_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 _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
|
|
_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
|
|
|
|
|
|
# Import probes for venv corruption after a failed lazy ``uv pip install`` (metadata can
|
|
# look fine while ``.py`` files were removed mid-install). Canonical tables live in the
|
|
# stdlib-only ``_early_recovery`` module so the early and full recovery layers never drift.
|
|
# See #57828.
|
|
_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
|
|
_early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES)
|
|
_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = _early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES
|
|
|
|
|
|
def _run_package_only_install(cmd: list[str], *, env: dict[str, str] | None = None) -> None:
|
|
"""Package-only pip/uv install — no shim quarantine: ``--force-reinstall <pkg>`` never rewrites
|
|
``hermes.exe``, and the quarantine path would rename shims uv then never recreates.
|
|
|
|
See #57828.
|
|
"""
|
|
_run_install_with_heartbeat(cmd, env=env)
|
|
|
|
|
|
def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]:
|
|
"""Map repair package names to their declared pin specs in pyproject.toml."""
|
|
project = _pyproject_project("lazy refresh repair spec lookup failed: %s")
|
|
if project is None:
|
|
return packages
|
|
name_to_spec = {
|
|
name.lower(): head
|
|
for name, _, head in _parse_requirements(project.get("dependencies", []) or [])}
|
|
return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
|
|
|
|
|
|
def _venv_probe(venv_python: Path, script: str, *args: str, env: dict[str, str] | None):
|
|
"""Run ``script`` in the target venv's interpreter, capturing UTF-8 stdout."""
|
|
return subprocess.run(
|
|
[str(venv_python), "-c", script, *args],
|
|
capture_output=True,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
check=False,
|
|
env=env)
|
|
|
|
|
|
def _nonblank_lines(text: str) -> list[str]:
|
|
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
|
|
|
|
def _detect_broken_lazy_refresh_imports(
|
|
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[str] | None:
|
|
"""Probe lazy-refresh packages via real imports: ``[]`` all clean, ``[dist, ...]`` failures,
|
|
``None`` when the probe could not run (no venv Python, subprocess failure, non-zero exit)
|
|
— *indeterminate*, not healthy."""
|
|
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
|
|
if venv_python is None:
|
|
return None
|
|
probe_lines = "\n".join(
|
|
f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES)
|
|
check_script = (
|
|
"import os\n"
|
|
"import sys\n"
|
|
"probes = [\n"
|
|
f"{probe_lines}\n"
|
|
"]\n"
|
|
"broken = []\n"
|
|
"for mod, attr in probes:\n"
|
|
" try:\n"
|
|
" imported = __import__(mod)\n"
|
|
" if not hasattr(imported, attr):\n"
|
|
" broken.append(mod)\n"
|
|
" elif mod == 'certifi':\n"
|
|
" # The module can import cleanly while cacert.pem is\n"
|
|
" # missing/corrupt (brew Python upgrade, interrupted venv\n"
|
|
" # rebuild) - every TLS call then fails (#29866).\n"
|
|
" bundle = imported.where()\n"
|
|
" if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n"
|
|
" broken.append(mod)\n"
|
|
" except Exception:\n"
|
|
" broken.append(mod)\n"
|
|
"print('\\n'.join(broken))\n")
|
|
try:
|
|
result = _venv_probe(venv_python, check_script, env=env)
|
|
except Exception as exc:
|
|
logger.debug("lazy refresh import probe failed: %s", exc)
|
|
return None
|
|
if result.returncode != 0:
|
|
logger.debug("lazy refresh import probe exited %s: %s",
|
|
result.returncode, (result.stderr or "")[:200])
|
|
return None
|
|
packages: list[str] = []
|
|
for mod in _nonblank_lines(result.stdout):
|
|
pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod)
|
|
if pkg and pkg not in packages:
|
|
packages.append(pkg)
|
|
return packages
|
|
|
|
|
|
def _repair_broken_lazy_refresh_imports(
|
|
install_cmd_prefix: list[str], packages: list[str], *, env: dict[str, str] | None = None
|
|
) -> bool:
|
|
"""Force-reinstall ``packages`` and re-probe imports. Never raises."""
|
|
if not packages:
|
|
return True
|
|
specs = _lazy_refresh_repair_specs(packages)
|
|
if not _run_repair_step(
|
|
_run_package_only_install, install_cmd_prefix + ["install", "--force-reinstall", *specs],
|
|
env=env, log_msg="lazy refresh venv repair failed: %s", fail_msg=None):
|
|
return False
|
|
# Indeterminate re-probe is not confirmed success.
|
|
return _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) == []
|
|
|
|
|
|
def _repair_venv_via_import_probes(
|
|
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> str:
|
|
"""Probe imports and force-reinstall any broken lazy-refresh packages.
|
|
|
|
Real ``import`` checks (not distribution metadata) catch a venv where METADATA remains
|
|
but ``.py`` files were wiped mid-install. Package-only reinstall — never rewrites
|
|
``hermes.exe``. Never raises. Returns ``"healthy"``, ``"repaired"``, ``"failed"``
|
|
(repair did not confirm clean) or ``"indeterminate"`` (probes could not run; NOT healthy).
|
|
|
|
See #57828.
|
|
"""
|
|
broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
|
|
if broken is None:
|
|
print(" ⚠ Import probes unavailable — cannot confirm venv package health.")
|
|
return "indeterminate"
|
|
if not broken:
|
|
return "healthy"
|
|
print(
|
|
" → Detected corrupted venv packages via import probes: "
|
|
f"{', '.join(broken)}; repairing...")
|
|
if _repair_broken_lazy_refresh_imports(install_cmd_prefix, broken, env=env):
|
|
print(" ✓ Venv repair succeeded")
|
|
return "repaired"
|
|
manual = " ".join(shlex.quote(s) for s in _lazy_refresh_repair_specs(broken))
|
|
print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:")
|
|
print(f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}")
|
|
return "failed"
|
|
|
|
|
|
def _is_uv_command(install_cmd_prefix: list[str]) -> bool:
|
|
"""True for a uv/uvx binary (bare or path) or ``python -m uv`` / ``python -m uvx``."""
|
|
if not install_cmd_prefix:
|
|
return False
|
|
first = str(install_cmd_prefix[0]).lower()
|
|
if "uv" in Path(first).name:
|
|
return True
|
|
return (
|
|
len(install_cmd_prefix) >= 3
|
|
and first.endswith(("python", "python.exe"))
|
|
and install_cmd_prefix[1] == "-m"
|
|
and install_cmd_prefix[2] in ("uv", "uvx"))
|
|
|
|
|
|
def _insert_python_pin(args: list[str]) -> list[str]:
|
|
"""Insert ``--python <sys.executable>`` into a uv command line; an explicit caller ``--python`` wins."""
|
|
if "--python" in args:
|
|
return args
|
|
return [args[0], "--python", str(sys.executable), *args[1:]]
|
|
|
|
|
|
def _interpreter_scripts_dir() -> Path | None:
|
|
"""Scripts/bin dir of ``sys.executable``: on a site-packages install ``PROJECT_ROOT/venv``
|
|
does not exist and the shims uv rewrites live next to the interpreter. Layout via the
|
|
canonical ``venv_bin_dir`` (hand-rolling Scripts/bin is lint-tested against).
|
|
|
|
See #76105.
|
|
"""
|
|
from hermes_constants import venv_bin_dir
|
|
exe = Path(sys.executable)
|
|
# sys.executable lives IN the bin/Scripts dir; parent.parent is the env root.
|
|
cand = venv_bin_dir(exe.parent.parent, windows=_is_windows())
|
|
if cand.is_dir():
|
|
return cand
|
|
return exe.parent if exe.parent.is_dir() else None
|
|
|
|
|
|
def _install_python_dependencies_with_optional_fallback(
|
|
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all"
|
|
) -> None:
|
|
"""Install base deps plus as many optional extras as the environment supports.
|
|
|
|
Targets ``.[all]`` by default; Termux callers pass ``group='termux-all'``. On Windows every
|
|
attempt quarantines the live ``hermes*.exe`` shims first. When ``env`` carries a
|
|
``VIRTUAL_ENV`` that does not exist (pip / site-packages install), ``uv pip`` fails with
|
|
``Failed to inspect Python interpreter from active virtual environment`` before doing any
|
|
work — pin the install at the running interpreter instead.
|
|
|
|
Pin the install at the running interpreter instead so the update/recovery path succeeds on those
|
|
installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the shared helper for the
|
|
remaining callers).
|
|
"""
|
|
scripts_dir = _venv_scripts_dir() if _is_windows() else None
|
|
|
|
# Only uv needs the explicit pin; pip resolves the target from sys.executable itself.
|
|
pin_python = bool(
|
|
env and env.get("VIRTUAL_ENV") and not Path(env["VIRTUAL_ENV"]).is_dir()
|
|
and install_cmd_prefix and _is_uv_command(install_cmd_prefix))
|
|
if pin_python:
|
|
env = {**env}
|
|
env.pop("VIRTUAL_ENV", None)
|
|
# Pinned to sys.executable, the shims uv rewrites live in THAT interpreter's Scripts
|
|
# dir, not PROJECT_ROOT/venv; quarantining the wrong dir leaves hermes.exe locked.
|
|
if scripts_dir is None and _is_windows():
|
|
scripts_dir = _interpreter_scripts_dir()
|
|
|
|
def _install(args: list[str]) -> None:
|
|
if pin_python:
|
|
args = _insert_python_pin(args)
|
|
# strict_quarantine: this is the UPDATE dependency sync; ShimQuarantineError propagates
|
|
# to the sync boundary, which defers via the update-incomplete marker instead.
|
|
# A shim that cannot be renamed aside proves a hard venv hold; running uv anyway is how installs
|
|
# strand half-updated (#87331).
|
|
_run_quarantined_install(
|
|
install_cmd_prefix + args, env=env, scripts_dir=scripts_dir, strict_quarantine=True)
|
|
|
|
try:
|
|
_install(["install", "-e", f".[{group}]"])
|
|
_verify_console_scripts_installed(install_cmd_prefix, env=env)
|
|
return
|
|
except subprocess.CalledProcessError:
|
|
print(
|
|
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
|
|
)
|
|
|
|
_install(["install", "-e", "."])
|
|
failed_extras: list[str] = []
|
|
installed_extras: list[str] = []
|
|
for extra in _load_installable_optional_extras(group=group):
|
|
try:
|
|
_install(["install", "-e", f".[{extra}]"])
|
|
installed_extras.append(extra)
|
|
except subprocess.CalledProcessError:
|
|
failed_extras.append(extra)
|
|
if installed_extras:
|
|
print(f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}")
|
|
if failed_extras:
|
|
print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}")
|
|
# uv's incremental resolver has left newly added base deps silently missing on a half-stale
|
|
# venv, surfacing hours later as a downstream ModuleNotFoundError. Verify here instead.
|
|
_verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
|
|
_verify_console_scripts_installed(install_cmd_prefix, env=env)
|
|
|
|
|
|
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 _verify_console_scripts_installed(
|
|
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
|
|
"""Ensure every declared console_script shim exists on disk after install.
|
|
|
|
On Windows ``uv pip install -e .`` can register ``hermes.exe`` in the wheel RECORD while the
|
|
file never lands (live shim locked, launcher write skipped), so ``hermes`` drops off PATH
|
|
after a "successful" install. Missing shims get ``--reinstall -e .`` under quarantine.
|
|
|
|
The symptom is ``hermes-agent.exe`` and ``hermes-acp.exe`` present but ``hermes.exe`` missing, so
|
|
``hermes`` drops off PATH even though the install reported success (issue #52931).
|
|
"""
|
|
if not _is_windows():
|
|
return
|
|
scripts_dir = _venv_scripts_dir()
|
|
names = _load_console_script_names() if scripts_dir is not None else []
|
|
if not names:
|
|
return
|
|
|
|
def _missing() -> list[str]:
|
|
return [name for name in names if not (scripts_dir / f"{name}.exe").is_file()]
|
|
|
|
missing = _missing()
|
|
if not missing:
|
|
return
|
|
print(
|
|
f" ⚠ Verification: {len(missing)} console script(s) missing on disk: "
|
|
f"{', '.join(missing)}")
|
|
print(" → Reinstalling entry points with --reinstall...")
|
|
if not _run_repair_step(
|
|
_run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."],
|
|
env=env, scripts_dir=scripts_dir,
|
|
log_msg="console script verification: repair install failed: %s",
|
|
fail_msg=(
|
|
" ⚠ Entry point repair failed; try `hermes update --force` after "
|
|
"closing other hermes processes.")):
|
|
return
|
|
_report_still_missing(
|
|
_missing(), "Workaround: python -m hermes_cli.main <command>",
|
|
ok=" ✓ All console entry points restored")
|
|
|
|
|
|
def _applicable_dependency_names(raw_deps: list[str]) -> list[str]:
|
|
"""Declared dep names whose ``;`` markers apply here (else ``ptyprocess ; sys_platform !=
|
|
'win32'`` would false-positive on Windows). An unevaluable marker counts as applicable."""
|
|
applicable: list[str] = []
|
|
for name, marker, _ in _parse_requirements(raw_deps):
|
|
try:
|
|
if marker is None or marker.evaluate(): # type: ignore[union-attr]
|
|
applicable.append(name)
|
|
except Exception:
|
|
applicable.append(name)
|
|
return applicable
|
|
|
|
|
|
_MISSING_DEPS_SCRIPT = (
|
|
"import importlib.metadata as md, sys\n"
|
|
"missing=[]\n"
|
|
"for name in sys.argv[1:]:\n"
|
|
" try: md.version(name)\n"
|
|
" except md.PackageNotFoundError: missing.append(name)\n"
|
|
"print('\\n'.join(missing))\n")
|
|
|
|
|
|
def _verify_core_dependencies_installed(
|
|
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all"
|
|
) -> None:
|
|
"""Check that every base dep from pyproject.toml is installed in the target venv; if not, retry.
|
|
|
|
Reads ``pyproject.toml`` directly (not the venv's stale metadata), drops deps whose ``;``
|
|
markers don't apply here, and probes ``importlib.metadata.version()`` in the venv
|
|
interpreter. Missing deps trigger a base-group ``--reinstall``, then a per-package force
|
|
install. The final state is a warning, not a hard failure, so one broken-on-PyPI dep can't
|
|
block an otherwise-successful update — but the partial install is visible where it happened.
|
|
"""
|
|
project = _pyproject_project("dep verification: failed to read pyproject.toml: %s")
|
|
if project is None:
|
|
return
|
|
raw_deps = project.get("dependencies", []) or []
|
|
applicable = _applicable_dependency_names(raw_deps)
|
|
if not applicable:
|
|
return
|
|
# Probe inside the venv Python — sys.executable may be the outer Python that drove
|
|
# ``hermes update``; the install prefix/env encode which environment we targeted.
|
|
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
|
|
if venv_python is None:
|
|
return
|
|
|
|
def _missing_deps() -> list[str]:
|
|
try:
|
|
result = _venv_probe(venv_python, _MISSING_DEPS_SCRIPT, *applicable, env=env)
|
|
except Exception as e:
|
|
logger.debug("dep verification: subprocess failed: %s", e)
|
|
return []
|
|
return _nonblank_lines(result.stdout)
|
|
|
|
missing = _missing_deps()
|
|
if not missing:
|
|
return
|
|
print(
|
|
f" ⚠ Verification: {len(missing)} declared dep(s) missing after install: "
|
|
f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}")
|
|
print(" → Reinstalling base group with --reinstall to repair...")
|
|
# Base group only, not ``[{group}]``: the missing dep is a *base* dep and the all-extras
|
|
# install costs minutes. Quarantine first: ``--reinstall -e .`` rewrites the shims.
|
|
scripts_dir = _venv_scripts_dir() if _is_windows() else None
|
|
if not _run_repair_step(
|
|
_run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."],
|
|
env=env, scripts_dir=scripts_dir,
|
|
log_msg="dep verification: repair install failed: %s",
|
|
fail_msg=" ⚠ Repair install failed; check `hermes update` output above."):
|
|
return
|
|
still_missing = _missing_deps()
|
|
if not still_missing:
|
|
print(" ✓ All declared core dependencies now installed")
|
|
return
|
|
# Last-ditch: install each remaining missing dep with its pin directly — uv's
|
|
# resolver can think the env is satisfied while on-disk metadata disagrees.
|
|
name_to_spec = dict(_naive_requirement(spec) for spec in raw_deps)
|
|
specs = [name_to_spec.get(n, n) for n in still_missing]
|
|
print(f" → Force-installing remaining missing dep(s): {', '.join(specs)}")
|
|
if not _run_repair_step(
|
|
_run_install_with_heartbeat, install_cmd_prefix + ["install", "--reinstall", *specs],
|
|
env=env,
|
|
log_msg="dep verification: per-package repair failed: %s",
|
|
fail_msg=(
|
|
f" ⚠ Could not install: {', '.join(still_missing)}. "
|
|
"Run `hermes update --force` after closing other hermes processes.")):
|
|
return
|
|
_report_still_missing(
|
|
_missing_deps(), "Run `hermes update --force` after closing other hermes processes.",
|
|
ok=" ✓ All declared core dependencies now installed")
|
|
|
|
|
|
def _resolve_install_target_python(
|
|
install_cmd_prefix: list[str], env: dict[str, str] | None) -> Path | None:
|
|
"""Python interpreter the install targeted: ``VIRTUAL_ENV`` from ``env`` for the
|
|
``[uv, pip]`` shape, else ``install_cmd_prefix[0]`` for ``[sys.executable, -m, pip]``."""
|
|
if env and "VIRTUAL_ENV" in env:
|
|
from hermes_constants import venv_python_path
|
|
candidate = venv_python_path(Path(env["VIRTUAL_ENV"]), windows=_is_windows())
|
|
if candidate.exists():
|
|
return candidate
|
|
if install_cmd_prefix:
|
|
first = Path(install_cmd_prefix[0])
|
|
if first.exists() and "uv" not in first.name.lower():
|
|
return first
|
|
return None
|
|
|
|
|
|
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 ``/mnt/c`` interop, ``.cmd``/``.exe``, UNC).
|
|
|
|
Callers use this only on a POSIX host — on native Windows ``npm.cmd`` is correct.
|
|
"""
|
|
low = npm_path.lower()
|
|
return low.endswith((".exe", ".cmd", ".bat")) or low.startswith("/mnt/") or "\\" in npm_path
|
|
|
|
|
|
def _resolve_node_runtime_npm() -> str | None:
|
|
"""Resolve an npm executable that belongs to the host's Node runtime.
|
|
|
|
On WSL, PATH interop can hand back a Windows npm that fails with EISDIR / symlink errors over
|
|
``\\\\wsl.localhost\\...`` UNC paths. Refuse it on a POSIX host and re-scan PATH minus the
|
|
``/mnt/*`` drive mounts. ``None`` when no suitable npm is reachable.
|
|
|
|
On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed through PATH interop. See #30271.
|
|
"""
|
|
from hermes_constants import find_node_executable
|
|
npm = find_node_executable("npm")
|
|
if _is_windows():
|
|
return npm
|
|
if not npm:
|
|
return None
|
|
if not _is_windows_npm_path(npm):
|
|
return npm
|
|
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
if not directory or directory.lower().startswith("/mnt/"):
|
|
continue
|
|
candidate = shutil.which("npm", path=directory)
|
|
if candidate and not _is_windows_npm_path(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
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"
|