refactor(update): use PM and shared source builders

PM owns Python dependency generations. Shared frontend builders own Node
preparation and compilation. Route source updates and launchers through
these owners instead of separate repair ladders.

Remove obsolete live-venv holder gates and soft build-failure plumbing.
Preserve source validation, staged publication, fleet outcomes, and
historical relaunch hooks.

Verification: 956 tests passed in the combined focused run, with 43 skips.
After the final ZIP exit fix, 583 focused tests passed. Shared JavaScript
builder tests, Ruff, and diff checks passed. Native Windows/macOS and full
packaged-app builds were not run.
This commit is contained in:
ethernet
2026-09-12 13:45:08 -04:00
parent cb17e9ba72
commit 26c4e8b160
60 changed files with 1815 additions and 6433 deletions
+13
View File
@@ -33,6 +33,19 @@ select an agent payload.
## Build and packaging entrypoints
Source updates and source UI launches use the same dependency provider and
product builders. `hermes_cli/source_build.py` selects the workspace union,
prepares it once, then invokes the shared recipes. An update builds TUI and
web, plus the local desktop app when one was present before the update.
Desktop packaging still belongs to the local desktop adapter.
The current-checkout retry, pulled-checkout, and ZIP update paths all use
`update_cmd_maint._prepare_updated_checkout`: one PM dependency sync, then a
fresh process on the selected interpreter for the frontend builds. Build
failure aborts completion; an existing stale product is not a successful
update. There is no updater-specific npm cache, fallback install, extra
refresh, or memory-provider reinstall. PM owns the complete Python union.
Build a desktop distribution from a checkout at its release tag. Use its
PM-prepared Python 3.14. The driver delegates Python dependency preparation to PM:
+2 -2
View File
@@ -7,8 +7,8 @@ from typing import NoReturn
def stop_for_relaunch() -> NoReturn:
"""Do not return: old callers would fall back to pip or claim completion."""
print(
"This updater is running code from before the checkout changed. "
"Stopping without installing dependencies; relaunch Hermes to continue.",
"You're updating from an older version of Hermes Agent."
"To complete this update, run `hermes` again.",
file=sys.stderr,
)
raise SystemExit(0)
+5 -59
View File
@@ -421,69 +421,15 @@ def _restart_killed_backends(
return unrecovered
def _norm_exe(path) -> str:
"""Canonical lower-cased executable path for comparison."""
try:
return str(Path(path).resolve()).lower()
except (OSError, ValueError):
return str(path).lower()
def _detect_concurrent_hermes_instances(
scripts_dir: Path, *, exclude_pid: int | None = None) -> list[tuple[int, str]]:
"""``(pid, name)`` of other live processes whose .exe is one of our entry-point shims.
"""Historical main export: stop old updaters without scanning live shims.
Windows blocks DELETE/REPLACE on a running .exe, so a Desktop-spawned ``hermes.EXE`` makes
the update's quarantine rename fail with ``[WinError 32]``. Excludes our PID and every
*shim* ancestor (the setuptools launcher is a separate native process from its
``python.exe``); ``proc.parents()`` at once because a per-hop loop bailed on the first
AccessDenied. Empty off-Windows / without psutil. Never raises.
PM stages a fresh generation instead of replacing a mapped hermes.exe.
Returning an empty list would let old callers continue into that mutation.
"""
from hermes_cli.main_install_repair import _hermes_exe_shims, _is_windows
if not _is_windows():
return []
try:
import psutil
except Exception:
return []
shim_paths = {_norm_exe(shim) for shim in _hermes_exe_shims(scripts_dir)}
if not shim_paths:
return []
seed = int(exclude_pid) if exclude_pid is not None else os.getpid()
exclude_pids: set[int] = {seed}
# Broad ``except Exception`` guards against partially-stubbed psutil in unit tests; this helper is
# documented as "never raises". Only the per-ancestor exe()/pid reads skip that ancestor; anything
# else aborts the whole walk (BASE semantics).
try:
for ancestor in psutil.Process(seed).parents():
try:
anc_exe = ancestor.exe()
except Exception:
continue
if not anc_exe:
continue
if _norm_exe(anc_exe) in shim_paths:
try:
exclude_pids.add(int(ancestor.pid))
except Exception:
continue
except Exception:
pass
matches: list[tuple[int, str]] = []
try:
proc_iter = psutil.process_iter(["pid", "exe", "name"])
except Exception:
return []
for proc in proc_iter:
try:
info = proc.info
except Exception:
continue
pid, exe = info.get("pid"), info.get("exe")
if exe and pid is not None and pid not in exclude_pids and _norm_exe(exe) in shim_paths:
matches.append((int(pid), str(info.get("name") or Path(exe).name)))
return matches
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _is_desktop_local_serve_cmdline(command: str) -> bool:
+12
View File
@@ -2320,15 +2320,27 @@ def cmd_update(args):
# = not SystemExit-shaped, so real exceptions keep their traceback.
_update_handoff_exit_code: int | None = None
from hermes_cli.update_cmd import _cmd_update_impl
from pm import InstallError
try:
_cmd_update_impl(args, gateway_mode=gateway_mode)
except (InstallError, OSError, subprocess.SubprocessError) as exc:
print(f"✗ Update failed: {exc}")
_finalize_update_receipt(1, f"{type(exc).__name__}: {exc}")
if gateway_mode:
from hermes_cli.update_cmd_fleet import _write_gateway_update_exit_code
_write_gateway_update_exit_code(False)
_update_handoff_exit_code = 1
raise SystemExit(1) from exc
except SystemExit as _update_exit:
# Receipt boundary: the impl has many early sys.exit paths that never
# reach an inner finalize. Persist any still-open receipt with the real
# exit code (no-op if already finalized), then let the exit proceed.
_code = _update_exit.code if isinstance(_update_exit.code, int) else 1
_finalize_update_receipt(_code, f"sys.exit({_code})")
if gateway_mode and _code:
from hermes_cli.update_cmd_fleet import _write_gateway_update_exit_code
_write_gateway_update_exit_code(False)
_update_handoff_exit_code = (
_update_exit.code if isinstance(_update_exit.code, int) else 0
)
+47 -281
View File
@@ -19,9 +19,8 @@ import time as _time_mod
from pathlib import Path
from typing import Optional
from hermes_cli.main_tui_launch import _npm_lifecycle_env
from hermes_cli.main_web_build import (
_hash_source_tree, _nixos_build_env, _stamp_is_current, _write_build_stamp)
_hash_source_tree, _stamp_is_current, _write_build_stamp)
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.main")
@@ -488,69 +487,6 @@ def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optio
return None, False
def _electron_download_cache_dirs() -> list[Path]:
"""Per-user Electron download caches (``electron_config_cache`` / ``ELECTRON_CACHE`` overrides
first): ``unpack-electron`` extracts from a zip here, NOT node_modules, so a corrupt zip poisons
the build."""
home = Path.home()
override = os.environ.get("electron_config_cache") or os.environ.get("ELECTRON_CACHE")
candidates: list[Optional[str | Path]] = [override]
if sys.platform == "darwin":
candidates.append(home / "Library" / "Caches" / "electron")
elif sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
candidates += [Path(local) / "electron" / "Cache" if local else None,
home / "AppData" / "Local" / "electron" / "Cache"]
else:
xdg = os.environ.get("XDG_CACHE_HOME")
candidates += [Path(xdg) / "electron" if xdg else None, home / ".cache" / "electron"]
return list(dict.fromkeys(Path(c).expanduser() for c in candidates if c))
def _purge_electron_build_cache(desktop_dir: Path, release_dir: Optional[Path] = None) -> list[Path]:
"""Purge the cached Electron zips + half-written unpacked dir so the next pack restarts from scratch.
A corrupt cached zip unpacks to a tree MISSING the ``electron`` binary
(``ENOENT … rename``) and every rerun repeats it. Deliberately no self-rolled
zip validation: stdlib ``zipfile`` tolerates exactly the concat-junk
``@electron/get`` rejects, so a gate would never self-heal — purge
unconditionally and let ``@electron/get``'s SHASUM check be the truth.
``release_dir`` points a stage-and-swap caller at its STAGING output so the
live app is never touched. Never raises; empty result ⇒ nothing to retry.
"""
removed: list[Path] = []
for cache_dir in _electron_download_cache_dirs():
if not cache_dir.is_dir():
continue
for zip_path in sorted(cache_dir.rglob("electron-*.zip")):
# locked/permission-denied: let the build report its own error
with contextlib.suppress(OSError):
zip_path.unlink()
removed.append(zip_path)
# Drop the half-written unpacked dir too: an interrupted prior pack leaves a partial tree that poisons
# the rename even after the zip is fixed. (before-pack.cjs also handles this, but clearing it here makes
# the retry robust even if the hook is somehow skipped.) ``release_dir`` lets a stage-and-swap caller
# point this at its STAGING output so a mid-retry purge never touches the live app under ``release/``
# (#86443).
if release_dir is None:
release_dir = desktop_dir / "release"
if release_dir.is_dir():
for unpacked in release_dir.glob("*-unpacked"):
with contextlib.suppress(OSError):
shutil.rmtree(unpacked, ignore_errors=True)
removed.append(unpacked)
return removed
# Last-resort Electron mirror after GitHub download fails. Only used when the
# user hasn't pinned ELECTRON_MIRROR.
# See #47266.
_ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
def _electron_dir(project_root: Path) -> Path:
"""The installed Electron package dir: workspace-local ``apps/desktop/node_modules/electron`` (where
``electronDist`` points) when present, else the root hoist npm sometimes uses instead."""
@@ -560,75 +496,6 @@ def _electron_dir(project_root: Path) -> Path:
return project_root / "node_modules" / "electron"
def _electron_dist_binary(project_root: Path) -> Path:
"""The Electron main binary inside the installed package — the exact file ``electronDist`` needs.
electron-builder reads the binary from ``build.electronDist`` since #38673, so this is the exact file
whose absence makes a pack fail with "The specified electronDist does not exist". The basename differs
per OS (the platform Electron is named for the host the build runs on).
"""
dist = _electron_dir(project_root) / "dist"
if sys.platform == "darwin":
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
if sys.platform == "win32":
return dist / "electron.exe"
return dist / "electron"
def _electron_dist_ok(project_root: Path) -> bool:
"""True when ``node_modules/electron/dist`` holds a usable binary (a partial dir counts as NOT ok)."""
try:
return _electron_dist_binary(project_root).exists()
except OSError:
return False
def _electron_pkg_staged_missing_dist(project_root: Path) -> bool:
"""electron staged (package.json + install.js) but dist missing — blocked postinstall."""
electron_dir = _electron_dir(project_root)
return (
(electron_dir / "package.json").is_file()
and (electron_dir / "install.js").is_file()
and not _electron_dist_ok(project_root))
def _redownload_electron_dist(project_root: Path, env: dict, *, mirror: Optional[str] = None) -> bool:
"""Best-effort: run electron's install.js to populate dist/ (optional mirror)."""
if _electron_dist_ok(project_root):
return True
electron_dir = _electron_dir(project_root)
installer = electron_dir / "install.js"
if not installer.is_file():
return False
from hermes_constants import find_node_executable, with_hermes_node_path
node = find_node_executable("node")
if not node:
return False
shutil.rmtree(electron_dir / "dist", ignore_errors=True)
with contextlib.suppress(OSError):
(electron_dir / "path.txt").unlink()
dl_env = with_hermes_node_path(env)
if mirror:
dl_env["ELECTRON_MIRROR"] = mirror
try:
subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False)
except OSError:
return False
return _electron_dist_ok(project_root)
def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
"""Canonical download, then fallback mirror unless the user pinned one."""
if _redownload_electron_dist(project_root, env):
return True
if env.get("ELECTRON_MIRROR"):
return False
return _redownload_electron_dist(project_root, env, mirror=_ELECTRON_FALLBACK_MIRROR)
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
"""Terminate a running desktop app whose exe lives INSIDE this build's ``release`` tree (Windows
only — its lock makes the pack die with ``Access is denied``; POSIX can unlink a running
@@ -1245,88 +1112,8 @@ def _register_linux_desktop_entry() -> None:
print(f"⚠ Could not install the desktop launcher entry: {exc}")
def _install_desktop_workspace_deps(npm: str, env: dict) -> None:
"""npm-install the desktop workspace; exits on a failure that isn't a repairable missing Electron dist."""
from hermes_cli.main import PROJECT_ROOT
from hermes_cli.main_web_build import _run_npm_install_deterministic
from hermes_constants import with_hermes_node_path
print("→ Installing desktop workspace dependencies...")
# Managed Node on PATH so npm's child scripts that shell out to bare `node`
# (e.g. electron-winstaller's select-7z-arch.js) resolve it even when the
# desktop updater chain lost shell PATH customizations. Wrapping the NixOS
# env keeps its PYTHON hint while restoring managed Node ahead of PATH.
from pm import env_for
nixos_env = env_for("npm", base_env=with_hermes_node_path(_nixos_build_env()))
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
if install_result.returncode == 0:
return
if not _electron_pkg_staged_missing_dist(PROJECT_ROOT):
print(f"✗ Desktop dependency install failed\n Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
if _try_redownload_electron_dist(PROJECT_ROOT, env):
print(" ⚠ Dependency install failed with a missing Electron dist; "
"repopulated it and continuing.")
else:
print(" ⚠ Dependency install failed with a missing Electron dist; "
"continuing to the build so electron-builder can attempt "
"the Electron fetch itself.")
def _run_desktop_pack_with_recovery(
desktop_dir: Path, build_cmd: list[str], npm_build_env: dict, env: dict, staging_dir: Optional[Path]
) -> subprocess.CompletedProcess:
"""Run the desktop build; a packaged build with NO staged exe retries after an Electron re-download, then via mirror.
A MISSING exe is the signature of the corrupt-download class; a late failure
(e.g. macOS signing) leaves it in place and a redownload retry would only
repeat the same slow failure.
"""
from hermes_cli.main import PROJECT_ROOT
def _staged_exe() -> Optional[Path]:
return _desktop_packaged_executable_in(staging_dir) if staging_dir else None
def _pack(run_env: dict) -> subprocess.CompletedProcess:
return subprocess.run(build_cmd, cwd=desktop_dir, env=run_env, check=False)
build_result = _pack(npm_build_env)
if build_result.returncode != 0 and staging_dir is not None and _staged_exe() is None:
# Corrupt cached Electron zip → partial unpack → ENOENT on rename. stdlib zipfile won't catch the
# common concat-junk case, so purge and retry once; @electron/get SHASUM is the real gate. Gate on a
# MISSING packaged executable: that is the signature of the corrupt-download class this recovery
# exists for. A late failure such as macOS code signing leaves the executable in place —
# redownloading Electron can't repair it, so the purge + retry would only add another slow,
# identical failure (#40187).
purged: list[Path] = []
restored = False
if not _electron_dist_ok(PROJECT_ROOT):
purged = _purge_electron_build_cache(desktop_dir, release_dir=staging_dir)
restored = _redownload_electron_dist(PROJECT_ROOT, env)
if restored:
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
for p in purged:
print(f" - {p}")
# The purge can't remove a win-unpacked tree whose Hermes.exe is
# still locked by a running instance; stop it before retry.
_stop_desktop_processes_locking_build(desktop_dir)
build_result = _pack(npm_build_env)
if (
build_result.returncode != 0
and staging_dir is not None
and not env.get("ELECTRON_MIRROR")
and _staged_exe() is None):
print(" ⚠ Desktop build still failing; the Electron download from "
"GitHub looks blocked. Re-downloading via a public mirror "
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
mirror_env = {**npm_build_env, "ELECTRON_MIRROR": _ELECTRON_FALLBACK_MIRROR}
if not _electron_dist_ok(PROJECT_ROOT):
_redownload_electron_dist(PROJECT_ROOT, env, mirror=_ELECTRON_FALLBACK_MIRROR)
_stop_desktop_processes_locking_build(desktop_dir)
build_result = _pack(mirror_env)
return build_result
def _promote_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Path:
"""Sign + integrity-gate the STAGED pack, then swap it over the live app. Exits (live app kept) on failure."""
"""Sign and verify the staged pack before replacing the live app."""
staged_executable = _desktop_packaged_executable_in(staging_dir)
# Locally-built apps are ad-hoc signed; make them relaunchable after an
# in-place self-update. Signs the STAGED bundle so the live app is never
@@ -1335,73 +1122,48 @@ def _promote_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Path:
# Windows integrity gate: never declare the rebuild a success on a
# Hermes.exe Windows cannot load. Verified on the STAGED exe, so a failure
# simply discards staging and fails loudly for the updater's retry-once.
# fails the build without replacing the live app.
verified_executable, rolled_back = _ensure_desktop_exe_launchable(desktop_dir, staged_executable)
if staged_executable is None or rolled_back or verified_executable is None:
_discard_desktop_staging(staging_dir)
if staged_executable is None:
print(f"✗ Desktop build produced no launchable app in {staging_dir}")
print(_PREVIOUS_APP_KEPT)
sys.exit(1)
raise RuntimeError(f"Desktop build produced no launchable app. {_PREVIOUS_APP_KEPT}")
packaged_executable = _swap_staged_desktop_app(desktop_dir, staging_dir)
if packaged_executable is None:
print(f"✗ Could not install the rebuilt desktop app into {desktop_dir / 'release'}")
print(_PREVIOUS_APP_KEPT)
sys.exit(1)
raise RuntimeError(f"Could not publish the desktop build. {_PREVIOUS_APP_KEPT}")
return packaged_executable
def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict) -> Optional[Path]:
"""npm-install + build the desktop app, stage-and-swapping the packaged tree. Returns the new
packaged exe (None in source mode). Exits on unrecoverable failure with the previous app kept."""
from hermes_cli.main import PROJECT_ROOT
_install_desktop_workspace_deps(npm, env)
def build_prepared_desktop(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict) -> Optional[Path]:
"""Build prepared desktop sources, then publish the verified staged app."""
project_root = desktop_dir.parent.parent
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
build_script = "build" if source_mode else "pack"
if _force_adhoc_macos_signing(env, source_mode=source_mode):
build_env = dict(env)
if _force_adhoc_macos_signing(build_env, source_mode=source_mode):
print(" → No Developer ID configured; ad-hoc signing this local rebuild "
"(CSC_IDENTITY_AUTO_DISCOVERY=false)")
npm_build_env = _npm_lifecycle_env(env)
# Stage-and-swap: electron-builder packs IN PLACE and before-pack.mjs wipes
# release/<unpacked> first, so a pack that fails afterwards used to leave
# the user with NO app. Build into a staging dir; the live release/ tree is
# only replaced — by rename — after the staged result verifies.
# See #86443.
staging_dir: Optional[Path] = None
build_cmd = [npm, "run", build_script]
if not source_mode:
staging_dir = _desktop_staging_dir(desktop_dir)
build_cmd = [npm, "run", "build" if source_mode else "pack"]
staging_dir = None if source_mode else _desktop_staging_dir(desktop_dir)
if staging_dir is not None:
# electron-builder packs in place; only the verified staging tree may
# replace the running app, never a failed or incomplete build.
build_cmd += ["--", f"-c.directories.output={staging_dir}"]
# A running desktop instance holds Hermes.exe locked on Windows, so the
# pack can't replace it ("Access is denied"). Stop it first.
stopped = _stop_desktop_processes_locking_build(desktop_dir)
if stopped:
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
build_result = _run_desktop_pack_with_recovery(desktop_dir, build_cmd, npm_build_env, env, staging_dir)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
try:
subprocess.run(build_cmd, cwd=desktop_dir, env=build_env, check=True)
packaged_executable = (
_promote_staged_desktop_app(desktop_dir, staging_dir) if staging_dir is not None else None
)
_write_desktop_build_stamp(project_root, source_mode=source_mode)
return packaged_executable
finally:
if staging_dir is not None:
_discard_desktop_staging(staging_dir)
if _desktop_packaged_executable(desktop_dir) is not None:
print(_PREVIOUS_APP_KEPT)
print(f" Run manually: cd apps/desktop && npm run {build_script}")
if sys.platform == "win32":
print(" If this says \"Access is denied\" on Hermes.exe, close any")
print(" running Hermes desktop window and retry.")
print(" If the log shows Electron download retries, rebuild via a mirror:")
print(" ELECTRON_MIRROR=<mirror-base-url> hermes desktop --force-build")
sys.exit(build_result.returncode or 1)
packaged_executable = None
if staging_dir is not None:
packaged_executable = _promote_staged_desktop_app(desktop_dir, staging_dir)
# Build succeeded — write the stamp so next run can skip
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
return packaged_executable
_WSL_DXG_DEVICE = Path("/dev/dxg")
@@ -1502,7 +1264,7 @@ def _packaged_desktop_launch_command(packaged_executable: Path) -> list[str]:
def cmd_gui(args: argparse.Namespace):
"""Build and launch the native Electron desktop GUI."""
from hermes_cli.main import PROJECT_ROOT
from hermes_cli.main_install_repair import _resolve_node_runtime_npm
from hermes_cli.source_build import prepare_source_dependencies, source_build_env
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
# A bundled install IS the app: no source tree, no build, and the
# launcher is a sibling of this payload rather than something we
@@ -1536,26 +1298,30 @@ def cmd_gui(args: argparse.Namespace):
packaged_executable = _desktop_packaged_executable(desktop_dir)
needs_build = not skip_build and (
force_build or _desktop_build_needed(desktop_dir, PROJECT_ROOT, source_mode=source_mode)
)
npm = None
if source_mode or not skip_build:
npm = _resolve_node_runtime_npm()
if not npm:
print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.")
print("Install Node.js, then run: hermes gui")
sys.exit(1)
if skip_build:
_check_desktop_skip_build(
desktop_dir, PROJECT_ROOT, source_mode=source_mode, packaged_executable=packaged_executable
)
elif force_build or _desktop_build_needed(desktop_dir, PROJECT_ROOT, source_mode=source_mode):
# --force-build overrides the content-hash stamp and always rebuilds.
built = _build_desktop_app(desktop_dir, source_mode=source_mode, npm=npm, env=env)
if not source_mode:
packaged_executable = built
else:
build_label = "source build" if source_mode else "packaged app"
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
try:
if source_mode or needs_build:
build_env = source_build_env(env)
npm = shutil.which("npm", path=build_env["PATH"])
env["PATH"] = build_env["PATH"]
if skip_build:
_check_desktop_skip_build(
desktop_dir, PROJECT_ROOT, source_mode=source_mode, packaged_executable=packaged_executable
)
elif needs_build:
prepare_source_dependencies(PROJECT_ROOT, ("ui-tui", "web", "apps/desktop"), env=build_env)
built = build_prepared_desktop(desktop_dir, source_mode=source_mode, npm=npm, env=build_env)
if not source_mode:
packaged_executable = built
else:
build_label = "source build" if source_mode else "packaged app"
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
print(f"✗ Desktop GUI build failed: {exc}")
raise SystemExit(1) from exc
# Best-effort and idempotent; a failure must never stop the app from launching.
_register_linux_desktop_entry()
+23 -355
View File
@@ -1,4 +1,4 @@
"""TUI (ui-tui) launcher: node/npm bootstrap, workspace/rebuild checks, argv/env assembly.
"""TUI (ui-tui) launcher: prepared source builds and argv/env assembly.
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).
@@ -73,208 +73,13 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
)
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert", "peer", "dev", "extraneous", "hasInstallScript", "optional"})
"""Lockfile fields npm writes non-deterministically at install time.
``ideallyInert`` marks packages npm skipped (per-platform opt-outs); ``peer`` is
dropped from the hidden ``.package-lock.json`` on dev-deps that are also peers.
``dev`` / ``optional`` / ``extraneous`` / ``hasInstallScript`` are boolean
annotations npm populates differently in the hidden lock (npm >= 10/11), and
may differ even when present in both. None indicate a real declared-vs-installed
skew — the authoritative check is the ``resolved``/``integrity`` pair, which the
intersection comparison in :func:`_tui_need_npm_install` always catches.
"""
def _workspace_root(dir: Path) -> Path:
"""The npm workspace root for *dir*: its parent when *dir* has ``package.json`` but the
lockfile lives one level up (hoisted node_modules), else *dir* (standalone / prebuilt).
Shared by the install check, TUI launcher and web build so their cwd can't diverge."""
if (
(dir / "package.json").is_file()
and not (dir / "package-lock.json").is_file()
and (dir.parent / "package-lock.json").is_file()):
return dir.parent
return dir
def _child_workspace_dirs(dir: Path):
"""Sorted ``dir/packages/*`` subdirs that carry a ``package.json``."""
packages_dir = dir / "packages"
if not packages_dir.is_dir():
return
for child in sorted(packages_dir.iterdir()):
if child.is_dir() and (child / "package.json").is_file():
yield child
def _termux_workspace_install_context(
dir: Path, *, include_child_workspaces: bool = False) -> tuple[Path, tuple[str, ...]]:
"""Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only."""
ws_root = _workspace_root(dir)
if ws_root == dir:
return dir, ()
try:
workspace = dir.relative_to(ws_root).as_posix()
except ValueError:
return ws_root, ()
workspace_args: list[str] = ["--workspace", workspace]
if include_child_workspaces:
for child in _child_workspace_dirs(dir):
workspace_args.extend(["--workspace", child.relative_to(ws_root).as_posix()])
workspace_args.append("--include-workspace-root=false")
return ws_root, tuple(workspace_args)
def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]:
"""Package-map keys reachable from the selected workspaces (*starts*: set or str) via npm resolution.
``devDependencies`` are followed for each start (npm installs every selected
workspace's dev toolchain) but not for transitive deps. None when no start is
in *packages* so callers fall back to the full comparison — which would report
every OTHER workspace's deps (``apps/desktop``, ``web``) as missing and
reinstall on every launch. Names resolve by walking up ``node_modules``
ancestors; ``link: true`` entries are followed to their real package.
The launch install is scoped with ``npm install --workspace ui-tui`` (see ``_make_tui_argv``), so only
the ui-tui workspace's dependency closure is written to the hidden ``.package-lock.json``. On Termux it
additionally selects ui-tui's child ``packages/*`` workspaces, so their devDependencies join the closure
too. See #66978.
"""
start_set = {starts} if isinstance(starts, str) else {s for s in starts if s}
present = [s for s in start_set if s in packages]
if not present:
return None
def resolve(from_key: str, dep: str) -> Optional[str]:
base = from_key
while True:
candidate = f"{base}/node_modules/{dep}" if base else f"node_modules/{dep}"
if candidate in packages:
return candidate
if not base:
return None
base = base.rsplit("/", 1)[0] if "/" in base else ""
seen: set = set()
stack = list(present)
while stack:
key = stack.pop()
if key in seen:
continue
seen.add(key)
entry = packages.get(key)
if not isinstance(entry, dict):
continue
resolved = entry.get("resolved")
if entry.get("link") and isinstance(resolved, str) and resolved in packages:
stack.append(resolved)
fields = ["dependencies", "optionalDependencies", "peerDependencies"]
if key in start_set:
fields.append("devDependencies")
for field in fields:
deps = entry.get(field)
if not isinstance(deps, dict):
continue
for dep in deps:
target = resolve(key, dep)
if target is not None:
stack.append(target)
return seen
def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set:
"""Lock-map keys the launch install scopes to: ui-tui, plus its child ``packages/*`` on Termux
(each a dev-included closure root). Empty when ui-tui isn't under *ws_root*."""
from hermes_cli.main import _is_termux_startup_environment
try:
keys = {tui_dir.relative_to(ws_root).as_posix()}
except ValueError:
return set()
if _is_termux_startup_environment():
for child in _child_workspace_dirs(tui_dir):
try:
keys.add(child.relative_to(ws_root).as_posix())
except ValueError:
continue
return keys
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
Prebuilt bundle (``dist/entry.js``, no lockfile): nothing to install. The root
lock is compared to npm's hidden ``node_modules/.package-lock.json`` by CONTENT
(git bumps mtimes without changing deps): missing from hidden → reinstall
unless ``optional``/``peer``/``link`` or outside ``node_modules/``; present in
both → compare the intersection of non-null fields minus
``_NPM_LOCK_RUNTIME_KEYS`` (``resolved``/``integrity`` are always in both).
Hidden-only entries are ignored; unparseable lockfiles fall back to mtime.
"""
entry = root / "dist" / "entry.js"
ws_root = _workspace_root(root)
lock = ws_root / "package-lock.json"
if entry.is_file() and not lock.is_file():
return False
if not (ws_root / "node_modules" / "@hermes" / "ink" / "package.json").is_file():
return True
if not lock.is_file():
return False
marker = ws_root / "node_modules" / ".package-lock.json"
if not marker.is_file():
return True
try:
wanted = json.loads(lock.read_text(encoding="utf-8-sig")).get("packages") or {}
installed = json.loads(marker.read_text(encoding="utf-8-sig")).get("packages") or {}
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return lock.stat().st_mtime > marker.stat().st_mtime
def entries_differ(pkg: dict, installed_pkg: dict) -> bool:
a = {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
b = {k: v for k, v in installed_pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
return any(a[k] is not None and b[k] is not None and a[k] != b[k] for k in a.keys() & b.keys())
# Shared workspace checkout: the launch install is scoped to ui-tui (+ child
# packages on Termux), so limit the comparison to that closure. Standalone /
# own-lockfile layouts do a full install and keep the full comparison.
# Limit the comparison to the same selected-workspace closure so unrelated workspace deps (apps/desktop,
# web, …) don't force a reinstall every launch (#66978).
closure: Optional[set] = None
if ws_root != root:
selected = _tui_selected_workspace_keys(root, ws_root)
if selected:
closure = _npm_lock_workspace_closure(wanted, selected)
for name, pkg in wanted.items():
if not name or (closure is not None and name not in closure) or not isinstance(pkg, dict):
continue
if name not in installed:
# Workspace link entries are never materialized by a partial
# `npm install --workspace ui-tui`; don't force a reinstall for them.
# Workspace link entries (`"link": true`, paths outside node_modules/ like `apps/desktop`,
# `node_modules/web`) are never materialized by a partial `npm install --workspace ui-tui` —
# they're deliberately skipped (see #38772) and would otherwise force a reinstall on every
# launch.
if pkg.get("optional") or pkg.get("peer") or pkg.get("link"):
continue
if not name.startswith("node_modules/"):
continue
return True
if isinstance(installed[name], dict) and entries_differ(pkg, installed[name]):
return True
return False
_TUI_BUILD_INPUT_DIRS = ("src", "packages/hermes-ink/src")
_TUI_BUILD_INPUT_DIRS = ("src", "packages/hermes-ink/src", "../apps/shared")
_TUI_BUILD_INPUT_FILES = (
"package.json",
"package-lock.json",
"../package.json",
"../package-lock.json",
"tsconfig.json",
"tsconfig.build.json",
"babel.compiler.config.cjs",
@@ -325,42 +130,6 @@ def _tui_need_rebuild(root: Path) -> bool:
return False
def _ensure_tui_node() -> None:
"""Ensure `node` + `npm` are on PATH: else run node-bootstrap.sh `ensure_node` and prepend
the resolved node dir to PATH. ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install."""
from hermes_cli.main import PROJECT_ROOT
if shutil.which("node") and shutil.which("npm"):
return
if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"):
return
helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
if not helper.is_file():
return
from hermes_constants import get_hermes_home
hermes_home = str(get_hermes_home())
try:
# Helper logs to stderr; stdout carries `command -v node` — subshell PATH
# edits don't leak back into Python, so the capture is the bridge.
result = subprocess.run(
["bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node'],
env={**os.environ, "HERMES_HOME": hermes_home},
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False)
except (OSError, subprocess.SubprocessError):
return
parts = os.environ.get("PATH", "").split(os.pathsep)
resolved = (result.stdout or "").strip()
extras = [Path(resolved).resolve().parent] if resolved else []
extras += [Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]
for extra in extras:
s = str(extra)
if extra.is_dir() and s not in parts:
parts.insert(0, s)
os.environ["PATH"] = os.pathsep.join(parts)
def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
"""Find a pre-built TUI entry.js bundled in the wheel."""
if hermes_cli_dir is None:
@@ -421,115 +190,22 @@ def _ensure_tui_workspace(tui_dir: Path) -> None:
sys.exit(1)
def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]:
"""Build a clean environment for the pinned UI toolchain lifecycle."""
run_env = {**os.environ, **(env or {}), "CI": "1"}
# esbuild treats this as an executable override. If a shell points it at a
# different release, the pinned package's postinstall rejects that binary.
run_env.pop("ESBUILD_BINARY_PATH", None)
# The repo-root ``.npmrc`` is git-tracked, so the updater's autostash parks
# any mirror/proxy line added there and every update reinstalls without it
# (restricted networks then prune optional native deps like get-windows and
# the rebuild fails). ``$HERMES_HOME`` lives outside the git tree and the
# update hand-off already carries ``HERMES_HOME`` down to every npm child.
# An explicit ``NPM_CONFIG_USERCONFIG`` wins (#106373).
from hermes_constants import get_hermes_home
npmrc = get_hermes_home() / "npmrc"
if npmrc.is_file():
run_env.setdefault("NPM_CONFIG_USERCONFIG", os.fspath(npmrc))
return run_env
def _tui_node_bin(bin: str) -> str:
"""Resolve ``node``/``npm`` for the TUI launch, or exit with a hint. ``HERMES_NODE`` wins for node;
``find_node_executable()`` sees the managed ``$HERMES_HOME/node`` tree a bare which() misses."""
"""Resolve the TUI runtime through PM; an explicit bundled HERMES_NODE wins."""
if bin == "node":
env_node = os.environ.get("HERMES_NODE")
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
return env_node
from hermes_constants import find_node_executable
path = find_node_executable(bin)
if not path and bin == "node":
with contextlib.suppress(Exception):
from hermes_cli.dep_ensure import ensure_dependency
if ensure_dependency("node"):
path = find_node_executable("node")
from pm import ensure
path = shutil.which(bin, path=ensure(bin).env["PATH"])
if not path:
print(f"{bin} not found — install Node.js to use the TUI.")
sys.exit(1)
return path
def _exit_on_npm_failure(result: subprocess.CompletedProcess, message: str, *, sep: str) -> None:
"""Print *message* plus the last 30 lines of npm output and exit 1 on a non-zero rc."""
if result.returncode == 0:
return
combined = f"{result.stdout or ''}{sep}{result.stderr or ''}".strip()
preview = "\n".join(combined.splitlines()[-30:])
print(message)
if preview:
print(preview)
sys.exit(1)
def _run_tui_npm_build(npm: str, cwd: Path, failure_message: str) -> None:
"""``npm run build`` in *cwd*; exit with *failure_message* + output tail on failure."""
result = subprocess.run(
[npm, "run", "build"], cwd=str(cwd), capture_output=True, text=True, encoding="utf-8",
errors="replace", env=_npm_lifecycle_env())
_exit_on_npm_failure(result, failure_message, sep="")
def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
"""``npm install`` for the TUI workspace, with one EBADENGINE repair retry. Exits on failure.
``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty) and
is omitted when ui-tui/ has its own lockfile. ``--include=dev``: the build
toolchain is in devDependencies and an inherited ``NODE_ENV=production`` /
``omit=dev`` would silently skip it.
"""
npm = _tui_node_bin("npm")
if not os.environ.get("HERMES_QUIET"):
print("Installing TUI dependencies…")
npm_cwd = _workspace_root(tui_dir)
# --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty). See #38772. When ui-tui/ has
# its own package-lock.json (e.g. curl install), _workspace_root() returns tui_dir itself. Passing
# --workspace in that case fails because npm cannot find a workspace named "ui-tui" inside ui-tui/. See
# #42973.
npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui")
if termux_startup:
npm_cwd, npm_workspace_args = _termux_workspace_install_context(tui_dir, include_child_workspaces=True)
npm_install_cmd = [
npm, "install", *npm_workspace_args,
"--include=dev", "--silent", "--no-fund", "--no-audit", "--progress=false",
]
def _run_tui_install() -> subprocess.CompletedProcess:
from hermes_constants import with_hermes_node_path
# Managed tree first on PATH: if the EBADENGINE repair provisioned a
# managed Node, npm's shebang/lifecycle scripts must resolve that node.
return subprocess.run(
npm_install_cmd, cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace",
env=_npm_lifecycle_env(with_hermes_node_path()))
result = _run_tui_install()
if result.returncode != 0:
# An npm outside the root `engines.npm` range fails before doing any work;
# repair once (upgrade a managed npm in place, or provision a managed
# runtime) and retry rather than dumping EBADENGINE at the user.
from hermes_cli.npm_engine import maybe_repair_npm_engine
repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
if repaired_npm:
npm_install_cmd[0] = repaired_npm
result = _run_tui_install()
_exit_on_npm_failure(result, "npm install failed.", sep="\n")
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
from hermes_cli.main import _is_termux_startup_environment
_ensure_tui_node()
# Footgun: --dev against a prebuilt bundle that has no source/node_modules.
ext_dir = os.environ.get("HERMES_TUI_DIR")
@@ -563,33 +239,25 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
if not ext_dir:
_ensure_tui_workspace(tui_dir)
# 2. Normal flow: npm install if needed, esbuild, then node dist/entry.js.
# --dev: npm install if needed, then tsx src/entry.tsx.
termux_startup = _is_termux_startup_environment()
termux_need_rebuild = termux_startup and not tui_dev and _tui_need_rebuild(tui_dir)
skip_install_for_fresh_termux_bundle = termux_startup and not tui_dev and not termux_need_rebuild
did_install = False
if not skip_install_for_fresh_termux_bundle and _tui_need_npm_install(tui_dir):
_install_tui_dependencies(tui_dir, termux_startup=termux_startup)
did_install = True
if not tui_dev and not _tui_need_rebuild(tui_dir):
return [_tui_node_bin("node"), "--expose-gc", str(tui_dir / "dist/entry.js")], tui_dir
from hermes_cli.source_build import build_source_tui, prepare_launch_dependencies, source_build_env
project_root = tui_dir.parent
env = source_build_env()
prepare_launch_dependencies(project_root, env=env)
if tui_dev:
# --dev runs src/entry.tsx directly, but @hermes/ink resolves through
# packages/hermes-ink/dist/entry-exports.js; a stale dist after a pull
# leaves newer hooks/components missing at runtime. Prebuild it here.
npm = _tui_node_bin("npm")
_run_tui_npm_build(npm, tui_dir / "packages" / "hermes-ink", "TUI dev prebuild failed.")
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
if tsx.exists():
return [str(tsx), "src/entry.tsx"], tui_dir
return [npm, "start"], tui_dir
# tsx imports @hermes/ink's built exports; the production bundle instead
# compiles its source directly through scripts/build/tui.mjs.
npm = shutil.which("npm", path=env["PATH"])
subprocess.run([npm, "run", "build"], cwd=tui_dir / "packages/hermes-ink", env=env, check=True)
tsx = tui_dir / "node_modules/.bin/tsx"
return ([str(tsx), "src/entry.tsx"] if tsx.exists() else [npm, "start"]), tui_dir
# Desktop/dev launches always rebuild; Termux cold starts use the freshness
# check because esbuild startup is expensive on old mobile CPUs.
if not termux_startup or did_install or termux_need_rebuild:
_run_tui_npm_build(_tui_node_bin("npm"), tui_dir, "TUI build failed.")
return [_tui_node_bin("node"), "--expose-gc", str(tui_dir / "dist" / "entry.js")], tui_dir
build_source_tui(project_root, env=env)
node = shutil.which("node", path=env["PATH"])
return [node, "--expose-gc", str(tui_dir / "dist/entry.js")], tui_dir
def _split_comma_items(items, *, split_non_str: bool = True) -> list[str]:
+29 -312
View File
@@ -1,4 +1,4 @@
"""Web UI (dashboard frontend) build: content-hash stamps, npm install/build with idle timeout, bytecode sweep.
"""Dashboard build freshness/serialization and checkout bytecode sweep.
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).
@@ -9,16 +9,11 @@ import contextlib
import hashlib
import json
import os
import shutil
import subprocess
import sys
import threading
import time as _time
from pathlib import Path
from typing import Callable
from hermes_cli.main_tui_launch import (
_npm_lifecycle_env, _termux_workspace_install_context, _workspace_root)
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.main")
@@ -208,332 +203,54 @@ def _console_print(text: str) -> None:
def _run_with_idle_timeout(
cmd: list[str], cwd: Path, *, idle_timeout_seconds: int = 180, indent: str = " ",
env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
"""Stream a subprocess, killing it after *idle_timeout_seconds* of silence (a silent captured
Vite build on a low-memory host looks like a hang and users reboot mid-install). Returns merged
stdout, empty stderr, rc 124 if terminate raced a clean exit; never raises on idle timeout.
Issue #33788: ``npm run build`` (Vite) was invoked with ``capture_output=True`` and no timeout. On
low-memory hosts (notably WSL2 with the default 4 GB cap) the build can stall or sit silent for minutes;
users see a frozen terminal, assume the update is hung, and reboot — leaving the editable install in a
half-state with the ``hermes`` launcher present but ``hermes_cli`` not importable.
This helper fixes both halves: stdout is streamed (so the user sees progress), and if no bytes have
appeared on stdout/stderr for ``idle_timeout_seconds``, the process is terminated and the call returns
with a non-zero ``returncode``. The caller's existing stale-dist fallback (#23817) takes over from
there.
"""
merged_chunks: list[str] = []
last_output_ts = _time.monotonic()
lock = threading.Lock()
try:
proc = subprocess.Popen(
cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1, env=env)
except OSError as exc:
# E.g. npm not on PATH between the which() check and now.
return subprocess.CompletedProcess(cmd, 127, stdout="", stderr=str(exc))
def _reader() -> None:
nonlocal last_output_ts
assert proc.stdout is not None
for line in proc.stdout:
_console_print(f"{indent}{line.rstrip()}")
sys.stdout.flush()
with lock:
merged_chunks.append(line)
last_output_ts = _time.monotonic()
reader_thread = threading.Thread(target=_reader, daemon=True)
reader_thread.start()
idle_killed = False
while True:
try:
rc = proc.wait(timeout=5)
break
except subprocess.TimeoutExpired:
with lock:
idle = _time.monotonic() - last_output_ts
if idle > idle_timeout_seconds:
idle_killed = True
proc.terminate()
try:
rc = proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
rc = proc.wait()
break
# Drain reader so we don't leak the stdout file descriptor.
reader_thread.join(timeout=2)
combined = "".join(merged_chunks)
if idle_killed:
combined += (
f"\n ⚠ Build produced no output for {idle_timeout_seconds}s — terminated.\n"
" Common causes: out-of-memory on a low-RAM host (WSL/container),\n"
" a stuck Node process, or an antivirus scan stalling I/O.\n"
)
if rc == 0:
rc = 124 # GNU `timeout` convention
return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr="")
"""Stop an old updater instead of running the retired build path."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _nixos_build_env() -> dict[str, str] | None:
"""``PYTHON=`` env for node-gyp on NixOS (bare PATH lookup fails outside nix-shell): the hermes
venv python3, else a ``nix-shell``-resolved store path. None off NixOS / python3 on PATH."""
from hermes_cli.main import PROJECT_ROOT
import re
try:
os_release = Path("/etc/os-release").read_text(encoding="utf-8-sig")
except OSError:
return None
if not re.search(r"^ID=nixos$", os_release, re.M) or shutil.which("python3"):
return None
for venv_name in ("venv", ".venv"):
venv_python = PROJECT_ROOT / venv_name / "bin" / "python3"
if venv_python.exists():
return {**os.environ, "PYTHON": str(venv_python)}
# nix-shell not available — caller will get None
with contextlib.suppress(Exception):
result = subprocess.run(
["nix-shell", "-p", "python3", "--run", "which python3"],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, timeout=15,
)
if result.returncode == 0:
python3_path = result.stdout.strip()
if python3_path and Path(python3_path).exists():
return {**os.environ, "PYTHON": python3_path}
return None
"""Stop an old updater instead of running the retired build path."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _run_npm_install_deterministic(
npm: str, cwd: Path, *, extra_args: tuple[str, ...] = (), capture_output: bool = True,
env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
"""Deterministic npm install that never mutates ``package-lock.json``.
``npm ci`` when a lockfile exists, else/on failure ``npm install --no-save``
(a rewritten lockfile makes every future ``npm ci`` fail). ``--include=dev``
is forced: an inherited ``NODE_ENV=production`` / ``omit=dev`` silently skips
the build toolchain and the build dies with ``tsc: not found``. An npm outside
``engines.npm`` fails every command, so it gets one engine-repair retry.
``--no-save`` on the ``npm install`` fallback keeps it true to this function's contract: never mutate
``package-lock.json``. Without it, an out-of-sync lockfile gets rewritten by the fallback, which drifts
the committed lockfile and makes every future ``npm ci`` fail — a self-reinforcing cycle where web
devDeps never install and a stale dist is served on every update (PR #65595).
"""
# CI=1 no-ops unicode-animations' postinstall that animates to /dev/tty.
run_env = _npm_lifecycle_env(env)
def _attempt(npm_exe: str) -> subprocess.CompletedProcess:
def _run(args: list[str]) -> subprocess.CompletedProcess:
return _run_npm_watching_for_engine_failure(
[npm_exe, *args, "--include=dev", *extra_args], cwd=cwd, env=run_env, capture_output=capture_output,
)
if (cwd / "package-lock.json").exists():
ci_result = _run(["ci"])
if ci_result.returncode == 0:
return ci_result
return _run(["install", "--no-save"])
result = _attempt(npm)
if result.returncode == 0:
return result
from hermes_cli.npm_engine import maybe_repair_npm_engine
repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
if not repaired_npm:
return result
# A freshly provisioned managed npm resolves `node` from PATH — put the
# managed tree first so it finds the managed Node, not a mismatched system one.
from hermes_constants import with_hermes_node_path
run_env["PATH"] = with_hermes_node_path(run_env)["PATH"]
return _attempt(repaired_npm)
def _run_npm_watching_for_engine_failure(
cmd: list[str], *, cwd: Path, env: dict[str, str], capture_output: bool
) -> subprocess.CompletedProcess:
"""Run *cmd*, always retaining stderr so ``EBADENGINE`` stays detectable.
``capture_output=False`` callers stream npm's progress live; tee stderr so it
is both forwarded as it arrives and accumulated for the engine-repair check.
"""
if capture_output:
return subprocess.run(
cmd, cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
)
captured: list[str] = []
with subprocess.Popen(
cmd, cwd=cwd, env=env, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
) as proc:
if proc.stderr is not None:
for line in proc.stderr:
captured.append(line)
sys.stderr.write(line)
sys.stderr.flush()
returncode = proc.wait()
return subprocess.CompletedProcess(cmd, returncode, None, "".join(captured))
def _missing_web_build_tool(output: str) -> str | None:
"""The build tool a failed ``npm run build`` could not resolve (dash/bash/cmd.exe phrasings)."""
lowered = output.lower()
for tool in ("tsc", "vite"):
phrases = (f"{tool}: not found", f"{tool}: command not found", f"'{tool}' is not recognized")
if any(phrase in lowered for phrase in phrases):
return tool
return None
"""Stop an old updater instead of running the retired build path."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI if npm is available, serialized across processes by flock: one builds, the
rest serve the existing dist (stale is fine) or block until the first build exists. Staleness is
checked inside :func:`_do_build_web_ui` after the lock is held."""
"""Serialize dashboard rebuilds, checking freshness only after acquiring the lock."""
from hermes_cli.runtime_state import _lock
if not (web_dir / "package.json").exists():
return True
try:
import fcntl
except ImportError:
# Windows: no flock — fall through to the unserialized build.
return _do_build_web_ui(web_dir, fatal=fatal)
project_root = _web_project_root(web_dir)
try:
lock_file = open(project_root / ".web_ui_build.lock", "a", encoding="utf-8")
except OSError:
return _do_build_web_ui(web_dir, fatal=fatal)
try:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
if (_web_dist_dir(web_dir) / "index.html").exists():
return True # another process is building — serve the current dist
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) # first-ever build: wait
return _do_build_web_ui(web_dir, fatal=fatal)
finally:
lock_file.close()
def _relay_npm_output(result: subprocess.CompletedProcess) -> None:
"""Print captured npm output so users can see *why* a step failed."""
for blob in (result.stdout, result.stderr):
if not blob:
continue
text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip()
if text:
_console_print(text)
def _web_npm_install_context(web_dir: Path) -> tuple[Path, tuple[str, ...]]:
"""``(cwd, workspace_args)`` for installing the web workspace's deps.
``--workspace web`` keeps desktop (Electron + node-pty) out of a web build; no
args when ``web/`` has its own lockfile. From the root this must name the SAME
closure as ``hermes update``'s ``_update_node_dependencies()`` (ui-tui + web +
root): ``npm ci`` wipes node_modules first, so a narrower closure silently
prunes what update just installed. ui-tui is named only when present.
"""
from hermes_cli.main import _is_termux_startup_environment
if _is_termux_startup_environment():
return _termux_workspace_install_context(web_dir)
npm_cwd = _workspace_root(web_dir)
# Scope the install to the web workspace only so that the full workspace graph (including apps/desktop
# with its Electron + node-pty deps) is never resolved here. Without --workspace the root package.json's
# apps/* glob would pull in desktop on every web build. See #38772. When web/ has its own
# package-lock.json, _workspace_root() returns web_dir itself and --workspace would fail. See #42973.
# When running from the workspace root, this must name the SAME closure as `hermes update`'s
# _update_node_dependencies() (ui-tui + web + --include-workspace-root): the helper prefers `npm ci`,
# which deletes node_modules before reifying the requested tree, so a narrower closure here silently
# prunes everything the update step just installed (root devDependencies and the ui-tui workspace) while
# still exiting 0 — and since the manifests digest was already recorded, later no-op updates skip the
# repair. See #43564/#64354.
if npm_cwd == web_dir:
return npm_cwd, ()
args: tuple[str, ...] = ("--workspace", "web", "--include-workspace-root")
if (npm_cwd / "ui-tui" / "package.json").exists():
args = ("--workspace", "ui-tui", *args)
return npm_cwd, args
def _report_web_build_failure(step: str, result: subprocess.CompletedProcess, *, fatal: bool) -> bool:
"""Print the standard ``Web UI <step> failed`` block + manual hint; returns False."""
_console_print(f" {'✗' if fatal else '⚠'} Web UI {step} failed" + ("" if fatal else " (hermes web will not be available)"))
_relay_npm_output(result)
if fatal:
_console_print(" Run manually: npm install --workspace web && npm run build -w web")
return False
with open(_web_project_root(web_dir) / ".web_ui_build.lock", "a", encoding="utf-8") as lock_file:
_lock(lock_file.fileno(), wait=True)
return _do_build_web_ui(web_dir, fatal=fatal)
except OSError as exc:
_console_print(f" ✗ Could not lock the web UI build: {exc}")
return False
def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available.
"""Build stale dashboard sources; failure is never reported as a usable build."""
from hermes_cli.source_build import build_source_web, prepare_launch_dependencies, source_build_env
``fatal`` prints error guidance and returns False on failure instead of a
soft warning (used by ``hermes web``). Returns True when the build succeeded
or was skipped (no package.json / up to date / stale dist served as fallback).
"""
from hermes_cli.main_install_repair import _resolve_node_runtime_npm
if not (web_dir / "package.json").exists() or not _web_ui_build_needed(web_dir):
return True
from hermes_constants import with_hermes_node_path
npm = _resolve_node_runtime_npm()
if not npm:
if fatal:
_console_print("Web UI frontend not built and npm is not available.")
_console_print("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
build_env = _npm_lifecycle_env(with_hermes_node_path())
project_root = _web_project_root(web_dir)
_console_print("→ Building web UI...")
npm_cwd, npm_workspace_args = _web_npm_install_context(web_dir)
def _install_web_deps(*, silent: bool) -> subprocess.CompletedProcess:
extra = (*npm_workspace_args, "--silent", "--prefer-offline") if silent else (*npm_workspace_args, "--prefer-offline")
return _run_npm_install_deterministic(npm, npm_cwd, extra_args=extra, env=build_env)
def _build() -> subprocess.CompletedProcess:
# Streamed + idle-killed (never capture_output on a long Vite build: it
# looks identical to a hang and users reboot mid-install).
return _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
r1 = _install_web_deps(silent=True)
if r1.returncode != 0:
return _report_web_build_failure("npm install", r1, fatal=fatal)
r2 = _build()
if r2.returncode != 0:
# The install can exit 0 over a half-installed tree (lockfile-hash skip,
# interrupted link step); a plain retry would keep `tsc: not found`
# forever. Reinstall non-silently first, then one delayed retry for
# boot-time races (antivirus scanning Node, npm cache not ready).
# First attempt — stream output via idle-timeout helper (issue #33788). capture_output=True on a
# long Vite build looks identical to a hang; users react by rebooting, which leaves the editable
# install in a half-state. Streaming + idle-kill makes failures observable AND recoverable (the
# stale-dist fallback below handles the kill path).
missing_tool = _missing_web_build_tool((r2.stdout or "") + (r2.stderr or ""))
if missing_tool:
_console_print(f" ⚠ Build could not resolve {missing_tool} — reinstalling web dependencies...")
_install_web_deps(silent=False)
r2 = _build()
if r2.returncode != 0:
_time.sleep(3)
r2 = _build()
if r2.returncode != 0:
# A stale dist is far better than no UI for non-interactive callers
# (Windows Scheduled Tasks, CI): serve it as a fallback instead of failing.
if (_web_dist_dir(web_dir) / "index.html").exists():
_console_print(" ⚠ Web UI build failed — serving stale dist as fallback")
# Idle-timeout merges stderr into stdout; subprocess.run keeps them split.
preview = ((r2.stderr or "") + (r2.stdout or "")).strip()
if preview:
_console_print(" Build error:\n " + "\n ".join(preview.splitlines()[-10:]))
return True
return _report_web_build_failure("build", r2, fatal=fatal)
try:
env = source_build_env()
prepare_launch_dependencies(project_root, env=env)
build_source_web(project_root, env=env)
except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
_console_print(f" {'✗' if fatal else '⚠'} Web UI build failed: {exc}")
return False
_console_print(" ✓ Web UI built")
_write_web_ui_build_stamp(_web_project_root(web_dir), web_dir)
return True
+69
View File
@@ -0,0 +1,69 @@
"""Retired dependency hooks requested by already-running historical updaters."""
from typing import NoReturn
from hermes_cli._old_updater import stop_for_relaunch
def _capture_active_lazy_features() -> NoReturn:
# PM owns the feature ledger; an old updater must not start a second install path.
stop_for_relaunch()
def _refresh_active_lazy_features(*args, **kwargs) -> NoReturn:
# Historical signatures varied; none may mutate the new environment.
stop_for_relaunch()
def _refresh_active_memory_provider_dependencies() -> NoReturn:
# Plugin dependencies participate in PM's union, not a last-writer reinstall.
stop_for_relaunch()
def _npm_lockfile_changed(hermes_root) -> NoReturn:
# Do not authorize an old npm install through a fabricated currency result.
stop_for_relaunch()
def _update_node_dependencies() -> NoReturn:
# Source builds use the shared Node dependency provider.
stop_for_relaunch()
def _rebuild_desktop_after_update(desktop_dir, *, had_desktop_app_before_update) -> NoReturn:
# Old callers must not report completion after skipping the build.
stop_for_relaunch()
def _path_uid(path) -> NoReturn:
# PM never mutates the old venv; do not re-enable its ownership preflight.
stop_for_relaunch()
# Historical main's lazy exports can request these after swapping the checkout.
# No holder classification or tree kill is needed to prepare a new PM generation.
def _leftover_pausable_gateway_pids(matches: list[tuple[int, str, str]]) -> NoReturn:
stop_for_relaunch()
def _ledger_manual_serve_holders(matches: list[tuple[int, str, str]]) -> NoReturn:
stop_for_relaunch()
def _relaunch_stopped_serves(token: dict) -> NoReturn:
stop_for_relaunch()
def _orphaned_desktop_backend_pids(matches: list[tuple[int, str, str]]) -> NoReturn:
stop_for_relaunch()
def _ledger_reapable_backend_pids(matches: list[tuple[int, str, str]]) -> NoReturn:
stop_for_relaunch()
def _handoff_reapable_backend_pids(matches: list[tuple[int, str, str]]) -> NoReturn:
stop_for_relaunch()
def _stop_process_trees(pids: list[int] | list[tuple[int, int]]) -> NoReturn:
stop_for_relaunch()
+82
View File
@@ -0,0 +1,82 @@
"""Source launch/update composition over the shared JavaScript builders."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
def source_build_env(base_env: dict | None = None) -> dict[str, str]:
from pm import ensure
from hermes_constants import get_hermes_home
env = {**os.environ, **(base_env or {}), "CI": "1", "HERMES_PYTHON": sys.executable,
"PYTHON": sys.executable}
env.pop("ESBUILD_BINARY_PATH", None)
npmrc = get_hermes_home() / "npmrc"
if npmrc.is_file():
env.setdefault("NPM_CONFIG_USERCONFIG", str(npmrc))
return ensure("npm", base_env=env, explicit=True).env
def run_source_script(project_root: Path, script: str, *args: str, env: dict) -> None:
subprocess.run(
[shutil.which("node", path=env["PATH"]), str(project_root / script), *args],
cwd=project_root, env=env, check=True,
)
def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict) -> None:
run_source_script(
project_root, "scripts/build/node-deps.mjs", "--source", str(project_root), "--reuse",
*(arg for workspace in workspaces for arg in ("--workspace", workspace)), env=env,
)
def prepare_launch_dependencies(project_root: Path, *, env: dict) -> None:
"""A launch rebuild must not prune another installed source frontend."""
from hermes_cli.main_desktop import _desktop_dist_exists, _desktop_packaged_executable
desktop_dir = project_root / "apps/desktop"
desktop = _desktop_dist_exists(desktop_dir) or _desktop_packaged_executable(desktop_dir) is not None
workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ())
prepare_source_dependencies(project_root, workspaces, env=env)
def build_source_tui(project_root: Path, *, env: dict) -> None:
run_source_script(project_root, "scripts/build/tui.mjs", env=env)
def build_source_web(project_root: Path, *, env: dict) -> None:
from hermes_cli.main_web_build import _write_web_ui_build_stamp
run_source_script(project_root, "scripts/generate-icons.mjs", env=env)
run_source_script(project_root, "scripts/build/web.mjs", env=env)
_write_web_ui_build_stamp(project_root, project_root / "web")
def build_update_products(project_root: Path, *, desktop: bool) -> None:
"""Prepare the selected union once; a failed product aborts the update."""
env = source_build_env()
workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ())
prepare_source_dependencies(project_root, workspaces, env=env)
build_source_tui(project_root, env=env)
build_source_web(project_root, env=env)
if desktop:
from hermes_cli.main_desktop import build_prepared_desktop
build_prepared_desktop(
project_root / "apps/desktop", source_mode=False,
npm=shutil.which("npm", path=env["PATH"]), env=env,
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Build source-install frontends")
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--desktop", action="store_true")
args = parser.parse_args()
build_update_products(args.source.resolve(), desktop=args.desktop)
+53 -247
View File
@@ -29,18 +29,16 @@ from hermes_cli.update_abort_recovery import ( # noqa: F401
_serve_unit_recovery_available, _surviving_pre_update_serve_runtimes,
_warn_stale_serve_runtimes)
from hermes_cli.update_cmd_windows import ( # noqa: F401
_HOLDER_VALUE_FLAGS_FALLBACK, _clear_windows_venv_holders_or_exit,
_HOLDER_VALUE_FLAGS_FALLBACK,
_cold_start_windows_gateway_after_update, _desktop_owns_gateway_lifecycle,
_detect_venv_python_processes, _format_venv_python_holders_message,
_handoff_reapable_backend_pids, _hermes_holder_subcommand, _holder_value_flags,
_holder_value_flags_cache, _ledger_manual_serve_holders, _ledger_reapable_backend_pids,
_leftover_pausable_gateway_pids, _looks_like_desktop_control_plane,
_orphaned_desktop_backend_pids, _pause_windows_gateways_for_update,
_detect_venv_python_processes, _hermes_holder_subcommand, _holder_value_flags,
_holder_value_flags_cache, _looks_like_desktop_control_plane,
_pause_windows_gateways_for_update,
_refresh_bootstrap_cache_scripts, _refresh_windows_gateway_launchers,
_refuse_gateway_ancestor_tree_kill, _relaunch_stopped_serves,
_refuse_gateway_ancestor_tree_kill,
_restore_windows_gateway_service, _resume_windows_gateways_after_update,
_resume_windows_gateways_and_merge_outcome, _self_and_non_gateway_ancestor_pids,
_serve_relaunch_commands, _start_windows_gateway_service, _stop_process_trees,
_start_windows_gateway_service,
_stop_windows_gateway_service, _venv_launcher_ancestors,
_wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker)
from hermes_cli.update_cmd_fleet import ( # noqa: F401
@@ -74,21 +72,16 @@ from hermes_cli.update_cmd_stash import ( # noqa: F401
from hermes_cli.update_cmd_config import ( # noqa: F401
_LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs,
_print_items, _reload_config_modules, _run_config_check_fresh, _run_migrate_config_fresh)
from hermes_cli.update_cmd_deps import ( # noqa: F401
_INSTALL_DEFINING_FILES, _UPDATE_CRITICAL_MODULES,
_capture_active_lazy_features,
_critical_module_import_failures,
_desktop_app_present,
_editable_install_is_current,
_npm_bin_exists,
_npm_lockfile_changed, _npm_manifest_paths, _npm_manifests_digest, _path_uid,
_rebuild_desktop_after_update, _record_npm_lockfile_hash, _refresh_active_lazy_features,
_refresh_active_memory_provider_dependencies, _refuse_update_if_venv_foreign_owned,
_repair_node_deps_on_current_checkout,
_sync_python_dependencies_after_pull, _update_node_dependencies,
_validate_critical_modules_import,
_venv_core_imports_healthy, _venv_foreign_owned_paths, _web_build_toolchain_ready,
_web_toolchain_roots)
from hermes_cli.update_cmd_validation import ( # historical updater imports
_UPDATE_CRITICAL_MODULES, _critical_module_import_failures,
_validate_critical_modules_import)
from hermes_cli.old_updater_deps import ( # historical updater imports only
_capture_active_lazy_features, _npm_lockfile_changed, _path_uid,
_rebuild_desktop_after_update, _refresh_active_lazy_features,
_refresh_active_memory_provider_dependencies, _update_node_dependencies,
_handoff_reapable_backend_pids, _ledger_manual_serve_holders, _ledger_reapable_backend_pids,
_leftover_pausable_gateway_pids, _orphaned_desktop_backend_pids,
_relaunch_stopped_serves, _stop_process_trees)
from hermes_cli.update_cmd_git import ( # noqa: F401
OFFICIAL_REPO_URL, OFFICIAL_REPO_URLS, SKIP_UPSTREAM_PROMPT_FILE, _ORPHAN_RESCUE_REFS_TO_KEEP,
_ORPHAN_RESCUE_REF_MAX_AGE_DAYS, _add_upstream_remote, _assess_parked_branch_switch,
@@ -101,11 +94,12 @@ from hermes_cli.update_cmd_git import ( # noqa: F401
_sync_with_upstream_if_needed)
from hermes_cli.update_cmd_maint import ( # noqa: F401
_PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _STALE_PURGE_PREFIXES,
_STALE_PURGE_PROTECTED, _UPDATE_RUNTIME_RELOAD_MODULES, _clear_stale_sqlite_sidecars,
_STALE_PURGE_PROTECTED, _clear_stale_sqlite_sidecars,
_ensure_acp_launcher, _ensure_fhs_path_guard, _finish_dashboard_update_cleanup,
_format_time_ago, _post_update_sqlite_runtime_status, _print_bundled_skills_sync_report,
_print_curator_first_run_notice, _print_curator_recent_run_notice,
_print_fts_optimize_available_notice, _print_update_completion, _print_update_summary,
_prepare_updated_checkout,
_print_verified_update_completion, _purge_stale_hermes_modules, _read_project_version,
_reload_process_scan_modules, _reload_updated_runtime_modules,
_resolve_pre_update_backup_mode, _restore_state_db_from_snapshot,
@@ -473,112 +467,23 @@ def _invalidate_update_cache():
pass
def _write_marker_file(path: Path, *, label: str) -> None:
"""Drop an update-recovery breadcrumb. Never raises."""
if _m()._pytest_owns_live_checkout(path.parent):
logger.debug("Skipping %s marker under pytest (live checkout)", label)
return
try:
path.write_text(
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
)
except OSError as exc:
logger.debug("Could not write %s marker: %s", label, exc)
def _write_update_incomplete_marker() -> None:
"""Drop the interrupted core-install breadcrumb. Never raises."""
_write_marker_file(_m()._update_marker_path(), label="update-incomplete")
# Historical updater hook. PM's successful facts determine completion.
stop_for_relaunch()
def _write_lazy_refresh_incomplete_marker() -> None:
"""Drop the interrupted lazy-refresh breadcrumb. Never raises."""
_write_marker_file(_m()._lazy_refresh_marker_path(), label="lazy-refresh-incomplete")
# Historical updater hook. There is no separate lazy-refresh transaction.
stop_for_relaunch()
def _format_concurrent_instances_message(
matches: list[tuple[int, str]], scripts_dir: Path
) -> str:
"""Build a human-readable explanation + remediation hint for the user."""
shim = scripts_dir / "hermes.exe"
lines = ["✗ Another hermes.exe is running:"]
for pid, name in matches:
lines.append(f" PID {pid} {name}")
lines.append("")
lines.append(f" Updating now would fail to overwrite {shim} because")
lines.append(" Windows blocks REPLACE on a running executable.")
lines.append("")
lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and")
lines.append(" stop the gateway (`hermes gateway stop`) before retrying.")
lines.append("")
if matches:
pid_args = " ".join(f"/PID {pid}" for pid, _ in matches)
lines.append(" If you've already closed everything and these PIDs are")
lines.append(" stale, terminate them directly, then retry the update:")
lines.append(f" taskkill {pid_args} /F")
lines.append("")
lines.append(" Override with `hermes update --force` if you've already")
lines.append(" confirmed those processes will not write to the venv.")
return "\n".join(lines)
def _classify_concurrent_instance(pid: int) -> str:
"""Return ``"gateway"`` when ``pid``'s command line is a gateway runtime.
Delegates to ``_is_pausable_gateway`` — the same canonical
``gateway run`` matcher (``gateway.status.looks_like_gateway_command_line``,
shlex-tokenized, profile-selector aware) used by the Desktop preflight
exemption and the venv-holder guard fallback — so a PID classified as
``"gateway"`` here is exactly the set the pause/kill+restart machinery
downstream will stop. That symmetry is what lets the pre-update
concurrent gate skip the abort for gateway-only matches: the gateway is
going to be stopped by ``_pause_windows_gateways_for_update()`` moments
later anyway, so refusing the update just to make the user kill it
manually is friction without benefit.
Returns ``"non-gateway"`` when the cmdline doesn't match, and
``"unknown"`` when psutil can't read it (process gone, access denied,
psutil missing). The gate treats ``"unknown"`` as non-gateway — we'd
rather block an update we could have completed than proceed against a
process we couldn't positively identify as a gateway.
"""
try:
import psutil # noqa: PLC0415
except Exception:
return "unknown"
try:
proc = psutil.Process(int(pid))
cmdline_list = proc.cmdline()
except Exception:
return "unknown"
from hermes_cli._scan_venv_blockers import _is_pausable_gateway # noqa: PLC0415
cmdline = " ".join(cmdline_list or [])
if _is_pausable_gateway(cmdline):
return "gateway"
return "non-gateway"
def _filter_non_gateway_concurrent_instances(
matches: list[tuple[int, str]],
) -> list[tuple[int, str]]:
"""Return only the concurrent-instance matches that are NOT the gateway.
Used by the pre-update concurrent gate to decide whether to abort
``hermes update``. If every concurrent instance is a gateway, the pause
machinery (``_pause_windows_gateways_for_update``) and the post-update
kill+restart block handle it — the update proceeds. If anything else (a
TUI shell, a Hermes Desktop backend child, an unrelated ``hermes`` REPL)
is in the list, the gate still aborts with the existing message, since
those have no pause machinery downstream.
"""
non_gateway: list[tuple[int, str]] = []
for pid, name in matches:
if _classify_concurrent_instance(pid) != "gateway":
non_gateway.append((pid, name))
return non_gateway
# Historical updater hook; PM never replaces a running venv's executables.
stop_for_relaunch()
def _log_only_write(text: str) -> None:
@@ -912,80 +817,18 @@ def _print_update_check_result(behind: int | None, compare_branch: str) -> None:
print(f" Run '{recommended_update_command()}' to install.")
def _repair_venv_on_current_checkout(
*, assume_yes, gateway_mode, pre_update_snapshot_id, desktop_dir,
had_desktop_app_before_update, active_lazy_features,
_windows_gateway_resume) -> bool:
"""Stage a replacement dependency environment; keep the marker on failure."""
_write_update_incomplete_marker()
import pm
try:
# A matching stamp cannot certify missing files. Restore the recorded
# graph first, then refresh it against the current checkout's inputs.
pm.sync_venv(repair=True)
pm.sync_venv(["all"] + list(active_lazy_features or []), explicit=True)
except (pm.InstallError, OSError, ValueError) as _sync_err:
print(f" ✗ {_sync_err}")
return False
healthy_after, detail_after = _venv_core_imports_healthy()
if not healthy_after:
print(f"⚠ Venv still unhealthy after repair: {detail_after}")
print(" Close all Hermes windows/gateways and re-run: hermes update")
return False
_m()._clear_update_incomplete_marker()
print("✓ Dependencies repaired!")
# Check for config migrations (#91360).
def _repair_current_checkout(
*, assume_yes, gateway_mode, pre_update_snapshot_id,
had_desktop_app_before_update, upstream_checked) -> bool:
"""A retry completes the same products as a newly pulled checkout."""
_prepare_updated_checkout(
_m().PROJECT_ROOT, desktop=had_desktop_app_before_update)
_check_and_apply_config_migration(
assume_yes=assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id)
# The Windows hand-off child lands here after doing the sync its parent could not, and
# the commits-pulled rebuild is never reached — rebuild the Desktop app here or it
# silently stays on the old build (#97343).
if _rebuild_desktop_after_update(
desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update):
return _print_verified_update_completion("✓ Update complete!")
_print_update_completion(
"⚠ Update partially complete — the desktop app was not rebuilt and is still on the previous build.")
return False
def _repair_current_checkout(
*, assume_yes, gateway_mode, pre_update_snapshot_id, desktop_dir,
had_desktop_app_before_update, active_lazy_features,
upstream_checked, _windows_gateway_resume) -> bool:
"""Already-up-to-date path: keep the managed runtime current, repair a broken venv.
Returns whether the checkout can be reported complete."""
# A current checkout does NOT imply a healthy install: a previous dependency sync may
# have failed partway (classic on Windows: a running gateway/desktop backend keeps .pyd
# locked and the installer dies with access-denied, stranding the venv between
# versions). Probe the venv's core imports and repair if broken — otherwise "Already up
# to date!" gaslights the user while their install stays bricked.
healthy, detail = _venv_core_imports_healthy()
# The Windows shim hand-off spawns this child precisely to run a sync its parent could
# not. The parent already pulled, so the checkout is current BY DESIGN and venv health
# is not the question — the pending sync is.
handed_off_sync = os.environ.get(_m()._UPDATE_REEXEC_ENV) == "1"
if handed_off_sync:
print("→ Finishing the dependency install handed off by hermes.exe...")
elif not healthy:
print("⚠ Checkout is current, but the venv is unhealthy:")
print(f" {detail}")
print("→ Repairing Python dependencies...")
if handed_off_sync or not healthy:
return _repair_venv_on_current_checkout(
assume_yes=assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update,
active_lazy_features=active_lazy_features,
_windows_gateway_resume=_windows_gateway_resume)
return _repair_node_deps_on_current_checkout(
_print_verified_update_completion, assume_yes=assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
completion_message=(
"✓ Already up to date!" if upstream_checked
else "✓ Up to date with your fork (official repo not checked)."),
had_desktop_app_before_update=had_desktop_app_before_update)
return _print_verified_update_completion(
"✓ Already up to date!" if upstream_checked
else "✓ Up to date with your fork (official repo not checked).")
def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha, *, target_ref=None) -> None:
@@ -1264,7 +1107,6 @@ def _prepare_checkout_for_update(
class _UpdateOptions:
"""Resolved ``hermes update`` inputs (flags, config, pre-update snapshots)."""
active_lazy_features: object
pre_update_version: object
gw_input_fn: object
assume_yes: bool
@@ -1275,9 +1117,6 @@ class _UpdateOptions:
def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions:
"""Snapshot pre-update state and resolve the flags/config ``_cmd_update_impl`` runs on."""
# Snapshot before a managed-runtime refresh can replace site-packages, while the old
# environment can still prove which optional backends were active.
active_lazy_features = _m()._capture_active_lazy_features()
# Captured before any pull so the completion line can report the transition.
# Snapshot the pre-update version before files are replaced so the completion line can report the
@@ -1305,16 +1144,13 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions:
_mode = str(_updates_config().get("non_interactive_local_changes", "stash")).lower()
discard_local_changes = _mode == "discard"
return _UpdateOptions(
active_lazy_features=active_lazy_features,
pre_update_version=pre_update_version,
gw_input_fn=gw_input_fn, assume_yes=assume_yes, keep_stash=keep_stash,
switch_branch=switch_branch, discard_local_changes=discard_local_changes)
def _begin_update_receipt_and_plan(args):
"""Open the receipt, snapshot the fleet, refuse on Windows shim holders. Returns the
pre-update plan (None if the probe failed); ``sys.exit(2)`` when a non-gateway hermes.exe
holds the venv shim."""
"""Open the receipt and snapshot the fleet before changing the checkout."""
# Structured receipt: record what this run discovers/does/skips so silent failures are diagnosable.
with _best_effort('Update receipt unavailable: %s'):
# See #74973, #81193, #85753, #88848, #91277.
@@ -1338,23 +1174,6 @@ def _begin_update_receipt_and_plan(args):
_profiles = ", ".join(sorted({r.profile for r in _pre_update_plan.runtimes}))
print(f"→ Fleet: {_n} running service(s) across profiles: {_profiles}")
# Windows: another hermes.exe holding the venv shim means WinError 32 spam and a
# deferred-rename leftover or silent ZIP fallback. Positively identified gateways are
# paused/restarted by the update instead; anything else still aborts.
# Continuing would result in a string of WinError 32 warnings and then either a deferred-rename leftover
# or a failed git-pull fast path that silently falls back to the slower ZIP route. See issue #26670.
# Exception (#37039): when every concurrent instance is a gateway runtime, the pause machinery a few
# lines below (``_pause_windows_gateways_for_update``) stops it before any file mutation, and the
# post-update restart phase brings it back. Aborting just to make the user run the same kill manually is
# friction without benefit. Anything not positively identified as a gateway (TUI shell, Desktop backend
# child, unreadable cmdline) still aborts exactly as before.
if _m()._is_windows() and not getattr(args, "force", False):
scripts_dir = _m()._venv_scripts_dir()
concurrent = _m()._detect_concurrent_hermes_instances(scripts_dir) if scripts_dir is not None else []
non_gateway = _m()._filter_non_gateway_concurrent_instances(concurrent) if concurrent else []
if non_gateway:
print(_format_concurrent_instances_message(non_gateway, scripts_dir))
sys.exit(2)
return _pre_update_plan
@@ -1447,12 +1266,14 @@ def _handle_update_called_process_error(
print(f"⚠ {stage}: {e}")
print("→ Falling back to ZIP download...")
print()
desktop_build_ok = _update_via_zip(
update_complete = _update_via_zip(
args, had_desktop_app_before_update=had_desktop_app_before_update,
target_sha=target_sha,
**({"target_repository": target_repository} if target_repository else {}))
if gateway_mode:
_write_gateway_update_exit_code(desktop_build_ok)
_write_gateway_update_exit_code(update_complete)
if not update_complete:
sys.exit(1)
else:
print(f"✗ {stage}: {e}")
_print_called_process_error_tail(e)
@@ -1478,8 +1299,8 @@ def _finalize_receipt(status: str, debug_message: str) -> None:
def _finish_already_up_to_date(
git_cmd, branch: str, current_branch: str, _plan, *, assume_yes: bool, gateway_mode: bool,
gw_input_fn, pre_update_snapshot_id, desktop_dir, had_desktop_app_before_update: bool,
active_lazy_features, _windows_gateway_resume) -> None:
gw_input_fn, pre_update_snapshot_id, had_desktop_app_before_update: bool,
_windows_gateway_resume) -> None:
""""Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet.
``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt)."""
_invalidate_update_cache()
@@ -1503,11 +1324,9 @@ def _finish_already_up_to_date(
current_checkout_complete = _repair_current_checkout(
assume_yes=assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=had_desktop_app_before_update,
active_lazy_features=active_lazy_features,
upstream_checked=_plan.upstream_checked,
_windows_gateway_resume=_windows_gateway_resume)
upstream_checked=_plan.upstream_checked)
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
# A prior pull may still owe the fleet a restart; catch up here too, BEFORE the exit
# gate so a partial outcome can't strand the fleet on stale code.
@@ -1545,15 +1364,7 @@ def _apply_pulled_update(
_m()._sync_with_upstream_if_needed(
git_cmd, _m().PROJECT_ROOT, assume_yes=opts.assume_yes, input_fn=opts.gw_input_fn)
# .[all], falling back to base + extras individually so one broken extra doesn't strip
# the rest; the ownership preflight refuses first on foreign-owned (sudo-pip) venv files.
# PM dep phase (update_cmd_deps owner): ``pm.sync_venv(["all"], explicit=True)`` — no
# pip/lazy_deps fallback — plus the node/web/desktop surfaces it owns, so the orchestrator
# consumes its outcome instead of recomputing it.
node_failures, desktop_build_ok = _sync_python_dependencies_after_pull(
git_cmd, branch, pre_pull_sha, active_lazy_features=opts.active_lazy_features,
_windows_gateway_resume=_windows_gateway_resume, desktop_dir=desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update)
_prepare_updated_checkout(_m().PROJECT_ROOT, desktop=had_desktop_app_before_update)
print()
print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}")
@@ -1562,7 +1373,6 @@ def _apply_pulled_update(
assume_yes=opts.assume_yes, gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=had_desktop_app_before_update,
node_failures=node_failures, desktop_build_ok=desktop_build_ok,
pre_update_version=opts.pre_update_version)
# Exit code *before* the restart: under --gateway this process lives in the gateway's
@@ -1575,13 +1385,11 @@ def _apply_pulled_update(
_resume_windows_gateways_and_merge_outcome(_restart, _windows_gateway_resume, gateway_mode)
_verify_fleet_after_update(
_restart, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume,
node_failures=node_failures, update_complete=update_complete)
update_complete=update_complete)
def _cmd_update_impl(args, gateway_mode: bool):
"""Body of ``cmd_update`` — kept separate so the wrapper can always restore stdio even on
``sys.exit``. Self-lock deferral deliberately does NOT run here (pre-fetch it stranded users
on the OLD checkout in an exit-2 loop); it runs right before the dependency sync."""
"""Apply the update; the command boundary owns errors, receipts and stdio."""
opts = _resolve_update_options(args, gateway_mode)
gw_input_fn, assume_yes = opts.gw_input_fn, opts.assume_yes
@@ -1602,14 +1410,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
import atexit as _atexit
_atexit.register(_m()._resume_windows_gateways_after_update, _windows_gateway_resume)
# Any venv python still running (typically the Desktop `hermes serve` backend) keeps .pyd
# locked and would corrupt the sync; refuse rather than race (the app respawns a killed
# backend). NOT bypassed by --force (desktop updater, shim guard only); --force-venv is.
if _m()._is_windows() and not getattr(args, "force_venv", False):
_clear_windows_venv_holders_or_exit(args, gateway_mode, _windows_gateway_resume)
desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop"
had_desktop_app_before_update = _desktop_app_present(desktop_dir)
had_desktop_app_before_update = (
_m()._desktop_packaged_executable(desktop_dir) is not None
or _m()._desktop_dist_exists(desktop_dir))
use_zip_update, git_cmd, is_fork = _prepare_git_command()
@@ -1643,14 +1448,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
if use_zip_update:
try:
desktop_build_ok = _update_via_zip(
update_complete = _update_via_zip(
args, had_desktop_app_before_update=had_desktop_app_before_update,
target_sha=release_sha,
**({"target_repository": target_repository} if target_repository else {}))
finally:
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
if gateway_mode:
_write_gateway_update_exit_code(desktop_build_ok)
_write_gateway_update_exit_code(update_complete)
if not update_complete:
sys.exit(1)
return
try:
@@ -1708,9 +1515,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
_finish_already_up_to_date(
git_cmd, branch, current_branch, _plan, assume_yes=assume_yes,
gateway_mode=gateway_mode, gw_input_fn=gw_input_fn,
pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=had_desktop_app_before_update,
active_lazy_features=opts.active_lazy_features,
_windows_gateway_resume=_windows_gateway_resume)
return
-732
View File
@@ -1,732 +0,0 @@
"""Post-``hermes update`` dependency sync: venv preflight, editable reinstall, lazy refresh,
npm/Desktop rebuilds, self-lock deferral. Names are re-imported by ``update_cmd`` (so
``hermes_cli.update_cmd.<name>`` resolves/monkeypatches); origin helpers are imported lazily."""
import logging
from contextlib import suppress
import hashlib
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
from hermes_constants import venv_python_path
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.update_cmd")
# Files defining the editable install; a pull touching none of them cannot invalidate it.
_INSTALL_DEFINING_FILES = "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "uv.lock"
def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool:
"""True when the pulled commits cannot have invalidated the editable install: ``uv pip install
-e .`` always rewrites console-script shims (Windows: ``hermes.exe`` quarantine, ``os error 32``
on a lost race), so skip it when only non-install files changed. Safe because the editable
finder uses a *static* module list. Fails closed: no pre-pull SHA or failed diff -> False."""
if not pre_pull_sha:
return False
try:
result = subprocess.run(
git_cmd + ["diff", "--name-only", f"{pre_pull_sha}..HEAD", "--"] + list(_INSTALL_DEFINING_FILES),
cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
except OSError:
return False
return result.returncode == 0 and not result.stdout.strip()
# Modules imported on every startup. Unlike _UPDATE_CRITICAL_FILES (only parsed) these are
# *imported*, catching cross-module breakage (a name pulled from a sibling no longer exists).
_UPDATE_CRITICAL_MODULES = "hermes_cli.main", "run_agent", "model_tools", "toolsets"
def _critical_module_import_failures(
root, *, report_runtime_errors: bool = False) -> dict[str, tuple[str, str]]:
"""Import each ``_UPDATE_CRITICAL_MODULES`` entry in a subprocess; return failures in probe order.
Syntax validation only *parses*: a partially-updated tree (Windows ZIP copy loop) parses yet
dies with ``ImportError: cannot import name``. The subprocess (venv interpreter when present —
the updater may run under another Python) keeps import side effects out of our ``sys.modules``.
Generic import-time exceptions are tolerated unless ``report_runtime_errors=True``.
"""
from hermes_cli.update_cmd import _UPDATE_CRITICAL_MODULES, _m
from hermes_constants import FIRST_PARTY_MODULE_ROOTS
import secrets
marker = f"__HERMES_IMPORT_HEALTH_{secrets.token_hex(16)}__"
probe = (
"import importlib, json, sys\n"
"failures = []\n"
"for name in %r:\n"
" try:\n"
" importlib.import_module(name)\n"
" except ModuleNotFoundError as exc:\n"
# A missing *third-party* module means deps aren't installed, not a skewed checkout;
# only our own packages count. Roots come from hermes_constants so the user hint can't drift.
" missing = (getattr(exc, 'name', '') or '').split('.')[0]\n"
" if missing in %r or missing.startswith('hermes_') or %r:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except ImportError as exc:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except Exception as exc:\n"
" if %r:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except BaseException as exc:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
"sys.stdout.write('\\n%s' + json.dumps(failures))\n"
% (_UPDATE_CRITICAL_MODULES, tuple(sorted(FIRST_PARTY_MODULE_ROOTS)), report_runtime_errors,
report_runtime_errors, marker))
try:
interpreter = sys.executable
with suppress(Exception):
venv_python = venv_python_path(Path(root) / "venv", windows=_m()._is_windows())
if venv_python.exists():
interpreter = str(venv_python)
result = subprocess.run(
[interpreter, "-c", probe], cwd=str(root), capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=120)
except subprocess.TimeoutExpired:
return _probe_failure("TimeoutExpired", "timed out before reporting import health")
except (OSError, subprocess.SubprocessError):
# Can't run the probe — don't block the update on our own tooling.
return {}
output = result.stdout or ""
if marker not in output:
return _probe_failure(
"ProbeTerminated",
f"terminated before reporting import health (exit code {result.returncode})")
try:
failures = json.loads(output.rsplit(marker, 1)[1])
if not isinstance(failures, list) or any(
not isinstance(item, list) or len(item) != 3 or not all(isinstance(v, str) for v in item)
for item in failures):
raise ValueError("invalid import-health payload")
return {str(module): (str(kind), str(detail)) for module, kind, detail in failures}
except (TypeError, ValueError):
return _probe_failure("MalformedPayload", "reported malformed import health data")
def _probe_failure(kind: str, detail: str) -> dict[str, tuple[str, str]]:
"""Failure row for the probe itself (as opposed to a module it imported)."""
return {"critical-module probe": (kind, detail)}
def _validate_critical_modules_import(
root, *, report_runtime_errors: bool = False) -> tuple[bool, str | None, str | None]:
"""Return the first critical-module import failure, if any."""
failures = _critical_module_import_failures(root, report_runtime_errors=report_runtime_errors)
if failures:
module = next(iter(failures))
return False, module, failures[module][1]
return True, None, None
def _npm_bin_exists(bin_dir: Path, name: str) -> bool:
"""True when an npm bin shim for *name* exists (POSIX or Windows)."""
return any((bin_dir / c).exists() for c in (name, f"{name}.cmd", f"{name}.ps1", f"{name}.exe"))
def _web_build_toolchain_ready(*roots: Path) -> bool:
"""True when ``tsc`` and ``vite`` shims are reachable from any of *roots*.
Callers must pass every root the build would search, or a healthy tree reads as broken."""
bin_dirs = [d for d in (root / "node_modules" / ".bin" for root in roots) if d.is_dir()]
return bool(bin_dirs) and all(
any(_npm_bin_exists(bin_dir, tool) for bin_dir in bin_dirs) for tool in ("tsc", "vite"))
def _web_toolchain_roots(web_dir: Path) -> tuple[Path, ...]:
"""Roots whose ``node_modules/.bin`` can satisfy the web build: ``npm run build`` searches the
package and each ancestor, so hoisted and package-local shims are equally valid.
``npm run build`` prepends ``node_modules/.bin`` for the package and each of its ancestors, so shims
hoisted to the workspace root and shims nested under a package that owns its lockfile (#42973) are
equally valid.
"""
return (web_dir, web_dir.parent)
def _capture_active_lazy_features() -> list[str]:
"""Snapshot active lazy backends before a managed runtime is replaced."""
try:
from pm.ensure import enabled_extras
return enabled_extras()
except Exception as exc:
logger.debug("Could not snapshot active lazy features: %s", exc)
return []
def _refresh_active_lazy_features(features: list[str] | None = None) -> bool:
"""Re-sync the venv's enabled extras against the (possibly new) uv.lock.
Extras live in the installed-state file and uv.lock owns every pin, so
a post-update refresh is one sync_venv() call: it re-installs exactly
the locked versions of everything enabled. Never raises.
"""
try:
from pm.client import sync_venv
sync_venv(features, explicit=True)
return True
except Exception as exc:
print(f" ⚠ Extra re-sync failed: {exc}")
print(" Rerun `hermes update` (or `hermes pm install`) once resolved.")
return False
def _refresh_active_memory_provider_dependencies() -> None:
"""Refresh pip deps for the configured external memory provider: its bridge packages live in
``plugin.yaml`` (not Hermes extras / ``LAZY_DEPS``), so the core reinstall can strip them;
re-run the ACTIVE provider's install last so its writes land last. Never raises.
Re-run the provider's declared install for the ACTIVE provider only, after the core install and lazy
refresh, so the last write to any shared package is the one the active provider needs. See #53272,
#70636.
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
except Exception as exc:
logger.debug("Memory provider refresh skipped (config load failed): %s", exc)
return
provider = ""
memory_cfg = cfg.get("memory") if isinstance(cfg, dict) else None
if isinstance(memory_cfg, dict):
if memory_cfg.get("enabled") is False:
return
provider = str(memory_cfg.get("provider") or "").strip()
# "default"/empty is the built-in file store — no pip deps.
if not provider or provider in {"default", "builtin", "none"}:
return
try:
from hermes_cli.memory_setup import _install_dependencies
except Exception as exc:
logger.debug("Memory provider refresh skipped (import failed): %s", exc)
return
print()
print(f"→ Refreshing active memory provider dependencies ({provider})...")
try:
_install_dependencies(provider, force=True)
except Exception as exc:
print(f" ⚠ {provider} dependencies failed to refresh: {exc}")
def _npm_manifest_paths() -> tuple[Path, ...]:
"""Manifests whose changes must defeat the update-skip. The lockfile alone isn't enough (a
package.json can be edited without running npm); workspaces come from the root ``workspaces``
globs so a new one can't escape the key, and every workspace counts (desktop too) because the
single lockfile spans the whole graph. Root manifests only if package.json is unreadable."""
from hermes_cli.update_cmd import _m
root_pkg = _m().PROJECT_ROOT / "package.json"
paths = [_m().PROJECT_ROOT / "package-lock.json", root_pkg]
with suppress(OSError, json.JSONDecodeError, TypeError):
workspaces = json.loads(root_pkg.read_text(encoding="utf-8-sig")).get("workspaces", [])
if isinstance(workspaces, dict): # legacy {"packages": [...]} form
workspaces = workspaces.get("packages", [])
for pattern in workspaces:
for match in sorted(_m().PROJECT_ROOT.glob(str(pattern))):
manifest = match / "package.json"
if manifest.is_file():
paths.append(manifest)
return tuple(paths)
def _npm_manifests_digest() -> str | None:
"""sha256 over lockfile + all workspace package.json; None when the lockfile is missing (never skip)."""
from hermes_cli.update_cmd import _m
if not (_m().PROJECT_ROOT / "package-lock.json").exists():
return None
h = hashlib.sha256()
for p in _npm_manifest_paths():
h.update(str(p.relative_to(_m().PROJECT_ROOT)).encode())
try:
h.update(p.read_bytes())
except OSError:
h.update(b"<missing>")
return h.hexdigest()
def _npm_lockfile_changed(hermes_root: Path) -> bool:
from hermes_cli.update_cmd import _m
current = _npm_manifests_digest()
if current is None:
return True
# Matching hash but no node_modules: cache was recorded by another checkout.
if not (_m().PROJECT_ROOT / "node_modules").is_dir():
return True
# Never skip when the web toolchain never landed, or later updates build on a half-installed tree.
web_dir = _m().PROJECT_ROOT / "web"
if (web_dir / "package.json").is_file() and not _web_build_toolchain_ready(
*_web_toolchain_roots(web_dir)):
return True
try:
cache_file = _npm_lock_cache_file(hermes_root)
if not cache_file.exists():
return True
return cache_file.read_text(encoding="utf-8-sig").strip() != current
except OSError:
return True
def _npm_lock_cache_file(hermes_root: Path) -> Path:
"""Per-checkout cache path: keyed by PROJECT_ROOT so parallel worktrees don't collide."""
from hermes_cli.update_cmd import _m
cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12]
return hermes_root / f".npm_lock_hash_{cache_key}"
def _record_npm_lockfile_hash(hermes_root: Path) -> None:
digest = _npm_manifests_digest()
if digest is None:
return
try:
_npm_lock_cache_file(hermes_root).write_text(digest, encoding="utf-8")
except OSError:
logger.debug("Could not write npm lockfile hash cache")
def _repair_node_deps_on_current_checkout(
print_completion,
*,
assume_yes: bool = False,
gateway_mode: bool = False,
pre_update_snapshot_id: str | None = None,
completion_message: str = "✓ Already up to date!",
had_desktop_app_before_update: bool = False) -> bool:
"""Repair Node deps on the ``commit_count == 0`` path: a failed npm install says "re-run hermes
update" but the early return used to skip the refresh. ``_update_node_dependencies`` self-gates
on the hash recorded only after a SUCCESSFUL install, so this is a cheap no-op when healthy.
See #77211.
"""
from hermes_cli.update_cmd import (
_check_and_apply_config_migration, _m, _rebuild_desktop_after_update, _update_node_dependencies)
node_failures = _update_node_dependencies()
if node_failures:
print(f" ⚠ Node.js refresh failed for: {', '.join(node_failures)}")
print(" Fix npm and re-run `hermes update`.")
print_completion("⚠ Checkout is current, but Node.js dependencies could not be repaired.")
return False
# Pair with the web build like every other call site; it staleness-checks internally.
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
_check_and_apply_config_migration(
assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id)
# A current checkout can still owe a Desktop rebuild (e.g. the Windows hand-off child
# never reaches the commits-pulled rebuild). Self-gates on the build stamp.
# Skipping it leaves a stale desktop app behind a successful-looking update. See #97343.
if not _rebuild_desktop_after_update(
_m().PROJECT_ROOT / "apps" / "desktop", had_desktop_app_before_update=had_desktop_app_before_update):
# Retry hint already printed; withhold success rather than claim completion.
# See #88251.
print_completion(
"⚠ Update partially complete — the desktop app was not rebuilt "
"and is still on the previous build.")
return False
return bool(print_completion(completion_message))
def _update_node_dependencies() -> list[str]:
"""Refresh Node deps for ui-tui and web. Returns labels whose npm install failed (empty on
success) so the caller reports a partial update instead of ``Update complete!``.
See #30271.
"""
from hermes_cli.update_cmd import _m
if not (_m().PROJECT_ROOT / "package.json").exists():
return []
npm = _m()._resolve_node_runtime_npm()
if not npm:
# Only a Windows npm reachable from WSL: flag loudly — skipping silently leaves
# deps stale, running it would corrupt the tree.
from hermes_constants import is_wsl
path_npm = shutil.which("npm")
if is_wsl() and path_npm and _m()._is_windows_npm_path(path_npm):
# Root package.json has no dependencies of its own (agent-browser and @streamdown/math were
# moved out — see #43564): agent-browser resolves at runtime via `npx agent-browser`
# (tools/browser_tool.py), and @streamdown/math is a desktop-only import now declared in
# apps/desktop/package.json. That means a plain workspace-scoped install can never prune
# anything root-only, so we only need to name the workspaces the CLI/TUI/web build actually
# requires. apps/desktop pulls in Electron as a devDependency with a ~200MB postinstall
# download, so it's deliberately never named here — desktop deps install on demand (see
# _desktop_build_needed).
print("→ Updating Node.js dependencies...")
print(" ⚠ Skipped: only a Windows npm is reachable from this WSL shell.")
print(" Install Node.js inside the WSL distro (nvm, or your distro's")
print(" package manager), then re-run `hermes update`.")
has_workspace = any(
(_m().PROJECT_ROOT / ws / "package.json").exists() for ws in ("ui-tui", "web"))
return ["ui-tui, web workspaces"] if has_workspace else []
return []
from hermes_constants import get_default_hermes_root
# node_modules is shared by every profile on this checkout: one per-checkout cache.
shared_hermes_root = get_default_hermes_root()
# Best-effort npx cache warm before the lockfile-unchanged early return. Can block
# ~11s on a cold cache — print first so it doesn't look like a hang.
# Runs before the lockfile-unchanged early return below since that's the common `hermes update` case.
# See #43564.
print("→ Warming npx cache for agent-browser...")
with suppress(Exception):
from tools.browser_tool_install import warm_agent_browser_npx_cache
warm_agent_browser_npx_cache()
if not _m()._npm_lockfile_changed(shared_hermes_root):
logger.info("npm lockfile unchanged, skipping npm install")
return []
# Root package.json has no deps of its own, so a workspace-scoped install prunes nothing
# root-only. apps/desktop is deliberately never named: its Electron devDependency has a
# ~200MB postinstall, so desktop deps install on demand (see _desktop_build_needed).
print("→ Updating Node.js dependencies...")
install_args = [
"--no-fund", "--no-audit", "--prefer-offline", "--progress=false",
"--workspace", "ui-tui", "--workspace", "web",
# Root devDependencies (shared ESLint config) would otherwise be pruned by the
# scoped install; apps/desktop stays excluded since it is never named above.
"--include-workspace-root"]
from hermes_constants import with_hermes_node_path
nixos_env = with_hermes_node_path(_m()._nixos_build_env())
# capture_output=False is deliberate: postinstall scripts print download progress and
# capturing makes a long download look hung.
# The chatty npm-deprecation noise during `hermes update` comes from the *desktop* build, not this step;
# that one is captured to update.log. See #18840.
result = _m()._run_npm_install_deterministic(
npm, _m().PROJECT_ROOT, extra_args=tuple(install_args), capture_output=False, env=nixos_env)
if result.returncode == 0:
_record_npm_lockfile_hash(shared_hermes_root)
print(" ✓ ui-tui, web workspaces installed (desktop skipped)")
return []
print(" ⚠ npm install failed")
stderr = (result.stderr or "").strip()
if stderr:
print(f" {stderr.splitlines()[-1]}")
print()
print(" ⚠ Node.js dependency refresh did not complete cleanly; the")
print(" installation may be in a mixed state (updated code, stale Node")
print(" deps). Fix npm and re-run `hermes update`.")
return ["ui-tui, web workspaces"]
def _venv_core_imports_healthy() -> tuple[bool, str]:
"""Probe the SELECTED dependency environment (in ITS interpreter — the updater may run under
another Python) for core imports, catching a half-updated environment that "Already up to
date!" would otherwise never re-sync.
Selection goes through ``hermes_cli.runtime_paths`` — the stdlib-only selection module — not
through the pm manager: the probe must run before any dependency of that environment has been
imported. The legacy ``<repo>/venv`` target is superseded: the PM model stages a fresh
generation under ``installs/<key>/environments/<gen>`` and commits the selection in
``facts.json`` (``pm.packages.VenvPackage.apply``), so the environment the update must judge
(and the one the repair below rebuilds) is the SELECTED one, never a hardcoded repo-relative
``venv`` directory.
Returns ``(healthy, detail)``; never raises, unknown states report healthy."""
from hermes_cli.update_cmd import _m
from hermes_cli import runtime_paths
try:
venv_dir = runtime_paths.selected_venv(_m().PROJECT_ROOT)
except FileNotFoundError:
venv_dir = runtime_paths.base_venv(_m().PROJECT_ROOT)
except RuntimeError as exc:
# An unreadable/invalid committed selection is a KNOWN-unhealthy state (the repair
# flow below re-syncs and re-commits); never raise.
return False, str(exc)
venv_python = venv_python_path(venv_dir, windows=_m()._is_windows())
if not venv_python.exists():
# No venv: normal for a dev checkout (healthy), but on a MANAGED install (bootstrap
# stamp or `.update-incomplete`) the venv IS the install — absence means an interrupted repair.
managed_markers = (_m().PROJECT_ROOT / ".hermes-bootstrap-complete", _m()._update_marker_path())
if any(m.exists() for m in managed_markers):
return False, f"venv python missing ({venv_python})"
return True, ""
# Import (not just metadata): dist-info can be intact with modules missing after an
# interrupted uninstall/install.
check = (
"import importlib\n"
"mods = ['fastapi', 'uvicorn', 'pydantic', 'openai', 'yaml']\n"
"missing = []\n"
"for m in mods:\n"
" try: importlib.import_module(m)\n"
" except Exception as e: missing.append(f'{m}: {e}')\n"
"print('\\n'.join(missing))\n")
try:
result = subprocess.run(
[str(venv_python), "-c", check], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=60, cwd=_m().PROJECT_ROOT)
except Exception as exc:
logger.debug("venv health probe failed to run: %s", exc)
return True, ""
missing = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
if result.returncode != 0 and not missing:
# Interpreter itself is broken — that IS unhealthy.
detail = (result.stderr or "").strip().splitlines()
return False, detail[0] if detail else "venv python failed to run"
if missing:
return False, "; ".join(missing[:4])
return True, ""
def _desktop_app_present(desktop_dir: Path) -> bool:
"""Return whether a packaged or source Desktop build exists."""
from hermes_cli.update_cmd import _m
return (
_m()._desktop_packaged_executable(desktop_dir) is not None
or _m()._desktop_dist_exists(desktop_dir))
def _rebuild_desktop_after_update(
desktop_dir: Path, *, had_desktop_app_before_update: bool) -> bool:
"""Rebuild an installed Desktop app when its source or artifact changed. Returns ``False``
only when a rebuild was attempted and failed (caller withholds ``✓ Update complete!`` and
writes a failing ``.update_exit_code`` in gateway mode); every other outcome is ``True``.
See #88251.
"""
from hermes_cli.update_cmd import _m
# The release tree is git-ignored and can vanish mid-update; pre-update presence suffices.
# Never make people who never used Desktop pay for an Electron build.
has_desktop_app = had_desktop_app_before_update or _desktop_app_present(desktop_dir)
if not (
(desktop_dir / "package.json").exists() and _m()._resolve_node_runtime_npm() and has_desktop_app):
return True
print("→ Checking if desktop app needs rebuilding...")
# Check the content-hash stamp IN-PROCESS first (the subprocess spends ~1-3 s importing the
# CLI to reach the same check). Update never passes --source, so source_mode=False.
# Any pre-check error falls through to the subprocess.
try:
skip_desktop_build = not _m()._desktop_build_needed(
desktop_dir, _m().PROJECT_ROOT, source_mode=False)
except Exception:
skip_desktop_build = False
if skip_desktop_build:
print(" ✓ Desktop app up to date")
return True
desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
# Capture the loud build output into update.log; retry once on failure (still-settling
# rebuild window), then surface the tail. Put Hermes-managed Node on PATH: the desktop
# updater chain loses shell PATH customizations, so a bare-PATH child hits `node: not found`.
from hermes_constants import with_hermes_node_path
build_env = with_hermes_node_path()
for _attempt in range(2):
build_result = _m()._run_logged_subprocess(
desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env)
if build_result.returncode == 0:
break
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (run `hermes desktop` to retry)")
tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:])
if tail:
print(tail)
from hermes_constants import display_hermes_home as _dhh
print(f" Full build log: {_dhh()}/logs/update.log")
return False
print(" ✓ Desktop app up to date")
return True
def _path_uid(path) -> Optional[int]:
"""Owner uid of ``path`` (``None`` when unreadable). Separate seam so tests can simulate
root-owned files without chown. Never raises."""
try:
return os.stat(path, follow_symlinks=False).st_uid
except OSError:
return None
def _venv_foreign_owned_paths(venv_root, limit: int = 5) -> list:
"""Up to ``limit`` ``(path_str, uid)`` venv entries not owned by the current user.
A venv touched by ``sudo pip``/``sudo hermes`` dies mid-update with ``venv/bin/hermes`` already
deleted — never mutate a venv we can't safely mutate. Deliberately BOUNDED (venv root,
``venv/bin``, first site-packages top level, ``*.dist-info`` children; ~2000 stats). POSIX-only:
``[]`` on Windows and as root; ``[]`` on any surprise — must NEVER raise or add latency.
See #83529.
A later normal ``hermes update`` then dies mid-mutation inside ``uv pip install -e .`` ("Permission
denied (os error 13)") with ``venv/bin/hermes`` already deleted — the CLI is bricked. Same philosophy as
the contended-venv gate (#87331): a venv we cannot safely mutate is never mutated at all.
"""
from hermes_cli.update_cmd import _path_uid
try:
if not hasattr(os, "geteuid"):
return [] # windows-footgun: ok — POSIX ownership concept only
euid = os.geteuid() # windows-footgun: ok — guarded by hasattr above
if euid == 0:
return [] # root can rewrite anything; nothing to refuse
venv_root = Path(venv_root)
budget = 2000 # max stat() calls — hard bound on preflight cost
foreign: list = []
def _check(p) -> bool:
"""stat one path; True while scan should continue."""
nonlocal budget
if budget <= 0 or len(foreign) >= limit:
return False
budget -= 1
uid = _path_uid(p)
if uid is not None and uid != euid:
foreign.append((str(p), uid))
return budget > 0 and len(foreign) < limit
def _entries(d) -> list:
try:
return list(os.scandir(d))
except OSError:
return []
def _scan_dir(d, recurse_dist_info: bool = False) -> None:
for entry in _entries(d):
if not _check(entry.path):
return
if recurse_dist_info and entry.name.endswith(".dist-info"):
for child in _entries(entry.path):
if not _check(child.path):
return
if not _check(venv_root):
return foreign[:limit]
_scan_dir(venv_root / "bin")
# First lib/python*/site-packages (POSIX venv layout).
site_packages = next(iter(sorted(venv_root.glob("lib/python*/site-packages"))), None)
if site_packages is not None:
_scan_dir(site_packages, recurse_dist_info=True)
return foreign[:limit]
except Exception:
# Advisory preflight: structural surprise = "no verdict", never a blocked update.
return []
def _refuse_update_if_venv_foreign_owned(project_root) -> None:
"""Refuse-before-mutate ownership gate, run after the pull and before the first venv mutation:
foreign-owned files would brick the install mid-mutation, so refuse with the recovery command
while the venv is intact. No subprocess calls — tests mock ``subprocess.run`` with sequenced effects.
See #83529.
"""
foreign = _venv_foreign_owned_paths(Path(project_root) / "venv")
if not foreign:
return
print("\n✗ Update stopped: this install's venv contains files owned by another user.")
print(" Updating now would fail midway (Permission denied) and leave Hermes broken.")
print(" This usually happens after running hermes or pip with sudo. Offending paths:")
for p, uid in foreign:
print(f" - {p} (owner uid {uid})")
print("\n Fix ownership, then re-run the update:")
print(f" sudo chown -R $(id -un): {project_root}")
print(" hermes update")
print("\n Nothing in the venv was modified.")
sys.exit(1)
def _sync_python_dependencies_after_pull(
git_cmd, branch, pre_pull_sha, *, active_lazy_features,
_windows_gateway_resume, desktop_dir, had_desktop_app_before_update):
"""Reinstall Python deps for the pulled checkout (PM flow). Order matters: ownership preflight ->
core marker -> ``pm.sync_venv(["all"], explicit=True)`` (stages + commits a fresh generation
environment; the live env of any running process is never mutated) -> module reload -> lazy/tool
refresh (own marker) -> memory-provider deps -> critical-import probe (warn only) -> node deps +
web + desktop rebuild. Returns ``(node_failures, desktop_build_ok)``."""
from hermes_cli.update_cmd import (
_m, _write_lazy_refresh_incomplete_marker, _write_update_incomplete_marker)
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
#
# Ownership preflight (#83529): refuse before the first venv mutation
# if the venv contains foreign-owned files (sudo-pip residue) — the
# install below would die mid-mutation and brick the CLI.
_refuse_update_if_venv_foreign_owned(_m().PROJECT_ROOT)
_write_update_incomplete_marker()
print("→ Syncing Python dependencies...")
import pm
try:
pm.sync_venv(["all"], explicit=True)
deps_synced = True
except pm.InstallError as _sync_err:
print(f" ✗ {_sync_err}")
print(" Re-run `hermes update` (or `hermes pm install`) once resolved.")
raise
# Core ``.[all]`` install finished. Clear the generic core breadcrumb
# before the lazy-refresh phase — that phase uses its own marker so a
# later lazy failure cannot be "healed" by clearing the core marker
# based on a narrow 7-package import probe (#58004 review).
_m()._clear_update_incomplete_marker()
# The update process is still the old Python interpreter process. Run
# one final cache/module refresh immediately before lazy backend
# refresh, which imports newly-pulled modules that may depend on fresh
# symbols in hermes_constants or pm. The dependency install
# above may also have regenerated bytecode from build-cache copies —
# this second sweep catches those stragglers (#60242, #65240).
removed = _m()._clear_bytecode_cache(_m().PROJECT_ROOT)
if removed:
print(
f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}"
)
_m()._record_bytecode_fingerprint()
_m()._refresh_bootstrap_cache_scripts(branch)
_m()._reload_updated_runtime_modules()
_write_lazy_refresh_incomplete_marker()
lazy_ok = _m()._refresh_active_lazy_features(active_lazy_features)
if lazy_ok:
_m()._clear_lazy_refresh_incomplete_marker()
else:
print(
" ⚠ Lazy-refresh recovery incomplete — run `hermes` again "
"to finish import-based venv repair."
)
# Heal the active memory provider's bridge packages last — the core
# reinstall + lazy refresh above may have stripped or downgraded
# plugin.yaml-declared deps that aren't in extras (#53272, #70636).
_m()._refresh_active_memory_provider_dependencies()
# Everything that can legitimately produce a transient ImportError has
# now run (bytecode sweep, dependency reinstall, lazy refresh), so a
# module that still won't import is real breakage. Warn only — never
# roll back here: `cannot import name X` is also the signature of the
# stale-bytecode class (#6207, #60242), and the launch-time sweep in
# _sweep_stale_bytecode_if_checkout_changed() self-heals that on the
# next run. A destructive reset would undo a good update over a state
# that fixes itself.
import_ok, failing_module, import_error = _validate_critical_modules_import(
_m().PROJECT_ROOT
)
if not import_ok:
print()
print(f" ⚠ {failing_module} still fails to import after updating:")
print(f" {import_error}")
print(" Run `hermes update` again — if it persists, reinstall:")
print(" https://hermes-agent.nousresearch.com")
node_failures = _update_node_dependencies()
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
desktop_build_ok = _rebuild_desktop_after_update(
desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update,
)
print()
return node_failures, desktop_build_ok
+11 -6
View File
@@ -1179,7 +1179,7 @@ def _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode: bool):
# handler fail closed on an empty survivor probe rather than reporting a clean update (#78574).
# Declared outside the restart try/except below (and never reset to None) so it's always safe to read
# afterwards even if that block raises before reaching its own restart bookkeeping — needed to forward
# already-restarted units to ``_finish_dashboard_update_cleanup`` (review on #83595).
# already-restarted units to ``_refresh_dashboard_after_update`` (review on #83595).
restarted_scoped_units: set = set()
# Purge stale cached Hermes modules FIRST: the import below loads new gateway
@@ -1283,23 +1283,25 @@ def _collect_fleet_snapshot(restart, rows_expected: bool) -> list:
return snapshot
def _verify_fleet_after_update(restart, *, _pre_update_plan, _windows_gateway_resume, node_failures, update_complete):
def _verify_fleet_after_update(restart, *, _pre_update_plan, _windows_gateway_resume, update_complete):
"""Post-restart verification: legacy-unit warning, dashboard cleanup, stale serve
probe, fleet version matrix, plan-vs-execution reconciliation, receipt finalize.
Exits 1 (leaving ``fleet_restart_pending`` for the next catch-up) when any gateway
may still be stale; otherwise clears the marker.
may still be stale; otherwise clears the marker. A failed SQLite verdict also
exits 1, without retaining a fulfilled fleet-restart obligation.
"""
from hermes_cli.update_cmd import (
_finish_dashboard_update_cleanup, _m, _surviving_pre_update_serve_runtimes, _warn_stale_serve_runtimes,
_m, _surviving_pre_update_serve_runtimes, _warn_stale_serve_runtimes,
)
from hermes_cli.update_cmd_maint import _refresh_dashboard_after_update
with _best_effort('Legacy unit check during update failed: %s'):
_print_legacy_units_warning()
# Restart a managed dashboard via systemd or stop stale manual ones (raw-killing
# a systemd-owned PID reads as clean stop and leaves the Cloudflare origin dead).
# Failed Node refresh leaves it untouched; already-restarted units aren't redone.
_finish_dashboard_update_cleanup(node_failures, already_restarted_units=set(restart.restarted_services))
# Already-restarted units aren't redone.
_refresh_dashboard_after_update(already_restarted_units=set(restart.restarted_services))
# Success-path twin of the abort-recovery probe: the restart phase only touches
# units, so a unit-less `hermes serve` keeps stale sys.modules. Runs AFTER
@@ -1401,6 +1403,9 @@ def _verify_fleet_after_update(restart, *, _pre_update_plan, _windows_gateway_re
# doesn't treat the fleet as healthy; leave the pending marker for catch-up.
sys.exit(1)
_clear_fleet_restart_pending_marker()
if not update_complete:
# Fleet caught up, but the independently checked SQLite runtime is unsafe.
sys.exit(1)
# Fleet is healthy on the new code: fold per-profile gateways into one multiplexer when nothing
# blocks it (deterministic; never prompts), else print the blockers and the one-liner to run later.
with _best_effort('Multiplex auto-migration after update failed: %s'):
+37 -61
View File
@@ -23,7 +23,20 @@ from hermes_cli.update_cmd_common import _best_effort
logger = logging.getLogger("hermes_cli.update_cmd")
_UPDATE_RUNTIME_RELOAD_MODULES = "hermes_constants", "tools.environments.local", "pm.extras"
def _prepare_updated_checkout(project_root: Path, *, desktop: bool) -> None:
"""PM publishes dependencies before the shared builders consume the checkout."""
import pm
pm.sync_venv(explicit=True, project_root=project_root)
from hermes_cli.runtime_paths import activation_environment, selected_venv
# The updater still holds pre-pull imports. Build only in the newly selected Python.
command = [str(venv_python_path(selected_venv(project_root))),
"-m", "hermes_cli.source_build", "--source", str(project_root)]
if desktop:
command.append("--desktop")
subprocess.run(command, cwd=project_root, env=activation_environment(project_root), check=True)
#: Package prefixes whose cached modules go stale when the checkout changes under this
#: process; purged (not reloaded) so any LATER import chain resolves against fresh source.
@@ -95,15 +108,10 @@ def _purge_stale_hermes_modules() -> None:
def _reload_updated_runtime_modules() -> None:
"""Reload the modules used by lazy-backend refresh: the pre-pull process's cached modules
can expose old symbols despite new source on disk."""
from hermes_cli.update_cmd import _m
with _best_effort('Could not refresh update runtime modules: %s'):
_reload_modules(
_UPDATE_RUNTIME_RELOAD_MODULES,
modules=_m().sys.modules,
log=lambda name, exc: logger.debug("Could not reload updated module %s: %s", name, exc),
)
# Historical updater hook: dependency activation belongs to the next process.
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _print_curator_first_run_notice() -> None:
@@ -290,7 +298,7 @@ def _reload_process_scan_modules() -> None:
added would otherwise ImportError after the code update succeeded. Called from the cleanup
entry point so every caller (git path, ZIP fallback) is covered.
``_finish_dashboard_update_cleanup`` runs in the PRE-update Python process, but
``_refresh_dashboard_after_update`` runs in the PRE-update Python process, but
``_scan_dashboard_processes`` does a function-level ``from hermes_cli._subprocess_compat import
bounded_probe_run``. If the update added a new symbol to ``_subprocess_compat`` (as #87134 did with
``bounded_probe_run``), the cached OLD module object doesn't have it and the cleanup step crashes with
@@ -309,6 +317,13 @@ def _reload_process_scan_modules() -> None:
def _finish_dashboard_update_cleanup(
node_failures: list[str], already_restarted_units: "set[str] | None" = None
) -> None:
"""Historical updater hook; do not continue a pre-PM update after the swap."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _refresh_dashboard_after_update(*, already_restarted_units: set[str] | None = None) -> None:
"""Refresh managed dashboards or stop stale manual ones after an update.
*already_restarted_units*: systemd unit names (no ``.service``) the fleet-restart loop
@@ -317,12 +332,6 @@ def _finish_dashboard_update_cleanup(
See #83595.
"""
from hermes_cli.update_cmd import _m, _reload_process_scan_modules
if node_failures:
print()
print(" ℹ Leaving running dashboard process(es) untouched because the")
print(" Node.js dependency refresh did not complete.")
return
_reload_process_scan_modules()
stop_result = _m()._kill_stale_dashboard_processes(
@@ -424,40 +433,10 @@ def _clear_stale_sqlite_sidecars(db_path: Path) -> None:
def _print_update_summary(*, node_failures: list, desktop_build_ok: bool, pre_update_version: str | None) -> bool:
"""Final banner. A failed Desktop rebuild is non-fatal but must not print ``✓ Update complete!``.
"""Historical updater hook; old soft-build results cannot establish completion."""
from hermes_cli._old_updater import stop_for_relaunch
See #88251.
"""
from hermes_cli.update_cmd import _post_update_sqlite_runtime_status, _update_complete_message
sqlite_runtime_ok, sqlite_info = _post_update_sqlite_runtime_status()
if sqlite_info is None:
# Grace path: only a POSITIVE vulnerable probe demotes success to partial.
sqlite_runtime_ok = True
print()
if node_failures or not desktop_build_ok or not sqlite_runtime_ok:
parts = []
if node_failures:
parts.append(f"Node.js dependencies for {', '.join(node_failures)} did not refresh")
if not desktop_build_ok:
parts.append("the desktop app was not rebuilt and is still on the previous build")
if not sqlite_runtime_ok and sqlite_info is not None:
parts.append(_SQLITE_WAL_BUG_DETAIL.format(sqlite_info.sqlite_version_string))
print("⚠ Update partially complete — " + "; ".join(parts) + ".")
if node_failures:
print(" Code and Python deps are updated, but the dashboard/TUI may")
print(" be in a mixed state until the Node deps are rebuilt.")
if not desktop_build_ok:
print(" Run `hermes desktop` to retry the desktop rebuild.")
if not sqlite_runtime_ok:
print(
" The Python runtime remediation did not complete. Run `hermes "
"update` again; if SQLite is unchanged, rebuild the Hermes venv "
"with a uv-managed Python, restart Hermes, then verify with "
"`hermes doctor`."
)
else:
_print_update_completion(_update_complete_message(pre_update_version))
return desktop_build_ok and sqlite_runtime_ok
stop_for_relaunch()
def _restore_state_db_from_snapshot(state_path: Path, snap_state: Path) -> bool:
@@ -858,10 +837,7 @@ def _sweep_bytecode_after_update(branch: str) -> None:
"""Clear stale ``__pycache__`` (else gateway restart ImportErrors on names absent from old
bytecode), re-stamp the fingerprint, refresh the bootstrap cache scripts."""
from hermes_cli.update_cmd import _m
# The update process is still the old Python interpreter process. Run one final cache/module refresh
# immediately before lazy backend refresh, which imports newly-pulled modules that may depend on fresh
# symbols in hermes_constants or pm.extras. The dependency install above may also have regenerated
# bytecode from build-cache copies — this second sweep catches those stragglers (#60242, #65240).
# Timestamp-based .pyc validation can accept old bytecode after the source swap.
removed = _m()._clear_bytecode_cache(_m().PROJECT_ROOT)
if removed:
print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}")
@@ -971,12 +947,13 @@ def _print_post_update_notices_and_self_heals() -> None:
def _run_post_update_maintenance(
*, assume_yes, gateway_mode, pre_update_snapshot_id, had_desktop_app_before_update, node_failures, desktop_build_ok,
*, assume_yes, gateway_mode, pre_update_snapshot_id, had_desktop_app_before_update,
pre_update_version,
) -> bool:
"""Post-pull housekeeping: state.db restore, catalog/skills/profile syncs, config migration,
the update summary (verdict returned), and best-effort notices/self-heals. Every step is
isolated so none can fail the update."""
"""Post-build housekeeping and completion, returning the SQLite runtime verdict.
Ancillary repairs and notices are best-effort; an unsafe runtime withholds success.
"""
from hermes_cli.update_cmd import _check_and_apply_config_migration, _m
# macOS TCC: Desktop bundles are re-signed each update, so old grants can go stale
# (toggle ON, yet macOS re-prompts with no Allow button). Tell users how to re-grant.
@@ -1023,9 +1000,8 @@ def _run_post_update_maintenance(
assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id,
)
update_complete = _print_update_summary(
node_failures=node_failures, desktop_build_ok=desktop_build_ok, pre_update_version=pre_update_version,
)
print()
update_complete = _print_verified_update_completion(_update_complete_message(pre_update_version))
_print_post_update_notices_and_self_heals()
return update_complete
+93
View File
@@ -0,0 +1,93 @@
"""Source import-integrity checks for update and stash restoration."""
from contextlib import suppress
import json
import subprocess
import sys
from pathlib import Path
from hermes_constants import venv_python_path
# Modules imported on every startup. Unlike _UPDATE_CRITICAL_FILES (only parsed) these are
# *imported*, catching cross-module breakage (a name pulled from a sibling no longer exists).
_UPDATE_CRITICAL_MODULES = "hermes_cli.main", "run_agent", "model_tools", "toolsets"
def _critical_module_import_failures(
root, *, report_runtime_errors: bool = False) -> dict[str, tuple[str, str]]:
"""Import each ``_UPDATE_CRITICAL_MODULES`` entry in a subprocess; return failures in probe order.
Syntax validation only *parses*: a partially-updated tree (Windows ZIP copy loop) parses yet
dies with ``ImportError: cannot import name``. The subprocess (venv interpreter when present —
the updater may run under another Python) keeps import side effects out of our ``sys.modules``.
Generic import-time exceptions are tolerated unless ``report_runtime_errors=True``.
"""
from hermes_cli.update_cmd import _UPDATE_CRITICAL_MODULES, _m
from hermes_constants import FIRST_PARTY_MODULE_ROOTS
import secrets
marker = f"__HERMES_IMPORT_HEALTH_{secrets.token_hex(16)}__"
probe = (
"import importlib, json, sys\n"
"failures = []\n"
"for name in %r:\n"
" try:\n"
" importlib.import_module(name)\n"
" except ModuleNotFoundError as exc:\n"
# A missing *third-party* module means deps aren't installed, not a skewed checkout;
# only our own packages count. Roots come from hermes_constants so the user hint can't drift.
" missing = (getattr(exc, 'name', '') or '').split('.')[0]\n"
" if missing in %r or missing.startswith('hermes_') or %r:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except ImportError as exc:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except Exception as exc:\n"
" if %r:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except BaseException as exc:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
"sys.stdout.write('\\n%s' + json.dumps(failures))\n"
% (_UPDATE_CRITICAL_MODULES, tuple(sorted(FIRST_PARTY_MODULE_ROOTS)), report_runtime_errors,
report_runtime_errors, marker))
try:
interpreter = sys.executable
with suppress(Exception):
venv_python = venv_python_path(Path(root) / "venv", windows=_m()._is_windows())
if venv_python.exists():
interpreter = str(venv_python)
result = subprocess.run(
[interpreter, "-c", probe], cwd=str(root), capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=120)
except subprocess.TimeoutExpired:
return _probe_failure("TimeoutExpired", "timed out before reporting import health")
except (OSError, subprocess.SubprocessError):
# Can't run the probe — don't block the update on our own tooling.
return {}
output = result.stdout or ""
if marker not in output:
return _probe_failure(
"ProbeTerminated",
f"terminated before reporting import health (exit code {result.returncode})")
try:
failures = json.loads(output.rsplit(marker, 1)[1])
if not isinstance(failures, list) or any(
not isinstance(item, list) or len(item) != 3 or not all(isinstance(v, str) for v in item)
for item in failures):
raise ValueError("invalid import-health payload")
return {str(module): (str(kind), str(detail)) for module, kind, detail in failures}
except (TypeError, ValueError):
return _probe_failure("MalformedPayload", "reported malformed import health data")
def _probe_failure(kind: str, detail: str) -> dict[str, tuple[str, str]]:
"""Failure row for the probe itself (as opposed to a module it imported)."""
return {"critical-module probe": (kind, detail)}
def _validate_critical_modules_import(
root, *, report_runtime_errors: bool = False) -> tuple[bool, str | None, str | None]:
"""Return the first critical-module import failure, if any."""
failures = _critical_module_import_failures(root, report_runtime_errors=report_runtime_errors)
if failures:
module = next(iter(failures))
return False, module, failures[module][1]
return True, None, None
+6 -383
View File
@@ -1,4 +1,4 @@
"""Windows gateway lifecycle for ``hermes update``: pause/resume/cold-start the service, sweep venv holders, reap orphaned backends.
"""Windows gateway lifecycle and process identity for ``hermes update``.
Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.<name>`` still resolves/monkeypatches.
Origin helpers are imported lazily per function (no cycle; test patches on the origin stay effective).
@@ -10,7 +10,6 @@ import os
import re
import shlex
import subprocess
import sys
import time as _time
from datetime import datetime, timezone
from pathlib import Path
@@ -260,34 +259,6 @@ def _hermes_holder_subcommand(cmdline: str) -> str | None:
return None
def _format_venv_python_holders_message(matches: list[tuple[int, str, str]]) -> str:
"""Explain which venv processes block the update and how to clear them.
Labels come from the parsed SUBCOMMAND, never substring: a standalone ``hermes dashboard`` must not be
called the Desktop backend, ``--preserve-cache`` must not match "serve". Unknown argv gets no hint.
See #90778.
"""
hint_by_subcommand = {
"serve": " ← Hermes backend (if the Desktop app is open, close it)",
"dashboard": " ← hermes dashboard (stop it: hermes dashboard stop, or close that terminal)",
"gateway": " ← gateway",
}
lines = ["✗ Other Hermes processes are running from this install's venv:"]
for pid, name, cmdline in matches[:6]:
hint = hint_by_subcommand.get(_hermes_holder_subcommand(cmdline) or "", "")
lines.append(f" PID {pid} {name} {cmdline[:120]}{hint}")
if len(matches) > 6:
lines.append(f" ... and {len(matches) - 6} more")
lines.append(
"\n On Windows these keep native extension files (.pyd) locked, so the\n"
" dependency update would fail partway and leave a broken install.\n"
" Close the Hermes desktop app / other Hermes terminals, then re-run:\n hermes update\n"
" (or use `hermes update --force-venv` to proceed anyway at your own risk)"
)
return "\n".join(lines)
def _venv_launcher_ancestors(pids: list[int]) -> list[int]:
"""Venv-interpreter parents of *pids* that hold the install open; never raises.
@@ -312,272 +283,14 @@ def _venv_launcher_ancestors(pids: list[int]) -> list[int]:
return found
def _leftover_pausable_gateway_pids(matches: list[tuple[int, str, str]]) -> list[int] | None:
"""PIDs from *matches* when EVERY remaining venv holder is a pausable gateway, else ``None`` (keep refusing).
A gateway respawned inside the pause->guard window (or via an unmapped spawn path) still holds ``.pyd`` files.
Uses the Desktop preflight's ``_is_pausable_gateway`` so exemption and tolerance cannot drift; live argv is
re-read via psutil when possible since the scan may hold only a cmdline prefix."""
from hermes_cli._scan_venv_blockers import _is_pausable_gateway
psutil = _psutil()
pids: list[int] = []
for pid, _name, cmdline in matches:
argv = cmdline
if psutil is not None:
with suppress(Exception):
argv = " ".join(psutil.Process(int(pid)).cmdline()) or cmdline
if not _is_pausable_gateway(argv):
return None
pids.append(int(pid))
return pids
def _refuse_gateway_ancestor_tree_kill(pids: list[int], *, gateway_mode: bool) -> bool:
"""Refuse a plain Windows update that would tree-kill its own ancestry (a chat agent's ``hermes update`` is
a gateway child; ``taskkill /T /F`` kills the updater first). ``--gateway`` is exempt (detached delivery).
Refuse only when a nominated gateway is positively an ancestor; unknown ancestry keeps existing recovery.
"""Historical post-swap import: stop old callers before their tree-kill ladder.
The leftover holder recovery below uses ``taskkill /T /F`` on Windows, so force-stopping that gateway
also kills the updater before it can mutate the checkout (#98814).
PM stages a fresh generation; current updates do not clear live venv holders.
This address remains for already-running updaters, not as a process probe.
"""
if gateway_mode or not pids:
return False
def _ancestors():
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
return [int(pid) for pid in pids if _is_pid_ancestor_of_current_process(int(pid))]
ancestors = _try_call(_ancestors, "Could not inspect gateway ancestry before tree-kill: %s")
if not ancestors:
return False
print(
"✗ Refusing to stop the gateway process tree because this updater "
f"is running inside it (gateway PID(s): {', '.join(str(pid) for pid in ancestors)}).\n"
" On Windows, taskkill /T would terminate the updater before the update can run.\n"
" From a chat platform, use `/update` instead.\n Otherwise, run `hermes update` from a separate terminal."
)
return True
def _ledger_manual_serve_holders(matches: list[tuple[int, str, str]]) -> list[dict]:
"""Full ledger entries for venv holders that are MANUAL serve/dashboard backends.
Positive identity only: self-registered purpose serve/dashboard, live (pid, create_time), recorded spawner
NOT alive (a Desktop-owned backend keeps its live Electron spawner and must keep the refusal — the app would
respawn what we kill). Full entries let the relauncher rebuild from host/port/profile, not argv."""
try:
from hermes_cli.process_identity import ledger_entries, spawner_is_dead
except Exception:
return []
holder_pids = {int(pid) for pid, _name, _cmd in matches}
return [
entry for entry in ledger_entries()
if entry.get("purpose") in _BACKEND_PURPOSES and isinstance(entry.get("pid"), int) and entry["pid"] in holder_pids
and spawner_is_dead(entry) is not False # False = live Desktop supervisor owns it; keep refusing
]
def _serve_relaunch_commands(entries: list[dict]) -> list[list[str]]:
"""Rebuild launch commands for stopped serves from ledger host/port/profile — never argv parsing
(joined argv cannot round-trip Windows paths with spaces). Entries without a port are skipped."""
from hermes_cli.update_cmd import _m
hermes = "hermes"
with suppress(Exception):
scripts_dir = _m()._venv_scripts_dir()
if scripts_dir is not None:
hermes = next((str(scripts_dir / n) for n in ("hermes.exe", "hermes") if (scripts_dir / n).is_file()), hermes)
commands: list[list[str]] = []
for entry in entries:
port = entry.get("port")
if not isinstance(port, int) or port <= 0:
continue
profile, host = str(entry.get("profile") or ""), str(entry.get("host") or "")
commands.append(
[hermes] + (["--profile", profile] if profile and profile != "default" else [])
+ [str(entry.get("purpose"))] + (["--host", host] if host else []) + ["--port", str(port)]
)
return commands
def _relaunch_stopped_serves(token: dict) -> None:
"""Idempotent atexit relaunch of manual serves stopped by the venv guard.
`pending` flips False on first invocation so explicit call + atexit registration cannot double-spawn."""
from hermes_cli.update_cmd import _m
from hermes_cli.update_receipt import record_step as _record_update_step
if not token.get("pending"):
return
token["pending"] = False
entries = token.get("entries") or []
if not entries:
return
commands = _serve_relaunch_commands(entries)
skipped = len(entries) - len(commands)
failed: list = []
if commands:
print(" ⟲ Relaunching stopped serve/dashboard backend(s)")
failed = _m()._respawn_dashboard_processes(commands)
if skipped or failed:
print(" ⚠ Some stopped backends could not be relaunched automatically; restart them manually (hermes serve --host <ip> --port <port>).")
_record_update_step(
"serve_relaunch", not failed and not skipped,
f"relaunched={len(commands) - len(failed)} failed={len(failed)} skipped={skipped}",
)
def _is_backend_argv(argv_low: str) -> bool:
"""Whether a lower-cased argv is a Desktop backend (``hermes_cli.main`` running ``serve``/``dashboard``)."""
return "hermes_cli.main" in argv_low and (" serve" in argv_low or " dashboard" in argv_low)
def _live_argv_low(psutil, pid, cmdline: str) -> str | None:
"""Current lower-cased argv of *pid* (falls back to the scanned *cmdline*); ``None`` if it exited."""
argv = cmdline
try:
argv = " ".join(psutil.Process(int(pid)).cmdline()) or cmdline
except psutil.NoSuchProcess:
return None
except Exception:
pass
return argv.lower()
def _orphaned_desktop_backend_pids(matches: list[tuple[int, str, str]]) -> list[tuple[int, int]] | None:
"""``(pid, start_time)`` roots from *matches* when every remaining holder is an ORPHANED backend, else ``None``.
Killing a Desktop-owned ``serve`` is futile (the app respawns it), but a straggler whose Desktop is gone
would dead-end the update with "Hermes is still running" and zero open windows. Qualifies only if cmdline
is a Hermes backend AND the parent is demonstrably gone (PID missing or reused). Tree-aware: holders inside
an accepted root's tree fold into it; only roots are returned (``taskkill /T`` reaps descendants). Any
live-parent backend, unjustified non-backend, unprovable case, or no psutil -> ``None``. Never raises.
The venv-holder guard refuses on the Desktop app's ``serve`` backend by design: while the Desktop is
open, killing its backend is futile (the app supervises and respawns it within seconds), so the user
must close the app. But in the GUI-updater handoff path the Desktop has *already exited* — by contract
it tree-kills its backends and waits for the venv shim before spawning hermes-setup, and the
update-in-progress marker parks any relaunched Desktop from spawning a fresh backend (#50238). A
``serve`` backend still holding the venv at that point is a straggler whose supervisor is gone: SIGTERM
raced its spawn, or it belongs to a crashed window. Nothing will respawn it, and refusing on it
dead-ends the update with "Hermes is still running" while the user stares at zero open windows (ryanc's
2026-08-09 01:59/02:17 failures).
"""
psutil = _psutil()
if psutil is None:
return None
# Pass 1: find orphaned backend ROOTS among the holders.
roots: list[tuple[int, int]] = []
remaining: list[int] = [] # holders still to justify
for pid, _name, cmdline in matches:
low = _live_argv_low(psutil, pid, cmdline)
if low is None:
continue # exited between scan and classification — nothing to reap
if not _is_backend_argv(low):
remaining.append(int(pid))
continue
try:
proc = psutil.Process(int(pid))
# Fingerprint from the SAME psutil handle, centisecond-quantized like
# gateway.status.get_process_start_time so pid_is_hermes round-trips at kill time.
process_start_time = int(round(proc.create_time() * 100))
except psutil.NoSuchProcess:
continue # exited during classification — nothing to reap
except Exception:
return None
try:
ppid = proc.ppid()
parent = psutil.Process(ppid) if ppid else None
# PID-reuse check: a "parent" created after its child is a recycled PID. A live parent is
# not a root but may be an orphan root's descendant (the venv trampoline re-execs uv
# python with the SAME argv) — defer to pass 2.
if parent is not None and parent.is_running() and parent.create_time() <= proc.create_time():
remaining.append(int(pid))
continue
except psutil.NoSuchProcess:
pass # parent gone → orphan
except Exception:
return None
roots.append((int(pid), process_start_time))
# Pass 2: every non-backend holder must descend from an accepted orphan root
# (dies with the tree reap); anything else keeps the refusal.
root_set = {pid for pid, _start_time in roots}
for pid in remaining:
try:
if not root_set or not root_set & {int(a.pid) for a in psutil.Process(pid).parents()}:
return None
except psutil.NoSuchProcess:
continue # exited already
except Exception:
return None
return roots
def _ledger_reapable_backend_pids(matches: list[tuple[int, str, str]]) -> list[int]:
"""PIDs the spawn ledger positively identifies as orphaned backends; never raises.
Strongest rung (no PPID/cmdline inference): qualifies when ``(pid, create_time)`` matches a live ledger entry
(PID reuse can't forge it), purpose is a REAPABLE kind (never interactive), and the recorded SPAWNER is
provably dead. Safe in ANY context. Unlisted holders fall to later rungs and never disqualify identified ones."""
try:
from hermes_cli.process_identity import REAPABLE_PURPOSES, ledger_entries, spawner_is_dead
entries = ledger_entries()
except Exception:
return []
by_pid = {e.get("pid"): e for e in entries if isinstance(e.get("pid"), int)}
return [
int(pid) for pid, _name, _cmdline in matches
if (entry := by_pid.get(int(pid))) and entry.get("purpose") in REAPABLE_PURPOSES and spawner_is_dead(entry) is True
]
def _handoff_reapable_backend_pids(matches: list[tuple[int, str, str]]) -> list[int] | None:
"""Backend PIDs safe to tree-reap during a GUI-updater hand-off, INCLUDING ones with a live parent; never raises.
The orphan-only rung bails on ANY live parent (mid-teardown Electron, launcher->worker chain) and hung a
hand-off. Inside the hand-off gate (marker + ``--gateway`` + no live ``hermes.exe`` shim) nothing legitimate
supervises a ``serve`` from this venv, so survivors are leaks. Any non-backend holder or no psutil ->
``None``. The CALLER must have confirmed the gate; outside it the stricter orphan-only path stands.
Any ``serve`` backend still holding the venv here is therefore a leak, live parent or not, and reaping
its tree is correct rather than a race. See #50238.
"""
psutil = _psutil()
if psutil is None:
return None
roots: list[int] = []
for pid, _name, cmdline in matches:
low = _live_argv_low(psutil, pid, cmdline)
if low is None:
continue # exited — nothing to reap
if not _is_backend_argv(low):
return None # unexpected non-backend holder: refuse the whole set
roots.append(int(pid))
return roots or None
def _stop_process_trees(pids: list[int] | list[tuple[int, int]]) -> None:
"""Force-stop each PID with its full child tree (Windows); best effort, never raises.
``taskkill /T /F``: stopping only the parent can leave a ``.hermes-runtime`` child holding the install open.
See #70026.
"""
from gateway.status import get_process_start_time
from hermes_cli._subprocess_compat import pid_is_hermes, windows_hide_flags
for entry in pids:
pid, expected_start_time = entry if isinstance(entry, tuple) else (int(entry), get_process_start_time(int(entry)))
try:
if expected_start_time is None:
logger.debug("Skipping taskkill of PID %s: process identity unavailable", pid)
continue
if not pid_is_hermes(pid, expected_start_time=expected_start_time):
logger.debug("Skipping taskkill of non-Hermes or changed PID %s", pid)
continue
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
except Exception as exc:
logger.debug("Could not stop process tree %s: %s", pid, exc)
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _looks_like_desktop_control_plane(cmdline: str) -> bool:
@@ -1167,93 +880,3 @@ def _resume_windows_gateways_and_merge_outcome(outcome, _windows_gateway_resume,
failed_units=outcome.failed_or_stale_units, incomplete=outcome.incomplete or bool(outcome.failed_or_stale_units),
phase_error="; ".join(outcome.phase_errors) or None,
)
def _reap_and_rescan(message: str, pids, stop=None) -> list[tuple[int, str, str]]:
"""Announce *message*, stop *pids* (tree-kill unless *stop* given), settle 1s, re-scan venv holders."""
from hermes_cli.update_cmd import _m
print(message)
(stop or _m()._stop_process_trees)(pids)
_time.sleep(1.0)
return _detect_venv_python_processes()
def _terminate_leftover_gateways(pids) -> None:
"""Force-stop leftover gateways one by one; a failure is logged, never raised."""
from gateway.status import get_process_start_time, terminate_pid
for _pid in pids:
_try_call(lambda p=int(_pid): terminate_pid(p, force=True, expected_start_time=get_process_start_time(p)),
"Could not stop leftover gateway %s: %s", _pid)
def _in_handoff_without_live_shim(args) -> bool:
"""GUI hand-off gate: ``--gateway`` + update-incomplete marker AND no live ``hermes.exe`` shim.
Fail closed: unverifiable marker or shim state reads as "not a hand-off" / "live shim"."""
from hermes_cli.update_cmd import _m
try:
if not (bool(getattr(args, "gateway", False)) and _m()._update_marker_path().exists()):
return False
scripts_dir = _m()._venv_scripts_dir()
return scripts_dir is not None and not _m()._detect_concurrent_hermes_instances(scripts_dir)
except Exception:
return False
def _clear_windows_venv_holders_or_exit(args, gateway_mode: bool, _windows_gateway_resume):
"""Windows: stop every venv-python holder we can positively identify, else resume paused gateways and exit 2.
Rungs in order: leftover pausable gateways -> ledger orphaned backends -> orphaned Desktop backends ->
ledger manual serve (relaunched at exit on the same bind) -> GUI hand-off leaks. Remaining holders are
refused (the sync would corrupt against a locked .pyd)."""
from hermes_cli.update_cmd import _m, _refuse_gateway_ancestor_tree_kill
from hermes_cli.update_receipt import record_step as _record_update_step
def _resume_and_exit():
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
sys.exit(2)
holders = _detect_venv_python_processes()
# Gateways the pause machinery owns (respawned in the pause->guard window or unmapped
# spawn path): stop and re-check; post-update resume brings them back.
if holders and (gateway_holders := _m()._leftover_pausable_gateway_pids(holders)) is not None:
if _refuse_gateway_ancestor_tree_kill(gateway_holders, gateway_mode=gateway_mode):
_resume_and_exit()
holders = _reap_and_rescan(
f" ⚠ {len(gateway_holders)} gateway process(es) still hold the venv after the pause; stopping them",
gateway_holders, stop=_terminate_leftover_gateways,
)
# Tree-reap rungs. Ledger rung = positive identity in any context (self-registered backend, spawner
# provably dead; no PPID archaeology). Orphan rung = Desktop `serve` whose app is GONE (nothing
# respawns an orphan); live-Desktop backends return None and keep the refusal.
for classifier, message in (
(_m()._ledger_reapable_backend_pids, "ledger-identified orphaned Hermes backend process(es) hold the venv"),
(_m()._orphaned_desktop_backend_pids, "orphaned Desktop backend process(es) still hold the venv"),
):
if holders and (backends := classifier(holders)):
holders = _reap_and_rescan(f" ⚠ {len(backends)} {message}; stopping their trees", backends)
# Manual serve/dashboard rung (e.g. `hermes serve --host <ip>` for a REMOTE Desktop): ledger identity
# only (spawner dead; Desktop-owned keep the refusal). Stop and register an idempotent atexit relaunch
# on the SAME host/port/profile — success or failure.
if holders and (serve_entries := _m()._ledger_manual_serve_holders(holders)):
def _stop_and_park(pids):
_m()._stop_process_trees(pids)
_record_update_step("serve_pause", True, f"stopped={len(serve_entries)}")
import atexit as _serve_atexit
_serve_atexit.register(_m()._relaunch_stopped_serves, {"pending": True, "entries": serve_entries})
holders = _reap_and_rescan(
f" ⚠ {len(serve_entries)} manual serve/dashboard backend(s) hold the venv; stopping them for "
"the update (they will be relaunched on their recorded endpoints)",
[int(e["pid"]) for e in serve_entries], stop=_stop_and_park,
)
# Final rung: in a GUI hand-off the Desktop is contractually gone; surviving `serve` backends are leaks
# even with a live parent (which made the orphan-only rung bail and hang) — reap by cmdline.
if holders and _in_handoff_without_live_shim(args) and (handoff_backends := _m()._handoff_reapable_backend_pids(holders)):
holders = _reap_and_rescan(
f" ⚠ {len(handoff_backends)} Hermes backend process(es) "
"still hold the venv after the Desktop hand-off; stopping their trees", handoff_backends,
)
if holders:
print(_format_venv_python_holders_message(holders))
_resume_and_exit()
+8 -53
View File
@@ -317,46 +317,23 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _reinstall_python_deps_after_zip() -> None:
"""Reinstall Python deps via the PM sync authority (pm.sync_venv, no pip fallback).
The PM sync stages a fresh generation environment and commits the selection — the live
environment of the running process is never mutated, so no self-lock deferral guards this
and no pip/uv restore re-arms tool deps into the superseded pre-swap environment
(pm-clean-audit-49945b1402 final-gates item 9)."""
from hermes_cli.update_cmd import _m
import pm
try:
pm.sync_venv(["all"], explicit=True)
except pm.InstallError as _sync_err:
print(f" ✗ {_sync_err}")
print(" Re-run `hermes update` (or `hermes pm install`) once resolved.")
raise
_m()._refresh_active_memory_provider_dependencies()
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
target_sha: str | None = None, target_repository: str | None = None) -> bool:
"""Update via ZIP archive; used on Windows when git file I/O is broken (antivirus / NTFS filter
drivers causing 'Invalid argument'). Returns ``False`` when a Desktop rebuild ran and failed.
"""Update via ZIP when Windows git file I/O fails; dependency/build failures propagate.
A supplied commit keeps the archive on the target selected before Git failed.
"""
from hermes_cli.update_cmd import (
_finish_dashboard_update_cleanup,
_m,
_print_curator_first_run_notice,
_print_curator_recent_run_notice,
_print_update_summary,
_read_project_version,
_rebuild_desktop_after_update,
_update_node_dependencies,
_validate_critical_modules_import,
_verify_and_restore_state_dbs_post_update,
)
from hermes_cli.update_cmd_maint import (
_prepare_updated_checkout, _refresh_dashboard_after_update,
_print_verified_update_completion, _update_complete_message)
from hermes_cli.update_cmd_maint import _print_bundled_skills_sync_report
from hermes_cli.update_cmd_maint import _sweep_bytecode_after_update
pre_update_version = _read_project_version() # snapshot before files are replaced, for the completion line
@@ -382,25 +359,7 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
raise ValueError("ZIP update requires a GitHub owner/repository")
_download_and_swap_zip(branch, f"https://github.com/{repository}/archive/{ref}.zip")
_sweep_bytecode_after_update(branch)
print("→ Updating Python dependencies...")
_reinstall_python_deps_after_zip()
# Verify the tree imports (catches the parse-OK-but-skewed tree an interrupted copy leaves). Runs
# *after* the dep reinstall so a genuinely-new third-party requirement isn't misreported as a partial
# copy. No SHA to roll back to — surface a concrete recovery step instead of success over a bricked install.
import_ok, failing_module, import_error = _validate_critical_modules_import(_m().PROJECT_ROOT)
if not import_ok:
print()
print("✗ Update left the install in an unimportable state:")
print(f" {failing_module}: {import_error}")
print()
print(" This usually means the copy was interrupted partway through.")
print(" Re-run `hermes update` to complete it.")
_m().sys.exit(1)
node_failures = _update_node_dependencies()
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
desktop_build_ok = _rebuild_desktop_after_update(
_m().PROJECT_ROOT / "apps" / "desktop", had_desktop_app_before_update=had_desktop_app_before_update,
)
_prepare_updated_checkout(_m().PROJECT_ROOT, desktop=had_desktop_app_before_update)
with suppress(Exception):
print("→ Syncing bundled skills...")
_print_bundled_skills_sync_report()
@@ -413,17 +372,13 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
with _best_effort('Post-update state.db integrity check (zip path) failed: %s'):
# See #97994.
_verify_and_restore_state_dbs_post_update()
update_complete = _print_update_summary(
node_failures=node_failures, desktop_build_ok=desktop_build_ok, pre_update_version=pre_update_version,
)
update_complete = _print_verified_update_completion(_update_complete_message(pre_update_version))
with _best_effort('Curator first-run notice failed: %s'):
_print_curator_first_run_notice()
with _best_effort('Curator recent-run notice failed: %s'):
_print_curator_recent_run_notice()
# Don't stop a working dashboard when the Node refresh failed — see the git-update path for rationale.
# See #30271.
_finish_dashboard_update_cleanup(node_failures)
_refresh_dashboard_after_update()
with _best_effort('Update receipt finalize (zip path) failed: %s'):
from hermes_cli.update_receipt import finalize_update_receipt
finalize_update_receipt("success" if update_complete and not node_failures else "partial")
finalize_update_receipt("success" if update_complete else "partial")
return update_complete
+61 -24
View File
@@ -332,11 +332,14 @@
"gateway/config_env.py",
"gateway/config_loader.py",
"gateway/control_socket.py",
"gateway/cwd_placeholder.py",
"gateway/display_config.py",
"gateway/lifecycle_ledger.py",
"gateway/platform_registry.py",
"gateway/platforms/_shared.py",
"gateway/profile_routing.py",
"gateway/restart.py",
"gateway/run.py",
"gateway/session_context.py",
"gateway/shutdown_forensics.py",
"gateway/shutdown_watchdog.py",
@@ -347,7 +350,6 @@
"hermes_cli/_launchers.py",
"hermes_cli/_old_updater.py",
"hermes_cli/_parser.py",
"hermes_cli/_scan_venv_blockers.py",
"hermes_cli/_subprocess_compat.py",
"hermes_cli/agent_plugins.py",
"hermes_cli/archive_safe.py",
@@ -367,6 +369,8 @@
"hermes_cli/env_loader.py",
"hermes_cli/fs_utils.py",
"hermes_cli/gateway.py",
"hermes_cli/gateway_migrate.py",
"hermes_cli/gateway_multiplex_served.py",
"hermes_cli/gateway_windows.py",
"hermes_cli/git_credentials.py",
"hermes_cli/gitlock.py",
@@ -375,17 +379,16 @@
"hermes_cli/macos_tcc_anchor.py",
"hermes_cli/main.py",
"hermes_cli/main_dashboard.py",
"hermes_cli/main_desktop.py",
"hermes_cli/main_install_repair.py",
"hermes_cli/managed_scope.py",
"hermes_cli/managed_uv.py",
"hermes_cli/mcp_security.py",
"hermes_cli/memory_setup.py",
"hermes_cli/model_catalog.py",
"hermes_cli/plugin_capabilities.py",
"hermes_cli/plugin_catalog.py",
"hermes_cli/plugin_compat.py",
"hermes_cli/plugins.py",
"hermes_cli/plugins_admission.py",
"hermes_cli/plugins_cmd.py",
"hermes_cli/plugins_cmd_catalog.py",
"hermes_cli/plugins_discovery.py",
@@ -405,6 +408,7 @@
"hermes_cli/runtime_paths.py",
"hermes_cli/runtime_state.py",
"hermes_cli/secret_prompt.py",
"hermes_cli/service_manager.py",
"hermes_cli/setup.py",
"hermes_cli/sizefmt.py",
"hermes_cli/source_releases.py",
@@ -421,11 +425,11 @@
"hermes_cli/update_cmd.py",
"hermes_cli/update_cmd_common.py",
"hermes_cli/update_cmd_config.py",
"hermes_cli/update_cmd_deps.py",
"hermes_cli/update_cmd_fleet.py",
"hermes_cli/update_cmd_git.py",
"hermes_cli/update_cmd_maint.py",
"hermes_cli/update_cmd_stash.py",
"hermes_cli/update_cmd_validation.py",
"hermes_cli/update_cmd_windows.py",
"hermes_cli/update_cmd_zip.py",
"hermes_cli/update_contract.py",
@@ -455,6 +459,7 @@
"pm/environment.py",
"pm/extras.py",
"pm/features.py",
"pm/filesystem.py",
"pm/lock.py",
"pm/network.py",
"pm/operations.py",
@@ -470,10 +475,6 @@
"pm/store.py",
"pm/update.py",
"pm/workspace.py",
"tools/browser_tool.py",
"tools/browser_tool_install.py",
"tools/browser_tool_lifecycle.py",
"tools/browser_tool_origin.py",
"tools/computer_use/cua_backend.py",
"tools/computer_use/cua_backend_driver.py",
"tools/env_passthrough.py",
@@ -485,6 +486,7 @@
"tools/skills_guard.py",
"tools/skills_sync.py",
"tools/skills_sync_optional.py",
"tools/terminal_scope.py",
"utils.py"
],
"entrypoint_paths": [
@@ -510,9 +512,12 @@
"gateway/config_env.py",
"gateway/config_loader.py",
"gateway/control_socket.py",
"gateway/cwd_placeholder.py",
"gateway/lifecycle_ledger.py",
"gateway/platforms/_shared.py",
"gateway/profile_routing.py",
"gateway/restart.py",
"gateway/run.py",
"gateway/session_context.py",
"gateway/shutdown_forensics.py",
"gateway/shutdown_watchdog.py",
@@ -522,7 +527,6 @@
"hermes_cli/_launchers.py",
"hermes_cli/_old_updater.py",
"hermes_cli/_parser.py",
"hermes_cli/_scan_venv_blockers.py",
"hermes_cli/_subprocess_compat.py",
"hermes_cli/agent_plugins.py",
"hermes_cli/archive_safe.py",
@@ -541,6 +545,8 @@
"hermes_cli/env_loader.py",
"hermes_cli/fs_utils.py",
"hermes_cli/gateway.py",
"hermes_cli/gateway_migrate.py",
"hermes_cli/gateway_multiplex_served.py",
"hermes_cli/gateway_windows.py",
"hermes_cli/git_credentials.py",
"hermes_cli/gitlock.py",
@@ -549,17 +555,16 @@
"hermes_cli/macos_tcc_anchor.py",
"hermes_cli/main.py",
"hermes_cli/main_dashboard.py",
"hermes_cli/main_desktop.py",
"hermes_cli/main_install_repair.py",
"hermes_cli/managed_scope.py",
"hermes_cli/managed_uv.py",
"hermes_cli/mcp_security.py",
"hermes_cli/memory_setup.py",
"hermes_cli/model_catalog.py",
"hermes_cli/plugin_capabilities.py",
"hermes_cli/plugin_catalog.py",
"hermes_cli/plugin_compat.py",
"hermes_cli/plugins.py",
"hermes_cli/plugins_admission.py",
"hermes_cli/plugins_cmd.py",
"hermes_cli/plugins_cmd_catalog.py",
"hermes_cli/plugins_discovery.py",
@@ -579,6 +584,7 @@
"hermes_cli/runtime_paths.py",
"hermes_cli/runtime_state.py",
"hermes_cli/secret_prompt.py",
"hermes_cli/service_manager.py",
"hermes_cli/setup.py",
"hermes_cli/sizefmt.py",
"hermes_cli/source_releases.py",
@@ -593,11 +599,11 @@
"hermes_cli/update_cmd.py",
"hermes_cli/update_cmd_common.py",
"hermes_cli/update_cmd_config.py",
"hermes_cli/update_cmd_deps.py",
"hermes_cli/update_cmd_fleet.py",
"hermes_cli/update_cmd_git.py",
"hermes_cli/update_cmd_maint.py",
"hermes_cli/update_cmd_stash.py",
"hermes_cli/update_cmd_validation.py",
"hermes_cli/update_cmd_windows.py",
"hermes_cli/update_cmd_zip.py",
"hermes_cli/update_contract.py",
@@ -623,6 +629,7 @@
"pm/environment.py",
"pm/extras.py",
"pm/features.py",
"pm/filesystem.py",
"pm/lock.py",
"pm/network.py",
"pm/operations.py",
@@ -638,9 +645,6 @@
"pm/store.py",
"pm/update.py",
"pm/workspace.py",
"tools/browser_tool_install.py",
"tools/browser_tool_lifecycle.py",
"tools/browser_tool_origin.py",
"tools/computer_use/cua_backend.py",
"tools/computer_use/cua_backend_driver.py",
"tools/env_passthrough.py",
@@ -652,13 +656,14 @@
"tools/skills_guard.py",
"tools/skills_sync.py",
"tools/skills_sync_optional.py",
"tools/terminal_scope.py",
"utils.py"
],
"commits_with_audited_changes": 1,
"revisions_read": 170,
"distinct_file_versions": 170,
"analysis_passes": 218,
"versions_prepared": 170,
"revisions_read": 172,
"distinct_file_versions": 172,
"analysis_passes": 216,
"versions_prepared": 172,
"mode": "tree"
}
},
@@ -694,18 +699,27 @@
"agent.redact::redact_sensitive_text",
"agent.retry_utils::jittered_backoff",
"agent.secret_scope::_is_global_env",
"agent.secret_scope::build_profile_secret_scope",
"agent.secret_scope::current_secret_scope",
"agent.secret_scope::get_secret",
"agent.secret_scope::is_multiplex_active",
"agent.secret_scope::load_env_file",
"agent.secret_scope::reset_secret_scope",
"agent.secret_scope::set_multiplex_active",
"agent.secret_scope::set_secret_scope",
"agent.secret_sources._cache::resolve_cache_home",
"agent.skill_utils::_NAMESPACE_RE",
"agent.skill_utils::_external_dirs_cache_clear",
"agent.skill_utils::get_external_skills_dirs",
"agent.ssl_verify::install_truststore",
"gateway.config::_env_multiplex_profiles_override",
"gateway.config::load_gateway_config",
"gateway.config_env::_apply_env_overrides",
"gateway.cwd_placeholder::resolve_placeholder_terminal_cwd",
"gateway.platform_registry::platform_registry",
"gateway.platforms._shared::profile_scoped",
"gateway.profile_routing::parse_profile_routes",
"gateway.run::_profile_runtime_scope",
"gateway.session_context::get_session_env",
"gateway.shutdown_forensics::parse_systemd_duration_to_us",
"gateway.status::_get_process_start_time",
@@ -715,6 +729,7 @@
"gateway.status::get_process_start_time",
"gateway.status::get_running_pid",
"gateway.status::get_running_pid_identity_strict",
"gateway.status::get_runtime_status_running_pid",
"gateway.status::looks_like_gateway_command_line",
"gateway.status::looks_like_gateway_runtime_command_line",
"gateway.status::profile_flag_value",
@@ -755,14 +770,19 @@
"hermes_cli.banner::_github_compare_behind",
"hermes_cli.banner::check_via_pypi",
"hermes_cli.build_info::get_code_identity",
"hermes_cli.config::TERMINAL_CONFIG_ENV_MAP",
"hermes_cli.config::_EXTRA_ENV_KEYS",
"hermes_cli.config::_ensure_default_soul_md",
"hermes_cli.config::_greedy_literal_match",
"hermes_cli.config::_parse_env_value",
"hermes_cli.config::_secure_dir",
"hermes_cli.config::_set_nested",
"hermes_cli.config::_split_key_path",
"hermes_cli.config::_terminal_env_value",
"hermes_cli.config::_write_user_config",
"hermes_cli.config::check_config_version",
"hermes_cli.config::detect_install_method",
"hermes_cli.config::fast_safe_load",
"hermes_cli.config::format_docker_update_message",
"hermes_cli.config::format_unsupported_install_warning",
"hermes_cli.config::get_config_path",
@@ -784,6 +804,7 @@
"hermes_cli.config::require_readable_config_before_write",
"hermes_cli.config::save_config",
"hermes_cli.config_backups::backup_config",
"hermes_cli.config_defaults::DEFAULT_CONFIG",
"hermes_cli.config_defaults::OPTIONAL_ENV_VARS",
"hermes_cli.config_home::initialize_home",
"hermes_cli.config_migrations::SUPPORT_FLOOR_VERSION",
@@ -791,6 +812,7 @@
"hermes_cli.config_migrations::support_floor_message",
"hermes_cli.curses_ui::curses_radiolist",
"hermes_cli.dashboard_procs::_scan_dashboard_processes",
"hermes_cli.env_loader::hydrate_profile_secret_sources",
"hermes_cli.fs_utils::rmtree_force",
"hermes_cli.gateway::GATEWAY_LOOP_WEDGED",
"hermes_cli.gateway::PROJECT_ROOT",
@@ -819,6 +841,7 @@
"hermes_cli.gateway::get_python_path",
"hermes_cli.gateway::has_legacy_hermes_units",
"hermes_cli.gateway::is_macos",
"hermes_cli.gateway::is_windows",
"hermes_cli.gateway::kill_gateway_processes",
"hermes_cli.gateway::launch_detached_gateway_restart_by_cmdline",
"hermes_cli.gateway::launch_detached_profile_gateway_restart",
@@ -827,10 +850,13 @@
"hermes_cli.gateway::probe_gateway_loop_liveness",
"hermes_cli.gateway::supports_systemd_services",
"hermes_cli.gateway::wait_for_launchd_gateway_supervision",
"hermes_cli.gateway_migrate::maybe_auto_migrate_after_update",
"hermes_cli.gateway_multiplex_served::recorded_served_profiles",
"hermes_cli.git_credentials::with_git_auth",
"hermes_cli.gitlock::clear_stale_git_locks",
"hermes_cli.gitlock::clear_stale_tmp_packs",
"hermes_cli.gitlock::prune_stale_shallow_grafts",
"hermes_cli.gitlock::repair_broken_shallow_boundaries",
"hermes_cli.image_provenance::read_image_provenance",
"hermes_cli.main::PROJECT_ROOT",
"hermes_cli.main::ShimQuarantineError",
@@ -920,15 +946,18 @@
"hermes_cli.profiles::_PROFILE_ID_RE",
"hermes_cli.profiles::_get_default_hermes_home",
"hermes_cli.profiles::_get_profiles_root",
"hermes_cli.profiles::_stop_gateway_process",
"hermes_cli.profiles::backfill_profile_envs",
"hermes_cli.profiles::get_active_profile_name",
"hermes_cli.profiles::list_profiles",
"hermes_cli.profiles::normalize_profile_name",
"hermes_cli.profiles::profiles_to_serve",
"hermes_cli.profiles::seed_profile_skills",
"hermes_cli.profiles::validate_profile_name",
"hermes_cli.psutil_android::PSUTIL_URL",
"hermes_cli.psutil_android::prepare_patched_psutil_sdist",
"hermes_cli.pt_input_extras::install_modify_other_keys_aliases",
"hermes_cli.runtime_paths::activation_environment",
"hermes_cli.runtime_paths::dependency_home_root",
"hermes_cli.runtime_paths::install_state_dir",
"hermes_cli.runtime_paths::runtime_facts_path",
@@ -942,6 +971,7 @@
"hermes_cli.runtime_state::collect_generations",
"hermes_cli.runtime_state::recover_publication",
"hermes_cli.runtime_state::runtime_lock",
"hermes_cli.service_manager::detect_service_manager",
"hermes_cli.setup::prompt_yes_no",
"hermes_cli.sizefmt::format_bytes",
"hermes_cli.source_releases::resolve_source_release",
@@ -1021,9 +1051,14 @@
"hermes_cli.update_cmd::get_default_hermes_root",
"hermes_cli.update_cmd::get_hermes_home",
"hermes_cli.update_cmd_config::",
"hermes_cli.update_cmd_fleet::_write_gateway_update_exit_code",
"hermes_cli.update_cmd_git::_git_run",
"hermes_cli.update_cmd_maint::_prepare_updated_checkout",
"hermes_cli.update_cmd_maint::_print_bundled_skills_sync_report",
"hermes_cli.update_cmd_maint::_print_verified_update_completion",
"hermes_cli.update_cmd_maint::_refresh_dashboard_after_update",
"hermes_cli.update_cmd_maint::_sweep_bytecode_after_update",
"hermes_cli.update_cmd_maint::_update_complete_message",
"hermes_cli.update_contract::COMMIT_BUILD_UPDATE_MESSAGE",
"hermes_cli.update_contract::evaluate_update_admission",
"hermes_cli.update_contract::is_commit_build",
@@ -1053,6 +1088,7 @@
"hermes_cli::__version__",
"hermes_cli::_early_recovery",
"hermes_cli::_subprocess_compat",
"hermes_cli::gateway",
"hermes_cli::gateway_windows",
"hermes_cli::main",
"hermes_cli::main_dashboard",
@@ -1083,6 +1119,7 @@
"hermes_constants::project_venv_dir",
"hermes_constants::reset_hermes_home_override",
"hermes_constants::set_hermes_home_override",
"hermes_constants::sudo_invoker_default_home",
"hermes_constants::venv_bin_dir",
"hermes_constants::venv_python_path",
"hermes_constants::with_hermes_node_path",
@@ -1138,6 +1175,7 @@
"pm.plugins_state::_is_directory",
"pm.plugins_state::enabled_plugins_ordered",
"pm.recovery::validate_environment",
"pm.registry::all_packages",
"pm.registry::get_package",
"pm.registry::package_definitions",
"pm.registry::walk",
@@ -1157,6 +1195,7 @@
"pm.workspace::member_sources",
"pm.workspace::members_stamp",
"pm::",
"pm::InstallError",
"pm::build_operations",
"pm::ensure_import",
"pm::operations",
@@ -1181,6 +1220,7 @@
"tools.plugin_guard::should_allow_plugin_install",
"tools.registry::registry",
"tools.skills_sync::sync_skills",
"tools.terminal_scope::install_and_reset_profile_terminal_scope",
"tools::",
"tools::skills_sync",
"utils::",
@@ -1199,7 +1239,6 @@
"agent.delegation_context::scrub_kanban_env",
"agent.outbound_webhooks::re_register_config_hooks",
"agent.secret_scope::UnscopedSecretError",
"agent.secret_scope::load_env_file",
"agent.secret_sources.bitwarden::BitwardenSource",
"agent.secret_sources.bitwarden::apply_bitwarden_secrets",
"agent.secret_sources.command::CommandSource",
@@ -1215,7 +1254,6 @@
"agent.skill_utils::discover_all_skill_config_vars",
"agent.terminal_env_registry::plugin_strip_env_keys",
"agent::curator",
"gateway.config::_env_multiplex_profiles_override",
"gateway.config::_normalize_multiplex_profile_allowlist",
"gateway.control_socket::identify_gateway",
"gateway.control_socket::pause_gateway_for_update",
@@ -1226,7 +1264,7 @@
"gateway.session_context::session_context_engaged",
"gateway.shutdown_watchdog::get_loop_heartbeat_path",
"gateway.shutdown_watchdog::get_loop_tick_socket_path",
"gateway.status::get_runtime_status_running_pid",
"gateway.status::recorded_gateway_home_conflicts",
"gateway.status::remove_pid_file",
"gateway.status::runtime_status_pid_is_live",
"gateway.status::write_planned_stop_marker",
@@ -1248,6 +1286,7 @@
"hermes_cli.config_defaults::",
"hermes_cli.config_migrations::",
"hermes_cli.dashboard_procs::",
"hermes_cli.env_loader::get_secret_source_values",
"hermes_cli.env_loader::load_hermes_dotenv",
"hermes_cli.env_loader::reset_secret_source_cache",
"hermes_cli.gateway::_get_restart_exit_wait_budget",
@@ -1256,11 +1295,9 @@
"hermes_cli.gateway::get_service_name",
"hermes_cli.gateway::get_systemd_linger_status",
"hermes_cli.gateway::is_linux",
"hermes_cli.gateway::is_windows",
"hermes_cli.gateway::named_profile_served_by_running_multiplexer",
"hermes_cli.gateway::refresh_launchd_plist_if_needed",
"hermes_cli.gateway_multiplex_served::live_default_gateway_pid",
"hermes_cli.gateway_multiplex_served::recorded_served_profiles",
"hermes_cli.gateway_windows::windowless_gateway_restart_spec",
"hermes_cli.macos_tcc_anchor::ensure_tcc_anchor",
"hermes_cli.managed_uv::_macos_sign_managed_python",
+2 -4
View File
@@ -5,8 +5,7 @@ Chat tab died with a 502 / "[session ended]". Root cause: the image installs
only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*),
so the actualized node_modules permanently disagrees with the canonical
package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls
through to ``_tui_need_npm_install`` (which returns True forever) and tries a
runtime ``npm install`` that can never converge and races itself across
through to source dependency preparation, racing itself across
concurrent /api/pty connections → ENOTEMPTY.
The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which
@@ -58,12 +57,11 @@ def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> Non
py = (
"import json\n"
"from pathlib import Path\n"
"from hermes_cli.main_tui_launch import _tui_need_npm_install, _find_bundled_tui, _make_tui_argv\n"
"from hermes_cli.main_tui_launch import _make_tui_argv\n"
"ui = Path('/opt/hermes/ui-tui')\n"
"argv, cwd = _make_tui_argv(ui, tui_dev=False)\n"
"out = {\n"
" 'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\n"
" 'need_npm_install': _tui_need_npm_install(ui),\n"
" 'argv': argv,\n"
" 'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\n"
"}\n"
@@ -236,20 +236,15 @@ class TestCmdGuiOnABundle:
builds.append([str(c) for c in cmd])
return subprocess.CompletedProcess(cmd, 0)
def record_npm_install(npm, root, **kw):
builds.append(["npm", "ci", str(root)])
return subprocess.CompletedProcess(["npm", "ci"], 0)
def record_popen(argv, **kw):
launches.append([str(a) for a in argv])
return SimpleNamespace(pid=4242)
from hermes_cli import main_desktop, main_install_repair, main_web_build
from hermes_cli import main_desktop, source_build
monkeypatch.setattr(cli_main, "PROJECT_ROOT", repo)
monkeypatch.setattr(main_install_repair, "_resolve_node_runtime_npm", lambda: "/usr/bin/npm")
monkeypatch.setattr(source_build, "source_build_env", lambda env: dict(env))
monkeypatch.setattr(main_desktop, "_desktop_build_needed", lambda *a, **k: True)
monkeypatch.setattr(main_desktop, "_write_desktop_build_stamp", lambda *a, **k: None)
monkeypatch.setattr(main_web_build, "_run_npm_install_deterministic", record_npm_install)
monkeypatch.setattr(main_desktop, "_stop_desktop_processes_locking_build", lambda *a, **k: [])
monkeypatch.setattr(main_desktop, "_desktop_linux_sandbox_fixup", lambda *a, **k: launcher_ok)
monkeypatch.setattr(main_desktop, "_desktop_linux_needs_no_sandbox", lambda: not launcher_ok)
@@ -18,7 +18,6 @@ from __future__ import annotations
from pathlib import Path
import hermes_cli.main as main_mod
from hermes_cli import update_cmd
from hermes_cli import _early_recovery as er
CHECKOUT_ROOT = Path(er.__file__).resolve().parent.parent
@@ -40,36 +39,6 @@ class TestPredicate:
assert main_mod._pytest_owns_live_checkout(CHECKOUT_ROOT) is False
class TestMarkerWrites:
def test_refuses_breadcrumb_at_live_repo_root(self):
target = CHECKOUT_ROOT / ".lazy-refresh-incomplete"
# The marker may legitimately pre-exist: upstream currently TRACKS a
# littered copy in git (the exact pollution this guard prevents), so
# the contract is content-unchanged, not never-exists.
before = target.read_text(encoding="utf-8") if target.exists() else None
try:
update_cmd._write_marker_file(target, label="lazy-refresh-incomplete")
after = (
target.read_text(encoding="utf-8") if target.exists() else None
)
assert after == before, (
"marker breadcrumb written into the LIVE checkout from a test"
)
finally:
# If the guard is broken (RED state), restore the pre-test state —
# leaving pollution behind is exactly the bug being pinned.
if before is None:
target.unlink(missing_ok=True)
else:
target.write_text(before, encoding="utf-8")
def test_still_writes_sandboxed(self, tmp_path):
target = tmp_path / ".lazy-refresh-incomplete"
update_cmd._write_marker_file(target, label="lazy-refresh-incomplete")
assert target.exists()
assert "pid=" in target.read_text(encoding="utf-8")
class TestEarlyRecovery:
def test_skips_live_checkout_before_any_probe_or_lock(self, monkeypatch):
# A probe call would mean recovery is proceeding against the live
+33 -605
View File
@@ -1,16 +1,12 @@
"""Tests for cmd_update — branch fallback when remote branch doesn't exist."""
import hashlib
import os
import subprocess
from types import SimpleNamespace
from unittest.mock import ANY, patch
from unittest.mock import patch
import pytest
from hermes_cli.main import cmd_update, PROJECT_ROOT
from hermes_cli import main_web_build
from hermes_cli import main_install_repair
from hermes_cli.main import cmd_update
from hermes_cli import update_cmd
@@ -22,6 +18,12 @@ def _isolate_venv_holders(monkeypatch):
monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: [])
@pytest.fixture(autouse=True)
def _isolate_product_preparation(monkeypatch):
"""These tests exercise update orchestration, not PM installs or npm builds."""
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **k: None)
def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"):
"""Build a side_effect function for subprocess.run that simulates git commands."""
@@ -56,85 +58,6 @@ pytestmark = pytest.mark.usefixtures(
"isolated_update_processes", "isolated_update_checkout",
)
class TestCmdUpdateNpmLockfileCache:
@staticmethod
def _cache_file(hermes_root, project_root):
cache_key = hashlib.sha256(str(project_root).encode()).hexdigest()[:12]
return hermes_root / f".npm_lock_hash_{cache_key}"
def test_record_npm_lockfile_hash(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
update_cmd._record_npm_lockfile_hash(tmp_path)
assert (
self._cache_file(tmp_path, tmp_path).read_text()
== update_cmd._npm_manifests_digest()
)
def test_package_json_only_edit_defeats_skip(self, tmp_path, monkeypatch):
"""Reviewer scenario (#61580): dev edits package.json WITHOUT running
npm — lockfile unchanged. `hermes update` must still install (the
npm-install fallback is what syncs node_modules in that state)."""
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "package.json").write_text('{"dependencies": {}}')
(tmp_path / "node_modules").mkdir()
update_cmd._record_npm_lockfile_hash(tmp_path)
assert hm._npm_lockfile_changed(tmp_path) is False
(tmp_path / "package.json").write_text(
'{"dependencies": {"left-pad": "^1.0.0"}}'
)
assert hm._npm_lockfile_changed(tmp_path) is True
def test_update_uses_one_shared_npm_cache_across_profiles(
self, tmp_path, monkeypatch
):
"""The npm cache describes checkout-global node_modules, not a profile."""
from hermes_cli import main as hm
import hermes_constants
checkout = tmp_path / "checkout"
checkout.mkdir()
(checkout / "package.json").write_text("{}")
shared_root = tmp_path / ".hermes"
named_profile = shared_root / "profiles" / "work"
named_profile.mkdir(parents=True)
monkeypatch.setattr(hm, "PROJECT_ROOT", checkout)
monkeypatch.setattr(hermes_constants.Path, "home", lambda: tmp_path)
monkeypatch.setattr(
hermes_constants, "find_node_executable", lambda _name: "/usr/bin/npm"
)
cache_roots = []
with patch.object(
hm,
"_npm_lockfile_changed",
side_effect=lambda root: cache_roots.append(root) or False,
):
monkeypatch.setenv("HERMES_HOME", str(shared_root))
update_cmd._update_node_dependencies()
monkeypatch.setenv("HERMES_HOME", str(named_profile))
update_cmd._update_node_dependencies()
assert cache_roots == [shared_root, shared_root]
class TestCmdUpdateBranchFallback:
"""cmd_update falls back to main when current branch has no remote counterpart."""
@@ -164,14 +87,6 @@ class TestCmdUpdateBranchFallback:
"_get_origin_url",
return_value="https://github.com/example/hermes-agent.git",
), patch.object(hm, "_sync_with_upstream_if_needed") as sync_mock, patch.object(
update_cmd,
"_venv_core_imports_healthy",
return_value=(True, ""),
), patch.object(
update_cmd, "_update_node_dependencies", return_value=[]
), patch.object(
update_cmd, "_rebuild_desktop_after_update", return_value=True
), patch.object(
update_cmd, "_check_and_apply_config_migration"
):
cmd_update(mock_args)
@@ -218,12 +133,7 @@ class TestCmdUpdateBranchFallback:
update_cmd, "_add_upstream_remote"
) as add_remote, patch.object(
update_cmd, "_mark_skip_upstream_prompt"
) as mark_skip, patch.object(
# PM-era node resolution lives on the purge-protected hub (it must
# survive _reload_updated_runtime_modules); stub it with the same
# "no npm" fact shutil.which(None) stubs.
hm, "_resolve_node_runtime_npm", return_value=None
), patch("builtins.input") as stdin_input:
) as mark_skip, patch("builtins.input") as stdin_input:
cmd_update(SimpleNamespace(yes=True))
stdin_input.assert_not_called()
@@ -234,25 +144,15 @@ class TestCmdUpdateBranchFallback:
assert "official repo not checked" in captured.out
assert "Already up to date!" not in captured.out
@pytest.mark.parametrize(
("health_after_repair", "runtime_status", "expected_runtime_checks"),
[
(True, (False, SimpleNamespace(sqlite_version_string="3.46.1")), 1),
(False, (True, None), 0),
],
)
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_current_checkout_python_repair_failure_is_durable(
def test_current_checkout_runtime_verification_failure_is_durable(
self,
mock_run,
_mock_which,
mock_args,
health_after_repair,
runtime_status,
expected_runtime_checks,
):
"""Python repair must not bypass runtime and durable outcome checks."""
"""Prepared products must still pass runtime and durable outcome checks."""
from hermes_cli import main as hm
from hermes_cli import update_cmd
@@ -266,22 +166,9 @@ class TestCmdUpdateBranchFallback:
"_get_origin_url",
return_value="https://github.com/example/hermes-agent.git",
), patch.object(hm, "_sync_with_upstream_if_needed"), patch.object(
update_cmd,
"_venv_core_imports_healthy",
side_effect=[
(False, "broken before repair"),
(health_after_repair, "broken after repair"),
],
), patch("pm.sync_venv"), patch.object(
update_cmd, "_rebuild_desktop_after_update", return_value=True
), patch.object(
update_cmd, "_write_update_incomplete_marker"
), patch.object(
hm, "_clear_update_incomplete_marker"
), patch.object(
update_cmd,
"_post_update_sqlite_runtime_status",
return_value=runtime_status,
return_value=(False, SimpleNamespace(sqlite_version_string="3.46.1")),
) as runtime_check, patch.object(
update_cmd, "_write_gateway_update_exit_code"
) as write_gateway_exit, patch(
@@ -293,47 +180,12 @@ class TestCmdUpdateBranchFallback:
cmd_update(mock_args)
assert exit_info.value.code == 1
assert runtime_check.call_count == expected_runtime_checks
runtime_check.assert_called_once_with()
write_gateway_exit.assert_called_once_with(False)
finalize_receipt.assert_called_once_with("partial")
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_current_checkout_node_repair_verification_failure_is_durable(
self, mock_run, _mock_which, mock_args
):
"""A failed Node-path runtime check must fail durable outcomes."""
from hermes_cli import main as hm
from hermes_cli import update_cmd
mock_args.gateway = True
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)
with patch.object(
hm,
"_get_origin_url",
return_value="https://github.com/example/hermes-agent.git",
), patch.object(hm, "_sync_with_upstream_if_needed"), patch.object(
update_cmd,
"_repair_node_deps_on_current_checkout",
return_value=False,
), patch.object(
update_cmd, "_write_gateway_update_exit_code"
) as write_gateway_exit, patch(
"hermes_cli.update_receipt.finalize_update_receipt"
) as finalize_receipt, patch(
"hermes_cli.update_receipt.finalize_pending_update_receipt"
):
with pytest.raises(SystemExit) as exit_info:
cmd_update(mock_args)
assert exit_info.value.code == 1
write_gateway_exit.assert_called_once_with(False)
finalize_receipt.assert_called_once_with("partial")
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_fork_upstream_sync_that_moves_head_runs_post_update_steps(
self, mock_run, _mock_which, mock_args, capsys
):
@@ -360,53 +212,27 @@ class TestCmdUpdateBranchFallback:
update_cmd,
"_capture_head_sha",
side_effect=lambda *_args, **_kwargs: next(shas, "ccccccc"),
), patch(
# The full post-update path runs the fleet version check, which
# reads the REAL machine's profile gateway_state.json files —
# live gateways on a dev box read as STALE vs this checkout and
# exit 1. Pin an empty fleet: this test asserts the post-update
# path RUNS, not the fleet's health.
"hermes_cli.update_receipt.collect_fleet_versions",
return_value=[],
), patch(
# Same isolation for the restart phase: without these, the real
# machine's live gateways enter the restart discovery, the
# mocked-subprocess restart phase can't verify replacements, and
# the fail-closed contract (#78574) exits 1 (locally the
# live-system guard blocks the os.kill outright).
"hermes_cli.gateway.find_gateway_pids",
return_value=[],
), patch(
"hermes_cli.gateway.find_profile_gateway_processes",
return_value=[],
), patch(
"hermes_cli.gateway._get_service_pids",
return_value=set(),
), patch.object(
hm, "_sync_with_upstream_if_needed"
), patch.object(
hm,
"_reload_updated_runtime_modules",
# Reaching the reload step IS the proof the post-update path ran
# (the bug returned from "Already up to date!" before it). Abort
# the pipeline right here: everything past this point (skills
# sync, desktop rebuild, gateway restart, fleet check) would run
# for real against the host machine.
update_cmd,
"_run_post_update_maintenance",
# Unlike product preparation, this phase only runs after a pull.
# Stop before skills sync and fleet restart; the regression took
# the current-checkout path instead and never reached this phase.
side_effect=SystemExit(0),
) as post_update_step:
with pytest.raises(SystemExit) as exit_info:
cmd_update(mock_args)
assert exit_info.value.code == 0
post_update_step.assert_called_once_with()
post_update_step.assert_called_once()
captured = capsys.readouterr()
assert "Already up to date!" not in captured.out
def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
with patch("shutil.which", return_value=None), patch(
"hermes_cli.main._resolve_node_runtime_npm", return_value=None
), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=["MISSING_KEY"]
@@ -451,8 +277,6 @@ class TestCmdUpdateMigrationPrompt:
):
"""Only the version moved → apply non-interactively, never prompt."""
with patch("shutil.which", return_value=None), patch(
"hermes_cli.main._resolve_node_runtime_npm", return_value=None
), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
@@ -492,8 +316,6 @@ class TestCmdUpdateMigrationPrompt:
warnings must be re-surfaced even in the silent branch.
"""
with patch("shutil.which", return_value=None), patch(
"hermes_cli.main._resolve_node_runtime_npm", return_value=None
), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
@@ -536,8 +358,6 @@ class TestCmdUpdateMigrationPrompt:
{"key": "display.new_widget", "description": "New config option: display.new_widget"},
]
with patch("shutil.which", return_value=None), patch(
"hermes_cli.main._resolve_node_runtime_npm", return_value=None
), patch(
"subprocess.run"
) as mock_run, patch("builtins.input", return_value="n"), patch(
"hermes_cli.config.get_missing_env_vars", return_value=env_items
@@ -632,7 +452,6 @@ class TestCmdUpdateProfileSkillSync:
patch("hermes_cli.profiles.list_profiles", return_value=all_profiles),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
patch("hermes_cli.main._resolve_node_runtime_npm", return_value=None),
):
cmd_update(mock_args)
@@ -667,7 +486,6 @@ class TestCmdUpdateProfileSkillSync:
patch("hermes_cli.profiles.list_profiles", return_value=[default_p]),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
patch("hermes_cli.main._resolve_node_runtime_npm", return_value=None),
):
cmd_update(mock_args)
@@ -725,10 +543,7 @@ class TestCmdUpdateBranchFlag:
)
args = SimpleNamespace(branch="bb/gui")
with patch(
"hermes_cli.main._resolve_node_runtime_npm", return_value=None
):
cmd_update(args)
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
@@ -915,130 +730,18 @@ class TestCmdUpdateZipBranchRefusal:
assert "Downloading latest version" not in out
class TestNodeRuntimeNpmResolution:
"""Regression tests for #30271 — WSL must not run Windows npm against the
Linux checkout, and a failed Node refresh must not report success."""
def test_node_failure_returns_failed_labels_and_warns(
self, tmp_path, monkeypatch, capsys
):
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_resolve_node_runtime_npm", lambda: "/usr/bin/npm")
monkeypatch.setattr(
hm,
"_run_npm_install_deterministic",
lambda *a, **k: subprocess.CompletedProcess([], 1, stdout="", stderr=""),
)
with patch(
"tools.browser_tool_install.warm_agent_browser_npx_cache", return_value=True
):
failed = update_cmd._update_node_dependencies()
assert failed == ["ui-tui, web workspaces"]
out = capsys.readouterr().out
assert "mixed state" in out
def test_wsl_update_skips_windows_npm_build_paths(self, mock_args, monkeypatch):
"""A Windows-only npm on WSL must not reach web or desktop builds."""
from hermes_cli import main as hm
import hermes_constants
windows_npm = "/mnt/c/Program Files/nodejs/npm"
monkeypatch.setattr(hm, "_is_windows", lambda: False)
monkeypatch.setattr(hermes_constants, "is_wsl", lambda: True)
monkeypatch.setattr(
hermes_constants,
"find_node_executable",
lambda command: windows_npm if command == "npm" else None,
)
monkeypatch.setattr(
hm.shutil,
"which",
lambda command, path=None: windows_npm if command == "npm" else "/usr/bin/uv",
)
monkeypatch.setenv("PATH", "/mnt/c/Program Files/nodejs")
with patch("subprocess.run") as mock_run, \
patch.object(main_web_build, "_web_ui_build_needed", return_value=True), \
patch.object(hm, "_desktop_packaged_executable", return_value=None), \
patch.object(hm, "_desktop_dist_exists", return_value=True), \
patch.object(hm, "_run_npm_install_deterministic") as mock_npm_install, \
patch.object(main_web_build, "_run_with_idle_timeout") as mock_idle_build, \
patch.object(hm, "_run_logged_subprocess") as mock_desktop_build:
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
mock_npm_install.assert_not_called()
mock_idle_build.assert_not_called()
mock_desktop_build.assert_not_called()
assert all(
not call.args or not call.args[0] or call.args[0][0] != windows_npm
for call in mock_run.call_args_list
)
def test_update_rebuilds_desktop_that_disappears_mid_update(self):
"""A previously packaged Desktop must be rebuilt when its release tree vanishes."""
from hermes_cli import main as hm
from hermes_cli import update_cmd
# Resolved live: the module autouse fixture pins PROJECT_ROOT to
# tmp_path, so the imported constant would be stale here.
desktop_dir = hm.PROJECT_ROOT / "apps" / "desktop"
# The rebuild gate requires a desktop workspace with a package.json;
# under the fixture's tmp PROJECT_ROOT nothing exists on disk.
desktop_dir.mkdir(parents=True, exist_ok=True)
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
packaged_exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
build_ok = subprocess.CompletedProcess([], 0, stdout="", stderr="")
with (
patch.object(
hm, "_desktop_packaged_executable", side_effect=[packaged_exe, None]
) as packaged,
patch.object(hm, "_desktop_dist_exists", return_value=False),
patch.object(hm, "_resolve_node_runtime_npm", return_value="npm.cmd"),
patch.object(hm, "_desktop_build_needed", return_value=True),
patch.object(hm, "_run_logged_subprocess", return_value=build_ok) as desktop_build,
):
had_desktop_app_before_update = update_cmd._desktop_app_present(desktop_dir)
assert not update_cmd._desktop_app_present(desktop_dir)
update_cmd._rebuild_desktop_after_update(
desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update,
)
assert packaged.call_count == 2
desktop_build.assert_called_once_with(
[hm.sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
cwd=hm.PROJECT_ROOT,
env=ANY,
)
def test_git_failure_zip_fallback_rebuilds_missing_desktop(self, tmp_path, monkeypatch):
class TestZipDesktopPreservation:
def test_git_failure_zip_fallback_preserves_desktop(self, tmp_path, monkeypatch):
"""The Windows ZIP fallback keeps Desktop intact when replacing ``apps/``.
Contract updated for the #70337/#87331 release-dir graft: the built
desktop app (release/win-unpacked/Hermes.exe) is preserved THROUGH
the swap — previously this test pinned the old repair shape (exe
deleted by the swap, then rebuilt from scratch). The rebuild hook
still runs (mocked _desktop_build_needed=True), but it now finds
the packaged exe alive rather than missing.
The built app survives the source swap and preparation retains the
pre-update desktop selection (#70337/#87331).
"""
import zipfile
from hermes_cli import main as hm
from hermes_cli import update_cmd
from hermes_cli import update_cmd_zip
from hermes_cli import update_cmd_maint, update_cmd_zip
project_root = tmp_path / "hermes-agent"
(project_root / ".git").mkdir(parents=True)
@@ -1056,11 +759,10 @@ class TestNodeRuntimeNpmResolution:
raise subprocess.CalledProcessError(1, command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
desktop_builds = []
preparations = []
def rebuild_desktop(*_args, **_kwargs):
desktop_builds.append(not packaged_exe.exists())
return subprocess.CompletedProcess([], 0, stdout="", stderr="")
def prepare_checkout(root, *, desktop):
preparations.append((root, desktop, packaged_exe.read_bytes()))
monkeypatch.setattr(hm, "PROJECT_ROOT", project_root)
monkeypatch.setattr(hm, "_is_windows", lambda: True)
@@ -1073,14 +775,11 @@ class TestNodeRuntimeNpmResolution:
lambda _desktop_dir: packaged_exe if packaged_exe.exists() else None,
)
monkeypatch.setattr(hm, "_desktop_dist_exists", lambda _desktop_dir: False)
monkeypatch.setattr(hm, "_resolve_node_runtime_npm", lambda: "npm.cmd")
monkeypatch.setattr(hm, "_desktop_build_needed", lambda *_args, **_kwargs: True)
monkeypatch.setattr(hm, "_run_logged_subprocess", rebuild_desktop)
monkeypatch.setattr(update_cmd_maint, "_prepare_updated_checkout", prepare_checkout)
monkeypatch.setattr(hm, "_clear_bytecode_cache", lambda *_args: 0)
monkeypatch.setattr(hm, "_record_bytecode_fingerprint", lambda: None)
monkeypatch.setattr(hm, "_refresh_bootstrap_cache_scripts", lambda _branch: None)
monkeypatch.setattr(hm, "_refresh_active_memory_provider_dependencies", lambda: None)
monkeypatch.setattr(hm, "_build_web_ui", lambda *_args: None)
monkeypatch.setattr(update_cmd, "_discard_lockfile_churn", lambda *_args: None)
monkeypatch.setattr(update_cmd, "_normalize_managed_eol", lambda *_args: None)
monkeypatch.setattr(
@@ -1088,10 +787,10 @@ class TestNodeRuntimeNpmResolution:
"_validate_critical_modules_import",
lambda *_args: (True, None, None),
)
monkeypatch.setattr(update_cmd, "_update_node_dependencies", lambda: [])
monkeypatch.setattr(update_cmd, "_print_curator_first_run_notice", lambda: None)
monkeypatch.setattr(update_cmd, "_print_curator_recent_run_notice", lambda: None)
monkeypatch.setattr(update_cmd, "_finish_dashboard_update_cleanup", lambda _failures: None)
monkeypatch.setattr("hermes_cli.update_cmd_maint._refresh_dashboard_after_update", lambda: None)
monkeypatch.setattr(update_cmd, "get_hermes_home", lambda: tmp_path / "hermes-home")
with (
@@ -1121,282 +820,11 @@ class TestNodeRuntimeNpmResolution:
gateway_mode=False,
)
# Release-dir graft (#70337): the packaged exe SURVIVES the swap, so
# the rebuild hook observed it present (False), and the bytes are the
# original build — never deleted, never rebuilt from nothing.
assert desktop_builds == [False]
assert preparations == [(project_root, True, b"desktop")]
assert packaged_exe.exists()
assert packaged_exe.read_bytes() == b"desktop"
class TestUpdateNodeDependencies:
"""Unit tests for _update_node_dependencies — issue #43564.
Root package.json has no dependencies of its own: agent-browser
resolves at runtime via npx (tools/browser_tool.py), and @streamdown/math
moved to apps/desktop/package.json since it's a desktop-only import.
With nothing root-only left to protect, a single workspace-scoped
install (ui-tui, web) is safe — apps/desktop is simply never named, so
its ~200 MB Electron devDependency is never resolved. Skipping is
governed by _npm_lockfile_changed (content hash over the lockfile +
every workspace package.json), tested separately in
TestNpmLockfileChanged.
Uses a tmp_path root so tests never touch real node_modules.
"""
@pytest.fixture(autouse=True)
def _stub_npx_warmup(self):
"""The npx cache warm-up is covered by its own dedicated test below;
stub it out everywhere else so it doesn't add a spurious npm/npx
call to the workspace-install assertions in this class."""
with patch("tools.browser_tool_install.warm_agent_browser_npx_cache", return_value=True):
yield
def _npm_calls(self, mock_run):
return [
call.args[0]
for call in mock_run.call_args_list
if call.args and "npm" in str(call.args[0][0])
]
def _make_popen(self, calls, returncode=0, stderr_lines=()):
"""Fake subprocess.Popen recording each invocation's cmd/kwargs.
_update_node_dependencies always runs npm with capture_output=False,
which routes through the Popen-based stderr-teeing path in
_run_npm_watching_for_engine_failure rather than subprocess.run.
"""
class _FakeProc:
def __init__(self, cmd, **kwargs):
calls.append({"cmd": cmd, "kwargs": kwargs})
self.stderr = iter(stderr_lines)
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def wait(self):
return returncode
return _FakeProc
def _popen_npm_calls(self, calls):
return [c["cmd"] for c in calls if c["cmd"] and "npm" in str(c["cmd"][0])]
@patch("subprocess.Popen")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_install_names_ui_tui_and_web_workspaces(self, _which, mock_popen, tmp_path, monkeypatch):
"""Regression for #43564: install ui-tui + web directly. apps/desktop
must never appear, so its Electron postinstall is never triggered.
"""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: True)
popen_calls = []
mock_popen.side_effect = self._make_popen(popen_calls)
update_cmd._update_node_dependencies()
calls = self._popen_npm_calls(popen_calls)
assert len(calls) == 1, f"expected exactly 1 npm call, got: {calls}"
joined = " ".join(str(a) for a in calls[0])
assert "--workspace ui-tui" in joined and "--workspace web" in joined, (
f"expected ui-tui + web workspace selectors; actual: {calls[0]}"
)
assert "desktop" not in joined, (
f"apps/desktop must not appear (avoids ~200 MB Electron download); actual: {calls[0]}"
)
assert "--workspaces=false" not in joined, (
f"no root-only deps remain to protect; --workspaces=false is unnecessary now; actual: {calls[0]}"
)
@patch("subprocess.Popen")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_install_includes_workspace_root_to_protect_root_devdependencies(
self, _which, mock_popen, tmp_path, monkeypatch
):
"""Root package.json still owns devDependencies (the shared ESLint
flat config every workspace's own eslint.config.mjs imports) even
though agent-browser and @streamdown/math were removed from root
`dependencies` (#43564). --include-workspace-root keeps them from
being pruned by this scoped install, while --workspace ui-tui
--workspace web still excludes the unnamed apps/desktop workspace
(confirmed empirically against npm 10.9.8 and 11.9.0 in PR #44772
review)."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: True)
popen_calls = []
mock_popen.side_effect = self._make_popen(popen_calls)
update_cmd._update_node_dependencies()
calls = self._popen_npm_calls(popen_calls)
assert len(calls) == 1
joined = " ".join(str(a) for a in calls[0])
assert "--include-workspace-root" in joined
assert "desktop" not in joined
@patch("subprocess.Popen")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_install_preserves_standard_flags(self, _which, mock_popen, tmp_path, monkeypatch):
"""--no-fund, --no-audit, --progress=false must survive."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: True)
popen_calls = []
mock_popen.side_effect = self._make_popen(popen_calls)
update_cmd._update_node_dependencies()
calls = self._popen_npm_calls(popen_calls)
assert len(calls) == 1
joined = " ".join(str(a) for a in calls[0])
for flag in ("--no-fund", "--no-audit", "--progress=false"):
assert flag in joined, f"{flag} missing from npm call; actual: {calls[0]}"
@patch("subprocess.run")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_skips_install_when_deps_up_to_date(self, _which, mock_run, tmp_path, monkeypatch):
"""When _npm_lockfile_changed reports no change, npm must not be called."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: False)
update_cmd._update_node_dependencies()
assert not self._npm_calls(mock_run), (
"npm must not run when _npm_lockfile_changed reports no change"
)
@patch("subprocess.Popen")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_runs_install_when_lockfile_changed(self, _which, mock_popen, tmp_path, monkeypatch):
"""When _npm_lockfile_changed reports a change, npm must run."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: True)
popen_calls = []
mock_popen.side_effect = self._make_popen(popen_calls)
update_cmd._update_node_dependencies()
calls = self._popen_npm_calls(popen_calls)
assert len(calls) == 1, f"expected npm to run when lockfile changed; got: {calls}"
@patch("subprocess.Popen")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_records_lockfile_hash_only_on_success(self, _which, mock_popen, tmp_path, monkeypatch):
"""A failed install must not record the lockfile hash (so the next
run retries instead of wrongly believing deps are up to date)."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: True)
recorded = []
# _update_node_dependencies lives in update_cmd_deps and calls its own module-level
# _record_npm_lockfile_hash, so that binding is the real seam (update_cmd's is dead).
from hermes_cli import update_cmd_deps
monkeypatch.setattr(update_cmd_deps, "_record_npm_lockfile_hash", lambda root: recorded.append(root))
mock_popen.side_effect = self._make_popen([], returncode=1, stderr_lines=["npm ERR!\n"])
update_cmd._update_node_dependencies()
assert not recorded, "lockfile hash must not be recorded when npm install fails"
@patch("subprocess.Popen")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_warms_npx_agent_browser_cache_regardless_of_install_result(
self, _which, mock_popen, tmp_path, monkeypatch
):
"""The npx warm-up must fire even when the workspace install fails —
it's independent of ui-tui/web dependency state (#43564)."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: True)
mock_popen.side_effect = self._make_popen([], returncode=1, stderr_lines=["npm ERR!\n"])
with patch(
"tools.browser_tool_install.warm_agent_browser_npx_cache", return_value=True
) as mock_warm:
update_cmd._update_node_dependencies()
mock_warm.assert_called_once()
@patch("subprocess.run")
@patch("shutil.which", return_value=None)
def test_returns_silently_when_npm_not_found(self, _which, mock_run, tmp_path, monkeypatch):
"""No npm on PATH → return without calling subprocess."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hm, "_resolve_node_runtime_npm", lambda: None)
update_cmd._update_node_dependencies()
mock_run.assert_not_called()
@patch("subprocess.run")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_returns_silently_when_package_json_absent(self, _which, mock_run, tmp_path, monkeypatch):
"""No package.json → return without calling npm."""
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
update_cmd._update_node_dependencies()
mock_run.assert_not_called()
@patch("subprocess.Popen")
@patch("shutil.which", return_value="/usr/bin/npm")
def test_install_runs_from_project_root(self, _which, mock_popen, tmp_path, monkeypatch):
"""npm install must execute from PROJECT_ROOT, not a workspace subdir."""
from hermes_cli import main as hm
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
popen_calls = []
mock_popen.side_effect = self._make_popen(popen_calls)
update_cmd._update_node_dependencies()
cwd_calls = [
c["kwargs"].get("cwd")
for c in popen_calls
if c["cmd"] and "npm" in str(c["cmd"][0])
]
assert cwd_calls, "expected at least one npm call"
for cwd in cwd_calls:
assert cwd == tmp_path, f"npm must run from PROJECT_ROOT; got cwd={cwd}"
class TestGitTrampolineSelfHeal:
"""Proactive Git-for-Windows trampoline self-heal (#87876).
@@ -0,0 +1,50 @@
"""Desktop launch prepares once, then stages and publishes the local pack."""
from argparse import Namespace
import subprocess
import pytest
from hermes_cli import main, main_desktop
from tests.hermes_cli.test_source_build import source_checkout, source_products, _events # noqa: F401
@pytest.fixture
def desktop_source(source_products, monkeypatch):
root, acquired = source_products
monkeypatch.setattr(main, "PROJECT_ROOT", root)
monkeypatch.setattr(main_desktop, "_desktop_launch_env", lambda args: ({}, []))
monkeypatch.setattr(main_desktop, "_register_linux_desktop_entry", lambda: None)
return root, acquired
@pytest.mark.platforms("linux")
def test_desktop_build_only_prepares_once_and_keeps_fresh_launch_fast(desktop_source):
root, acquired = desktop_source
main_desktop.cmd_gui(Namespace(build_only=True))
app = root / "apps/desktop/release/linux-unpacked/hermes"
assert app.read_text() == "desktop"
assert [event["step"] for event in _events(root)] == ["deps", "desktop"]
assert acquired == ["npm"]
assert (root / "node_modules/ui-tui").exists()
assert (root / "node_modules/web").exists()
assert (root / "node_modules/apps-desktop").exists()
assert not (root / "node_modules/unrelated").exists()
main_desktop.cmd_gui(Namespace(build_only=True))
assert acquired == ["npm"]
assert len(_events(root)) == 2
@pytest.mark.platforms("linux")
def test_failed_pack_exits_without_launching_or_replacing_the_app(desktop_source):
root, acquired = desktop_source
app = root / "apps/desktop/release/linux-unpacked/hermes"
app.parent.mkdir(parents=True)
app.write_text("previous app")
(root / "fail-desktop").touch()
with pytest.raises(SystemExit) as error:
main_desktop.cmd_gui(Namespace(build_only=True))
assert error.value.code != 0
assert app.read_text() == "previous app"
assert acquired == ["npm"]
assert [event["step"] for event in _events(root)] == ["deps", "desktop"]
assert not list((root / "apps/desktop").glob(".staging-*"))
@@ -1,85 +0,0 @@
"""Tests for lazy-backend refresh venv repair (#57828 / #58004)."""
from __future__ import annotations
from types import SimpleNamespace
import hermes_cli.main as m
import hermes_cli.main_install_repair as hermes_cli_main_install_repair
import pytest
def test_refresh_failure_reports_pm_error(monkeypatch, capsys):
import importlib
ensure = importlib.import_module("pm.client")
def fail(*args, **kwargs):
raise RuntimeError("resolution failed")
monkeypatch.setattr(ensure, "sync_venv", fail)
assert m._refresh_active_lazy_features(["matrix"]) is False
assert "resolution failed" in capsys.readouterr().out
def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch):
import importlib
ensure = importlib.import_module("pm.client")
calls = []
monkeypatch.setattr(ensure, "sync_venv", lambda extras, **kwargs: calls.append((extras, kwargs)))
assert m._refresh_active_lazy_features(["telegram"]) is True
assert calls == [(["telegram"], {"explicit": True})]
def test_cmd_update_repairs_before_refreshing_dependency_inputs(tmp_path, monkeypatch):
"""The current-checkout repair must bypass PM's matching-stamp shortcut."""
from hermes_cli import update_cmd
(tmp_path / ".git").mkdir()
snapshot = ["platform.telegram"]
refresh_calls = []
class SyncReached(Exception):
pass
def fake_run(cmd, **kwargs):
if "rev-parse" in cmd:
return SimpleNamespace(returncode=0, stdout="main\n", stderr="")
if "rev-list" in cmd:
return SimpleNamespace(returncode=0, stdout="0\n", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
def fake_sync_raises(extras=None, *, explicit=False, repair=False):
refresh_calls.append((extras, explicit, repair))
raise SyncReached
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(m, "_capture_active_lazy_features", lambda: snapshot.copy())
monkeypatch.setattr(m, "_is_windows", lambda: False)
monkeypatch.setattr(hermes_cli_main_install_repair, "_is_windows", lambda: False)
monkeypatch.setattr(m, "_run_pre_update_backup", lambda args: None)
monkeypatch.setattr(m, "_pause_windows_gateways_for_update", lambda: None)
monkeypatch.setattr(m, "_resume_windows_gateways_after_update", lambda state: None)
monkeypatch.setattr(update_cmd, "_discard_lockfile_churn", lambda *args: None)
monkeypatch.setattr(m, "_get_origin_url", lambda *args: "https://github.com/NousResearch/hermes-agent.git")
monkeypatch.setattr(m, "_resolve_update_branch", lambda args: "main")
monkeypatch.setattr(m, "_stash_local_changes_if_needed", lambda *args: None)
monkeypatch.setattr(update_cmd, "_invalidate_update_cache", lambda: None)
monkeypatch.setattr(
update_cmd, "_venv_core_imports_healthy", lambda: (False, "broken")
)
monkeypatch.setattr(update_cmd, "_write_update_incomplete_marker", lambda: None)
monkeypatch.setattr(m.subprocess, "run", fake_run)
import pm
monkeypatch.setattr(pm, "sync_venv", fake_sync_raises)
args = SimpleNamespace(
yes=True,
force=False,
force_venv=False,
no_backup=True,
backup=False,
branch=None,
)
with pytest.raises(SyncReached):
update_cmd._cmd_update_impl(args, gateway_mode=False)
# Repair must bypass freshness before the normal update can refresh inputs.
assert refresh_calls == [(None, False, True)]
+2 -21
View File
@@ -2,9 +2,8 @@
Covers ``register_child`` (the ledger mirror of ``register_self`` for
subprocesses that never import Hermes code), the live-spawner protection
contract, dead-spawner reap eligibility through BOTH consumers (the updater's
``_ledger_reapable_backend_pids`` rung and the startup
``reap_orphaned_mcp_helpers`` sweep), and prune-on-write of exited children.
contract, dead-spawner reap eligibility through the startup
``reap_orphaned_mcp_helpers`` sweep, and prune-on-write of exited children.
Uses REAL subprocesses (``sleep``) and the real psutil so the
``(pid, create_time)`` identity pair is exercised end-to-end, with the ledger
@@ -177,24 +176,6 @@ def test_reap_ignores_non_mcp_purposes(ledger, child):
assert psutil.pid_exists(child.pid)
# ---------------------------------------------------------------------------
# Updater rung (_ledger_reapable_backend_pids) flow-through
# ---------------------------------------------------------------------------
def test_updater_ledger_rung_flows_mcp_helper(ledger, child):
from hermes_cli import update_cmd
pi.register_child(child.pid, "mcp-helper")
matches = [(child.pid, "python", "sleep 300")]
# Live spawner (this process) → never selected.
assert update_cmd._ledger_reapable_backend_pids(matches) == []
# Provably dead spawner → positively identified as reapable.
_orphan_entry_for(child.pid)
assert update_cmd._ledger_reapable_backend_pids(matches) == [child.pid]
# ---------------------------------------------------------------------------
# Prune-on-write of exited children
# ---------------------------------------------------------------------------
-37
View File
@@ -206,40 +206,3 @@ def test_spawner_is_dead_tristate():
# PID reuse: recorded spawner create differs from live process → dead.
assert pi.spawner_is_dead(_entry(1, 1.0, spawner_pid=500, spawner_create=999.0)) is True
assert pi.spawner_is_dead(_entry(1, 1.0)) is None
# ---------------------------------------------------------------------------
# Updater rung: _ledger_reapable_backend_pids
# ---------------------------------------------------------------------------
def _holders(*pids):
return [(p, "python.exe", f"python.exe -m hermes_cli.main --profile p{p} serve") for p in pids]
def test_updater_reaps_ledger_proven_orphans():
from hermes_cli import main as cli_main
entries = [
_entry(200, 2.0, spawner_pid=700, spawner_create=7.0), # spawner dead → reap
_entry(201, 2.1, spawner_pid=500, spawner_create=5.0), # spawner alive → keep
_entry(202, 2.2, purpose="chat", spawner_pid=700, spawner_create=7.0), # not reapable purpose
]
fake = _fake_psutil({500: 5.0})
with patch.dict(sys.modules, {"psutil": fake}), \
patch.object(pi, "ledger_entries", return_value=entries), \
patch.object(pi, "spawner_is_dead", wraps=pi.spawner_is_dead):
assert cli_main._ledger_reapable_backend_pids(_holders(200, 201, 202, 203)) == [200]
def test_updater_ledger_rung_empty_without_ledger():
from hermes_cli import main as cli_main
with patch.object(pi, "ledger_entries", return_value=[]):
assert cli_main._ledger_reapable_backend_pids(_holders(200)) == []
def test_updater_ledger_rung_never_raises():
from hermes_cli import main as cli_main
with patch.object(pi, "ledger_entries", side_effect=RuntimeError("boom")):
assert cli_main._ledger_reapable_backend_pids(_holders(200)) == []
+19 -33
View File
@@ -1,37 +1,23 @@
"""Coverage for _run_with_idle_timeout — the streaming subprocess helper.
"""Retired build entrypoints stop old in-memory updaters without side effects."""
import sys
Kept in a dedicated test file because the tests spawn real ``subprocess.Popen``
instances; pytest-isolate runs each test file in its own worker process, so
isolating these here prevents real-Popen state from racing with the
``subprocess.run`` / ``_run_with_idle_timeout`` patches used by
``test_web_ui_build.py``.
import pytest
Added for issue #33788: ``hermes update`` got stuck at "webui-build" because
``npm run build`` ran with ``capture_output=True`` and no timeout. The helper
fixes both halves — streams output AND idle-kills the process.
"""
import sys as _sys
import time
from hermes_cli.main_web_build import _run_with_idle_timeout
from hermes_cli.main_web_build import _nixos_build_env, _run_npm_install_deterministic, _run_with_idle_timeout
def test_streams_output_and_returns_zero_on_success(tmp_path):
script = tmp_path / "ok.py"
script.write_text("print('line one'); print('line two')\n")
result = _run_with_idle_timeout(
[_sys.executable, str(script)], cwd=tmp_path, idle_timeout_seconds=10
)
assert result.returncode == 0
assert "line one" in result.stdout
assert "line two" in result.stdout
def test_returns_127_when_binary_missing(tmp_path):
result = _run_with_idle_timeout(
["/nonexistent/binary/does/not/exist"],
cwd=tmp_path,
idle_timeout_seconds=5,
)
assert result.returncode == 127
@pytest.mark.parametrize("helper", ["idle", "install", "nixos"])
def test_retired_build_helper_stops_before_running_any_command(tmp_path, helper, capsys):
marker = tmp_path / "ran"
script = tmp_path / "command.py"
script.write_text(f"from pathlib import Path; Path({str(marker)!r}).touch()")
with pytest.raises(SystemExit) as error:
if helper == "idle":
_run_with_idle_timeout([sys.executable, str(script)], tmp_path, idle_timeout_seconds=10)
elif helper == "install":
_run_npm_install_deterministic(sys.executable, tmp_path, extra_args=(str(script),))
else:
_nixos_build_env()
assert error.value.code == 0
assert not marker.exists()
assert "run `hermes` again" in capsys.readouterr().err
@@ -1,23 +1,11 @@
"""Serve-kind runtime inventory + stop/relaunch rung (#63206, campaign #91277).
A network-bound `hermes serve --host <ip>` powering a remote Desktop used to
be invisible to the update pipeline: not in the inventory, a dead-end at the
venv-holder guard, and never relaunched after `hermes update` killed it. The
fix threads the spawn ledger's structured launch identity (host/port/profile,
registered at serve startup) through inventory → guard rung → relaunch.
"""
"""Serve runtime identity, inventory and dashboard discovery contracts."""
from __future__ import annotations
import sys
from types import SimpleNamespace
from unittest.mock import patch # noqa: F401 - kept for parity with siblings
import hermes_cli.update_cmd as update_cmd
import hermes_cli.update_inventory as update_inventory
from hermes_cli import main as cli_main
import hermes_cli.main_install_repair as main_install_repair
import hermes_cli.main_dashboard as main_dashboard
def _ledger_entry(**over):
@@ -116,79 +104,6 @@ def test_describe_restart_mechanism_respawn_argv():
assert "relaunch" in text
# ---------------------------------------------------------------------------
# update_cmd: guard rung helpers
# ---------------------------------------------------------------------------
def test_ledger_manual_serve_holders_filters_correctly(monkeypatch):
manual = _ledger_entry(pid=100)
desktop_owned = _ledger_entry(pid=200, spawner_pid=999, spawner_create=1.0)
gateway = _ledger_entry(pid=300, purpose="gateway")
not_a_holder = _ledger_entry(pid=400)
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [manual, desktop_owned, gateway, not_a_holder],
spawner_is_dead=lambda e: False if e["pid"] == 200 else None,
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
holders = [(100, "python.exe", "..."), (200, "python.exe", "..."), (300, "python.exe", "...")]
result = update_cmd._ledger_manual_serve_holders(holders)
pids = [e["pid"] for e in result]
assert pids == [100], (
"only the manual serve holder qualifies: desktop-owned keeps the "
"refusal, gateways belong to the pause machinery, non-holders skipped"
)
def test_serve_relaunch_commands_built_from_structured_identity(monkeypatch):
monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: None)
monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: None)
entries = [
_ledger_entry(), # default profile
_ledger_entry(pid=5000, profile="work", port=9200, host=""),
_ledger_entry(pid=6000, port=None), # no port → skipped
_ledger_entry(pid=7000, purpose="dashboard", host="0.0.0.0", port=9300),
]
cmds = update_cmd._serve_relaunch_commands(entries)
assert ["hermes", "serve", "--host", "100.94.65.93", "--port", "9119"] in cmds
assert ["hermes", "--profile", "work", "serve", "--port", "9200"] in cmds
assert ["hermes", "dashboard", "--host", "0.0.0.0", "--port", "9300"] in cmds
assert len(cmds) == 3 # the port-less entry is skipped
def test_relaunch_stopped_serves_is_idempotent(monkeypatch):
calls = []
monkeypatch.setattr(
cli_main, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
monkeypatch.setattr(
main_dashboard, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: None)
monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: None)
token = {"pending": True, "entries": [_ledger_entry()]}
update_cmd._relaunch_stopped_serves(token)
update_cmd._relaunch_stopped_serves(token) # atexit double-fire
assert len(calls) == 1, "relaunch must fire exactly once"
assert token["pending"] is False
def test_relaunch_stopped_serves_untriggered_token_noop(monkeypatch):
calls = []
monkeypatch.setattr(
cli_main, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
monkeypatch.setattr(
main_dashboard, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
update_cmd._relaunch_stopped_serves({"pending": False, "entries": [_ledger_entry()]})
assert calls == []
# ---------------------------------------------------------------------------
# dashboard_procs: ledger augmentation of the scan (#81564 half)
# ---------------------------------------------------------------------------
@@ -130,16 +130,12 @@ def test_cli_entrypoint_registers_and_warns_once_for_live_shared_home(homes, tmp
assert shared_profile_warning(project_root=stable)
# A CLI record must not make a terminal process an update-owned backend.
import hermes_constants
from hermes_cli.update_cmd_windows import _ledger_manual_serve_holders, _ledger_reapable_backend_pids
from hermes_cli.update_inventory import UpdatePlan, _collect_ledger_runtimes
with monkeypatch.context() as patcher:
patcher.setattr(hermes_constants, "PROJECT_ROOT", canary, raising=False)
assert own == process_identity.ledger_entries(verified_only=True)
matches = [(child.pid, "python", own[0]["argv"])]
assert own[0]["purpose"] not in process_identity.REAPABLE_PURPOSES
assert _ledger_manual_serve_holders(matches) == []
assert _ledger_reapable_backend_pids(matches) == []
plan = UpdatePlan()
_collect_ledger_runtimes(plan, set())
assert plan.runtimes == []
+219
View File
@@ -0,0 +1,219 @@
"""Source orchestration uses real node-deps/npm in an isolated checkout.
Only PM's tool acquisition is substituted with the host's node/npm. Small
workspace scripts stand in for the expensive UI compilers; subprocess failures,
locked dependency selection, environment propagation and publication are real.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
import pm
from pm.package import Runner
@pytest.fixture
def source_checkout(tmp_path, monkeypatch):
node, npm = shutil.which("node"), shutil.which("npm")
assert node and npm, "source-build integration requires node and npm"
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools"))
monkeypatch.setenv("npm_config_cache", str(tmp_path / "npm-cache"))
monkeypatch.setenv("ESBUILD_BINARY_PATH", "/wrong/esbuild")
monkeypatch.setenv("HERMES_PYTHON", "/wrong/python")
(home / "npmrc").write_text("fund=false\n", encoding="utf-8")
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
acquired = []
def acquire(name, *, base_env=None, explicit=False):
acquired.append(name)
assert name == "npm"
assert explicit
return Runner(name, {**(base_env or os.environ), "PATH": os.pathsep.join(
[str(Path(node).parent), str(Path(npm).parent), os.environ["PATH"]])})
monkeypatch.setattr(pm, "ensure", acquire)
root = tmp_path / "source with spaces"
root.mkdir()
workspaces = ["ui-tui", "web", "apps/desktop", "unrelated"]
manifest = {"name": "build-fixture", "private": True, "version": "1.0.0",
"workspaces": workspaces, "scripts": {"postinstall": "node log.mjs deps"}}
(root / "package.json").write_text(json.dumps(manifest), encoding="utf-8")
for workspace in workspaces:
directory = root / workspace
directory.mkdir(parents=True)
(directory / "package.json").write_text(json.dumps({
"name": workspace.replace("/", "-"), "version": "1.0.0",
"scripts": {"pack": "node ../../scripts/build/package-desktop.mjs"}
if workspace == "apps/desktop" else {},
}), encoding="utf-8")
(root / "log.mjs").write_text(
"import { appendFileSync } from 'node:fs';\n"
"appendFileSync('events.jsonl', JSON.stringify({step: process.argv[2], "
"python: process.env.HERMES_PYTHON, ci: process.env.CI, "
"esbuild: process.env.ESBUILD_BINARY_PATH, "
"npmrc: process.env.NPM_CONFIG_USERCONFIG}) + '\\n');\n",
encoding="utf-8",
)
subprocess.run([npm, "install", "--package-lock-only", "--ignore-scripts", "--offline",
"--no-audit", "--no-fund"], cwd=root, check=True)
scripts = root / "scripts" / "build"
scripts.mkdir(parents=True)
repository = Path(__file__).resolve().parents[2]
shutil.copy2(repository / "scripts/build/node-deps.mjs", scripts / "node-deps.mjs")
(root / ".gitignore").write_text("node_modules/\n**/dist/\n", encoding="utf-8")
return root, acquired
@pytest.fixture
def source_products(source_checkout):
root, acquired = source_checkout
(root / "product.mjs").write_text(
"import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';\n"
"import { dirname, join } from 'node:path';\n"
"import { fileURLToPath } from 'node:url';\n"
"const root = dirname(fileURLToPath(import.meta.url));\n"
"export function build(step, output) {\n"
" appendFileSync(join(root, 'events.jsonl'), JSON.stringify({step}) + '\\n');\n"
" if (existsSync(join(root, 'fail-' + step))) throw new Error('fixture ' + step + ' failure');\n"
" if (step === 'web' && !existsSync(join(root, 'web/public/favicon.ico'))) throw new Error('icons missing');\n"
" const path = join(root, output); mkdirSync(dirname(path), { recursive: true });\n"
" writeFileSync(path, step);\n"
"}\n",
encoding="utf-8",
)
for script, step, output in [
("generate-icons.mjs", "icons", "web/public/favicon.ico"),
("build/tui.mjs", "tui", "ui-tui/dist/entry.js"),
("build/web.mjs", "web", "hermes_cli/web_dist/index.html"),
]:
relative = "../../" if script.startswith("build/") else "../"
(root / "scripts" / script).write_text(
f"import {{ build }} from '{relative}product.mjs'; build({step!r}, {output!r});\n",
encoding="utf-8",
)
(root / "scripts/build/package-desktop.mjs").write_text(
"import { relative } from 'node:path';\n"
"import { build } from '../../product.mjs';\n"
"const flag = '-c.directories.output=';\n"
"const staging = process.argv.find(arg => arg.startsWith(flag)).slice(flag.length);\n"
"build('desktop', relative('../..', staging) + '/linux-unpacked/hermes');\n",
encoding="utf-8",
)
return root, acquired
def _events(root):
path = root / "events.jsonl"
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
@pytest.mark.platforms("posix")
def test_preparation_reuses_only_the_exact_completed_workspace_union(source_checkout):
from hermes_cli.source_build import prepare_source_dependencies, source_build_env
root, acquired = source_checkout
before = (root / "package-lock.json").read_bytes()
env = source_build_env()
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
first = _events(root)
assert [event["step"] for event in first] == ["deps"]
assert first[0]["python"] == sys.executable
assert first[0]["ci"] == "1"
assert "esbuild" not in first[0]
assert first[0]["npmrc"] == str(Path(os.environ["HERMES_HOME"]) / "npmrc")
assert (root / "node_modules/ui-tui").exists()
assert (root / "node_modules/web").exists()
assert not (root / "node_modules/apps-desktop").exists()
assert not (root / "node_modules/unrelated").exists()
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert _events(root) == first
prepare_source_dependencies(root, ("ui-tui", "web", "apps/desktop"), env=env)
assert len(_events(root)) == 2
assert (root / "node_modules/apps-desktop").exists()
assert not (root / "node_modules/unrelated").exists()
assert (root / "package-lock.json").read_bytes() == before
assert acquired == ["npm"]
# A lock failure must not fall back to npm install and rewrite the lock.
(root / "package-lock.json").write_text("not json", encoding="utf-8")
with pytest.raises(subprocess.CalledProcessError):
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert (root / "package-lock.json").read_text() == "not json"
assert len(_events(root)) == 2
assert acquired == ["npm"]
# Exercise PM rather than hiding a provisioning error behind system npm.
from unittest.mock import patch
with patch.object(pm, "ensure", side_effect=pm.InstallError("npm", "unavailable")):
with pytest.raises(pm.InstallError, match="unavailable"):
source_build_env()
assert len(_events(root)) == 2
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("desktop", [False, True])
def test_update_builds_selected_products_after_one_union_preparation(source_products, desktop):
from hermes_cli.source_build import build_update_products
from hermes_cli.main_web_build import _web_ui_build_needed
root, acquired = source_products
app = root / "apps/desktop/release/linux-unpacked/hermes"
app.parent.mkdir(parents=True)
app.write_text("previous app")
build_update_products(root, desktop=desktop)
steps = [event["step"] for event in _events(root)]
assert steps == ["deps", "tui", "icons", "web"] + (["desktop"] if desktop else [])
assert acquired == ["npm"]
assert (root / "ui-tui/dist/entry.js").read_text() == "tui"
assert (root / "hermes_cli/web_dist/index.html").read_text() == "web"
assert not _web_ui_build_needed(root / "web")
assert (root / "node_modules/apps-desktop").exists() == desktop
assert not (root / "node_modules/unrelated").exists()
assert app.read_text() == ("desktop" if desktop else "previous app")
assert not list((root / "apps/desktop").glob(".staging-*"))
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("step", ["tui", "icons", "web", "desktop"])
def test_update_failure_raises_without_retries_or_replacing_live_app(source_products, step):
from hermes_cli.source_build import build_update_products
root, acquired = source_products
app = root / "apps/desktop/release/linux-unpacked/hermes"
app.parent.mkdir(parents=True)
app.write_text("previous app")
(root / f"fail-{step}").touch()
with pytest.raises(subprocess.CalledProcessError):
build_update_products(root, desktop=True)
assert app.read_text() == "previous app"
assert not list((root / "apps/desktop").glob(".staging-*"))
order = ["deps", "tui", "icons", "web", "desktop"]
assert [event["step"] for event in _events(root)] == order[:order.index(step) + 1]
assert acquired == ["npm"]
assert not (Path(os.environ["HERMES_HOME"]) / "desktop-build-stamp.json").exists()
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("desktop", [False, True])
def test_module_cli_builds_the_requested_products(source_products, desktop, monkeypatch):
import runpy
root, acquired = source_products
monkeypatch.setattr(sys, "argv", ["source_build", "--source", str(root)] + (["--desktop"] if desktop else []))
# run_module exercises __main__ while substituting only tool acquisition.
monkeypatch.delitem(sys.modules, "hermes_cli.source_build", raising=False)
runpy.run_module("hermes_cli.source_build", run_name="__main__")
assert acquired == ["npm"]
assert (root / "hermes_cli/web_dist/index.html").is_file()
assert (root / "apps/desktop/release/linux-unpacked/hermes").exists() == desktop
+5 -43
View File
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
"""Regression tests for the fail-closed PID-ownership guard.
Refs #90471 / #89614. The three patched Windows ``taskkill`` boundaries:
Refs #90471 / #89614. The shared Windows ``taskkill`` boundaries:
- ``hermes_cli/_subprocess_compat.pid_is_hermes`` / ``kill_process_tree``
- ``hermes_cli/dashboard_procs._kill_stale_dashboard_processes`` (win32)
- ``hermes_cli/update_cmd._stop_process_trees``
Acceptance from #90471:
1. missing / unreadable / non-matching identity fails closed -> no taskkill
@@ -20,7 +19,6 @@ import pytest
from hermes_cli import _subprocess_compat
from hermes_cli import dashboard_procs
from hermes_cli import update_cmd
def _probe_stdout(value: str) -> mock.Mock:
@@ -139,40 +137,8 @@ class TestKillProcessTree:
assert str(4321) in argv
class TestStopProcessTrees:
"""update_cmd._stop_process_trees guard behaviour."""
def test_foreign_pids_only_probed(self):
with mock.patch(
"gateway.status.get_process_start_time", return_value=123
), mock.patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=False
), mock.patch.object(update_cmd.subprocess, "run") as run:
update_cmd._stop_process_trees([1111, 2222])
run.assert_not_called()
def test_hermes_pid_probed_then_taskkilled(self):
with mock.patch(
"gateway.status.get_process_start_time", return_value=123
), mock.patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=True
), mock.patch.object(
update_cmd.subprocess, "run", return_value=mock.Mock(returncode=0)
) as run:
update_cmd._stop_process_trees([1111])
assert len(run.call_args_list) == 1
assert run.call_args.args[0][0] == "taskkill"
def test_probe_timeout_skips_taskkill(self):
with mock.patch(
"gateway.status.get_process_start_time", return_value=123
), mock.patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=False
), mock.patch.object(update_cmd.subprocess, "run") as run:
update_cmd._stop_process_trees([1111, 2222]) # must not raise
run.assert_not_called()
# taskkill dispatch must execute on Windows, not under a fake sys.platform.
@pytest.mark.platforms("windows")
class TestKillStaleDashboardProcesses:
"""dashboard_procs win32 kill branch guard behaviour."""
@@ -182,9 +148,7 @@ class TestKillStaleDashboardProcesses:
return mock.patch.object(main_dashboard, "_find_stale_dashboard_pids", return_value=list(pids))
def test_foreign_pid_reported_not_killed(self):
with self._patch_find(), mock.patch.object(
dashboard_procs.sys, "platform", "win32"
), mock.patch(
with self._patch_find(), mock.patch(
"gateway.status.get_process_start_time", return_value=123
), mock.patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=False
@@ -197,9 +161,7 @@ class TestKillStaleDashboardProcesses:
run.assert_not_called()
def test_hermes_pid_killed(self):
with self._patch_find(), mock.patch.object(
dashboard_procs.sys, "platform", "win32"
), mock.patch(
with self._patch_find(), mock.patch(
"gateway.status.get_process_start_time", return_value=123
), mock.patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=True
@@ -12,10 +12,7 @@ Class under test (#98814 / #89614):
mismatched identity.
- ``hermes_cli._subprocess_compat.pid_is_hermes`` fails closed on foreign
processes and identity mismatches.
- ``hermes_cli.update_cmd._refuse_gateway_ancestor_tree_kill`` refuses to
nominate any ancestor of the current process for a tree-kill.
"""
import os
import subprocess
import sys
import time
@@ -147,46 +144,3 @@ class TestPidIsHermesLive:
from hermes_cli._subprocess_compat import pid_is_hermes
assert pid_is_hermes(2**24) is False
class TestAncestorRefusalLive:
def test_real_parent_chain_is_refused(self, capsys):
"""Walk the REAL psutil parent chain: every ancestor of this test
process must be refused as a tree-kill target (#98814)."""
import psutil
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
from hermes_cli.update_cmd import _refuse_gateway_ancestor_tree_kill
ancestors = [os.getpid()]
parent = psutil.Process(os.getpid()).parent()
while parent is not None and len(ancestors) < 6:
ancestors.append(parent.pid)
parent = parent.parent()
for pid in ancestors:
assert _is_pid_ancestor_of_current_process(pid) is True, pid
refused = _refuse_gateway_ancestor_tree_kill(
ancestors, gateway_mode=False
)
assert refused is True
out = capsys.readouterr().out
assert "taskkill /T" in out
assert "separate terminal" in out
def test_unrelated_live_process_is_not_refused(self):
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
from hermes_cli.update_cmd import _refuse_gateway_ancestor_tree_kill
proc = _spawn_sleeper()
try:
assert _is_pid_ancestor_of_current_process(proc.pid) is False
assert (
_refuse_gateway_ancestor_tree_kill(
[proc.pid], gateway_mode=False
)
is False
)
finally:
_cleanup(proc)
+136 -666
View File
@@ -1,25 +1,13 @@
"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile."""
"""TUI launch consumes the shared dependency preparation and compiler."""
import json
import os
import types
from pathlib import Path
import shutil
import subprocess
import pytest
from hermes_cli import main_tui_launch
@pytest.fixture
def main_mod():
import hermes_cli.main as m
return m
def _touch_ink(root: Path) -> None:
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
ink.parent.mkdir(parents=True, exist_ok=True)
ink.write_text("{}")
from tests.hermes_cli.test_source_build import source_checkout, source_products, _events # noqa: F401
def _touch_tui_entry(root: Path) -> None:
@@ -28,241 +16,14 @@ def _touch_tui_entry(root: Path) -> None:
entry.write_text("console.log('tui')")
def _assert_utf8_replace_capture(kwargs: dict) -> None:
assert kwargs["text"] is True
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"
def test_make_tui_argv_uses_bundled_tui_when_workspace_missing(
tmp_path: Path, main_mod, monkeypatch
) -> None:
"""Prebuilt-install regression (#56665): a prebuilt install (Docker
image, Nix build, or prior `npm run build`) ships
hermes_cli/tui_dist/entry.js but never ships ui-tui/ (that directory only
exists in a git checkout). _make_tui_argv must try the bundled entry.js
BEFORE _ensure_tui_workspace() — requiring the workspace first hard-exits
every prebuilt dashboard Chat tab connection with `sys.exit(1)` (surfaced
to the user as the unhelpful "Chat unavailable: 1") despite a perfectly
runnable bundled TUI on disk. The bundled shortcut must succeed without
ever touching the (missing) ui-tui workspace or git.
"""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"peer":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
assert main_tui_launch._tui_need_npm_install(tmp_path) is False
def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_mod) -> None:
"""The peer-drop tolerance must not mask a real version skew."""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"2.0.0","dev":true,"peer":true}}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0","dev":true}}}'
)
assert main_tui_launch._tui_need_npm_install(tmp_path) is True
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
os.utime(tmp_path / "package-lock.json", (100, 100))
os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200))
assert main_tui_launch._tui_need_npm_install(tmp_path) is False
def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")
assert main_tui_launch._tui_need_npm_install(tmp_path) is True
def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
assert main_tui_launch._tui_need_npm_install(tmp_path) is False
# ── workspace-scoped comparison (#66978) ────────────────────────────
#
# In a shared workspace checkout the launch install is scoped to the ui-tui
# workspace, so only its dependency closure lands in the hidden lock while the
# root lock lists every other workspace's deps too. The comparison must ignore
# those unrelated packages instead of reinstalling on every launch.
def _write_ws(root: Path, ws_lock: str, hidden_lock: str) -> Path:
"""Lay out a workspace root + ui-tui member and return the ui-tui dir.
``@hermes/ink`` and the marker live at the workspace root (hoisted);
``ui-tui/`` has no lockfile of its own so ``_workspace_root`` treats the
parent as the workspace root and the launch scopes to ``--workspace ui-tui``.
"""
(root / "package-lock.json").write_text(ws_lock)
_touch_ink(root)
(root / "node_modules" / ".package-lock.json").write_text(hidden_lock)
tui_dir = root / "ui-tui"
tui_dir.mkdir(parents=True, exist_ok=True)
# package.json (and no own lockfile) is what makes _workspace_root treat the
# parent as the workspace root and the launch scope to --workspace ui-tui.
(tui_dir / "package.json").write_text('{"name":"hermes-tui"}')
return tui_dir
def test_no_install_when_only_other_workspace_deps_missing(tmp_path: Path, main_mod) -> None:
"""Deps that belong to apps/desktop / web (never installed by the ui-tui
scoped install) must not trigger a reinstall on every launch (#66978)."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"},'
'"apps/desktop":{"dependencies":{"desktop-only":"1.0.0"}},'
'"node_modules/desktop-only":{"version":"1.0.0"},'
'"apps/desktop/node_modules/nested":{"version":"1.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"}'
"}}",
)
assert main_tui_launch._tui_need_npm_install(tui_dir) is False
def test_need_install_when_ui_tui_dep_missing_in_workspace_layout(tmp_path: Path, main_mod) -> None:
"""A genuinely missing ui-tui dependency is still caught after scoping."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0","bar":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"},'
'"node_modules/bar":{"version":"1.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0","bar":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"}'
"}}",
)
assert main_tui_launch._tui_need_npm_install(tui_dir) is True
def test_need_install_when_linked_workspace_dep_missing(tmp_path: Path, main_mod) -> None:
"""The closure follows workspace symlinks (@hermes/ink → ui-tui/packages/…)
so a linked workspace's own missing dep triggers a reinstall."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
'"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
'"ui-tui/packages/hermes-ink":{"dependencies":{"inkdep":"1.0.0"}},'
'"node_modules/inkdep":{"version":"1.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
'"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
'"ui-tui/packages/hermes-ink":{"dependencies":{"inkdep":"1.0.0"}}'
"}}",
)
assert main_tui_launch._tui_need_npm_install(tui_dir) is True
def test_need_install_when_closure_package_version_drifts(tmp_path: Path, main_mod) -> None:
"""Version drift on an in-closure package still forces a reinstall."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"2.0.0"}},'
'"node_modules/foo":{"version":"2.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"2.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"}'
"}}",
)
assert main_tui_launch._tui_need_npm_install(tui_dir) is True
def test_workspace_closure_includes_dev_deps_of_scoped_workspace(main_mod) -> None:
"""ui-tui's devDependencies (esbuild/typescript build toolchain) are part of
the closure; a transitive package's devDependencies are not."""
packages = {
"ui-tui": {
"dependencies": {"foo": "1"},
"devDependencies": {"esbuild": "1"},
},
"node_modules/foo": {"devDependencies": {"foo-dev-only": "1"}},
"node_modules/esbuild": {},
"node_modules/foo-dev-only": {},
}
closure = main_tui_launch._npm_lock_workspace_closure(packages, "ui-tui")
assert "node_modules/esbuild" in closure
assert "node_modules/foo-dev-only" not in closure
def test_workspace_closure_returns_none_when_start_absent(main_mod) -> None:
"""Missing workspace key → None so the caller falls back to full compare."""
assert main_tui_launch._npm_lock_workspace_closure({"node_modules/foo": {}}, "ui-tui") is None
def test_workspace_closure_includes_dev_deps_of_selected_child_workspace(main_mod) -> None:
"""The closure includes each explicitly-selected workspace's devDependencies,
so a dev dep unique to a selected child is NOT dropped (regression for the
child-scope false-negative)."""
packages = {
"ui-tui": {"dependencies": {"@hermes/ink": "*"}},
"node_modules/@hermes/ink": {
"link": True,
"resolved": "ui-tui/packages/hermes-ink",
},
"ui-tui/packages/hermes-ink": {"devDependencies": {"child-dev-only": "1"}},
"node_modules/child-dev-only": {},
}
# Only ui-tui selected (desktop): the child's dev dep is not installed.
desktop = main_tui_launch._npm_lock_workspace_closure(packages, {"ui-tui"})
assert "node_modules/child-dev-only" not in desktop
# ui-tui + child selected: the child's dev dep is in the closure.
with_child = main_tui_launch._npm_lock_workspace_closure(
packages, {"ui-tui", "ui-tui/packages/hermes-ink"}
)
assert "node_modules/child-dev-only" in with_child
def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
_touch_tui_entry(tmp_path)
assert main_tui_launch._tui_need_npm_install(tmp_path) is False
def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path, main_mod) -> None:
def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "entry.tsx").write_text("console.log('src')")
assert main_tui_launch._tui_need_rebuild(tmp_path) is True
def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) -> None:
def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path) -> None:
_touch_tui_entry(tmp_path)
src = tmp_path / "src"
src.mkdir()
@@ -273,7 +34,7 @@ def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod)
assert main_tui_launch._tui_need_rebuild(tmp_path) is False
def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> None:
def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path) -> None:
_touch_tui_entry(tmp_path)
src = tmp_path / "src"
src.mkdir()
@@ -284,434 +45,143 @@ def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) ->
assert main_tui_launch._tui_need_rebuild(tmp_path) is True
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(
"hermes_constants.find_node_executable",
lambda name: f"/bin/{name}",
)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_tui_launch._make_tui_argv(tui_dir, tui_dev=False)
assert calls[0][0][0] == [
"/bin/npm",
"install",
"--workspace",
"ui-tui",
"--include=dev",
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
]
assert calls[0][1]["cwd"] == str(tmp_path)
_assert_utf8_replace_capture(calls[0][1])
_assert_utf8_replace_capture(calls[1][1])
def test_make_tui_argv_npm_install_forces_include_dev(
tmp_path: Path, main_mod, monkeypatch
) -> None:
"""The TUI-launch npm install must force --include=dev: ui-tui's build
toolchain (esbuild, typescript) lives in devDependencies, and an inherited
NODE_ENV=production (container shells; a parent TUI sets it on its own
subprocess env) or an npm `omit=dev` config would silently skip them,
breaking the TUI build with `tsc`/`esbuild: command not found."""
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setenv("NODE_ENV", "production")
monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(
"hermes_constants.find_node_executable",
lambda name: f"/bin/{name}",
)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_tui_launch._make_tui_argv(tui_dir, tui_dev=False)
install_cmd = calls[0][0][0]
assert install_cmd[:2] == ["/bin/npm", "install"]
assert "--include=dev" in install_cmd
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(
"hermes_constants.find_node_executable",
lambda name: f"/bin/{name}",
)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_tui_launch._make_tui_argv(tmp_path, tui_dev=False)
assert calls
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
_assert_utf8_replace_capture(calls[0][1])
def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace(
tmp_path: Path, main_mod, monkeypatch
) -> None:
ink_dir = tmp_path / "packages" / "hermes-ink"
ink_dir.mkdir(parents=True)
tsx = tmp_path / "node_modules" / ".bin" / "tsx"
tsx.parent.mkdir(parents=True)
tsx.write_text("")
monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(
"hermes_constants.find_node_executable",
lambda name: f"/bin/{name}",
)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
argv, cwd = main_tui_launch._make_tui_argv(tmp_path, tui_dev=True)
assert argv == [str(tsx), "src/entry.tsx"]
assert cwd == tmp_path
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
assert calls[0][1]["cwd"] == str(ink_dir)
_assert_utf8_replace_capture(calls[0][1])
def test_make_tui_argv_exits_with_recovery_hint_when_workspace_unrecoverable(
tmp_path: Path, main_mod, monkeypatch, capsys
) -> None:
"""Missing ui-tui + no git checkout → clean error, never touches node/npm."""
@pytest.fixture
def tui_source(source_products, monkeypatch):
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
monkeypatch.setattr(main_tui_launch, "_ensure_tui_node", lambda: None)
bundled_entry = tmp_path / "bundled" / "entry.js"
bundled_entry.parent.mkdir(parents=True)
bundled_entry.write_text("// bundled TUI")
monkeypatch.setattr(main_tui_launch, "_find_bundled_tui", lambda: bundled_entry)
def which(name: str) -> str | None:
if name == "node":
return "/usr/bin/node"
raise AssertionError(f"unexpected shutil.which({name!r}) call — bundled path must not need npm/git")
monkeypatch.setattr("hermes_constants.find_node_executable", which)
def fail_run(*_args, **_kwargs):
raise AssertionError("bundled TUI path must not spawn any subprocess (no npm install/build, no git restore)")
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
# ui-tui/ deliberately does not exist under tmp_path, and there is no
# .git either — this mirrors a prebuilt (Docker/Nix) install exactly.
tui_dir = tmp_path / "ui-tui"
assert not tui_dir.exists()
argv, cwd = main_tui_launch._make_tui_argv(tui_dir, tui_dev=False)
assert argv == ["/usr/bin/node", "--expose-gc", str(bundled_entry)]
assert cwd == bundled_entry.parent
# ── _workspace_root helper ──────────────────────────────────────────
# (Smoke test: just confirm _tui_need_npm_install doesn't crash)
# It won't need install because the lockfile exists and there's no
# hidden lockfile to compare against, and ink is missing → True.
# But the key invariant is: ws_root for the need-check == ws_root
# for the install cwd — both use _workspace_root(sub).
def test_need_npm_install_false_with_reduced_npm11_hidden_lockfile(
tmp_path: Path, main_mod
) -> None:
"""npm >= 10/11 writes a reduced hidden `.package-lock.json` that omits
declarative fields (version/dependencies/dev) and adds `extraneous`,
and it never materializes workspace `"link": true` entries. A fresh
install therefore used to look perpetually stale and re-ran `npm install`
on every TUI launch (#84617). After the fix it must be stable."""
ws = tmp_path / "ui-tui"
ws.mkdir()
(ws / "package.json").write_text("{}")
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
json.dumps(
{
"packages": {
"node_modules/ink": {
"version": "5.0.0",
"resolved": "https://reg/ink.tgz",
"integrity": "sha512-aaaa",
"dependencies": {"yocto": "^1.0.0"},
},
"apps/desktop": {"link": True, "resolved": "apps/desktop"},
}
}
)
)
# Hidden lockfile as npm 11 writes it: reduced, plus extraneous.
(tmp_path / "node_modules").mkdir(parents=True, exist_ok=True)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
json.dumps(
{
"packages": {
"node_modules/ink": {
"resolved": "https://reg/ink.tgz",
"integrity": "sha512-aaaa",
"extraneous": True,
},
"apps/desktop": {"link": True, "resolved": "apps/desktop"},
}
}
)
)
# Must be False: real skew keys (resolved/integrity) match, declarative
# omissions and extraneous are ignored, and the workspace link is skipped.
assert main_tui_launch._tui_need_npm_install(ws) is False
def test_need_npm_install_true_when_resolved_drifts(tmp_path: Path, main_mod) -> None:
"""A genuinely stale install (lockfile bumped the resolved URL/integrity
while node_modules is behind) must still be detected — the reduced-lockfile
fix must not paper over real skew (#84617)."""
ws = tmp_path / "ui-tui"
ws.mkdir()
(ws / "package.json").write_text("{}")
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
json.dumps(
{
"packages": {
"node_modules/ink": {
"version": "5.0.0",
"resolved": "https://reg/ink-NEW.tgz",
"integrity": "sha512-bbbb",
},
}
}
)
)
(tmp_path / "node_modules").mkdir(parents=True, exist_ok=True)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
json.dumps(
{
"packages": {
"node_modules/ink": {
"resolved": "https://reg/ink-OLD.tgz",
"integrity": "sha512-aaaa",
},
}
}
)
)
# resolved/integrity differ on both sides → must reinstall.
assert main_tui_launch._tui_need_npm_install(ws) is True
def test_need_npm_install_true_when_regular_pkg_missing(tmp_path: Path, main_mod) -> None:
"""A real non-link node_modules/ package missing from the install must
still trigger a reinstall — only workspace links and optional/peer skips
are exempt (#84617)."""
ws = tmp_path / "ui-tui"
ws.mkdir()
(ws / "package.json").write_text("{}")
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
json.dumps(
{
"packages": {
"node_modules/ink": {
"resolved": "https://reg/ink.tgz",
"integrity": "sha512-aaaa",
},
"node_modules/missing-pkg": {
"resolved": "https://reg/missing.tgz",
"integrity": "sha512-cccc",
},
}
}
)
)
(tmp_path / "node_modules").mkdir(parents=True, exist_ok=True)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
json.dumps(
{
"packages": {
"node_modules/ink": {
"resolved": "https://reg/ink.tgz",
"integrity": "sha512-aaaa",
},
}
}
)
)
assert main_tui_launch._tui_need_npm_install(ws) is True
def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None:
"""Workspace sub-directories must not contain their own package-lock.json.
With a single workspace root lockfile, per-directory lockfiles are
always accidental (typically from running ``npm install`` inside the
wrong directory). They cause ``_workspace_root`` to treat the
sub-package as standalone, which breaks hoisted ``node_modules``
resolution and can silently diverge the install cwd from the
lockfile-check root.
This is an invariant, not a change-detector: the workspace structure
is not expected to gain per-dir lockfiles.
"""
root = main_mod.PROJECT_ROOT
# Workspace members that live one level below the root and should
# NOT have their own lockfile. (ui-tui/packages/* members are
# two levels deep and even less likely to get accidental lockfiles,
# but we check them too for completeness.)
subdirs = [
root / "ui-tui",
root / "web",
root / "apps" / "desktop",
root / "apps" / "shared",
]
# Also sweep ui-tui/packages/* (hermes-ink etc.)
tui_pkgs = root / "ui-tui" / "packages"
if tui_pkgs.is_dir():
subdirs.extend(d for d in tui_pkgs.iterdir() if d.is_dir())
stray = [d for d in subdirs if (d / "package-lock.json").is_file()]
assert not stray, (
"stray package-lock.json found in workspace sub-directory(es); "
"delete them and run `npm install` from the repo root instead: "
+ ", ".join(str(d / "package-lock.json") for d in stray)
)
def test_make_tui_argv_omits_workspace_and_scrubs_esbuild_override(
tmp_path: Path, main_mod, monkeypatch
) -> None:
"""When ui-tui/ has its own package-lock.json, _workspace_root returns
tui_dir itself. npm install --workspace ui-tui would fail in that case
because npm cannot find a workspace named "ui-tui" inside ui-tui/.
The fix omits --workspace and runs plain npm install from tui_dir.
See #42973. The npm child must also ignore an inherited esbuild binary
override: a version mismatch makes esbuild's postinstall abort (#87405).
"""
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
# Simulate curl-install layout: tui_dir has its own lockfile
(tui_dir / "package-lock.json").write_text("{}")
# Parent also has lockfile (but _workspace_root prefers tui_dir's own)
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.delenv("HERMES_TUI_FORCE_BUILD", raising=False)
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setenv("ESBUILD_BINARY_PATH", "/opt/esbuild-0.28.2")
monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(
"hermes_constants.find_node_executable",
lambda name: f"/bin/{name}",
)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_tui_launch._make_tui_argv(tui_dir, tui_dev=False)
install_cmd = calls[0][0][0]
# Must NOT contain --workspace when npm_cwd == tui_dir
assert "--workspace" not in install_cmd, (
f"npm install should omit --workspace when tui_dir has its own lockfile, got: {install_cmd}"
)
assert Path(install_cmd[0]).name in {"npm", "npm.cmd"}
assert install_cmd[1] == "install"
# cwd must be tui_dir (standalone), not parent
assert calls[0][1]["cwd"] == str(tui_dir)
assert "ESBUILD_BINARY_PATH" not in calls[0][1]["env"]
assert calls[1][0][0][1:] == ["run", "build"]
assert "ESBUILD_BINARY_PATH" not in calls[1][1]["env"]
monkeypatch.setenv("HERMES_NODE", shutil.which("node"))
monkeypatch.setattr(main_tui_launch, "_find_bundled_tui", lambda: None)
return source_products
class TestPersistentNpmUserconfig:
"""$HERMES_HOME/npmrc must reach every npm lifecycle child process (#106373)."""
@pytest.mark.platforms("posix")
def test_source_launch_prepares_base_union_but_compiles_only_tui(tui_source):
root, acquired = tui_source
argv, cwd = main_tui_launch._make_tui_argv(root / "ui-tui", tui_dev=False)
assert cwd == root / "ui-tui"
assert argv[-1] == str(cwd / "dist/entry.js")
assert (cwd / "dist/entry.js").read_text() == "tui"
assert [event["step"] for event in _events(root)] == ["deps", "tui"]
assert acquired == ["npm"]
assert (root / "node_modules/ui-tui").exists()
assert (root / "node_modules/web").exists()
assert not (root / "node_modules/apps-desktop").exists()
def test_hermes_home_npmrc_sets_userconfig(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
(home / "npmrc").write_text(
"node_get_windows_binary_host_mirror=https://mirror.example/get-windows/\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
env = main_tui_launch._npm_lifecycle_env()
@pytest.mark.platforms("posix")
def test_source_compile_failure_stops_launch_without_reinstall(tui_source):
root, acquired = tui_source
(root / "fail-tui").touch()
with pytest.raises(subprocess.CalledProcessError):
main_tui_launch._make_tui_argv(root / "ui-tui", tui_dev=False)
assert [event["step"] for event in _events(root)] == ["deps", "tui"]
assert acquired == ["npm"]
assert env["NPM_CONFIG_USERCONFIG"] == os.fspath(home / "npmrc")
def test_explicit_userconfig_wins_and_missing_file_sets_nothing(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("termux", [False, True])
def test_fresh_bundle_does_not_prepare_or_compile(tui_source, monkeypatch, termux):
root, acquired = tui_source
_touch_tui_entry(root / "ui-tui")
if termux:
monkeypatch.setenv("TERMUX_VERSION", "test")
argv, cwd = main_tui_launch._make_tui_argv(root / "ui-tui", tui_dev=False)
assert argv[-1] == str(cwd / "dist/entry.js")
assert _events(root) == []
assert acquired == []
assert "NPM_CONFIG_USERCONFIG" not in main_tui_launch._npm_lifecycle_env()
(home / "npmrc").write_text("registry=https://example.invalid\n", encoding="utf-8")
monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / "custom-npmrc"))
assert main_tui_launch._npm_lifecycle_env()["NPM_CONFIG_USERCONFIG"] == str(tmp_path / "custom-npmrc")
@pytest.mark.platforms("posix")
def test_prebuilt_bundle_launch_does_not_touch_missing_source(tmp_path, monkeypatch):
bundled = tmp_path / "bundle/entry.js"
bundled.parent.mkdir()
bundled.write_text("console.log('prebuilt')")
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
monkeypatch.setenv("HERMES_NODE", shutil.which("node"))
monkeypatch.setattr(main_tui_launch, "_find_bundled_tui", lambda: bundled)
argv, cwd = main_tui_launch._make_tui_argv(tmp_path / "missing-source", tui_dev=False)
result = subprocess.run(argv, cwd=cwd, check=True, capture_output=True, text=True)
assert result.stdout.strip() == "prebuilt"
assert not (tmp_path / "missing-source").exists()
monkeypatch.delenv("NPM_CONFIG_USERCONFIG")
env = main_tui_launch._npm_lifecycle_env({"NPM_CONFIG_USERCONFIG": "/from-caller/npmrc"})
assert env["NPM_CONFIG_USERCONFIG"] == "/from-caller/npmrc"
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("local_tsx", [False, True])
def test_dev_launch_builds_ink_before_running_source(tui_source, local_tsx):
root, acquired = tui_source
ink = root / "ui-tui/packages/hermes-ink"
ink.mkdir(parents=True)
(ink / "package.json").write_text(json.dumps({
"name": "fixture-ink", "version": "1.0.0", "scripts": {"build": "node build.mjs"},
}))
(ink / "build.mjs").write_text("import { writeFileSync } from 'node:fs'; writeFileSync('built', 'ink');")
manifest = json.loads((root / "package.json").read_text())
manifest["workspaces"].append("ui-tui/packages/hermes-ink")
(root / "package.json").write_text(json.dumps(manifest))
subprocess.run([shutil.which("npm"), "install", "--package-lock-only", "--ignore-scripts", "--offline"], cwd=root, check=True)
tsx = root / "ui-tui/node_modules/.bin/tsx"
if local_tsx:
hook = root / "log.mjs"
hook.write_text(hook.read_text() + "\nimport { mkdirSync, writeFileSync } from 'node:fs'; "
"mkdirSync('ui-tui/node_modules/.bin', {recursive: true}); "
"writeFileSync('ui-tui/node_modules/.bin/tsx', 'fixture tsx');\n")
argv, cwd = main_tui_launch._make_tui_argv(root / "ui-tui", tui_dev=True)
assert (ink / "built").read_text() == "ink"
assert argv == ([str(tsx), "src/entry.tsx"] if local_tsx else [shutil.which("npm"), "start"])
assert cwd == root / "ui-tui"
assert acquired == ["npm"]
assert [event["step"] for event in _events(root)] == ["deps"]
@pytest.mark.platforms("posix")
def test_runtime_node_comes_from_pm_without_legacy_repair(tmp_path, monkeypatch):
import pm
from pm.package import Runner
node = shutil.which("node")
managed = tmp_path / "bin/node"
managed.parent.mkdir()
managed.symlink_to(node)
monkeypatch.delenv("HERMES_NODE", raising=False)
acquired = []
def acquire(name):
acquired.append(name)
return Runner(name, {"PATH": str(managed.parent)})
monkeypatch.setattr(pm, "ensure", acquire)
assert main_tui_launch._tui_node_bin("node") == str(managed)
assert acquired == ["node"]
@pytest.mark.platforms("linux")
def test_tui_rebuild_preserves_the_prepared_desktop_and_web_union(tui_source):
from hermes_cli.source_build import build_update_products
root, acquired = tui_source
build_update_products(root, desktop=True)
before = _events(root)
main_tui_launch._make_tui_argv(root / "ui-tui", tui_dev=False)
assert _events(root) == before, "fresh launch must not prepare or rebuild"
src = root / "ui-tui/src/entry.tsx"
src.parent.mkdir()
src.write_text("changed tui source")
os.utime(root / "ui-tui/dist/entry.js", (1, 1))
main_tui_launch._make_tui_argv(root / "ui-tui", tui_dev=False)
assert _events(root) == [*before, {"step": "tui"}]
assert acquired == ["npm", "npm"]
assert (root / "node_modules/web").exists()
assert (root / "node_modules/apps-desktop").exists()
@pytest.mark.parametrize("changed", ["package.json", "package-lock.json", "apps/shared/shared.ts"])
def test_tui_rebuild_tracks_root_and_shared_inputs(tmp_path, changed):
tui = tmp_path / "ui-tui"
_touch_tui_entry(tui)
changed_file = tmp_path / changed
changed_file.parent.mkdir(parents=True, exist_ok=True)
changed_file.write_text("changed")
os.utime(tui / "dist/entry.js", (1, 1))
assert main_tui_launch._tui_need_rebuild(tui)
+1 -66
View File
@@ -38,24 +38,6 @@ def main_mod(monkeypatch):
return mod
def test_exit_after_oneshot_flushes_stdio_and_calls_os_exit(
monkeypatch, main_mod
):
@@ -86,10 +68,6 @@ def test_exit_after_oneshot_flushes_stdio_and_calls_os_exit(
assert flushed == ["stdout", "stderr"]
def test_oneshot_subprocess_exits_without_teardown_abort():
program = textwrap.dedent(
"""
@@ -116,12 +94,6 @@ def test_oneshot_subprocess_exits_without_teardown_abort():
assert b"Traceback" not in result.stderr
def _stub_plugin_discovery(monkeypatch):
monkeypatch.setitem(
sys.modules,
@@ -130,8 +102,6 @@ def _stub_plugin_discovery(monkeypatch):
)
def test_oneshot_wires_session_db_for_recall(monkeypatch):
"""hermes -z bypasses HermesCLI, but recall still needs SessionDB."""
from hermes_cli.oneshot import _run_agent
@@ -201,6 +171,7 @@ def test_oneshot_wires_session_db_for_recall(monkeypatch):
def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
monkeypatch.setenv("HERMES_PYTHON", sys.executable)
captured = {}
active_path_during_call = None
@@ -234,39 +205,3 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
assert active_path_during_call == active_path
assert not active_path.exists()
assert env["NODE_ENV"] == "production"
def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path):
tui_dir = tmp_path / "ui-tui"
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
ink_dir = tui_dir / "packages" / "hermes-ink"
tsx.parent.mkdir(parents=True)
ink_dir.mkdir(parents=True)
tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8")
monkeypatch.setattr(main_tui_launch, "_ensure_tui_node", lambda: None)
monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _tui_dir: False)
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}")
calls = []
def fake_run(cmd, cwd=None, **_kwargs):
calls.append((cmd, cwd))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
argv, cwd = main_tui_launch._make_tui_argv(tui_dir, tui_dev=True)
assert argv == [str(tsx), "src/entry.tsx"]
assert cwd == tui_dir
assert len(calls) == 1
assert calls[0][0][-2:] == ["run", "build"]
assert calls[0][1] == str(ink_dir)
+2 -44
View File
@@ -30,6 +30,7 @@ def _patch_gateway_discovery(monkeypatch):
monkeypatch.setattr(hermes_main, "_pause_windows_gateways_for_update", lambda: None)
monkeypatch.setattr(hermes_main, "_resume_windows_gateways_after_update", lambda *a: None)
monkeypatch.setattr("pm.sync_venv", lambda *a, **k: None)
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **kw: None)
monkeypatch.setattr("hermes_cli.update_cmd_maint._run_post_update_maintenance", lambda *a, **k: None)
with patch("hermes_cli.gateway.find_gateway_pids", return_value=[]), \
patch("hermes_cli.gateway.supports_systemd_services", return_value=False), \
@@ -54,7 +55,7 @@ def _patch_gateway_discovery(monkeypatch):
# ---------------------------------------------------------------------------
# Update uses .[all] with fallback to .
# Update orchestration fixtures
# ---------------------------------------------------------------------------
def _setup_update_mocks(monkeypatch, tmp_path):
@@ -67,43 +68,14 @@ def _setup_update_mocks(monkeypatch, tmp_path):
monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: [])
monkeypatch.setattr(hermes_config, "check_config_version", lambda **_kwargs: (5, 5))
monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []})
monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda *a, **kw: True)
def test_refresh_active_memory_provider_dependencies_reinstalls_active_provider(monkeypatch):
"""#53272/#70636: update must re-run the active provider's dep install."""
recorded = []
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"memory": {"provider": "mem0"}},
)
monkeypatch.setattr(
"hermes_cli.memory_setup._install_dependencies",
lambda provider_name, force=False: recorded.append((provider_name, force)),
)
hermes_main._refresh_active_memory_provider_dependencies()
assert recorded == [("mem0", True)]
def test_reload_updated_runtime_modules_restores_new_hermes_constants_symbol(monkeypatch):
"""A pre-pull module object missing a new helper is repaired by reload."""
import hermes_constants
monkeypatch.delattr(hermes_constants, "apply_subprocess_home_env", raising=False)
assert not hasattr(hermes_constants, "apply_subprocess_home_env")
hermes_main._reload_updated_runtime_modules()
assert callable(hermes_constants.apply_subprocess_home_env)
@@ -736,7 +708,6 @@ def test_restore_rejects_invalid_python_and_keeps_clean_updated_tree(
"""A cleanly-applied stash must not be allowed to brick every agent turn."""
import subprocess
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
def git(*args, check=True):
return subprocess.run(
@@ -760,7 +731,6 @@ def test_restore_rejects_invalid_python_and_keeps_clean_updated_tree(
stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path)
assert stash_ref
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ())
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ())
with pytest.raises(SystemExit) as exc_info:
hermes_main._restore_stashed_changes(
@@ -783,7 +753,6 @@ def test_restore_rejects_new_import_time_failure_and_preserves_stash(
"""A valid-Python stash must not introduce a critical import failure."""
import subprocess
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
def git(*args, check=True):
return subprocess.run(
@@ -806,7 +775,6 @@ def test_restore_rejects_new_import_time_failure_and_preserves_stash(
stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path)
assert stash_ref
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
with pytest.raises(SystemExit) as exc_info:
hermes_main._restore_stashed_changes(
@@ -827,7 +795,6 @@ def test_restore_allows_preexisting_import_time_failure(monkeypatch, tmp_path):
"""A restore may proceed when it does not worsen an environment failure."""
import subprocess
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
def git(*args, check=True):
return subprocess.run(
@@ -853,7 +820,6 @@ def test_restore_allows_preexisting_import_time_failure(monkeypatch, tmp_path):
stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path)
assert stash_ref
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
assert hermes_main._restore_stashed_changes(
["git"], tmp_path, stash_ref, prompt_user=False
@@ -868,7 +834,6 @@ def test_restore_rejects_later_failure_masked_by_preexisting_failure(
"""Every critical module must be compared, not only the first failure."""
import subprocess
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
def git(*args, check=True):
return subprocess.run(
@@ -894,7 +859,6 @@ def test_restore_rejects_later_failure_masked_by_preexisting_failure(
stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path)
assert stash_ref
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("first", "second"))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("first", "second"))
with pytest.raises(SystemExit) as exc_info:
hermes_main._restore_stashed_changes(
@@ -917,7 +881,6 @@ def test_restore_rejects_system_exit_masked_by_preexisting_failure(
"""A terminating import must be compared instead of hiding the marker."""
import subprocess
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
def git(*args, check=True):
return subprocess.run(
@@ -943,7 +906,6 @@ def test_restore_rejects_system_exit_masked_by_preexisting_failure(
stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path)
assert stash_ref
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("first", "second"))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("first", "second"))
with pytest.raises(SystemExit) as exc_info:
hermes_main._restore_stashed_changes(
@@ -964,7 +926,6 @@ def test_restore_rejects_probe_termination(monkeypatch, tmp_path, capsys):
"""A stash cannot bypass import validation by terminating the probe."""
import subprocess
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
def git(*args, check=True):
return subprocess.run(
@@ -987,7 +948,6 @@ def test_restore_rejects_probe_termination(monkeypatch, tmp_path, capsys):
stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path)
assert stash_ref
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
with pytest.raises(SystemExit) as exc_info:
hermes_main._restore_stashed_changes(
@@ -1051,7 +1011,6 @@ def test_restore_rejects_unknown_restored_python_paths(
import subprocess
from hermes_cli import update_cmd
import hermes_cli.update_cmd_stash as update_cmd_stash
import hermes_cli.update_cmd_deps as update_cmd_deps
def git(*args, check=True):
return subprocess.run(
@@ -1073,7 +1032,6 @@ def test_restore_rejects_unknown_restored_python_paths(
stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path)
assert stash_ref
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ())
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ())
monkeypatch.setattr(update_cmd, "_restored_python_paths", lambda *_args: None)
monkeypatch.setattr(update_cmd_stash, "_restored_python_paths", lambda *_args: None)
@@ -1,9 +1,4 @@
"""Tests for issue #26670 — concurrent hermes.exe detection and improved
quarantine retry / reboot-deferred fallback during `hermes update` on Windows.
These tests force ``_is_windows`` to return ``True`` via patching so the
Windows-specific code paths can be exercised on any host.
"""
"""Windows gateway pause/resume and launcher identity remain live after PM cutover."""
from __future__ import annotations
@@ -11,144 +6,18 @@ import json
import os
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import main as cli_main
from hermes_cli import dashboard_procs
from hermes_cli import main_install_repair
from hermes_cli import update_cmd
# Tests in this module either exercise the REAL _detect_concurrent_hermes_instances
# helper (and need the autouse stub in tests/hermes_cli/conftest.py disabled),
# or supply their own explicit return value via patch.object. Mark the whole
# module so the conftest fixture skips its default stub.
pytestmark = pytest.mark.real_concurrent_gate
# ---------------------------------------------------------------------------
# _detect_concurrent_hermes_instances
# ---------------------------------------------------------------------------
def _make_proc(pid: int, exe: str, name: str = "hermes.exe"):
"""Build a duck-typed psutil Process stand-in with the .info dict."""
proc = MagicMock()
proc.info = {"pid": pid, "exe": exe, "name": name}
return proc
# ---------------------------------------------------------------------------
# Parent-chain exclusion (issue #30768 follow-up — the setuptools .exe
# launcher on Windows is a separate native process that spawns python.exe;
# excluding only ``os.getpid()`` flags the launcher as a concurrent instance.
# ---------------------------------------------------------------------------
def _fake_psutil_with_parent_chain(
parent_chain: list[int],
proc_iter_rows: list,
*,
ancestor_exe: str | None = None,
):
"""Build a psutil stand-in that has Process()/parents()/exe() AND process_iter().
``parent_chain`` is the ordered list of ancestor PIDs (closest first)
returned by ``proc.parents()`` on the seed (``os.getpid()``).
``ancestor_exe`` is the executable path reported by each ancestor's
``.exe()``; when it matches one of our shim paths the ancestor is
excluded (the launcher-shim case). Pass ``None`` to model an ancestor
whose exe can't be read (psutil error) — it stays in the candidate set.
"""
class _FakeProc:
def __init__(self, pid: int, exe_path: str | None):
self.pid = pid
self._exe = exe_path
def exe(self):
if self._exe is None:
raise OSError("exe unavailable")
return self._exe
def parents(self):
return [_FakeProc(p, ancestor_exe) for p in parent_chain]
class _NoSuchProcess(Exception):
pass
class _AccessDenied(Exception):
pass
def _process(pid=None):
return _FakeProc(pid if pid is not None else os.getpid(), ancestor_exe)
return types.SimpleNamespace(
Process=_process,
NoSuchProcess=_NoSuchProcess,
AccessDenied=_AccessDenied,
process_iter=lambda attrs: iter(proc_iter_rows),
)
@patch.object(main_install_repair, "_is_windows", return_value=True)
def test_detect_concurrent_parents_call_robust_to_one_bad_hop(_winp, tmp_path):
"""The launcher shim is still excluded even when an ancestor exe is unreadable.
Field regression (issues #29341, #34795): the old per-hop ``parent()``
walk bailed on the FIRST psutil error, so an AccessDenied on any hop left
the launcher shim in the candidate set and re-triggered the false
positive. ``parents()`` returns the whole list at once; we evaluate each
ancestor independently, so one unreadable hop never strands the launcher.
"""
scripts_dir = tmp_path
shim = scripts_dir / "hermes.exe"
shim.write_bytes(b"")
me = os.getpid()
launcher_pid = me + 100
rows = [
_make_proc(me, str(shim), "python.exe"),
_make_proc(launcher_pid, str(shim), "hermes.exe"),
]
# ancestor_exe=None → every ancestor's .exe() raises OSError. The helper
# must swallow it per-ancestor and not crash; the launcher won't be
# excluded in this degenerate case, but a real run reads the shim exe.
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[launcher_pid],
proc_iter_rows=rows,
ancestor_exe=None,
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
# No crash; helper completes. (Degenerate stub: launcher exe unreadable.)
assert result == [(launcher_pid, "hermes.exe")]
# ---------------------------------------------------------------------------
# _format_concurrent_instances_message
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Windows gateway pause/resume before update mutation
# ---------------------------------------------------------------------------
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_pause_windows_gateways_for_update_stops_profile_and_unmapped_pids(
_winp,
monkeypatch,
tmp_path,
capsys,
@@ -222,9 +91,9 @@ def test_pause_windows_gateways_for_update_stops_profile_and_unmapped_pids(
assert "Restart manually after update" not in captured
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_pause_and_resume_windows_gateway_service(
_winp,
monkeypatch,
tmp_path,
):
@@ -309,9 +178,9 @@ def test_pause_and_resume_windows_gateway_service(
assert started == ["HermesGateway"]
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_pause_windows_gateway_service_failure_restores_every_attempted_service(
_winp,
monkeypatch,
):
"""A service that times out after accepting stop is restarted too."""
@@ -354,9 +223,9 @@ def test_pause_windows_gateway_service_failure_restores_every_attempted_service(
assert restarted == ["HermesGatewayPicasso", "HermesGateway"]
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_pause_windows_gateway_service_surfaces_rollback_start_failure(
_winp,
monkeypatch,
):
import hermes_cli.gateway as gateway_mod
@@ -425,9 +294,9 @@ def test_restore_windows_gateway_service_waits_out_stop_pending(monkeypatch):
assert restarted == ["HermesGateway"]
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_pause_windows_gateways_aborts_when_service_discovery_is_indeterminate(
_winp,
monkeypatch,
):
import hermes_cli.gateway as gateway_mod
@@ -449,9 +318,9 @@ def test_pause_windows_gateways_aborts_when_service_discovery_is_indeterminate(
cli_main._pause_windows_gateways_for_update()
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_pause_windows_gateways_aborts_when_gateway_pid_discovery_is_indeterminate(
_winp,
monkeypatch,
):
import hermes_cli.gateway as gateway_mod
@@ -492,9 +361,9 @@ def test_stop_windows_gateway_service_waits_for_original_descendants(
)
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_resume_windows_gateway_service_failure_stays_retryable(
_winp,
monkeypatch,
):
import hermes_cli.update_cmd as update_cmd
@@ -525,9 +394,9 @@ def test_resume_windows_gateway_service_failure_stays_retryable(
assert token["services"] == ["HermesGateway"]
@patch.object(cli_main, "_is_windows", return_value=True)
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_resume_windows_gateway_launcher_refresh_failure_stays_retryable(
_winp,
monkeypatch,
):
token = {
@@ -549,19 +418,6 @@ def test_resume_windows_gateway_launcher_refresh_failure_stays_retryable(
assert token["services"] == ["HermesGateway"]
# ---------------------------------------------------------------------------
# venv-side launcher ancestors (the uv launcher/worker split)
#
# A gateway started through the venv shim is two processes:
# venv\Scripts\python.exe (launcher) -> uv\python\...\python.exe (worker)
# The gateway's PID file records the WORKER, so find_gateway_pids() (and the
# pause set built from it) only ever sees the worker. The venv-holder guard
# matches on the venv path prefix, so it only ever sees the LAUNCHER. The two
# sets were disjoint: a gateway the updater had just stopped still tripped the
# guard, aborting every update ("venv-blocked: N process(es) hold the install").
# ---------------------------------------------------------------------------
def _fake_psutil_tree(tree, venv_exe, worker_exe, dead=None):
"""Build a psutil stand-in where ``tree`` maps worker pid -> parent pid.
@@ -597,9 +453,10 @@ def _fake_psutil_tree(tree, venv_exe, worker_exe, dead=None):
return mod
@patch.object(cli_main, "_is_windows", return_value=True)
def test_venv_launcher_ancestors_returns_venv_side_parent(_winp, monkeypatch):
"""The worker's venv-side parent is reported so the guard set is covered."""
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_venv_launcher_ancestors_returns_venv_side_parent(monkeypatch):
"""The worker's venv-side parent is included in the gateway pause."""
venv_exe = str(cli_main.PROJECT_ROOT / "venv" / "Scripts" / "python.exe")
worker_exe = r"C:\Users\x\AppData\Roaming\uv\python\cpython-3.11\python.exe"
@@ -610,8 +467,9 @@ def test_venv_launcher_ancestors_returns_venv_side_parent(_winp, monkeypatch):
assert cli_main._venv_launcher_ancestors([200]) == [100]
@patch.object(cli_main, "_is_windows", return_value=True)
def test_venv_launcher_ancestors_ignores_non_venv_parents(_winp, monkeypatch):
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_venv_launcher_ancestors_ignores_non_venv_parents(monkeypatch):
"""A Scheduled Task's cmd.exe / an operator shell is not a venv holder."""
venv_exe = str(cli_main.PROJECT_ROOT / "venv" / "Scripts" / "python.exe")
worker_exe = r"C:\Windows\System32\cmd.exe"
@@ -623,25 +481,20 @@ def test_venv_launcher_ancestors_ignores_non_venv_parents(_winp, monkeypatch):
assert cli_main._venv_launcher_ancestors([200]) == []
@patch.object(cli_main, "_is_windows", return_value=True)
def test_venv_launcher_ancestors_is_empty_without_pids(_winp):
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_venv_launcher_ancestors_is_empty_without_pids():
"""No mapped gateways means nothing to walk up from."""
assert cli_main._venv_launcher_ancestors([]) == []
@patch.object(cli_main, "_is_windows", return_value=True)
def test_pause_kill_set_covers_venv_guard_abort_set(
_winp,
# Windows lifecycle dispatch must run on its real host.
@pytest.mark.platforms("windows")
def test_pause_stops_launcher_after_worker_drain(
monkeypatch,
tmp_path,
):
"""INVARIANT: whatever the venv guard would abort on must be stopped.
This is the contract the two PID-resolution paths must satisfy. Before the
launcher walk existed, ``terminated`` held only the uv-side worker while
the guard reported the venv-side launcher, so the update aborted forever
despite a "successful" pause.
"""
"""Capture the launcher identity while its worker is still inspectable."""
import hermes_cli.gateway as gateway_mod
import gateway.status as status_mod
@@ -695,410 +548,7 @@ def test_pause_kill_set_covers_venv_guard_abort_set(
cli_main._pause_windows_gateways_for_update()
# What the downstream venv-holder guard would report as blocking.
guard_would_abort_on = {launcher_pid}
assert guard_would_abort_on.issubset(set(terminated)), (
f"pause stopped {sorted(terminated)} but the venv guard aborts on "
f"{sorted(guard_would_abort_on)} — disjoint sets abort the update"
)
# ---------------------------------------------------------------------------
# _leftover_pausable_gateway_pids (the guard-level gateway fallback)
#
# The pause stops every gateway discovery finds, but the venv-holder guard
# sees the process table as it is NOW. A supervisor (Scheduled Task, login
# watchdog) can respawn a gateway inside the pause→guard window, and some
# spawn paths never register in discovery at all. Those holders are exactly
# what the pause machinery exists to stop — the guard nominates them for a
# stop-and-recheck instead of dead-ending, and refuses the moment any
# non-gateway holder is present.
# ---------------------------------------------------------------------------
GATEWAY_ARGV = [
r"C:\x\venv\Scripts\python.exe",
"-m",
"hermes_cli.main",
"gateway",
"run",
]
def _fake_psutil_cmdlines(argv_by_pid):
"""psutil stand-in serving live argv per pid; unknown pids raise."""
class FakeProc:
def __init__(self, pid):
if pid not in argv_by_pid:
raise ValueError(f"no such pid {pid}")
self._argv = argv_by_pid[pid]
def cmdline(self):
return self._argv
return types.SimpleNamespace(Process=FakeProc)
def test_leftover_holders_that_are_all_gateways_are_nominated(monkeypatch):
"""Respawned/unmapped gateway holders get stopped, not dead-ended on."""
monkeypatch.setitem(
sys.modules,
"psutil",
_fake_psutil_cmdlines({300: GATEWAY_ARGV, 301: GATEWAY_ARGV}),
)
matches = [
(300, "python.exe", "truncated..."),
(301, "python.exe", "truncated..."),
]
assert cli_main._leftover_pausable_gateway_pids(matches) == [300, 301]
def test_plain_update_refuses_to_tree_kill_its_gateway_ancestor(
monkeypatch, capsys
):
"""#98814: terminal-launched update must survive to report the refusal."""
import hermes_cli.gateway as gateway_cli
import hermes_cli.update_cmd as update_cmd
monkeypatch.setattr(
gateway_cli,
"_is_pid_ancestor_of_current_process",
lambda pid: pid == 300,
)
refused = update_cmd._refuse_gateway_ancestor_tree_kill(
[300, 301], gateway_mode=False
)
assert refused is True
output = capsys.readouterr().out
assert "taskkill /T" in output
assert "`/update`" in output
assert "separate terminal" in output
def test_gateway_handoff_keeps_leftover_gateway_recovery(monkeypatch, capsys):
"""The detached `/update` hand-off still owns leftover gateway cleanup."""
import hermes_cli.gateway as gateway_cli
import hermes_cli.update_cmd as update_cmd
ancestry_checks = []
monkeypatch.setattr(
gateway_cli,
"_is_pid_ancestor_of_current_process",
lambda pid: ancestry_checks.append(pid) or True,
)
assert (
update_cmd._refuse_gateway_ancestor_tree_kill(
[300], gateway_mode=True
)
is False
)
assert ancestry_checks == []
assert capsys.readouterr().out == ""
def test_one_non_gateway_holder_keeps_the_hard_refusal(monkeypatch):
"""A REPL/backend holder means the guard must abort exactly as before."""
monkeypatch.setitem(
sys.modules,
"psutil",
_fake_psutil_cmdlines(
{300: GATEWAY_ARGV, 400: [r"C:\x\venv\Scripts\python.exe", "-i"]}
),
)
matches = [(300, "python.exe", "..."), (400, "python.exe", "...")]
assert cli_main._leftover_pausable_gateway_pids(matches) is None
def test_unreadable_argv_falls_back_to_the_captured_prefix(monkeypatch):
"""psutil failure degrades to the scan's captured cmdline, not a crash.
The captured prefix decides: a gateway invocation still qualifies, and
anything else still refuses.
"""
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_cmdlines({}))
gateway_prefix = r"venv\Scripts\python.exe -m hermes_cli.main gateway run"
assert cli_main._leftover_pausable_gateway_pids(
[(300, "python.exe", gateway_prefix)]
) == [300]
assert (
cli_main._leftover_pausable_gateway_pids(
[
(300, "python.exe", gateway_prefix),
(400, "python.exe", "python.exe -i"),
]
)
is None
)
# ---------------------------------------------------------------------------
# cmd_update integration — concurrent-instance gate
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# _classify_concurrent_instance / _filter_non_gateway_concurrent_instances
#
# #37039: the pre-update concurrent-instance gate lets the update proceed
# when every concurrent hermes.exe is a gateway runtime — the pause
# machinery (_pause_windows_gateways_for_update) stops those before any
# file mutation and the post-update restart phase brings them back.
# Classification delegates to _is_pausable_gateway → the canonical
# gateway.status.looks_like_gateway_command_line matcher, so the gate's
# exemption and the pause discovery cannot drift apart.
# ---------------------------------------------------------------------------
def _fake_psutil_classify(argv_by_pid):
"""psutil stand-in serving .cmdline() per pid; unknown pids raise."""
class FakeProc:
def __init__(self, pid):
if pid not in argv_by_pid:
raise ValueError(f"no such pid {pid}")
self._argv = argv_by_pid[pid]
def cmdline(self):
return self._argv
return types.SimpleNamespace(Process=FakeProc)
def test_classify_concurrent_instance_recognises_gateway_runtimes(monkeypatch):
"""Gateway runtime command lines classify as ``gateway`` regardless of
launcher shape (python -m, hermes.exe shim, hermes-gateway.exe,
gateway/run.py, bare `hermes gateway` which defaults to run)."""
cases = [
[r"C:\venv\Scripts\python.exe", "-m", "hermes_cli.main", "gateway", "run"],
[r"C:\venv\Scripts\hermes.exe", "gateway", "run"],
[r"C:\venv\Scripts\hermes-gateway.exe"],
[r"C:\venv\Scripts\python.exe", "gateway/run.py"],
["hermes.exe", "GATEWAY", "RUN"], # matcher is case-insensitive
["hermes.exe", "gateway"], # bare `hermes gateway` defaults to run
# profile selector before the subcommand — canonical matcher strips it
["hermes.exe", "--profile", "work", "gateway", "run"],
]
for argv in cases:
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_classify({77: argv}))
result = update_cmd._classify_concurrent_instance(77)
assert result == "gateway", f"expected gateway for {argv!r}, got {result!r}"
def test_classify_concurrent_instance_recognises_non_gateways(monkeypatch):
"""Non-runtime command lines classify as ``non-gateway`` — including
gateway MANAGEMENT subcommands (`gateway status`), which the canonical
matcher rejects but a substring matcher would misclassify. These keep
the pre-update abort."""
cases = [
[r"C:\venv\Scripts\hermes.exe"], # interactive REPL
[r"C:\venv\Scripts\hermes.exe", "dashboard"],
["hermes.exe", "gateway", "status"], # management, not runtime
["hermes.exe", "gateway", "stop"],
["python", "-m", "hermes_cli.main"],
[],
]
for argv in cases:
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_classify({77: argv}))
result = update_cmd._classify_concurrent_instance(77)
assert result == "non-gateway", (
f"expected non-gateway for {argv!r}, got {result!r}"
)
def test_classify_concurrent_instance_unknown_on_psutil_error(monkeypatch):
"""Unreadable cmdline (process gone / AccessDenied) → ``unknown`` —
treated as non-gateway by the filter, so the gate still aborts."""
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_classify({}))
assert update_cmd._classify_concurrent_instance(4242) == "unknown"
def test_classify_concurrent_instance_unknown_without_psutil(monkeypatch):
"""Missing psutil entirely → ``unknown``, never a crash."""
monkeypatch.setitem(sys.modules, "psutil", None)
assert update_cmd._classify_concurrent_instance(4242) == "unknown"
def test_filter_non_gateway_concurrent_instances_splits(monkeypatch):
"""Gateway PIDs drop out of the abort list; REPL/dashboard/unknown stay."""
monkeypatch.setitem(
sys.modules,
"psutil",
_fake_psutil_classify(
{
100: ["hermes.exe", "gateway", "run"],
200: ["hermes.exe"], # REPL — keep
300: ["hermes.exe", "dashboard"], # keep
# 400 missing → unknown → keep
}
),
)
matches = [
(100, "hermes.exe"),
(200, "hermes.exe"),
(300, "hermes.exe"),
(400, "hermes.exe"),
]
kept = cli_main._filter_non_gateway_concurrent_instances(matches)
assert kept == [(200, "hermes.exe"), (300, "hermes.exe"), (400, "hermes.exe")]
def test_filter_non_gateway_concurrent_instances_gateway_only(monkeypatch):
"""All-gateway match list filters to empty — the gate lets the update
proceed and the pause machinery handles the gateways."""
monkeypatch.setitem(
sys.modules,
"psutil",
_fake_psutil_classify(
{
111: ["hermes.exe", "gateway", "run"],
222: [r"C:\venv\Scripts\hermes-gateway.exe"],
}
),
)
matches = [(111, "hermes.exe"), (222, "hermes-gateway.exe")]
assert cli_main._filter_non_gateway_concurrent_instances(matches) == []
# ---------------------------------------------------------------------------
# _cmd_update_impl integration with the relaxed pre-update gate (#37039)
# ---------------------------------------------------------------------------
def _update_args():
return SimpleNamespace(
check=False,
gateway=False,
yes=False,
force=False,
backup=False,
no_backup=True,
)
@patch.object(cli_main, "_is_windows", return_value=True)
def test_update_gate_skips_abort_when_only_concurrent_is_gateway(
_winp, tmp_path, capsys
):
"""Regression test for #37039: with only gateway processes concurrent,
the gate must NOT sys.exit(2) — the update proceeds to the pre-update
backup step (sentinel), and the pause machinery owns the gateways."""
scripts_dir = tmp_path / "Scripts"
scripts_dir.mkdir()
with patch.object(
cli_main, "_venv_scripts_dir", return_value=scripts_dir
), patch.object(
cli_main,
"_detect_concurrent_hermes_instances",
return_value=[(1000, "hermes.exe"), (2000, "hermes-gateway.exe")],
), patch.object(
cli_main, "_filter_non_gateway_concurrent_instances", return_value=[]
) as mock_filter, patch.object(
cli_main, "_run_pre_update_backup"
) as mock_backup:
mock_backup.side_effect = RuntimeError("reached post-gate body")
with pytest.raises(RuntimeError, match="reached post-gate body"):
update_cmd._cmd_update_impl(_update_args(), gateway_mode=False)
mock_filter.assert_called_once()
mock_backup.assert_called_once()
captured = capsys.readouterr().out
assert "Another hermes.exe is running" not in captured
@patch.object(cli_main, "_is_windows", return_value=True)
def test_update_gate_still_aborts_on_non_gateway_concurrent(
_winp, tmp_path, capsys
):
"""A non-gateway concurrent instance must still abort with exit 2, and
the message must list only the non-gateway PIDs (the gateway is not the
user's problem to kill)."""
scripts_dir = tmp_path / "Scripts"
scripts_dir.mkdir()
with patch.object(
cli_main, "_venv_scripts_dir", return_value=scripts_dir
), patch.object(
cli_main,
"_detect_concurrent_hermes_instances",
return_value=[(1000, "hermes.exe"), (3000, "hermes.exe")],
), patch.object(
cli_main,
"_filter_non_gateway_concurrent_instances",
return_value=[(3000, "hermes.exe")],
), patch.object(
cli_main, "_run_pre_update_backup"
) as mock_backup:
with pytest.raises(SystemExit) as excinfo:
update_cmd._cmd_update_impl(_update_args(), gateway_mode=False)
assert excinfo.value.code == 2
mock_backup.assert_not_called()
captured = capsys.readouterr().out
assert "3000" in captured
assert "1000" not in captured # gateway PID no longer blamed
assert "--force" in captured
@patch.object(cli_main, "_is_windows", return_value=True)
def test_update_impl_refuses_before_terminating_gateway_ancestor(
_winp, monkeypatch, capsys
):
"""#98814: the live holder path must gate the destructive call itself."""
import gateway.status as status_mod
import hermes_cli.gateway as gateway_cli
holder = (
300,
"python.exe",
r"C:\x\venv\Scripts\python.exe -m hermes_cli.main gateway run",
)
monkeypatch.setattr(
gateway_cli,
"_is_pid_ancestor_of_current_process",
lambda pid: pid == 300,
)
with patch.object(
cli_main, "_venv_scripts_dir", return_value=None
), patch.object(
cli_main, "_run_pre_update_backup", return_value=None
), patch.object(
cli_main, "_pause_windows_gateways_for_update", return_value=None
), patch(
"hermes_cli.update_cmd_windows._detect_venv_python_processes", return_value=[holder]
), patch.object(
cli_main, "_leftover_pausable_gateway_pids", return_value=[300]
), patch.object(
cli_main, "_resume_windows_gateways_after_update"
) as resume, patch.object(
status_mod, "terminate_pid"
) as terminate:
with pytest.raises(SystemExit) as excinfo:
update_cmd._cmd_update_impl(_update_args(), gateway_mode=False)
assert excinfo.value.code == 2
terminate.assert_not_called()
resume.assert_called_once_with(None)
output = capsys.readouterr().out
assert "taskkill /T" in output
assert "`/update`" in output
assert terminated == [launcher_pid]
def test_stop_service_refuses_pid_reuse_before_sc_stop(monkeypatch):
@@ -1120,5 +570,3 @@ def test_stop_service_refuses_pid_reuse_before_sc_stop(monkeypatch):
)
assert calls == []
@@ -13,12 +13,11 @@ from unittest.mock import MagicMock, patch
from hermes_cli import update_cmd
def test_repair_node_deps_runs_config_migration_on_version_bump(capsys):
"""When on-disk config version is behind, _repair_node_deps_on_current_checkout
must run _check_and_apply_config_migration and migrate the config."""
completion = MagicMock()
def test_current_checkout_runs_config_migration_on_version_bump(capsys):
"""A retry migrates old config after preparing the updated checkout."""
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_update_node_dependencies", return_value=[]),
patch.object(update_cmd, "_prepare_updated_checkout") as prepare,
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_reload_config_modules"),
patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)),
@@ -29,11 +28,15 @@ def test_repair_node_deps_runs_config_migration_on_version_bump(capsys):
"_run_migrate_config_fresh",
return_value={"env_added": [], "config_added": ["migrated to v38"], "warnings": []},
) as mock_migrate,
patch.object(update_cmd, "_rebuild_desktop_after_update", return_value=True),
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
update_cmd._repair_node_deps_on_current_checkout(completion)
complete = update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, upstream_checked=True,
)
m.return_value._build_web_ui.assert_called_once()
assert complete is True
prepare.assert_called_once_with(m.return_value.PROJECT_ROOT, desktop=False)
mock_migrate.assert_called_once_with(interactive=False, quiet=True)
completion.assert_called_once_with("✓ Already up to date!")
out = capsys.readouterr().out
@@ -42,22 +45,26 @@ def test_repair_node_deps_runs_config_migration_on_version_bump(capsys):
assert "Config format updated" in out
def test_repair_node_deps_up_to_date_config(capsys):
def test_current_checkout_up_to_date_config(capsys):
"""When config is already up to date, it reports up to date without error."""
completion = MagicMock()
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_update_node_dependencies", return_value=[]),
patch.object(update_cmd, "_prepare_updated_checkout") as prepare,
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_reload_config_modules"),
patch.object(update_cmd, "_run_config_check_fresh", return_value=(38, 38)),
patch("hermes_cli.config.get_missing_env_vars", return_value=[]),
patch("hermes_cli.config.get_missing_config_fields", return_value=[]),
patch.object(update_cmd, "_run_migrate_config_fresh") as mock_migrate,
patch.object(update_cmd, "_rebuild_desktop_after_update", return_value=True),
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
update_cmd._repair_node_deps_on_current_checkout(completion)
complete = update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, upstream_checked=True,
)
m.return_value._build_web_ui.assert_called_once()
assert complete is True
prepare.assert_called_once_with(m.return_value.PROJECT_ROOT, desktop=False)
mock_migrate.assert_not_called()
completion.assert_called_once_with("✓ Already up to date!")
out = capsys.readouterr().out
@@ -1,46 +0,0 @@
"""The commit_count == 0 path must repair Node deps, not just Python (#77211).
A previous ``hermes update`` whose npm install failed printed "Fix npm and
re-run `hermes update`" — but re-running hit the "Already up to date!" early
return before the Node refresh, so the advice could never work. The repair
now runs through ``_repair_node_deps_on_current_checkout``, which delegates
to ``_update_node_dependencies`` (self-gating on the lockfile hash, recorded
only after a successful install, so healthy installs stay a cheap no-op).
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from hermes_cli import update_cmd
def test_current_checkout_repairs_failed_node_deps(capsys):
"""A recorded failure surfaces the fix-npm hint, not 'Already up to date!'."""
completion = MagicMock()
with patch.object(
update_cmd, "_update_node_dependencies", return_value=["ui-tui, web workspaces"]
), patch.object(update_cmd, "_m") as m:
update_cmd._repair_node_deps_on_current_checkout(completion)
m.return_value._build_web_ui.assert_not_called()
completion.assert_called_once()
assert "could not be repaired" in completion.call_args[0][0]
out = capsys.readouterr().out
assert "Node.js refresh failed for: ui-tui, web workspaces" in out
assert "Fix npm and re-run `hermes update`." in out
def test_current_checkout_healthy_node_deps_reports_up_to_date():
"""A clean refresh (or lockfile-hash no-op) still says 'Already up to date!'."""
completion = MagicMock()
with patch.object(
update_cmd, "_update_node_dependencies", return_value=[]
), patch.object(update_cmd, "_m") as m, patch.object(
update_cmd, "_rebuild_desktop_after_update", return_value=True
):
update_cmd._repair_node_deps_on_current_checkout(completion)
# The refresh pairs with the web build like every other call site.
m.return_value._build_web_ui.assert_called_once()
completion.assert_called_once_with("✓ Already up to date!")
@@ -1,178 +1,144 @@
"""A failed Desktop pack must not look like a successful update.
"""Retired soft-build hooks stop old updaters instead of claiming completion.
#88251: ``hermes update`` treated a failed desktop pack as non-fatal, printed
an early warning, then still ended with ``✓ Update complete!``. The Python
side moved on; the Electron app stayed on the previous build.
``_rebuild_desktop_after_update`` returns False only when a rebuild was
attempted and failed. The final banner then prints ``⚠ Update partially
complete`` instead of the success line, and gateway mode writes ``1`` to
``.update_exit_code``.
Live source builds now raise on failure before maintenance runs. Old updaters
can still import these frozen names after swapping their checkout.
"""
from types import SimpleNamespace
import pytest
from hermes_cli import update_cmd
import hermes_cli.update_cmd_maint as update_cmd_maint
from hermes_cli.update_cmd import (
_print_update_summary,
_rebuild_desktop_after_update,
_write_gateway_update_exit_code,
from hermes_cli import update_cmd_maint
@pytest.mark.parametrize(
"name,args,kwargs",
[
("_print_update_summary", (), {
"node_failures": [], "desktop_build_ok": True, "pre_update_version": None,
}),
("_print_update_summary", (), {
"node_failures": ["dashboard"], "desktop_build_ok": False,
"pre_update_version": "0.20.1",
}),
("_finish_dashboard_update_cleanup", ([],), {}),
("_finish_dashboard_update_cleanup", (["dashboard"],), {
"already_restarted_units": {"hermes-serve"},
}),
],
)
def test_historical_completion_hooks_stop_before_work(name, args, kwargs, monkeypatch, capsys):
def forbidden(*args, **kwargs):
pytest.fail("old updater attempted post-update work")
class _Result:
def __init__(self, returncode: int, stdout: str = ""):
self.returncode = returncode
self.stdout = stdout
@pytest.fixture()
def desktop_env(tmp_path, monkeypatch):
"""A desktop dir that looks installed and a faked CLI main module."""
desktop_dir = tmp_path / "apps" / "desktop"
desktop_dir.mkdir(parents=True)
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
calls = {"builds": 0, "build_needed": True}
class _FakeMain:
PROJECT_ROOT = tmp_path
@staticmethod
def _resolve_node_runtime_npm():
return "/fake/npm"
@staticmethod
def _desktop_build_needed(*_a, **_kw):
return calls["build_needed"]
@staticmethod
def _run_logged_subprocess(cmd, cwd=None, env=None):
calls["builds"] += 1
return _Result(1, stdout="Error: [stage-native-deps] boom")
monkeypatch.setattr(update_cmd, "_m", lambda: _FakeMain)
monkeypatch.setattr(update_cmd, "_post_update_sqlite_runtime_status", forbidden)
monkeypatch.setattr(update_cmd, "_reload_process_scan_modules", forbidden)
monkeypatch.setattr(
"hermes_constants.with_hermes_node_path", lambda: {}, raising=False
update_cmd, "_m", lambda: SimpleNamespace(_kill_stale_dashboard_processes=forbidden),
)
monkeypatch.setattr(
"hermes_constants.display_hermes_home", lambda: str(tmp_path), raising=False
)
return desktop_dir, calls
with pytest.raises(SystemExit) as exc:
getattr(update_cmd_maint, name)(*args, **kwargs)
assert exc.value.code == 0
output = capsys.readouterr()
assert "run `hermes` again" in output.err
assert "Update complete" not in output.out
def _run(desktop_dir):
return _rebuild_desktop_after_update(
desktop_dir, had_desktop_app_before_update=True
)
def test_gateway_exit_code_file_tracks_verified_outcome(tmp_path, monkeypatch):
monkeypatch.setattr(update_cmd, "get_hermes_home", lambda: tmp_path)
update_cmd._write_gateway_update_exit_code(True)
assert (tmp_path / ".update_exit_code").read_text(encoding="utf-8") == "0"
update_cmd._write_gateway_update_exit_code(False)
assert (tmp_path / ".update_exit_code").read_text(encoding="utf-8") == "1"
def test_failed_rebuild_returns_false_and_keeps_the_retry_hint(desktop_env, capsys):
desktop_dir, calls = desktop_env
assert _run(desktop_dir) is False
assert calls["builds"] == 2
out = capsys.readouterr().out
assert "Desktop build failed" in out
assert "stage-native-deps" in out
assert "Update complete" not in out
def test_verified_completion_keeps_success_banner(capsys, monkeypatch):
monkeypatch.setattr(update_cmd, "_branch_head_suffix", lambda: "")
monkeypatch.setattr(update_cmd, "_post_update_sqlite_runtime_status", lambda: (True, None))
assert update_cmd_maint._print_verified_update_completion("✓ Update complete!") is True
def test_successful_rebuild_returns_true(desktop_env, monkeypatch, capsys):
desktop_dir, _calls = desktop_env
builds = []
monkeypatch.setattr(
update_cmd._m(),
"_run_logged_subprocess",
staticmethod(lambda cmd, cwd=None, env=None: builds.append(cmd) or _Result(0)),
)
assert _run(desktop_dir) is True
assert len(builds) == 1
assert "Desktop app up to date" in capsys.readouterr().out
def test_up_to_date_desktop_returns_true_without_spawning(desktop_env):
desktop_dir, calls = desktop_env
calls["build_needed"] = False
assert _run(desktop_dir) is True
assert calls["builds"] == 0
def test_desktop_never_installed_returns_true(tmp_path, monkeypatch):
spawned = []
monkeypatch.setattr(
update_cmd,
"_m",
lambda: type(
"_M",
(),
{
"PROJECT_ROOT": tmp_path,
"_resolve_node_runtime_npm": staticmethod(lambda: "/fake/npm"),
"_run_logged_subprocess": staticmethod(
lambda *a, **k: spawned.append(1) or _Result(0)
),
},
),
)
missing = tmp_path / "apps" / "desktop"
missing.mkdir(parents=True)
assert _run(missing) is True
assert spawned == []
def test_summary_omits_success_banner_when_desktop_rebuild_failed(capsys):
_print_update_summary(
node_failures=[],
desktop_build_ok=False,
pre_update_version="0.20.1",
)
out = capsys.readouterr().out
assert "Update complete" not in out
assert "partially complete" in out
assert "desktop app was not rebuilt" in out
assert "hermes desktop" in out
def test_summary_keeps_success_banner_when_desktop_ok(capsys, monkeypatch):
monkeypatch.setattr(
update_cmd, "_update_complete_message", lambda _v: "✓ Update complete! (v0.20.2)"
)
monkeypatch.setattr(
update_cmd_maint, "_update_complete_message", lambda _v: "✓ Update complete! (v0.20.2)"
)
monkeypatch.setattr(update_cmd, "_branch_head_suffix", lambda *a, **k: "")
monkeypatch.setattr(
update_cmd, "_post_update_sqlite_runtime_status", lambda: (True, None)
)
monkeypatch.setattr(
update_cmd_maint, "_post_update_sqlite_runtime_status", lambda: (True, None)
)
_print_update_summary(
node_failures=[],
desktop_build_ok=True,
pre_update_version="0.20.1",
)
out = capsys.readouterr().out
assert "✓ Update complete!" in out
assert "partially complete" not in out
assert "desktop" not in out
assert "Node" not in out
assert "=== hermes-update completed" not in out
def test_summary_combines_node_and_desktop_failures(capsys):
_print_update_summary(
node_failures=["dashboard"],
desktop_build_ok=False,
pre_update_version="0.20.1",
def test_verified_completion_emits_dashboard_receipt_only_on_success(monkeypatch, capsys):
action_id = "a" * 32
monkeypatch.setenv("HERMES_ACTION_ID", action_id)
monkeypatch.setattr(update_cmd, "_branch_head_suffix", lambda: "")
monkeypatch.setattr(
update_cmd, "_post_update_sqlite_runtime_status",
lambda: (False, SimpleNamespace(sqlite_version_string="3.46.1")),
)
out = capsys.readouterr().out
assert "Update complete" not in out
assert "dashboard" in out
assert "desktop app was not rebuilt" in out
assert update_cmd_maint._print_verified_update_completion("✓ Update complete!") is False
assert f"=== hermes-update completed {action_id} ===" not in capsys.readouterr().out
monkeypatch.setattr(update_cmd, "_post_update_sqlite_runtime_status", lambda: (True, None))
assert update_cmd_maint._print_verified_update_completion("✓ Update complete!") is True
assert f"=== hermes-update completed {action_id} ===" in capsys.readouterr().out
def test_gateway_exit_code_file_tracks_desktop_rebuild(tmp_path, monkeypatch):
monkeypatch.setattr(update_cmd, "get_hermes_home", lambda: tmp_path)
_write_gateway_update_exit_code(True)
assert (tmp_path / ".update_exit_code").read_text(encoding="utf-8") == "0"
_write_gateway_update_exit_code(False)
assert (tmp_path / ".update_exit_code").read_text(encoding="utf-8") == "1"
def test_unavailable_sqlite_probe_retains_existing_nonblocking_behavior(monkeypatch, capsys):
monkeypatch.setattr(update_cmd, "_branch_head_suffix", lambda: "")
monkeypatch.setattr(update_cmd, "_post_update_sqlite_runtime_status", lambda: (False, None))
assert update_cmd_maint._print_verified_update_completion("✓ Update complete!") is True
assert "✓ Update complete!" in capsys.readouterr().out
def test_maintenance_returns_sqlite_verdict_without_frontend_flags(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(update_cmd, "_m", lambda: SimpleNamespace(PROJECT_ROOT=tmp_path))
monkeypatch.setattr("hermes_cli.macos_tcc_anchor.ensure_tcc_anchor", lambda: None)
monkeypatch.setattr("hermes_cli.model_catalog.seed_cache_from_checkout", lambda root: False)
monkeypatch.setattr(update_cmd, "_check_and_apply_config_migration", lambda **kwargs: None)
for name in (
"_verify_and_restore_state_dbs_post_update", "_print_bundled_skills_sync_report",
"_sync_profiles_after_update", "_print_post_update_notices_and_self_heals",
):
monkeypatch.setattr(update_cmd_maint, name, lambda: None)
monkeypatch.setattr(
update_cmd, "_post_update_sqlite_runtime_status",
lambda: (False, SimpleNamespace(sqlite_version_string="3.46.1")),
)
complete = update_cmd_maint._run_post_update_maintenance(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, pre_update_version=None,
)
assert complete is False
assert "Update complete" not in capsys.readouterr().out
monkeypatch.setattr(update_cmd, "_post_update_sqlite_runtime_status", lambda: (True, None))
monkeypatch.setattr(update_cmd, "_branch_head_suffix", lambda: "")
assert update_cmd_maint._run_post_update_maintenance(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, pre_update_version=None,
) is True
assert "✓ Update complete!" in capsys.readouterr().out
@pytest.mark.parametrize("already_restarted_units", [None, {"hermes-serve"}])
def test_dashboard_refresh_reloads_then_preserves_restart_bookkeeping(
already_restarted_units, monkeypatch, capsys,
):
order = []
monkeypatch.setattr(update_cmd, "_reload_process_scan_modules", lambda: order.append("reload"))
def kill(**kwargs):
order.append(kwargs)
return {"unrecovered": [1234]}
monkeypatch.setattr(
update_cmd, "_m", lambda: SimpleNamespace(_kill_stale_dashboard_processes=kill),
)
update_cmd_maint._refresh_dashboard_after_update(already_restarted_units=already_restarted_units)
assert order == ["reload", {
"restart_managed": True, "already_restarted_units": already_restarted_units,
}]
assert "could not be auto-restarted" in capsys.readouterr().out
@@ -0,0 +1,63 @@
"""SQLite completion and fleet verification remain independent update outcomes."""
from contextlib import nullcontext
import json
import pytest
from hermes_cli import update_cmd, update_cmd_fleet, update_cmd_maint, update_receipt
from hermes_constants import get_hermes_home
@pytest.mark.parametrize(
"update_complete,state",
[(True, "current"), (False, "current"), (True, "stale"), (True, "down"), (True, None)],
)
def test_fleet_completion_preserves_runtime_verdict_and_restart_obligation(
update_complete, state, monkeypatch,
):
refreshed, migrated = [], []
snapshot = [{"profile": "default", "pid": 1234, "state": state}] if state else []
restart = update_cmd_fleet._GatewayRestartOutcome(
incomplete=False, phase_errors=[], pre_restart_gateway_pids=[1234],
restarted_services=["hermes-gateway"], failed_or_stale_units=[],
relaunched_profiles=[], externally_supervised_profiles=[], killed_pids=set(),
)
monkeypatch.setattr(update_cmd_fleet, "_print_legacy_units_warning", lambda: None)
monkeypatch.setattr(update_cmd, "_surviving_pre_update_serve_runtimes", lambda plan: [])
monkeypatch.setattr(
update_cmd_maint, "_refresh_dashboard_after_update",
lambda **kwargs: refreshed.append(kwargs),
)
monkeypatch.setattr(update_receipt, "_code_identity", lambda **kwargs: {})
monkeypatch.setattr(
"hermes_cli.gateway_migrate.maybe_auto_migrate_after_update", lambda: migrated.append(True),
)
def collect(outcome, rows_expected):
assert outcome is restart
assert rows_expected is True
return snapshot
monkeypatch.setattr(update_cmd_fleet, "_collect_fleet_snapshot", collect)
update_cmd_fleet._write_fleet_restart_pending_marker()
marker = update_cmd_fleet._fleet_restart_pending_marker_path()
assert marker.exists()
healthy = update_complete and state == "current"
with update_receipt.update_receipt_scope():
update_receipt.begin_update_receipt()
with nullcontext() if healthy else pytest.raises(SystemExit) as exc:
update_cmd_fleet._verify_fleet_after_update(
restart, _pre_update_plan=None, _windows_gateway_resume=None,
update_complete=update_complete,
)
if not healthy:
assert exc.value.code == 1
receipt = json.loads((get_hermes_home() / "logs/update_receipts/latest.json").read_text())
assert receipt["outcome"] == ("success" if healthy else "partial")
assert receipt["fleet"] == snapshot
assert restart.incomplete is (state != "current")
assert marker.exists() is (state != "current")
assert migrated == ([True] if healthy else [])
assert refreshed == [{"already_restarted_units": {"hermes-gateway"}}]
@@ -26,7 +26,6 @@ import hermes_cli.main_web_build as main_web_build
import hermes_cli.main_install_repair as main_install_repair
from hermes_cli import update_cmd
import hermes_cli.update_cmd_fleet as update_cmd_fleet
import hermes_cli.update_cmd_deps as update_cmd_deps
from hermes_cli.update_receipt import COMMAND_BOUNDARY_STOP_REASON
from hermes_constants import get_hermes_home
@@ -83,7 +82,7 @@ def _patch_update_deps(monkeypatch, tmp_path, run_side_effect):
"""Patch ``_cmd_update_impl`` helpers. Mirrors test_update_head_moved_gate."""
monkeypatch.setattr(hermes_main.subprocess, "run", run_side_effect)
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr("pm.sync_venv", lambda *a, **k: None)
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **k: None)
(tmp_path / ".git").mkdir()
monkeypatch.setattr(hermes_main, "_resolve_update_branch", lambda args: "main")
monkeypatch.setattr(hermes_main, "_is_windows", lambda: False)
@@ -117,8 +116,6 @@ def _patch_update_deps(monkeypatch, tmp_path, run_side_effect):
monkeypatch.setattr(
hermes_main, "_resume_windows_gateways_after_update", lambda *a, **k: None
)
monkeypatch.setattr(hermes_main, "_write_update_incomplete_marker", lambda: None)
monkeypatch.setattr(hermes_main, "_clear_update_incomplete_marker", lambda: None)
# _install_hangup_protection wraps sys.stdout in a mirror stream that
# survives the test and breaks later capsys captures — no-op it.
monkeypatch.setattr(
@@ -139,12 +136,8 @@ def _patch_update_deps(monkeypatch, tmp_path, run_side_effect):
"hermes_cli.update_cmd._reload_config_modules",
lambda *a, **k: None,
)
# Upstream refactor: _clear_update_incomplete_marker now lives in
# main_install_repair; no-op it there too (kept from upstream side).
monkeypatch.setattr(main_install_repair, "_clear_update_incomplete_marker", lambda: None)
# Upstream refactor: _finish_dashboard_update_cleanup moved back under
# update_cmd (was reached via hermes_main on our branch).
monkeypatch.setattr(update_cmd, "_finish_dashboard_update_cleanup", lambda *a, **k: None
monkeypatch.setattr(
"hermes_cli.update_cmd_maint._refresh_dashboard_after_update", lambda **k: None,
)
# The startup version-info probe runs git rev-parse HEAD (and the
# result is cached per-process, so whether it runs depends on test
@@ -154,13 +147,6 @@ def _patch_update_deps(monkeypatch, tmp_path, run_side_effect):
"hermes_cli.version_info.get_version_info",
lambda *a, **k: SimpleNamespace(),
)
monkeypatch.setattr(hermes_main, "_build_web_ui", lambda *a, **k: None)
monkeypatch.setattr(main_web_build, "_build_web_ui", lambda *a, **k: None)
monkeypatch.setattr(
update_cmd, "_venv_core_imports_healthy", lambda: (True, "")
)
monkeypatch.setattr(update_cmd, "_update_node_dependencies", lambda: [])
monkeypatch.setattr(update_cmd_deps, "_update_node_dependencies", lambda: [])
monkeypatch.setattr(update_cmd, "_purge_stale_hermes_modules", lambda: None)
monkeypatch.setattr(hermes_main, "_purge_stale_hermes_modules", lambda: None)
@@ -1,150 +0,0 @@
"""Tests for the GUI-updater hand-off backend reap (_handoff_reapable_backend_pids).
Field incident (2026-08-20, Teknium's Windows box): a Desktop update hand-off
(`hermes update --yes --gateway --force`) left a *swarm* of per-profile `serve`
backends (mr-tester, probe-inherit, turqoise, clippy, maroon, …) holding
`cryptography\\_rust.pyd`. Some still had a live parent (the tearing-down
Electron process, or the venv launcher→worker two-hop chain mid-exit), so the
strict orphan-only reap (_orphaned_desktop_backend_pids) disqualified the whole
set and the update dead-ended — a 12-minute hang, then a force-close that
stranded bot sessions.
_handoff_reapable_backend_pids is the additional rung that ONLY runs in the
hand-off context (caller gates on args.gateway + the update-incomplete marker +
no live hermes.exe shim). There, any surviving Hermes `serve`/`dashboard`
backend from this venv is a leak — live parent or not — and safe to reap.
A non-backend holder still disqualifies the whole set.
Runs on any host via a fake psutil module (same approach as
test_update_orphan_backend_reap.py).
"""
from __future__ import annotations
import sys
import types
from unittest.mock import MagicMock, patch
from hermes_cli import main as cli_main
from hermes_cli import update_cmd
class _FakeNoSuchProcess(Exception):
pass
def _fake_psutil(procs: dict[int, MagicMock]):
def _process(pid: int):
if pid not in procs:
raise _FakeNoSuchProcess(pid)
return procs[pid]
return types.SimpleNamespace(Process=_process, NoSuchProcess=_FakeNoSuchProcess)
def _proc(pid: int, cmdline: list[str]):
proc = MagicMock()
proc.pid = pid
proc.cmdline.return_value = cmdline
return proc
def _serve_argv(profile: str = "mr-tester") -> list[str]:
return [
"C:\\hermes\\venv\\Scripts\\python.exe",
"-m",
"hermes_cli.main",
"--profile",
profile,
"serve",
"--host",
"127.0.0.1",
"--port",
"0",
]
def _holder(pid: int, cmdline: str):
return (pid, "python.exe", cmdline)
def test_live_parent_backend_reaped_in_handoff():
# The exact case the orphan-only path REFUSES: a serve backend that still
# has a live parent. In the hand-off context it must still be reaped.
backend = _proc(200, _serve_argv("mr-tester"))
fake = _fake_psutil({200: backend})
with patch.dict(sys.modules, {"psutil": fake}):
holders = [_holder(200, "python.exe -m hermes_cli.main --profile mr-tester serve")]
assert cli_main._handoff_reapable_backend_pids(holders) == [200]
def test_swarm_of_profile_backends_all_reaped():
profiles = ["mr-tester", "probe-inherit", "turqoise", "clippy", "maroon"]
procs = {200 + i: _proc(200 + i, _serve_argv(p)) for i, p in enumerate(profiles)}
fake = _fake_psutil(procs)
with patch.dict(sys.modules, {"psutil": fake}):
holders = [
_holder(200 + i, f"python.exe -m hermes_cli.main --profile {p} serve")
for i, p in enumerate(profiles)
]
assert sorted(cli_main._handoff_reapable_backend_pids(holders)) == sorted(procs)
def test_dashboard_backend_reaped():
backend = _proc(200, ["python.exe", "-m", "hermes_cli.main", "dashboard"])
fake = _fake_psutil({200: backend})
with patch.dict(sys.modules, {"psutil": fake}):
holders = [_holder(200, "python.exe -m hermes_cli.main dashboard")]
assert cli_main._handoff_reapable_backend_pids(holders) == [200]
def test_non_backend_holder_disqualifies_whole_set():
# An operator REPL / stray script during a hand-off is unexpected — refuse
# the whole set rather than reap something we can't justify.
backend = _proc(200, _serve_argv("mr-tester"))
repl = _proc(300, ["python.exe", "-m", "hermes_cli.main", "chat"])
fake = _fake_psutil({200: backend, 300: repl})
with patch.dict(sys.modules, {"psutil": fake}):
holders = [
_holder(200, "python.exe -m hermes_cli.main --profile mr-tester serve"),
_holder(300, "python.exe -m hermes_cli.main chat"),
]
assert cli_main._handoff_reapable_backend_pids(holders) is None
def test_exited_holder_skipped_not_fatal():
# A holder that vanished between scan and classification is skipped, and the
# remaining real backend still qualifies.
backend = _proc(200, _serve_argv("mr-tester"))
fake = _fake_psutil({200: backend}) # 300 absent → NoSuchProcess
with patch.dict(sys.modules, {"psutil": fake}):
holders = [
_holder(300, "python.exe -m hermes_cli.main --profile gone serve"),
_holder(200, "python.exe -m hermes_cli.main --profile mr-tester serve"),
]
assert cli_main._handoff_reapable_backend_pids(holders) == [200]
def test_no_holders_returns_none():
fake = _fake_psutil({})
with patch.dict(sys.modules, {"psutil": fake}):
assert cli_main._handoff_reapable_backend_pids([]) is None
def test_psutil_unavailable_returns_none():
# Can't re-read argv to classify → refuse (leave the decision to the
# caller's existing rungs / the dead-end).
import builtins
real_import = builtins.__import__
def _no_psutil(name, *a, **k):
if name == "psutil":
raise ImportError("no psutil")
return real_import(name, *a, **k)
with patch.dict(sys.modules, {}, clear=False):
sys.modules.pop("psutil", None)
with patch("builtins.__import__", _no_psutil):
holders = [_holder(200, "python.exe -m hermes_cli.main --profile x serve")]
assert cli_main._handoff_reapable_backend_pids(holders) is None
@@ -1,54 +1,53 @@
"""The current-checkout repair path must rebuild the Desktop app (#97343).
A Windows git install runs `hermes update` from `hermes.exe`, which reexecs a
venv-Python child to finish the dependency sync. That child completes through
``_repair_node_deps_on_current_checkout`` / the hand-off repair branch, never
through the commits-pulled path that owns the Desktop rebuild — so a
successful-looking update left the packaged desktop app on the previous build.
A retry with no new commits must still prepare the Desktop product selected
before the update. Failure must stop before configuration and success reporting.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import update_cmd
def test_current_checkout_repair_rebuilds_desktop_under_project_root():
"""The repair passes PROJECT_ROOT/apps/desktop and the pre-update flag."""
def test_current_checkout_repair_rebuilds_desktop_under_project_root(tmp_path):
"""The retry preserves the pre-update desktop selection and checkout root."""
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_update_node_dependencies", return_value=[]),
patch.object(update_cmd, "_prepare_updated_checkout") as prepare,
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_check_and_apply_config_migration"),
patch.object(
update_cmd, "_rebuild_desktop_after_update", return_value=True
) as rebuild,
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
m.return_value.PROJECT_ROOT = update_cmd.Path("/fake/hermes")
complete = update_cmd._repair_node_deps_on_current_checkout(
completion, had_desktop_app_before_update=True
m.return_value.PROJECT_ROOT = tmp_path
complete = update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=True, upstream_checked=True,
)
assert complete is True
rebuild.assert_called_once()
assert rebuild.call_args[0][0] == update_cmd.Path("/fake/hermes/apps/desktop")
assert rebuild.call_args[1]["had_desktop_app_before_update"] is True
prepare.assert_called_once_with(tmp_path, desktop=True)
completion.assert_called_once_with("✓ Already up to date!")
def test_failed_desktop_rebuild_withholds_success_completion():
"""A failed rebuild must not report success and must return False."""
def test_failed_desktop_rebuild_withholds_success_completion(tmp_path):
"""A failed build propagates before config migration or success reporting."""
completion = MagicMock(return_value=True)
with (
patch.object(update_cmd, "_update_node_dependencies", return_value=[]),
patch.object(update_cmd, "_m") as m,
patch.object(update_cmd, "_check_and_apply_config_migration"),
patch.object(update_cmd, "_rebuild_desktop_after_update", return_value=False),
patch.object(update_cmd, "_check_and_apply_config_migration") as migrate,
patch.object(update_cmd, "_prepare_updated_checkout", side_effect=RuntimeError("desktop build failed")),
patch.object(update_cmd, "_print_verified_update_completion", completion),
):
m.return_value.PROJECT_ROOT = update_cmd.Path("/fake/hermes")
complete = update_cmd._repair_node_deps_on_current_checkout(completion)
m.return_value.PROJECT_ROOT = tmp_path
with pytest.raises(RuntimeError, match="desktop build failed"):
update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=True, upstream_checked=True,
)
assert complete is False
for call in completion.call_args_list:
assert not call[0][0].startswith("✓")
migrate.assert_not_called()
completion.assert_not_called()
@@ -154,7 +154,7 @@ def _patch_update_deps(monkeypatch, tmp_path, run_side_effect):
# stays inert on both import paths.
monkeypatch.setattr(main_install_repair, "_clear_update_incomplete_marker", lambda: None)
# Gateway restart path (called after a successful update).
monkeypatch.setattr(update_cmd, "_finish_dashboard_update_cleanup", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.update_cmd_maint._refresh_dashboard_after_update", lambda **k: None)
# Keep the (now surfaced — #78574) gateway auto-restart phase away from
# this machine's real gateways: discovery returns nothing, systemd is
# unsupported, so the phase is a clean no-op for both snapshots.
+1 -14
View File
@@ -22,7 +22,6 @@ import pytest
from hermes_cli import main as hermes_main
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
from hermes_constants import partial_update_hint
@@ -58,7 +57,6 @@ def test_syntax_guard_passes_but_import_guard_catches_skew(monkeypatch, tmp_path
# The import guard catches it.
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(tmp_path)
assert ok is False
assert module == "consumer"
@@ -68,7 +66,6 @@ def test_syntax_guard_passes_but_import_guard_catches_skew(monkeypatch, tmp_path
def test_import_guard_passes_on_consistent_tree(monkeypatch, tmp_path):
_write_skewed_tree(tmp_path, skewed=False)
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
assert update_cmd._validate_critical_modules_import(tmp_path) == (True, None, None)
@@ -80,7 +77,6 @@ def test_import_guard_ignores_non_import_errors(monkeypatch, tmp_path):
"raise RuntimeError('no API key configured')\n"
)
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, _, _ = update_cmd._validate_critical_modules_import(tmp_path)
assert ok is True
@@ -90,7 +86,6 @@ def test_import_guard_can_report_non_import_errors(monkeypatch, tmp_path):
"""Stash restore can compare runtime failures before and after apply."""
(tmp_path / "consumer.py").write_text("raise RuntimeError('broken config')\n")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(
tmp_path, report_runtime_errors=True
@@ -107,7 +102,6 @@ def test_import_guard_can_report_missing_third_party_dependency(
"""Stash comparison must see newly introduced missing dependencies."""
(tmp_path / "consumer.py").write_text("import totally_not_installed_pkg\n")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(
tmp_path, report_runtime_errors=True
@@ -121,7 +115,6 @@ def test_import_guard_can_report_missing_third_party_dependency(
def test_import_failure_comparison_preserves_exception_type(monkeypatch, tmp_path):
source = tmp_path / "consumer.py"
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
source.write_text("raise RuntimeError('stopped')\n")
runtime_failure = update_cmd._critical_module_import_failures(
tmp_path, report_runtime_errors=True
@@ -141,7 +134,6 @@ def test_import_guard_reports_probe_termination_when_comparing_states(
"""A terminating import is unsafe when validating a restored stash."""
(tmp_path / "consumer.py").write_text("import os\nos._exit(7)\n")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(
tmp_path, report_runtime_errors=True
@@ -156,7 +148,6 @@ def test_import_guard_reports_probe_termination_by_default(monkeypatch, tmp_path
"""A missing health marker must not classify a terminated probe as healthy."""
(tmp_path / "consumer.py").write_text("import os\nos._exit(9)\n")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(tmp_path)
@@ -169,7 +160,6 @@ def test_import_guard_reports_system_exit_by_default(monkeypatch, tmp_path):
"""Catchable terminating imports must not complete with a healthy marker."""
(tmp_path / "consumer.py").write_text("raise SystemExit('stopped')\n")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(tmp_path)
@@ -187,7 +177,6 @@ def test_import_guard_does_not_accept_forged_static_marker(monkeypatch, tmp_path
"os._exit(7)\n"
)
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(tmp_path)
@@ -285,7 +274,7 @@ def test_import_guard_prefers_the_project_venv_interpreter(monkeypatch, tmp_path
"""``hermes update`` can run under a different Python than the install's.
Probing ``sys.executable`` would then validate a tree the user never
actually runs -- the same reasoning behind ``_venv_core_imports_healthy``.
actually runs.
On Windows (the platform this guard exists for) the driving interpreter
and the venv interpreter routinely differ.
"""
@@ -322,7 +311,6 @@ def test_import_guard_ignores_missing_third_party_dependency(monkeypatch, tmp_pa
"""
(tmp_path / "consumer.py").write_text("import totally_not_installed_pkg\n")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
assert update_cmd._validate_critical_modules_import(tmp_path) == (True, None, None)
@@ -333,7 +321,6 @@ def test_import_guard_flags_missing_first_party_module(monkeypatch, tmp_path):
(tmp_path / "tools" / "__init__.py").write_text("")
(tmp_path / "consumer.py").write_text("import tools.nonexistent_module\n")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("consumer",))
ok, module, error = update_cmd._validate_critical_modules_import(tmp_path)
assert ok is False
@@ -1,316 +0,0 @@
"""Tests for the orphaned-Desktop-backend reap in the venv-holder guard.
The GUI-updater handoff race (ryanc's 2026-08-09 failures): the Desktop app
fires SIGTERM + app.quit() and spawns hermes-setup, but its Python backend
(``python.exe -m hermes_cli.main serve``) survives the teardown race. The
Desktop is gone — nothing will respawn that backend — yet the venv-holder
guard refused on it and the update dead-ended with "Hermes is still running"
while the user had zero windows open.
``_orphaned_desktop_backend_pids`` classifies holders: a ``serve``/
``dashboard`` backend whose supervising parent is provably dead is safe to
reap (with its full child tree — the managed .hermes-runtime interpreter
child included, #70026); anything else keeps the refusal.
All paths run on any host via a fake psutil module (same approach as
test_update_venv_health.py).
"""
from __future__ import annotations
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from hermes_cli import main as cli_main
from hermes_cli import update_cmd
class _FakeNoSuchProcess(Exception):
pass
def _fake_psutil(procs: dict[int, MagicMock]):
"""Build a psutil stand-in whose Process(pid) serves from *procs*."""
def _process(pid: int):
if pid not in procs:
raise _FakeNoSuchProcess(pid)
return procs[pid]
return types.SimpleNamespace(
Process=_process, NoSuchProcess=_FakeNoSuchProcess
)
def _proc(
pid: int,
cmdline: list[str],
*,
ppid: int = 0,
create_time: float = 100.0,
parents: list[MagicMock] | None = None,
):
proc = MagicMock()
proc.pid = pid
proc.cmdline.return_value = cmdline
proc.ppid.return_value = ppid
proc.create_time.return_value = create_time
proc.is_running.return_value = True
proc.parents.return_value = parents or []
return proc
_SERVE_ARGV = [
"C:\\hermes\\venv\\Scripts\\python.exe",
"-m",
"hermes_cli.main",
"serve",
"--host",
"127.0.0.1",
]
def _holders(pid=200, cmdline="python.exe -m hermes_cli.main serve"):
return [(pid, "python.exe", cmdline)]
# ---------------------------------------------------------------------------
# _orphaned_desktop_backend_pids classification
# ---------------------------------------------------------------------------
def test_orphan_backend_dead_parent_qualifies():
backend = _proc(200, _SERVE_ARGV, ppid=999) # 999 not in table → dead
fake = _fake_psutil({200: backend})
with patch.dict(sys.modules, {"psutil": fake}):
assert cli_main._orphaned_desktop_backend_pids(_holders()) == [(200, 10000)]
def test_backend_with_live_parent_keeps_refusal():
parent = _proc(50, ["Hermes.exe"], create_time=10.0)
backend = _proc(200, _SERVE_ARGV, ppid=50, create_time=100.0)
fake = _fake_psutil({50: parent, 200: backend})
with patch.dict(sys.modules, {"psutil": fake}):
assert cli_main._orphaned_desktop_backend_pids(_holders()) is None
def test_recycled_parent_pid_counts_as_orphan():
# "Parent" created AFTER the child = PID reuse; real supervisor is dead.
recycled = _proc(50, ["notepad.exe"], create_time=500.0)
backend = _proc(200, _SERVE_ARGV, ppid=50, create_time=100.0)
fake = _fake_psutil({50: recycled, 200: backend})
with patch.dict(sys.modules, {"psutil": fake}):
assert cli_main._orphaned_desktop_backend_pids(_holders()) == [(200, 10000)]
def test_non_backend_holder_keeps_refusal():
repl = _proc(300, ["python.exe", "-m", "hermes_cli.main", "chat"], ppid=999)
fake = _fake_psutil({300: repl})
with patch.dict(sys.modules, {"psutil": fake}):
holders = _holders(pid=300, cmdline="python.exe -m hermes_cli.main chat")
assert cli_main._orphaned_desktop_backend_pids(holders) is None
def test_mixed_holders_keep_refusal():
# One orphan backend + one operator REPL → the whole set is refused.
backend = _proc(200, _SERVE_ARGV, ppid=999)
repl = _proc(300, ["python.exe", "some_script.py"], ppid=998)
fake = _fake_psutil({200: backend, 300: repl})
with patch.dict(sys.modules, {"psutil": fake}):
holders = _holders() + [(300, "python.exe", "python.exe some_script.py")]
assert cli_main._orphaned_desktop_backend_pids(holders) is None
def test_orphan_root_plus_managed_runtime_descendant_qualifies():
# helix4u's review case (#82179): the scanner returns BOTH the orphaned
# serve root and its .hermes-runtime interpreter child. The child's live
# parent IS the orphan root, so the set is safe — only the root is
# returned (taskkill /T reaps the descendant with it).
backend = _proc(200, _SERVE_ARGV, ppid=999)
child_argv = [
"C:\\hermes\\.hermes-runtime\\python\\generation-1\\python.exe",
"worker.py",
]
child = _proc(210, child_argv, ppid=200, parents=[backend])
fake = _fake_psutil({200: backend, 210: child})
with patch.dict(sys.modules, {"psutil": fake}):
holders = _holders() + [(210, "python.exe", " ".join(child_argv))]
assert cli_main._orphaned_desktop_backend_pids(holders) == [(200, 10000)]
def test_descendant_of_grandchild_depth_qualifies():
# Descendant two hops below the orphan root (root → child → grandchild):
# psutil.parents() walks the full chain, so ancestry still matches.
backend = _proc(200, _SERVE_ARGV, ppid=999)
mid = _proc(210, ["python.exe", "mid.py"], ppid=200, parents=[backend])
grand = _proc(
220, ["python.exe", "leaf.py"], ppid=210, parents=[mid, backend]
)
fake = _fake_psutil({200: backend, 210: mid, 220: grand})
with patch.dict(sys.modules, {"psutil": fake}):
holders = _holders() + [(220, "python.exe", "python.exe leaf.py")]
assert cli_main._orphaned_desktop_backend_pids(holders) == [(200, 10000)]
def test_non_descendant_alongside_orphan_root_keeps_refusal():
# A stray process that is NOT under the orphan root disqualifies the set
# even though an orphan root exists.
backend = _proc(200, _SERVE_ARGV, ppid=999)
unrelated_parent = _proc(50, ["explorer.exe"])
stray = _proc(
300, ["python.exe", "stray.py"], ppid=50, parents=[unrelated_parent]
)
fake = _fake_psutil({50: unrelated_parent, 200: backend, 300: stray})
with patch.dict(sys.modules, {"psutil": fake}):
holders = _holders() + [(300, "python.exe", "python.exe stray.py")]
assert cli_main._orphaned_desktop_backend_pids(holders) is None
def test_descendant_exited_between_scan_and_classify_is_skipped():
backend = _proc(200, _SERVE_ARGV, ppid=999)
fake = _fake_psutil({200: backend}) # descendant 210 already gone
with patch.dict(sys.modules, {"psutil": fake}):
holders = _holders() + [(210, "python.exe", "python.exe worker.py")]
assert cli_main._orphaned_desktop_backend_pids(holders) == [(200, 10000)]
def test_holder_gone_between_scan_and_classify_is_skipped():
fake = _fake_psutil({}) # PID vanished entirely
with patch.dict(sys.modules, {"psutil": fake}):
assert cli_main._orphaned_desktop_backend_pids(_holders()) == []
def test_missing_psutil_keeps_refusal():
import builtins
real_import = builtins.__import__
def _no_psutil(name, *args, **kwargs):
if name == "psutil":
raise ImportError("no psutil")
return real_import(name, *args, **kwargs)
with patch.dict(sys.modules, {"psutil": None}), patch.object(
builtins, "__import__", _no_psutil
):
assert cli_main._orphaned_desktop_backend_pids(_holders()) is None
# ---------------------------------------------------------------------------
# _stop_process_trees
# ---------------------------------------------------------------------------
def test_stop_process_trees_kills_full_tree():
from hermes_cli import update_cmd
with patch("gateway.status.get_process_start_time", return_value=123), patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=True
), patch.object(update_cmd.subprocess, "run") as run:
cli_main._stop_process_trees([111, 222])
calls = [c.args[0] for c in run.call_args_list]
assert calls == [
["taskkill", "/PID", "111", "/T", "/F"],
["taskkill", "/PID", "222", "/T", "/F"],
]
def test_stop_process_trees_never_raises():
from hermes_cli import update_cmd
with patch.object(
update_cmd.subprocess, "run", side_effect=OSError("no taskkill")
):
cli_main._stop_process_trees([111]) # must not raise
# ---------------------------------------------------------------------------
# Guard integration: orphan reap clears the dead-end
# ---------------------------------------------------------------------------
def _update_args(**overrides):
defaults = dict(
gateway=False,
check=False,
no_backup=True,
backup=False,
yes=True,
branch=None,
force=False,
force_venv=False,
)
defaults.update(overrides)
return SimpleNamespace(**defaults)
def _run_guard(detect_side_effect, orphan_return):
"""Drive _cmd_update_impl to the venv-holder guard (harness mirrors
test_update_venv_health.py)."""
class _PastGuard(Exception):
pass
class _RootSentinel:
def __truediv__(self, _other):
raise _PastGuard
killed: list[list[int]] = []
with patch.object(cli_main, "_is_windows", return_value=True), patch.object(
cli_main, "_venv_scripts_dir", return_value=None
), patch.object(cli_main, "_run_pre_update_backup"), patch.object(
cli_main, "_pause_windows_gateways_for_update", return_value=None
), patch.object(
cli_main, "_resume_windows_gateways_after_update"
), patch(
"hermes_cli.update_cmd_windows._detect_venv_python_processes", side_effect=detect_side_effect
), patch.object(
cli_main, "_leftover_pausable_gateway_pids", return_value=None
), patch.object(
cli_main, "_orphaned_desktop_backend_pids", return_value=orphan_return
), patch.object(
cli_main, "_stop_process_trees", side_effect=killed.append
), patch.object(
cli_main, "PROJECT_ROOT", _RootSentinel()
), patch(
"time.sleep"
):
try:
update_cmd._cmd_update_impl(_update_args(), gateway_mode=False)
except _PastGuard:
return "past_guard", killed
except SystemExit as exc:
return f"exit_{exc.code}", killed
return "returned", killed
def test_guard_reaps_orphan_backend_and_proceeds():
holders = _holders()
# 1st scan: backend present; 2nd (post-reap) scan: clear.
result, killed = _run_guard(
detect_side_effect=[holders, []], orphan_return=[200]
)
assert result == "past_guard"
assert killed == [[200]]
def test_guard_still_refuses_when_not_orphaned():
holders = _holders()
result, killed = _run_guard(
detect_side_effect=[holders, holders], orphan_return=None
)
assert result == "exit_2"
assert killed == []
def test_guard_refuses_when_reap_does_not_clear_holders():
holders = _holders()
# Reap runs but a holder survives (unkillable child) → refuse.
result, killed = _run_guard(
detect_side_effect=[holders, holders], orphan_return=[200]
)
assert result == "exit_2"
assert killed == [[200]]
@@ -270,7 +270,7 @@ def _patch_update_flow(monkeypatch, repo, run_real_git=True):
monkeypatch.setattr(
hermes_main, "_resume_windows_gateways_after_update", lambda *a, **k: None
)
monkeypatch.setattr(hermes_main, "_capture_active_lazy_features", lambda: [])
def test_update_skips_and_warns_on_dirty_parked_branch(
@@ -318,11 +318,10 @@ def test_update_switches_unmerged_parked_branch_with_kept_notice(
class _StopFlow(Exception):
pass
# Retired self-lock guard replaced by the first post-pull dependency phase as the
# flow-stop sentinel (pm-clean-audit-49945b1402 item 9).
# Stop at product preparation, after the real Git update but before any build.
monkeypatch.setattr(
update_cmd,
"_sync_python_dependencies_after_pull",
"_prepare_updated_checkout",
lambda *a, **k: (_ for _ in ()).throw(_StopFlow()),
)
args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False)
@@ -374,11 +373,10 @@ def test_update_updates_unmerged_branch_in_place_when_configured(
class _StopFlow(Exception):
pass
# Retired self-lock guard replaced by the first post-pull dependency phase as the
# flow-stop sentinel (pm-clean-audit-49945b1402 item 9).
# Stop at product preparation, after the real Git update but before any build.
monkeypatch.setattr(
update_cmd,
"_sync_python_dependencies_after_pull",
"_prepare_updated_checkout",
lambda *a, **k: (_ for _ in ()).throw(_StopFlow()),
)
args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False)
@@ -431,11 +429,10 @@ def test_switch_branch_flag_overrides_in_place_strategy(
class _StopFlow(Exception):
pass
# Retired self-lock guard replaced by the first post-pull dependency phase as the
# flow-stop sentinel (pm-clean-audit-49945b1402 item 9).
# Stop at product preparation, after the real Git update but before any build.
monkeypatch.setattr(
update_cmd,
"_sync_python_dependencies_after_pull",
"_prepare_updated_checkout",
lambda *a, **k: (_ for _ in ()).throw(_StopFlow()),
)
args = SimpleNamespace(
@@ -483,11 +480,10 @@ def test_unmerged_branch_still_updates_in_place_without_the_flag(
class _StopFlow(Exception):
pass
# Retired self-lock guard replaced by the first post-pull dependency phase as the
# flow-stop sentinel (pm-clean-audit-49945b1402 item 9).
# Stop at product preparation, after the real Git update but before any build.
monkeypatch.setattr(
update_cmd,
"_sync_python_dependencies_after_pull",
"_prepare_updated_checkout",
lambda *a, **k: (_ for _ in ()).throw(_StopFlow()),
)
args = SimpleNamespace(
@@ -514,16 +510,14 @@ def test_update_auto_switches_clean_merged_parked_branch(
say so, and STAY on main afterwards (sabotage-proven: reverting the
guard re-parks the checkout and this test fails on the branch assert)."""
_patch_update_flow(monkeypatch, repo_pair)
# Stop the flow right after the pull/branch logic: the dependency
# install phase begins with _sync_python_dependencies_after_pull.
# Stop right after the pull/branch logic, before product preparation.
class _StopFlow(Exception):
pass
# Retired self-lock guard replaced by the first post-pull dependency phase as the
# flow-stop sentinel (pm-clean-audit-49945b1402 item 9).
# Stop at product preparation, after the real Git update but before any build.
monkeypatch.setattr(
update_cmd,
"_sync_python_dependencies_after_pull",
"_prepare_updated_checkout",
lambda *a, **k: (_ for _ in ()).throw(_StopFlow()),
)
args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False)
@@ -602,11 +596,10 @@ def test_update_on_main_fast_path_unchanged(repo_pair, monkeypatch, capsys):
class _StopFlow(Exception):
pass
# Retired self-lock guard replaced by the first post-pull dependency phase as the
# flow-stop sentinel (pm-clean-audit-49945b1402 item 9).
# Stop at product preparation, after the real Git update but before any build.
monkeypatch.setattr(
update_cmd,
"_sync_python_dependencies_after_pull",
"_prepare_updated_checkout",
lambda *a, **k: (_ for _ in ()).throw(_StopFlow()),
)
args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False)
+100
View File
@@ -0,0 +1,100 @@
"""Update retries use the same dependency transaction as a newly pulled tree."""
import json
import subprocess
import venv
from types import SimpleNamespace
import pytest
import pm
from hermes_cli import main, update_cmd
def test_current_checkout_dependency_failure_prevents_completion(tmp_path, monkeypatch):
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(update_cmd, "_print_verified_update_completion", lambda *a: pytest.fail("reported completion"))
def fail_sync(*args, **kwargs):
raise pm.InstallError("venv", "dependency conflict")
monkeypatch.setattr(pm, "sync_venv", fail_sync)
with pytest.raises(pm.InstallError, match="dependency conflict"):
update_cmd._repair_current_checkout(
assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None,
had_desktop_app_before_update=False, upstream_checked=True,
)
def test_build_runs_in_selected_python_and_propagates_failure(tmp_path, monkeypatch):
from hermes_cli.update_cmd_maint import _prepare_updated_checkout
from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path
from hermes_constants import venv_python_path
root = tmp_path / "checkout"
package = root / "hermes_cli"
package.mkdir(parents=True)
(package / "__init__.py").write_text("")
(package / "source_build.py").write_text(
"import json, os, pathlib, sys\n"
"pathlib.Path('build-process.json').write_text(json.dumps({"
"'python': sys.executable, 'argv': sys.argv[1:], 'path': sys.path}))\n"
"raise SystemExit(23)\n"
)
calls = []
selected = install_state_dir(root) / "environments/selected/venv"
# A stale repo-local venv must not win over PM's selected generation.
venv.EnvBuilder(with_pip=False).create(root / "venv")
def sync(*args, **kwargs):
calls.append((args, kwargs))
# Publication creates the interpreter the next process must use.
venv.EnvBuilder(with_pip=False).create(selected)
pm.Facts(runtime_facts_path(root)).record_state("venv", "prepared", [], environment=selected)
monkeypatch.setattr(pm, "sync_venv", sync)
monkeypatch.setenv("PYTHONPATH", str(tmp_path / "obsolete-deps"))
with pytest.raises(subprocess.CalledProcessError) as error:
_prepare_updated_checkout(root, desktop=True)
assert error.value.returncode == 23
assert calls == [((), {"explicit": True, "project_root": root})]
record = json.loads((root / "build-process.json").read_text())
assert record["python"] == str(venv_python_path(selected))
assert record["argv"] == ["--source", str(root), "--desktop"]
assert str(tmp_path / "obsolete-deps") not in record["path"]
assert not (root / ".update-incomplete").exists()
assert not (root / ".lazy-refresh-incomplete").exists()
@pytest.mark.parametrize("failure", [
pm.InstallError("venv", "conflict"),
subprocess.CalledProcessError(23, ["python", "-m", "hermes_cli.source_build"]),
])
def test_command_reports_failed_preparation_and_releases_lock(tmp_path, monkeypatch, capsys, failure):
from hermes_cli import update_lock, update_receipt
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
finalized = []
monkeypatch.setattr(main, "_finalize_update_output", finalized.append)
def fail(args, gateway_mode):
update_receipt.begin_update_receipt()
raise failure
monkeypatch.setattr(update_cmd, "_cmd_update_impl", fail)
with pytest.raises(SystemExit) as error:
main.cmd_update(SimpleNamespace(gateway=True))
assert error.value.code == 1
receipt = update_receipt.read_latest_receipt()
assert receipt is not None
assert receipt["exit_code"] == 1
assert receipt["outcome"] == "failed"
assert (tmp_path / ".update_exit_code").read_text().strip() == "1"
assert finalized == [None]
lock = update_lock.UpdateLock()
assert lock.acquire()
lock.release()
assert "Update failed" in capsys.readouterr().out
@@ -5,7 +5,6 @@ from types import SimpleNamespace
from hermes_cli import update_cmd
import hermes_cli.update_cmd_maint as update_cmd_maint
import hermes_cli.update_cmd_deps as update_cmd_deps
def test_runtime_status_probes_running_venv_outside_checkout(tmp_path, monkeypatch):
@@ -50,10 +49,8 @@ def test_summary_withholds_success_when_sqlite_remediation_failed(capsys, monkey
lambda _version: "✓ Update complete! (v0.20.5)",
)
complete = update_cmd._print_update_summary(
node_failures=[],
desktop_build_ok=True,
pre_update_version="0.20.4",
complete = update_cmd_maint._print_verified_update_completion(
update_cmd_maint._update_complete_message("0.20.4"),
)
out = capsys.readouterr().out
@@ -86,22 +83,19 @@ def test_current_checkout_completion_is_verified_before_success(capsys, monkeypa
def test_current_checkout_repair_returns_verified_completion_result(monkeypatch):
monkeypatch.setattr(update_cmd, "_update_node_dependencies", lambda: [])
monkeypatch.setattr(update_cmd_deps, "_update_node_dependencies", lambda: [])
monkeypatch.setattr(update_cmd._m(), "_build_web_ui", lambda _path: None)
monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **k: None)
monkeypatch.setattr(update_cmd, "_check_and_apply_config_migration", lambda **k: None)
monkeypatch.setattr(
update_cmd,
"_rebuild_desktop_after_update",
lambda _dir, **_kwargs: True,
"_print_verified_update_completion",
lambda _message: False,
)
monkeypatch.setattr(
update_cmd_deps,
"_rebuild_desktop_after_update",
lambda _dir, **_kwargs: True,
)
complete = update_cmd._repair_node_deps_on_current_checkout(
lambda _message: False
complete = update_cmd._repair_current_checkout(
assume_yes=True,
gateway_mode=False,
pre_update_snapshot_id=None,
had_desktop_app_before_update=False,
upstream_checked=True,
)
assert complete is False
@@ -27,7 +27,7 @@ from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes
from hermes_cli import dashboard_procs
from hermes_cli import main_dashboard
from hermes_cli import update_cmd
from hermes_cli.update_cmd import _finish_dashboard_update_cleanup
from hermes_cli import update_cmd_maint
from hermes_cli.main_dashboard import _restart_managed_dashboard_service
from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes as _warn_stale_dashboard_processes
@@ -42,13 +42,11 @@ def _refresh_bindings_against_live_module():
patches the *new* one, so every patch becomes a no-op and the kill path
silently returns early. Refreshing the bindings keeps them consistent.
"""
global _finish_dashboard_update_cleanup
global _find_stale_dashboard_pids
global _kill_stale_dashboard_processes
global _restart_managed_dashboard_service
global _warn_stale_dashboard_processes
_finish_dashboard_update_cleanup = update_cmd._finish_dashboard_update_cleanup
_find_stale_dashboard_pids = main_dashboard._find_stale_dashboard_pids
_kill_stale_dashboard_processes = dashboard_procs._kill_stale_dashboard_processes
_restart_managed_dashboard_service = main_dashboard._restart_managed_dashboard_service
@@ -316,7 +314,7 @@ class TestDashboardUpdateCleanup:
return_value={"matched": [12345], "killed": [], "failed": [(12345, "denied")],
"unrecovered": []},
):
_finish_dashboard_update_cleanup([])
update_cmd_maint._refresh_dashboard_after_update()
assert "stopped during update" not in capsys.readouterr().out
@@ -860,7 +858,7 @@ class TestPostUpdateStaleModuleReload:
"""
def test_cleanup_reloads_before_scanning(self):
"""_finish_dashboard_update_cleanup must reload the process-scan
"""Dashboard refresh must reload the process-scan
modules BEFORE calling _kill_stale_dashboard_processes, on every
call path (git update and ZIP fallback both route here)."""
from hermes_cli import update_cmd
@@ -873,22 +871,10 @@ class TestPostUpdateStaleModuleReload:
"hermes_cli.main._kill_stale_dashboard_processes",
side_effect=lambda **kw: order.append("kill") or {"unrecovered": []},
):
update_cmd._finish_dashboard_update_cleanup([])
update_cmd_maint._refresh_dashboard_after_update()
assert order == ["reload", "kill"]
def test_node_failures_skip_reload_and_kill(self):
"""A failed Node refresh leaves the running dashboard untouched —
no reload, no kill (existing safety rule preserved)."""
from hermes_cli import update_cmd
with patch.object(update_cmd, "_reload_process_scan_modules") as mock_reload, \
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill:
update_cmd._finish_dashboard_update_cleanup(["dashboard"])
mock_reload.assert_not_called()
mock_kill.assert_not_called()
def test_reload_restores_missing_symbol(self):
"""Simulate the stale-module state: strip ``bounded_probe_run`` off
the cached module object (what an old pre-#87134 module looks like)
@@ -11,7 +11,7 @@ import urllib.request
import pytest
from hermes_cli import main as cli_main, update_cmd, update_cmd_zip, update_receipt
from hermes_cli import main as cli_main, update_cmd, update_cmd_maint, update_receipt
class DependencyBoundary(Exception):
@@ -57,7 +57,6 @@ def update_tree(tmp_path, monkeypatch):
monkeypatch.setattr(cli_main, 'PROJECT_ROOT', clone)
monkeypatch.setattr(update_receipt, '_code_identity', lambda **_: {'commit': base})
monkeypatch.setattr(cli_main, '_capture_active_lazy_features', lambda: [])
monkeypatch.setattr(cli_main, '_run_pre_update_backup', lambda *_: None)
monkeypatch.setattr(cli_main, '_pause_windows_gateways_for_update', lambda: None)
resumed = []
@@ -71,8 +70,8 @@ def update_tree(tmp_path, monkeypatch):
def stop_at_dependencies(*_args, **_kwargs):
raise DependencyBoundary()
monkeypatch.setattr(update_cmd, '_sync_python_dependencies_after_pull', stop_at_dependencies)
monkeypatch.setattr(update_cmd_zip, '_reinstall_python_deps_after_zip', stop_at_dependencies)
monkeypatch.setattr(update_cmd, '_prepare_updated_checkout', stop_at_dependencies)
monkeypatch.setattr(update_cmd_maint, '_prepare_updated_checkout', stop_at_dependencies)
repaired = []
monkeypatch.setattr(update_cmd, '_repair_current_checkout', lambda **_: repaired.append(True) or True)
monkeypatch.setattr(update_cmd, '_apply_pending_fleet_restart_catchup', lambda: None)
+9 -125
View File
@@ -1,52 +1,16 @@
"""Tests for the Windows half-updated-venv hardening (July 2026 incident).
"""Live lifecycle discovery still identifies Windows venv processes.
Covers three additions to ``hermes update``:
1. ``_venv_core_imports_healthy`` — the venv health probe that lets an
"Already up to date" checkout still repair a broken dependency install.
2. ``_detect_venv_python_processes`` — the venv-interpreter process guard
that refuses to mutate the venv while a desktop backend / stray python
holds .pyd files mapped.
3. The commit_count == 0 repair branch wiring in ``_cmd_update_impl``.
All Windows-specific paths are exercised via ``_is_windows`` patching so
they run on any host (same approach as test_update_concurrent_quarantine).
These tests exercise the retained scan, not the retired update admission gate.
"""
from __future__ import annotations
import subprocess
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import main as cli_main
from hermes_cli import update_cmd, update_cmd_windows
# ---------------------------------------------------------------------------
# _venv_core_imports_healthy
# ---------------------------------------------------------------------------
def _fake_venv_python(tmp_path, *, windows: bool = False):
bin_dir = tmp_path / "venv" / ("Scripts" if windows else "bin")
bin_dir.mkdir(parents=True)
py = bin_dir / ("python.exe" if windows else "python")
py.write_bytes(b"")
return py
# ---------------------------------------------------------------------------
# _detect_venv_python_processes
# ---------------------------------------------------------------------------
from hermes_cli import update_cmd_windows
def _proc(pid: int, exe: str, name: str, cmdline: list[str] | None = None, cwd: str = ""):
@@ -61,10 +25,8 @@ def _proc(pid: int, exe: str, name: str, cmdline: list[str] | None = None, cwd:
return proc
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_venv_python_excludes_self_and_ancestors(_winp, tmp_path):
@pytest.mark.platforms("windows")
def test_detect_venv_python_excludes_self_and_ancestors(tmp_path):
import os as _os
venv_py = str(tmp_path / "venv" / "Scripts" / "python.exe")
@@ -87,8 +49,8 @@ def test_detect_venv_python_excludes_self_and_ancestors(_winp, tmp_path):
assert update_cmd_windows._detect_venv_python_processes() == []
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_venv_python_prefetches_only_cheap_process_fields(_winp, tmp_path):
@pytest.mark.platforms("windows")
def test_detect_venv_python_prefetches_only_cheap_process_fields(tmp_path):
venv_py = str(tmp_path / "venv" / "Scripts" / "python.exe")
holder = _proc(101, venv_py, "python.exe", [venv_py, "-m", "hermes_cli.main", "serve"])
unrelated = _proc(102, r"C:\Program Files\Browser\browser.exe", "browser.exe")
@@ -119,8 +81,8 @@ def test_detect_venv_python_prefetches_only_cheap_process_fields(_winp, tmp_path
unrelated.cwd.assert_not_called()
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_venv_python_keeps_external_interpreter_fallback(_winp, tmp_path):
@pytest.mark.platforms("windows")
def test_detect_venv_python_keeps_external_interpreter_fallback(tmp_path):
external = _proc(
103,
r"C:\Python311\python.exe",
@@ -142,81 +104,3 @@ def test_detect_venv_python_keeps_external_interpreter_fallback(_winp, tmp_path)
assert [match[0] for match in matches] == [103]
external.cmdline.assert_called_once_with()
external.cwd.assert_called_once_with()
# ---------------------------------------------------------------------------
# --force vs --force-venv gating of the venv-holder guard
# ---------------------------------------------------------------------------
def _update_args(**overrides):
defaults = dict(
gateway=False,
check=False,
no_backup=True,
backup=False,
yes=True,
branch=None,
force=False,
force_venv=False,
)
defaults.update(overrides)
return SimpleNamespace(**defaults)
def _run_update_until_guard(args):
"""Drive _cmd_update_impl just far enough to hit the venv-holder guard.
Everything before the guard is stubbed; the guard firing is observed via
SystemExit(2). The first statement AFTER the guard is
``git_dir = PROJECT_ROOT / ".git"`` — a PROJECT_ROOT sentinel whose
``__truediv__`` raises marks 'guard passed'."""
class _PastGuard(Exception):
pass
class _RootSentinel:
def __truediv__(self, _other):
raise _PastGuard
with patch.object(cli_main, "_is_windows", return_value=True), patch.object(
cli_main, "_venv_scripts_dir", return_value=None
), patch.object(cli_main, "_run_pre_update_backup"), patch.object(
cli_main, "_pause_windows_gateways_for_update", return_value=None
), patch.object(
cli_main, "_resume_windows_gateways_after_update"
), patch(
"hermes_cli.update_cmd_windows._detect_venv_python_processes",
return_value=[(101, "python.exe", "python.exe -m hermes_cli.main serve")],
), patch.object(
# Pin the orphan classifier: this test exercises --force/--force-venv
# gating, not orphan detection (covered in
# test_update_orphan_backend_reap.py). None = "not provably orphaned"
# → the guard refuses exactly as before the orphan-reap addition.
cli_main, "_orphaned_desktop_backend_pids", return_value=None
), patch.object(
cli_main, "PROJECT_ROOT", _RootSentinel()
):
try:
update_cmd._cmd_update_impl(args, gateway_mode=False)
except _PastGuard:
return "past_guard"
except SystemExit as exc:
return f"exit_{exc.code}"
return "returned"
@pytest.mark.parametrize(
"force,force_venv,expected",
[
(False, False, "exit_2"), # guard fires
(True, False, "exit_2"), # plain --force does NOT bypass the venv guard
(False, True, "past_guard"), # --force-venv is the explicit escape hatch
(True, True, "past_guard"),
],
)
def test_venv_holder_guard_force_semantics(force, force_venv, expected, capsys):
result = _run_update_until_guard(_update_args(force=force, force_venv=force_venv))
assert result == expected, capsys.readouterr().out
@@ -0,0 +1,92 @@
"""PM updates do not require the currently running venv to become unoccupied."""
import os
from copy import deepcopy
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from hermes_cli import main, update_cmd, update_cmd_windows
@pytest.mark.real_concurrent_gate
@pytest.mark.parametrize(
"module,name,args,kwargs",
[
(update_cmd, "_refuse_gateway_ancestor_tree_kill", ([123, 456],), {"gateway_mode": False}),
(update_cmd, "_refuse_gateway_ancestor_tree_kill", ([123, 456],), {"gateway_mode": True}),
(main, "_filter_non_gateway_concurrent_instances", ([(123, "hermes.exe")],), {}),
(main, "_detect_concurrent_hermes_instances", (Path("Scripts"),), {"exclude_pid": 123}),
(main, "_leftover_pausable_gateway_pids", ([(123, "python.exe", "hermes serve")],), {}),
(main, "_ledger_manual_serve_holders", ([(123, "python.exe", "hermes serve")],), {}),
(main, "_ledger_reapable_backend_pids", ([(123, "python.exe", "hermes serve")],), {}),
(main, "_orphaned_desktop_backend_pids", ([(123, "python.exe", "hermes serve")],), {}),
(main, "_handoff_reapable_backend_pids", ([(123, "python.exe", "hermes serve")],), {}),
(main, "_relaunch_stopped_serves", ({"pending": True, "entries": [{"pid": 123, "port": 9000}]},), {}),
(main, "_stop_process_trees", ([123, (456, 789)],), {}),
],
)
def test_historical_holder_hooks_stop_without_inspecting_or_killing(
monkeypatch, capsys, module, name, args, kwargs,
):
import hermes_cli.gateway as gateway
from hermes_cli import process_identity
import psutil
forbidden = Mock(side_effect=AssertionError("retired holder gate performed work"))
monkeypatch.setattr(gateway, "_is_pid_ancestor_of_current_process", forbidden)
monkeypatch.setattr(update_cmd_windows, "_psutil", forbidden)
monkeypatch.setattr(process_identity, "ledger_entries", forbidden)
monkeypatch.setattr(psutil, "process_iter", forbidden)
monkeypatch.setattr(psutil, "Process", forbidden)
monkeypatch.setattr(update_cmd_windows.subprocess, "run", forbidden)
monkeypatch.setattr(os, "kill", forbidden)
before = deepcopy((args, kwargs))
with pytest.raises(SystemExit) as stopped:
# Frozen and historical main addresses must not return into the old
# updater's destructive ladder, even when asked for a read-only probe.
getattr(module, name)(*args, **kwargs)
assert stopped.value.code == 0
assert "run `hermes` again" in capsys.readouterr().err.lower()
assert (args, kwargs) == before
forbidden.assert_not_called()
def test_command_reaches_checkout_preparation_without_holder_gates(monkeypatch, tmp_path):
from hermes_cli import update_inventory
class ReachedCheckout(BaseException):
pass
reached = []
forbidden = Mock(side_effect=AssertionError("current update called a retired holder gate"))
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kwargs: None)
monkeypatch.setattr(main, "_finalize_update_output", lambda token: None)
monkeypatch.setattr(update_inventory, "collect_runtime_inventory", lambda: update_inventory.UpdatePlan())
monkeypatch.setattr(main, "_run_pre_update_backup", lambda args: reached.append("backup"))
monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: reached.append("pause"))
monkeypatch.setattr(main, "_desktop_packaged_executable", lambda root: None)
monkeypatch.setattr(main, "_desktop_dist_exists", lambda root: False)
# Recreate the former call-site names as tripwires, not host-OS fakes.
# The scan remains live for lifecycle ownership, but must not gate updates.
monkeypatch.setattr(main, "_detect_concurrent_hermes_instances", forbidden, raising=False)
monkeypatch.setattr(update_cmd, "_clear_windows_venv_holders_or_exit", forbidden, raising=False)
monkeypatch.setattr(update_cmd_windows, "_detect_venv_python_processes", forbidden)
monkeypatch.setattr(update_cmd, "_refuse_gateway_ancestor_tree_kill", forbidden)
monkeypatch.setattr(main, "_is_windows", forbidden)
monkeypatch.setattr(os, "kill", forbidden)
def prepare_checkout():
reached.append("checkout")
raise ReachedCheckout
monkeypatch.setattr(update_cmd, "_prepare_git_command", prepare_checkout)
with pytest.raises(ReachedCheckout):
main.cmd_update(SimpleNamespace(gateway=False, check=False, yes=True, force=False, force_venv=False))
assert reached == ["backup", "pause", "checkout"]
forbidden.assert_not_called()
@@ -1,152 +0,0 @@
"""Venv ownership preflight for ``hermes update`` (#83529).
A venv touched by ``sudo pip`` / ``sudo hermes`` contains root-owned files
(e.g. ``site-packages/hermes_agent-*.dist-info/INSTALLER``). A later normal
``hermes update`` then dies mid-mutation inside ``uv pip install -e .``
("Permission denied (os error 13)") with ``venv/bin/hermes`` already deleted,
bricking the CLI. The preflight refuses BEFORE the first venv mutation and
prints the exact chown recovery command.
The helper must be pure ``os.stat``/``os.scandir`` — no subprocess calls —
because update-path tests mock ``subprocess.run`` with sequenced side effects.
"""
import os
import pytest
from hermes_cli import update_cmd
import hermes_cli.update_cmd_deps as update_cmd_deps
def _make_fake_venv(tmp_path):
"""Minimal POSIX venv layout with a dist-info directory."""
venv = tmp_path / "venv"
(venv / "bin").mkdir(parents=True)
(venv / "bin" / "hermes").write_text("#!stub\n")
(venv / "bin" / "python").write_text("#!stub\n")
sp = venv / "lib" / "python3.12" / "site-packages"
dist_info = sp / "hermes_agent-1.0.0.dist-info"
dist_info.mkdir(parents=True)
(dist_info / "INSTALLER").write_text("pip\n")
(dist_info / "RECORD").write_text("\n")
(sp / "some_pkg").mkdir()
return venv
def test_all_owned_returns_empty(tmp_path):
venv = _make_fake_venv(tmp_path)
assert update_cmd._venv_foreign_owned_paths(venv) == []
def test_all_owned_preflight_proceeds(tmp_path, monkeypatch, capsys):
"""Gate is a no-op (no exit, no output) when everything is user-owned."""
_make_fake_venv(tmp_path)
update_cmd._refuse_update_if_venv_foreign_owned(tmp_path)
assert capsys.readouterr().out == ""
@pytest.mark.platforms("posix")
def test_foreign_owned_dist_info_child_detected(tmp_path, monkeypatch):
venv = _make_fake_venv(tmp_path)
installer = str(
venv / "lib" / "python3.12" / "site-packages"
/ "hermes_agent-1.0.0.dist-info" / "INSTALLER"
)
real_uid = update_cmd._path_uid
def fake_uid(path):
if str(path) == installer:
return 0 # simulate root-owned sudo-pip residue
return real_uid(path)
monkeypatch.setattr(update_cmd, "_path_uid", fake_uid)
monkeypatch.setattr(update_cmd_deps, "_path_uid", fake_uid)
foreign = update_cmd._venv_foreign_owned_paths(venv)
assert foreign == [(installer, 0)]
@pytest.mark.platforms("posix")
def test_foreign_owned_refuses_with_chown_hint(tmp_path, monkeypatch, capsys):
venv = _make_fake_venv(tmp_path)
hermes_bin = str(venv / "bin" / "hermes")
real_uid = update_cmd._path_uid
monkeypatch.setattr(
update_cmd,
"_path_uid",
lambda p: 0 if str(p) == hermes_bin else real_uid(p),
)
monkeypatch.setattr(
update_cmd_deps,
"_path_uid",
lambda p: 0 if str(p) == hermes_bin else real_uid(p),
)
with pytest.raises(SystemExit) as exc:
update_cmd._refuse_update_if_venv_foreign_owned(tmp_path)
assert exc.value.code == 1
out = capsys.readouterr().out
assert hermes_bin in out
assert "owner uid 0" in out
assert f"sudo chown -R $(id -un): {tmp_path}" in out
assert "Nothing in the venv was modified." in out
def test_limit_caps_reported_paths(tmp_path, monkeypatch):
venv = _make_fake_venv(tmp_path)
bin_dir = venv / "bin"
for i in range(10):
(bin_dir / f"tool{i}").write_text("x")
monkeypatch.setattr(
update_cmd,
"_path_uid",
lambda p: 0 if str(p).startswith(str(bin_dir) + os.sep) else 12345,
)
monkeypatch.setattr(
update_cmd_deps,
"_path_uid",
lambda p: 0 if str(p).startswith(str(bin_dir) + os.sep) else 12345,
)
monkeypatch.setattr(update_cmd.os, "geteuid", lambda: 12345, raising=False)
foreign = update_cmd._venv_foreign_owned_paths(venv, limit=3)
assert len(foreign) == 3
def test_no_geteuid_returns_empty(tmp_path, monkeypatch):
"""Windows (no os.geteuid) skips the preflight entirely."""
venv = _make_fake_venv(tmp_path)
class _NoGeteuidOS:
def __getattr__(self, name):
if name == "geteuid":
raise AttributeError(name)
return getattr(os, name)
monkeypatch.setattr(update_cmd, "os", _NoGeteuidOS())
assert update_cmd._venv_foreign_owned_paths(venv) == []
def test_running_as_root_returns_empty(tmp_path, monkeypatch):
venv = _make_fake_venv(tmp_path)
monkeypatch.setattr(update_cmd.os, "geteuid", lambda: 0, raising=False)
# Even with foreign uids everywhere, root skips the gate.
monkeypatch.setattr(update_cmd, "_path_uid", lambda p: 4242)
monkeypatch.setattr(update_cmd_deps, "_path_uid", lambda p: 4242)
assert update_cmd._venv_foreign_owned_paths(venv) == []
def test_never_raises_on_scandir_permission_error(tmp_path, monkeypatch):
venv = _make_fake_venv(tmp_path)
def boom(path):
raise PermissionError(13, "Permission denied", str(path))
monkeypatch.setattr(update_cmd.os, "scandir", boom)
assert update_cmd._venv_foreign_owned_paths(venv) == []
def test_never_raises_on_missing_venv(tmp_path):
assert update_cmd._venv_foreign_owned_paths(tmp_path / "no-venv") == []
def test_path_uid_none_on_oserror(tmp_path):
assert update_cmd._path_uid(tmp_path / "does-not-exist") is None
@@ -1,37 +1,102 @@
"""A failed dependency transaction must stop the ZIP update, not report success."""
from types import SimpleNamespace
import subprocess
from unittest.mock import patch
import pytest
import pm
import hermes_cli.main as main
from hermes_cli import update_cmd_zip
from hermes_cli import update_cmd, update_cmd_maint, update_cmd_zip
def test_zip_dependency_failure_propagates_before_followup_mutations(monkeypatch):
monkeypatch.setattr(pm, "ensure", lambda *_args, **_kwargs: None)
def fail(*args, **kwargs):
raise pm.InstallError("venv", "network unavailable")
monkeypatch.setattr(pm, "sync_venv", fail)
def forbidden(*args, **kwargs):
raise AssertionError("follow-up mutation after failed sync")
monkeypatch.setattr(main, "_refresh_active_memory_provider_dependencies", forbidden)
with pytest.raises(pm.InstallError, match="network unavailable"):
update_cmd_zip._reinstall_python_deps_after_zip()
def test_zip_dependency_failure_propagates_before_followup_mutations(tmp_path, monkeypatch):
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
with (
patch.object(update_cmd_zip, "_abort_zip_update_if_dirty_tree"),
patch.object(update_cmd_zip, "_download_and_swap_zip"),
patch.object(update_cmd_maint, "_sweep_bytecode_after_update"),
patch.object(
update_cmd_maint, "_prepare_updated_checkout",
side_effect=pm.InstallError("venv", "network unavailable"),
) as prepare,
patch.object(update_cmd_maint, "_print_bundled_skills_sync_report") as skills,
patch.object(update_cmd_maint, "_print_verified_update_completion") as summary,
):
with pytest.raises(pm.InstallError, match="network unavailable"):
update_cmd_zip._update_via_zip(SimpleNamespace(branch="main"))
prepare.assert_called_once_with(tmp_path, desktop=False)
skills.assert_not_called()
summary.assert_not_called()
def test_pull_dependency_failure_keeps_recovery_marker(tmp_path, monkeypatch):
from hermes_cli import update_cmd_deps, update_cmd
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(main, "_refuse_update_for_contended_shims", lambda *a: None, raising=False)
monkeypatch.setattr(update_cmd, "_write_update_incomplete_marker", lambda: None)
def fail(*a, **k):
raise pm.InstallError("venv", "sync stopped")
monkeypatch.setattr(pm, "sync_venv", fail)
def forbidden(*a, **k):
raise AssertionError("must retain recovery marker")
monkeypatch.setattr(main, "_clear_update_incomplete_marker", forbidden)
with pytest.raises(pm.InstallError, match="sync stopped"):
update_cmd_deps._sync_python_dependencies_after_pull(
["git"], "main", "a" * 40, active_lazy_features=[],
_windows_gateway_resume=[], desktop_dir=tmp_path, had_desktop_app_before_update=False,
)
post_pull_sha = "b" * 40
with (
patch.object(update_cmd, "_verify_head_after_pull", return_value=post_pull_sha),
patch.object(update_cmd, "_sweep_bytecode_after_update"),
patch.object(
update_cmd, "_prepare_updated_checkout",
side_effect=pm.InstallError("venv", "sync stopped"),
) as prepare,
patch.object(update_cmd, "_run_post_update_maintenance") as maintenance,
patch.object(update_cmd, "_restart_gateway_fleet_after_update") as restart,
):
with pytest.raises(pm.InstallError, match="sync stopped"):
update_cmd._apply_pulled_update(
["git"], "main", "a" * 40, SimpleNamespace(in_place_update=False),
SimpleNamespace(assume_yes=True, gw_input_fn=None),
gateway_mode=False, is_fork=False, desktop_dir=tmp_path / "apps" / "desktop",
had_desktop_app_before_update=False, pre_update_snapshot_id=None,
_pre_update_plan=None, _windows_gateway_resume=[],
)
prepare.assert_called_once_with(tmp_path, desktop=False)
marker = update_cmd._fleet_restart_pending_marker_path()
assert f"expected_sha={post_pull_sha}" in marker.read_text(encoding="utf-8")
maintenance.assert_not_called()
restart.assert_not_called()
@pytest.mark.parametrize("route", ["direct", "git-failure"])
@pytest.mark.parametrize("gateway_mode", [False, True])
@pytest.mark.parametrize("complete", [False, True])
def test_zip_callers_propagate_completion(tmp_path, monkeypatch, route, gateway_mode, complete):
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(update_cmd, "_update_via_zip", lambda *args, **kwargs: complete)
exit_markers = []
monkeypatch.setattr(update_cmd, "_write_gateway_update_exit_code", exit_markers.append)
resumed = []
args = SimpleNamespace(branch="main")
if route == "direct":
monkeypatch.setattr(update_cmd, "_resolve_update_options", lambda *args: SimpleNamespace(
gw_input_fn=None, assume_yes=True))
monkeypatch.setattr(update_cmd, "_begin_update_receipt_and_plan", lambda args: None)
monkeypatch.setattr(main, "_run_pre_update_backup", lambda args: None)
monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: None)
monkeypatch.setattr(main, "_resume_windows_gateways_after_update", resumed.append)
monkeypatch.setattr(main, "_desktop_packaged_executable", lambda root: None)
monkeypatch.setattr(main, "_desktop_dist_exists", lambda root: False)
monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (True, [], False))
monkeypatch.setattr(update_cmd, "_source_update_channel", lambda args: "main")
def invoke():
update_cmd._cmd_update_impl(args, gateway_mode=gateway_mode)
else:
monkeypatch.setattr(update_cmd, "_should_zip_fallback_on_update_error", lambda error: True)
def invoke():
update_cmd._handle_update_called_process_error(
subprocess.CalledProcessError(1, ["git", "fetch"]), args, gateway_mode, False)
if complete:
invoke()
else:
with pytest.raises(SystemExit) as failure:
invoke()
assert failure.value.code == 1
assert exit_markers == ([complete] if gateway_mode else [])
assert resumed == ([None] if route == "direct" else [])
@@ -1,11 +1,8 @@
"""Cross-platform unit tests for the venv-holder message classifier (#90778)."""
"""Cross-platform unit tests for the Hermes command-line classifier (#90778)."""
import pytest
from hermes_cli.update_cmd import (
_format_venv_python_holders_message,
_hermes_holder_subcommand,
)
from hermes_cli.update_cmd_windows import _hermes_holder_subcommand
class TestHolderSubcommand:
@@ -39,32 +36,3 @@ class TestHolderSubcommand:
)
def test_parses_subcommand(self, cmdline, expected):
assert _hermes_holder_subcommand(cmdline) == expected
class TestHolderMessage:
def _msg(self, cmdline):
return _format_venv_python_holders_message([(4242, "python.exe", cmdline)])
def test_dashboard_not_labeled_desktop_backend(self):
message = self._msg(r"C:\v\Scripts\python.exe -m hermes_cli.main dashboard")
assert "close the desktop app" not in message.lower()
assert "hermes dashboard" in message
def test_preserve_cache_not_labeled_serve(self):
message = self._msg(r"python -m hermes_cli.main kanban --preserve-cache")
holder_line = next(l for l in message.splitlines() if "PID 4242" in l)
# the holder LINE gets no serve/desktop hint (generic footer text
# legitimately mentions the desktop app)
assert "←" not in holder_line
def test_serve_gets_backend_hint(self):
message = self._msg(r"python -m hermes_cli.main serve --host 127.0.0.1 --port 0")
assert "Hermes backend" in message
def test_gateway_hint(self):
message = self._msg(r"python -m hermes_cli.main gateway run")
assert "← gateway" in message
def test_unknown_argv_gets_no_hint(self):
message = self._msg(r"python -c import this")
assert "←" not in message
@@ -119,73 +119,6 @@ class TestDetection:
_kill(proc)
class TestClassification:
def test_pausable_exemption_sees_long_path_gateway(self):
"""#78089 follow-through: `_leftover_pausable_gateway_pids` must
classify the long-path gateway as pausable (not None)."""
from hermes_cli.update_cmd import _leftover_pausable_gateway_pids
padding = os.path.join("C:\\", "Users", "y" * 90, ".hermes-runtime")
proc = _spawn([padding, "-m", "hermes_cli.main", "gateway", "run"])
try:
matches = [m for m in _detect() if m[0] == proc.pid]
assert matches, "gateway not detected"
pids = _leftover_pausable_gateway_pids(matches)
assert pids == [proc.pid], (
f"pausable exemption failed for long-path gateway: {pids}"
)
finally:
_kill(proc)
def test_serve_backend_not_classified_pausable(self):
"""#81774 premise probe: a serve backend is NOT pausable today —
pinning current behavior so the consolidation change is visible."""
from hermes_cli.update_cmd import _leftover_pausable_gateway_pids
proc = _spawn(["-m", "hermes_cli.main", "serve"])
try:
matches = [m for m in _detect() if m[0] == proc.pid]
assert matches, "serve backend not detected"
assert _leftover_pausable_gateway_pids(matches) is None
finally:
_kill(proc)
class TestHolderMessage:
"""#90778 — the refusal message must name holders accurately."""
def test_dashboard_not_labeled_desktop_backend(self):
from hermes_cli.update_cmd import _format_venv_python_holders_message
proc = _spawn(["-m", "hermes_cli.main", "dashboard"])
try:
matches = [m for m in _detect() if m[0] == proc.pid]
assert matches, "dashboard process not detected"
message = _format_venv_python_holders_message(matches)
assert "close the desktop app" not in message.lower(), (
"standalone `hermes dashboard` mislabeled as the Desktop "
f"backend (#90778):\n{message}"
)
finally:
_kill(proc)
def test_substring_subcommand_not_mislabeled(self):
"""`--preserve-cache` contains 'serve'; the classifier must not
label an unrelated subcommand as the Desktop backend (#90778)."""
from hermes_cli.update_cmd import _format_venv_python_holders_message
proc = _spawn(["-m", "hermes_cli.main", "kanban", "--preserve-cache"])
try:
matches = [m for m in _detect() if m[0] == proc.pid]
assert matches, "kanban process not detected"
message = _format_venv_python_holders_message(matches)
assert "close the desktop app" not in message.lower(), (
f"substring match mislabeled `--preserve-cache` (#90778):\n{message}"
)
finally:
_kill(proc)
class TestAncestorExclusion:
"""#87594 — when the updater is a CHILD of the gateway (/update path),
ancestor-exclusion must not hide the gateway from the scan entirely:
@@ -255,61 +188,6 @@ class TestAncestorExclusion:
)
class TestConcurrentGateClassification:
"""#37039 — the pre-update concurrent-instance gate must classify LIVE
processes: gateway runtimes drop out of the abort list (the pause
machinery owns them), everything else keeps aborting the update."""
def test_live_gateway_process_classified_gateway(self):
"""A real process whose argv carries `-m hermes_cli.main gateway run`
classifies as ``gateway`` via real psutil against the live table."""
from hermes_cli.update_cmd import _classify_concurrent_instance
proc = _spawn(["-m", "hermes_cli.main", "gateway", "run"])
try:
assert _classify_concurrent_instance(proc.pid) == "gateway"
finally:
_kill(proc)
def test_live_non_gateway_processes_keep_the_abort(self):
"""A REPL-shaped process and a gateway MANAGEMENT command both
classify as ``non-gateway`` — they stay in the abort list."""
from hermes_cli.update_cmd import _classify_concurrent_instance
repl = _spawn(["-m", "hermes_cli.main"])
mgmt = _spawn(["-m", "hermes_cli.main", "gateway", "status"])
try:
assert _classify_concurrent_instance(repl.pid) == "non-gateway"
assert _classify_concurrent_instance(mgmt.pid) == "non-gateway"
finally:
_kill(repl, mgmt)
def test_live_filter_drops_only_the_gateway(self):
"""End-to-end filter over a mixed live process set: the gateway PID
drops, the serve-backend PID stays, a dead PID stays (unknown)."""
from hermes_cli.update_cmd import (
_filter_non_gateway_concurrent_instances,
)
gw = _spawn(["-m", "hermes_cli.main", "gateway", "run"])
backend = _spawn(["-m", "hermes_cli.main", "serve", "--port", "8127"])
dead = _spawn([])
_kill(dead) # reaped → unreadable cmdline → unknown → kept
try:
matches = [
(gw.pid, "hermes.exe"),
(backend.pid, "hermes.exe"),
(dead.pid, "hermes.exe"),
]
kept = _filter_non_gateway_concurrent_instances(matches)
kept_pids = {pid for pid, _ in kept}
assert gw.pid not in kept_pids, "gateway must drop from abort list"
assert backend.pid in kept_pids, "serve backend must keep aborting"
assert dead.pid in kept_pids, "unknown must keep aborting"
finally:
_kill(gw, backend)
class TestUpdaterOwnedBackendDeferral:
"""#98336 — ledger-verified serve/dashboard holders defer to the CLI
updater's stop/relaunch rungs instead of dead-ending the Desktop
+82 -289
View File
@@ -1,16 +1,4 @@
"""Tests for _web_ui_build_needed — staleness check for the web UI dist.
The freshness check uses a SHA-256 content hash of the web source tree
(mirroring the desktop build), recorded in a stamp file under $HERMES_HOME,
NOT mtime comparison — so ``git pull`` / ``hermes update`` that rewrite
source mtimes without changing content no longer fool it.
Critical invariant: the dashboard Vite build outputs to hermes_cli/web_dist/
(vite.config.ts: outDir: "../../hermes_cli/web_dist"), NOT web/dist/.
The sentinel must be checked in the correct output directory or the
freshness check is a no-op and the OOM rebuild always runs.
"""
"""Web launch keeps freshness/serialization but never masks a failed build."""
import os
import time
from pathlib import Path
@@ -18,9 +6,11 @@ from unittest.mock import patch
import pytest
from hermes_cli.main_web_build import _build_web_ui, _run_npm_install_deterministic
from hermes_cli.main_web_build import _web_ui_build_needed, _compute_web_ui_content_hash, _missing_web_build_tool, _web_ui_stamp_path, _write_web_ui_build_stamp
from hermes_cli.update_cmd import _web_build_toolchain_ready, _web_toolchain_roots
from hermes_cli.main_web_build import (
_build_web_ui, _web_ui_build_needed, _compute_web_ui_content_hash,
_web_ui_stamp_path, _write_web_ui_build_stamp,
)
from tests.hermes_cli.test_source_build import source_checkout, source_products, _events # noqa: F401
@pytest.fixture(autouse=True)
@@ -63,9 +53,6 @@ class TestWebUIBuildNeeded:
_write_web_ui_build_stamp(self._root(web_dir), web_dir)
def test_mtime_only_change_is_not_stale(self, tmp_path):
"""The whole point: bumping mtimes without changing bytes (what
``git pull`` / ``hermes update`` do) must NOT report stale."""
@@ -83,7 +70,6 @@ class TestWebUIBuildNeeded:
assert _web_ui_build_needed(web_dir) is False
def test_content_hash_is_deterministic(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
(web_dir / "src").mkdir(parents=True, exist_ok=True)
@@ -106,184 +92,8 @@ class TestWebUIBuildNeeded:
assert data["contentHash"] == _compute_web_ui_content_hash(self._root(web_dir), web_dir)
class TestBuildWebUISkipsWhenFresh:
def test_web_install_omits_workspace_and_scrubs_esbuild_override(
self, tmp_path, monkeypatch
):
"""web/ with its own lockfile => _workspace_root returns web_dir, so
--workspace web would fail (npm can't find that workspace from inside
web/). The flag must be dropped and the install run plainly from web_dir.
Symmetric to the TUI fix in test_tui_npm_install.py. See #42973.
With web's own lockfile present at cwd, _run_npm_install_deterministic
uses ``npm ci`` (not ``npm install``). The shared installer must also
remove an inherited esbuild binary override so package/binary versions
cannot diverge (#87405).
"""
web_dir, _ = _make_web_dir(tmp_path)
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setenv("ESBUILD_BINARY_PATH", "/opt/esbuild-0.28.2")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp) as mock_build:
result = _build_web_ui(web_dir)
assert result is True
args, kwargs = mock_run.call_args
assert "--workspace" not in args[0]
assert Path(args[0][0]).name in {"npm", "npm.cmd"}
assert args[0][1:] == ["ci", "--include=dev", "--silent", "--prefer-offline"]
assert kwargs["cwd"] == web_dir
assert "ESBUILD_BINARY_PATH" not in kwargs["env"]
assert "ESBUILD_BINARY_PATH" not in mock_build.call_args.kwargs["env"]
def test_workspace_root_install_names_update_closure(self, tmp_path, monkeypatch):
"""From the workspace root, _build_web_ui must install the SAME
closure as `hermes update` (ui-tui + web + --include-workspace-root).
The install helper prefers `npm ci`, which deletes node_modules before
reifying the requested tree — a narrower `--workspace web`-only pass
right after the update step silently pruned root devDependencies and
the ui-tui workspace while exiting 0. See #43564/#64354.
"""
web_dir, _ = _make_web_dir(tmp_path)
# Root lockfile only => _workspace_root(web_dir) == tmp_path.
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
(tmp_path / "ui-tui").mkdir()
(tmp_path / "ui-tui" / "package.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("PREFIX", "/usr")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp):
result = _build_web_ui(web_dir)
assert result is True
args, kwargs = mock_run.call_args
cmd = args[0]
assert "--include-workspace-root" in cmd
assert cmd.count("--workspace") == 2
assert "ui-tui" in cmd and "web" in cmd
assert kwargs["cwd"] == tmp_path
def test_workspace_root_install_skips_missing_ui_tui(self, tmp_path, monkeypatch):
"""A checkout without the ui-tui workspace must not name it — npm
fails hard on a --workspace that doesn't exist."""
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("PREFIX", "/usr")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp):
result = _build_web_ui(web_dir)
assert result is True
cmd = mock_run.call_args[0][0]
assert "ui-tui" not in cmd
assert "--include-workspace-root" in cmd
assert "web" in cmd
def test_web_build_uses_idle_timeout_helper(self, tmp_path):
"""npm run build now goes through _run_with_idle_timeout (issue #33788).
The install step keeps its capture_output behavior (the existing
retry-on-EPERM contract depends on it); only the long-running build
step is streamed + idle-killed.
"""
web_dir, _ = _make_web_dir(tmp_path)
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=install_cp), \
patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp) as mock_idle:
result = _build_web_ui(web_dir)
assert result is True
# Build was invoked through the idle-timeout helper, not subprocess.run.
mock_idle.assert_called_once()
args, kwargs = mock_idle.call_args
# Positional: [npm, "run", "build"]; cwd passed as kwarg.
assert args[0][-2:] == ["run", "build"]
assert kwargs["cwd"] == web_dir
class TestBuildWebUIRetryAndStaleFallback:
"""Coverage for the retry + stale-dist fallback added in #23824 / issue #23817."""
def test_retries_build_once_on_failure(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
Subprocess = __import__("subprocess")
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
# build attempt 1: fail; build attempt 2: success.
build_fail = Subprocess.CompletedProcess([], 1, stdout="EPERM", stderr="")
build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main_web_build._time.sleep") as mock_sleep, \
patch("hermes_cli.main.subprocess.run", return_value=install_ok), \
patch("hermes_cli.main_web_build._run_with_idle_timeout",
side_effect=[build_fail, build_ok]) as mock_idle:
result = _build_web_ui(web_dir)
assert result is True
assert mock_idle.call_count == 2 # build + retry
mock_sleep.assert_called_once_with(3)
def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):
web_dir, dist_dir = _make_web_dir(tmp_path)
# Stale dist exists but is older than source
_touch(dist_dir / "index.html", offset=-100)
_touch(web_dir / "src" / "App.tsx") # newer source -> build_needed=True
Subprocess = __import__("subprocess")
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
build_fail = Subprocess.CompletedProcess([], 1, stdout="vite ENOMEM", stderr="")
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main_web_build._time.sleep"), \
patch("hermes_cli.main.subprocess.run", return_value=install_ok), \
patch("hermes_cli.main_web_build._run_with_idle_timeout",
side_effect=[build_fail, build_fail]):
result = _build_web_ui(web_dir, fatal=True)
# MUST return True (serve stale) — issue #23817 — even with fatal=True,
# because cmd_dashboard passes fatal=True and is the primary caller.
assert result is True
out = capsys.readouterr().out
assert "serving stale dist as fallback" in out
assert "vite ENOMEM" in out # combined output surfaced to user
@pytest.mark.platforms("linux")
class TestBuildWebUIFlock:
"""Cross-process build serialization (salvaged from PR #63455).
One process builds under an exclusive flock on <root>/.web_ui_build.lock;
contenders either serve the existing (possibly stale) dist or, when no
dist exists yet, block until the builder finishes. The staleness walk
itself runs inside _do_build_web_ui, i.e. under the lock, so a process
that queued behind a successful build skips the rebuild.
"""
def test_contended_lock_without_dist_waits_then_skips_fresh_build(self, tmp_path):
"""First-ever build race: the waiter blocks, and once it acquires the
lock the callee's own staleness check (running under the lock) sees
@@ -307,8 +117,7 @@ class TestBuildWebUIFlock:
t = threading.Timer(0.2, release_after_building)
t.start()
try:
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run") as mock_run:
with patch("hermes_cli.source_build.source_build_env", side_effect=AssertionError("fresh build must skip preparation")) as mock_run:
result = build(web_dir)
finally:
t.join()
@@ -316,104 +125,88 @@ class TestBuildWebUIFlock:
assert result is True
mock_run.assert_not_called() # fresh after the wait -> no rebuild
def test_lock_file_is_gitignored(self):
gitignore = Path(__file__).resolve().parents[2] / ".gitignore"
assert ".web_ui_build.lock" in gitignore.read_text(encoding="utf-8")
@pytest.mark.platforms("posix")
def test_web_build_prepares_once_and_skips_a_current_product(source_products):
root, acquired = source_products
assert _build_web_ui(root / "web", fatal=True)
assert [event["step"] for event in _events(root)] == ["deps", "icons", "web"]
assert acquired == ["npm"]
assert not _web_ui_build_needed(root / "web")
assert _build_web_ui(root / "web", fatal=True)
assert acquired == ["npm"]
assert len(_events(root)) == 3
def _link_shims(bin_dir: Path, *names: str) -> None:
bin_dir.mkdir(parents=True, exist_ok=True)
for name in names:
(bin_dir / name).touch()
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("fatal", [False, True])
def test_web_failure_is_not_success_even_with_an_old_dist(source_products, fatal):
root, acquired = source_products
dist = root / "hermes_cli/web_dist/index.html"
dist.parent.mkdir(parents=True)
dist.write_text("old product")
(root / "fail-web").touch()
assert not _build_web_ui(root / "web", fatal=fatal)
assert acquired == ["npm"]
assert [event["step"] for event in _events(root)] == ["deps", "icons", "web"]
assert dist.read_text() == "old product"
assert not _web_ui_stamp_path().exists()
class TestWebBuildToolchainReady:
"""A tree is ready when the build can resolve tsc AND vite from any root.
``npm run build`` searches ``node_modules/.bin`` from the script's own
package up through every ancestor, so a shim in either place counts.
"""
def test_missing_toolchain_is_not_ready(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
assert _web_build_toolchain_ready(web_dir, tmp_path) is False
@pytest.mark.platforms("posix")
def test_failed_preparation_never_runs_web_compilation(source_products):
root, acquired = source_products
(root / "package-lock.json").write_text("not json")
assert not _build_web_ui(root / "web", fatal=True)
assert acquired == ["npm"]
assert _events(root) == []
assert not _web_ui_stamp_path().exists()
def test_hoisted_shims_at_workspace_root_are_ready(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
_link_shims(tmp_path / "node_modules" / ".bin", "tsc", "vite")
assert _web_build_toolchain_ready(web_dir, tmp_path) is True
@pytest.mark.platforms("linux")
def test_web_rebuild_reuses_the_existing_desktop_union(source_products):
from hermes_cli.source_build import build_update_products
root, acquired = source_products
build_update_products(root, desktop=True)
before = _events(root)
(root / "web/changed.ts").write_text("changed web source")
assert _build_web_ui(root / "web", fatal=True)
assert _events(root) == [*before, {"step": "icons"}, {"step": "web"}]
assert acquired == ["npm", "npm"]
assert (root / "node_modules/apps-desktop").exists()
@pytest.mark.parametrize("shim", ["tsc.cmd", "tsc.ps1", "tsc.exe"])
def test_windows_shim_extensions_count(self, tmp_path, shim):
web_dir, _ = _make_web_dir(tmp_path)
_link_shims(tmp_path / "node_modules" / ".bin", shim, "vite.cmd")
assert _web_build_toolchain_ready(web_dir, tmp_path) is True
@pytest.mark.platforms("linux")
def test_contended_stale_dist_waits_for_the_lock_holder(tmp_path):
import fcntl
import threading
web, dist = _make_web_dir(tmp_path)
dist.mkdir(parents=True)
(dist / "index.html").write_text("stale")
holder = open(tmp_path / ".web_ui_build.lock", "a")
fcntl.flock(holder, fcntl.LOCK_EX)
def finish_build():
(dist / "index.html").write_text("winner")
_write_web_ui_build_stamp(tmp_path, web)
holder.close()
worker = threading.Timer(2, finish_build)
worker.start()
try:
assert _build_web_ui(web, fatal=True)
at_return = (dist / "index.html").read_text()
finally:
worker.join()
assert at_return == "winner", "a contended stale index must not count as success"
class TestWebToolchainRoots:
def test_searches_the_package_and_its_workspace_root(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
assert _web_toolchain_roots(web_dir) == (web_dir, tmp_path)
class TestMissingWebBuildTool:
"""Every shell words an unresolvable binary differently."""
@pytest.mark.parametrize(
"output,expected",
[
("sh: 1: tsc: not found\nnpm error code 127", "tsc"),
("bash: line 1: vite: command not found", "vite"),
("'tsc' is not recognized as an internal or external command", "tsc"),
("error TS2307: Cannot find module './x'", None),
("", None),
],
)
def test_detects_the_unresolvable_tool(self, output, expected):
assert _missing_web_build_tool(output) == expected
class TestBuildRecoversFromMissingToolchain:
def test_reinstalls_and_retries_when_the_build_cannot_resolve_tsc(self, tmp_path):
"""The generic retry reruns the same command, so it can't fix this alone."""
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
install_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_fail = __import__("subprocess").CompletedProcess(
[], 127, stdout="sh: 1: tsc: not found\n", stderr=""
)
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main_web_build._run_with_idle_timeout", side_effect=[build_fail, build_ok]) as mock_build, \
patch("hermes_cli.main_web_build._web_ui_build_needed", return_value=True), \
patch("hermes_cli.main_web_build._write_web_ui_build_stamp"), \
patch("hermes_cli.main_web_build._time.sleep"):
result = _build_web_ui(web_dir)
assert result is True
assert mock_install.call_count == 2
assert mock_build.call_count == 2
def test_healthy_tree_builds_without_an_extra_install(self, tmp_path):
"""No pre-build probing: a build that works is never second-guessed."""
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
_link_shims(web_dir / "node_modules" / ".bin", "tsc", "vite")
install_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_ok) as mock_build, \
patch("hermes_cli.main_web_build._web_ui_build_needed", return_value=True), \
patch("hermes_cli.main_web_build._write_web_ui_build_stamp"):
result = _build_web_ui(web_dir)
assert result is True
assert mock_install.call_count == 1
assert mock_build.call_count == 1
@pytest.mark.platforms("posix")
def test_lock_open_failure_does_not_start_an_unprotected_build(source_products):
root, acquired = source_products
(root / ".web_ui_build.lock").mkdir()
assert not _build_web_ui(root / "web", fatal=True)
assert acquired == []
assert _events(root) == []
+47 -2
View File
@@ -1,5 +1,9 @@
"""Historical main imports must not restart pre-PM updater work after a swap."""
import builtins
from copy import deepcopy
import importlib
import io
import os
from pathlib import Path
import socket
@@ -18,7 +22,7 @@ def inert_main(monkeypatch):
def forbidden(*args, **kwargs):
pytest.fail("historical main shim attempted updater work")
for name in ("sync_venv", "ensure_environment", "build_environment"):
for name in ("ensure", "sync_venv", "ensure_environment", "build_environment", "ensure_python_tool"):
monkeypatch.setattr(pm, name, forbidden)
for name in ("Popen", "run"):
monkeypatch.setattr(subprocess, name, forbidden)
@@ -63,6 +67,47 @@ def test_historical_main_data_and_skipped_probes_preserve_caller_shapes(inert_ma
assert env == {"VIRTUAL_ENV": str(tmp_path)}
@pytest.mark.parametrize(
"name,args,kwargs",
[
("_capture_active_lazy_features", (), {}),
("_refresh_active_lazy_features", (), {}),
("_refresh_active_lazy_features", (["browser"],), {}),
("_refresh_active_lazy_features", (["uv", "pip"],),
{"env": {"VIRTUAL_ENV": "venv"}, "features": ["browser"]}),
("_refresh_active_memory_provider_dependencies", (), {}),
("_npm_lockfile_changed", (Path("checkout"),), {}),
("_write_update_incomplete_marker", (), {}),
("_reload_updated_runtime_modules", (), {}),
],
)
def test_historical_main_lazy_dependency_hooks_stop_without_work(
name, args, kwargs, inert_main, monkeypatch, capsys,
):
main, forbidden = inert_main
before_args = deepcopy((args, kwargs))
before_env = dict(os.environ)
with monkeypatch.context() as guard:
# Exercise PEP 562 even if an earlier test already cached this export.
# The temporary slot also makes monkeypatch restore an absent attribute.
guard.setitem(main.__dict__, name, None)
guard.delitem(main.__dict__, name)
guard.setattr(importlib, "reload", forbidden)
guard.setattr(builtins, "open", forbidden)
guard.setattr(io, "open", forbidden)
for _ in range(2): # both the cold lookup and cached historical caller
with pytest.raises(SystemExit) as exc:
try:
getattr(main, name)(*args, **kwargs)
except Exception:
forbidden()
forbidden()
assert exc.value.code == 0
assert "run `hermes` again" in capsys.readouterr().err.lower()
assert (args, kwargs) == before_args
assert dict(os.environ) == before_env
def test_historical_main_entrypoints_stop_before_install_or_success_fallback(
inert_main, tmp_path, capsys,
):
@@ -104,7 +149,7 @@ def test_historical_main_entrypoints_stop_before_install_or_success_fallback(
# Returning also lets old callers claim completion or try a fallback.
forbidden()
assert exc.value.code == 0, name
assert "relaunch" in capsys.readouterr().err.lower(), name
assert "run `hermes` again" in capsys.readouterr().err.lower(), name
assert cmd == ["uv", "pip", "install", "-e", "."]
assert env == {"VIRTUAL_ENV": str(tmp_path)}
assert failed == []
+62 -6
View File
@@ -1,5 +1,8 @@
"""The post-swap import boundary must never revive retired installers."""
import builtins
from copy import deepcopy
import io
import importlib
import importlib.util
import os
@@ -20,7 +23,7 @@ def no_external_work(monkeypatch):
def forbidden(*args, **kwargs):
pytest.fail("old-updater shim attempted external work")
for name in ("sync_venv", "ensure_environment", "build_environment"):
for name in ("ensure", "sync_venv", "ensure_environment", "build_environment", "ensure_python_tool"):
monkeypatch.setattr(pm, name, forbidden)
monkeypatch.setattr(subprocess, "Popen", forbidden)
monkeypatch.setattr(os, "system", forbidden)
@@ -53,7 +56,7 @@ def test_retired_managed_uv_stops_before_fallback(name, args, kwargs, no_externa
getattr(module, name)(*args, **kwargs)
assert exc.value.code == 0
output = capsys.readouterr()
assert "relaunch" in (output.out + output.err).lower()
assert "run `hermes` again" in (output.out + output.err).lower()
assert dict(os.environ) == before_env
assert set(Path(os.environ["HERMES_HOME"]).rglob("*")) == before_home
@@ -70,7 +73,7 @@ def test_ensure_uv_stops_both_historical_return_contracts(unpack, no_external_wo
# A falsy result is NOT inert: old callers install through pip instead.
subprocess.run([uv, "pip", "install"] if uv else [sys.executable, "-m", "pip", "install"])
assert exc.value.code == 0
assert "relaunch" in capsys.readouterr().err.lower()
assert "run `hermes` again" in capsys.readouterr().err.lower()
@pytest.mark.parametrize(
@@ -90,10 +93,63 @@ def test_other_dependency_entrypoints_stop_cleanly(module, name, args, kwargs, n
shim = getattr(importlib.import_module(module), name)
shim(*args, **kwargs)
assert exc.value.code == 0
assert "relaunch" in capsys.readouterr().err.lower()
assert "run `hermes` again" in capsys.readouterr().err.lower()
assert set(Path(os.environ["HERMES_HOME"]).rglob("*")) == before_home
@pytest.mark.parametrize(
"module,name,args,kwargs",
[
("update_cmd", "_capture_active_lazy_features", (), {}),
("update_cmd", "_refresh_active_lazy_features", (), {}),
("update_cmd", "_refresh_active_lazy_features", (["browser"],), {}),
("update_cmd", "_refresh_active_lazy_features", (["uv", "pip"],),
{"env": {"VIRTUAL_ENV": "venv"}, "features": ["browser"]}),
("update_cmd", "_refresh_active_memory_provider_dependencies", (), {}),
("update_cmd", "_npm_lockfile_changed", (Path("checkout"),), {}),
("update_cmd", "_update_node_dependencies", (), {}),
("update_cmd", "_rebuild_desktop_after_update", (Path("desktop"),),
{"had_desktop_app_before_update": True}),
("update_cmd", "_rebuild_desktop_after_update", (Path("desktop"),),
{"had_desktop_app_before_update": False}),
("update_cmd", "_path_uid", (Path("venv"),), {}),
("update_cmd", "_write_update_incomplete_marker", (), {}),
("update_cmd", "_write_lazy_refresh_incomplete_marker", (), {}),
("update_cmd", "_reload_updated_runtime_modules", (), {}),
("update_cmd_maint", "_reload_updated_runtime_modules", (), {}),
],
)
def test_retired_dependency_hooks_stop_before_fallback_or_completion(
module, name, args, kwargs, no_external_work, monkeypatch, capsys,
):
# Resolve real exports, including imports moved out of update_cmd_deps.
shim = getattr(importlib.import_module(f"hermes_cli.{module}"), name)
before_args = deepcopy((args, kwargs))
before_env = dict(os.environ)
before_modules = dict(sys.modules)
with monkeypatch.context() as guard:
for attr in ("write_text", "write_bytes", "touch", "rename", "replace", "unlink", "mkdir"):
guard.setattr(Path, attr, no_external_work)
for attr in ("rename", "replace", "unlink", "mkdir"):
guard.setattr(os, attr, no_external_work)
guard.setattr(importlib, "reload", no_external_work)
guard.setattr(builtins, "open", no_external_work)
guard.setattr(io, "open", no_external_work)
with pytest.raises(SystemExit) as exc:
try:
shim(*args, **kwargs)
except Exception:
# Old dependency callers retry on ordinary exceptions. Returning
# either false or true can install again or report false success.
no_external_work()
no_external_work()
assert exc.value.code == 0
assert "run `hermes` again" in capsys.readouterr().err.lower()
assert (args, kwargs) == before_args
assert dict(os.environ) == before_env
assert all(sys.modules.get(name) is module for name, module in before_modules.items())
def test_retired_probes_and_refreshes_do_no_work(no_external_work, tmp_path):
from hermes_cli import _install_repair, backup, banner, config, main, update_cmd
from tools import browser_tool
@@ -135,7 +191,7 @@ def test_old_android_updater_stops_before_download(old_updater, no_external_work
with pytest.raises(SystemExit) as exc:
old_updater._install_psutil_android_compat(["uv", "pip"])
assert exc.value.code == 0
assert "relaunch" in capsys.readouterr().err.lower()
assert "run `hermes` again" in capsys.readouterr().err.lower()
def test_old_updater_retains_its_code_but_loads_new_managed_uv(old_updater, no_external_work, capsys, tmp_path):
@@ -166,7 +222,7 @@ def test_old_updater_retains_its_code_but_loads_new_managed_uv(old_updater, no_e
)
assert exc.value.code == 0
assert prefix == ["ownership", "self-lock", "old-marker"]
assert "relaunch" in capsys.readouterr().err.lower()
assert "run `hermes` again" in capsys.readouterr().err.lower()
assert set(tmp_path.rglob("*")) == before
@@ -39,6 +39,13 @@ entry shims stop the old updater cleanly and ask for a relaunch instead of invok
PM or falling back to pip. Completion belongs to the new launcher, not that mixed
old-code/new-files process.
Current source updates use one PM sync for the recorded extras and enabled
plugins, then build frontend products in a fresh process on the selected
Python. A retry on an already-current checkout follows the same path.
Dependency or build failures stop completion; the updater does not retry
through pip, reinstall providers separately, or create incomplete markers.
Use `hermes pm repair` for damaged dependency files.
## Source installs and packaged builds
Source installers provision the required tools plus Python. They select the