"""Hermes update pipeline: dispatchers (``_cmd_update_impl``/``_cmd_update_check``) + git plumbing. Each concern lives in ``update_cmd_.py`` and is re-imported here so ``hermes_cli.update_cmd.`` keeps resolving (and stays monkeypatchable). Imports are one-way: main -> update_cmd -> update_cmd_*; ``_m()`` resolves ``hermes_cli.main`` at call time. """ import logging from contextlib import suppress import os import shlex import shutil # noqa: F401 (tests patch update_cmd.shutil.*; split modules resolve it here) import subprocess import sys import time as _time from dataclasses import dataclass from pathlib import Path from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd) from hermes_cli import update_handoff as _update_handoff from hermes_cli.update_cmd_common import _best_effort from hermes_constants import get_default_hermes_root, project_venv_dir, venv_python_path # Re-exports: every split-module name stays reachable (and monkeypatchable) as update_cmd.. from hermes_cli.update_abort_recovery import ( # noqa: F401 _abort_recovery_is_complete, _qualified_serve_skips, _recover_gateway_restart_after_abort, _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, _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, _refresh_bootstrap_cache_scripts, _refresh_windows_gateway_launchers, _refuse_gateway_ancestor_tree_kill, _relaunch_stopped_serves, _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, _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 _FLEET_RESTART_PENDING_NAME, _FRESH_RESTART_SUPERVISORS, _GatewayRestartOutcome, _apply_pending_fleet_restart_catchup, _clear_fleet_restart_pending_marker, _current_checkout_sha, _defer_fleet_restart_after_update, _drain_or_signal_gateway_for_update, _fleet_probe_expected_runtimes, _fleet_restart_pending_marker_path, _for_each_systemd_gateway_unit, _gateway_recovery_partition, _gateway_service_matches_profile, _pending_fleet_restart_needed, _receipt_looks_unfinished, _receipt_reports_stale_runtime, _resolve_manage_cmd, _restart_gateway_fleet_after_update, _restart_launchd_gateway_after_update, _restart_macos_launchd_gateways, _restart_phase_failure_is_incomplete, _restart_systemd_gateway_units, _restart_systemd_gateway_units_best_effort, _run_pending_fleet_restart, _service_restart_sec, _service_unit_supports_graceful_sigusr1_restart, _surviving_gateway_pids_after_failed_restart, _systemctl, _systemctl_reset_and_restart, _verify_fleet_after_update, _wait_for_service_active, _warn_gateway_restart_phase_aborted, _warn_incomplete_gateway_fleet_restart, _warn_pending_fleet_restart, _warn_pending_fleet_restart_on_startup, _write_fleet_restart_pending_marker, _write_gateway_update_exit_code) from hermes_cli.update_cmd_zip import ( # noqa: F401 _ZIP_PRESERVED_TOP_LEVEL, _ZIP_STAGING_ARTIFACT_SUFFIXES, _abort_zip_update_if_dirty_tree, _atomic_replace_dir, _commit_staged_replacements, _discard_staged, _finish_zip_update, _is_zip_preserved_entry_status_line, _is_zip_staging_artifact_status_line, _stage_replacement, _update_via_zip, _zip_overlay_block_reason) from hermes_cli.update_cmd_stash import ( # noqa: F401 _AUTOSTASH_NAME_PREFIX, _AUTOSTASH_WARN_AGE_DAYS, _discard_stashed_changes, _git_untracked_paths, _park_stashed_changes, _print_stash_cleanup_guidance, _reject_unsafe_stash_restore, _resolve_stash_selector, _restore_stashed_changes, _restored_python_paths, _stash_apply_failed_only_on_existing_untracked, _stash_local_changes_if_needed, _warn_orphaned_update_autostashes) from hermes_cli.update_cmd_config import ( # noqa: F401 _LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs, _print_items, _run_config_check_fresh, _run_migrate_config_fresh) from hermes_cli.update_cmd_deps import ( # noqa: F401 _INSTALL_DEFINING_FILES, _SELF_LOCKING_NATIVE_MODULES, _UPDATE_CRITICAL_MODULES, _abort_dependency_sync_if_self_locked, _capture_active_lazy_features, _capture_active_tool_dependencies, _critical_module_import_failures, _defer_update_for_self_lock, _dependency_sync_would_rewrite, _desktop_app_present, _detect_self_loaded_native_modules, _editable_install_is_current, _ensure_uv_for_termux, _ensure_venv_pip, _install_psutil_android_compat, _is_android_python, _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, _reapply_plugin_python_dependencies, _refresh_active_memory_provider_dependencies, _refuse_update_if_venv_foreign_owned, _repair_node_deps_on_current_checkout, _restore_active_tool_dependencies, _sync_python_dependencies_after_pull, _update_node_dependencies, _upgrade_pip_before_lazy_refresh, _validate_critical_modules_import, _venv_core_imports_healthy, _venv_dependency_set_stale, _venv_foreign_owned_paths, _web_build_toolchain_ready, _web_toolchain_roots) 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, _branch_head_label, _branch_head_suffix, _classify_fetch_failure, _count_commits_between, _discard_lockfile_churn, _ensure_non_trampoline_git, _get_origin_url, _git_is_trampoline, _has_upstream_remote, _is_fork, _locate_real_git, _mark_skip_upstream_prompt, _normalize_managed_eol, _portable_git_candidates, _print_fetch_failure, _print_parked_branch_kept_notice, _print_parked_branch_skip_warning, _prune_orphan_rescue_refs, _should_skip_upstream_prompt, _sync_fork_with_upstream, _sync_with_upstream_if_needed) from hermes_cli.update_cmd_maint import ( # noqa: F401 _PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _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, _print_verified_update_completion, _read_project_version, _resolve_pre_update_backup_mode, _restore_state_db_from_snapshot, _run_post_update_maintenance, _run_pre_update_backup, _sweep_bytecode_after_update, _update_complete_message, _verify_and_restore_one_state_db, _verify_and_restore_state_dbs_post_update) logger = logging.getLogger(__name__) def _m(): """Lazy ``hermes_cli.main`` handle: keeps main-side test patches effective, import one-way.""" from hermes_cli import main return main def _updates_config() -> dict: """The ``updates:`` config section (``{}`` when absent/malformed); may raise on config errors.""" from hermes_cli.config import load_config section = (load_config() or {}).get("updates", {}) return section if isinstance(section, dict) else {} def _no_prompt_git_kwargs() -> dict: """``subprocess.run`` kwargs for network git: a 401 (GitHub outage) would block forever on ``Username for ...``; disable only the *prompt* (credential helpers still run) so it fails fast.""" env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"} return {"stdin": subprocess.DEVNULL, "env": env} # CLI-startup files (+ web_server.py, launched by a fresh Windows Desktop install) that must # parse post-update; the syntax guard rolls back when one doesn't. _UPDATE_CRITICAL_FILES = ( "hermes_cli/main.py", "hermes_cli/config.py", "hermes_cli/__init__.py", "hermes_cli/web_server.py", "cli.py", "run_agent.py", "model_tools.py", "toolsets.py", "hermes_constants.py") def _record_update_step(step: str, ok: bool, detail: str = "") -> None: """Best-effort ``update_receipt.record_step``; the receipt must never break an update.""" with suppress(Exception): from hermes_cli.update_receipt import record_step record_step(step, ok, detail) # A fetch whose transport dead-stalls (HTTP/2 to GitHub on some networks, a black-holed proxy) # otherwise leaves `hermes update` on "Fetching updates..." forever (#93759, #95777). Five # minutes is generous for a scoped single-branch fetch and still ends in a real error. NETWORK_GIT_TIMEOUT_SECONDS = 300 def _record_update_skip(step: str, reason: str) -> None: """Best-effort ``update_receipt.record_skip``; the receipt must never break an update.""" with suppress(Exception): from hermes_cli.update_receipt import record_skip record_skip(step, reason) def _record_pre_update_backup_outcome(args, snapshot_id) -> None: """Record the pre-update backup as a skip when it was disabled, else as a step. ``snapshot_id`` is None both when the backup was deliberately turned off (config ``updates.pre_update_backup: off``/``false``, or ``--no-backup``) and when a requested backup produced nothing. Recording both as ``ok=false, "disabled or failed"`` made an opt-out indistinguishable from a real failure in the receipt, so a disabled safety net read as a broken one (#94944 is the shipped-opt-out case). A deliberate opt-out is a SKIP WITH its reason; only a requested-but-empty backup is a failed step. """ if snapshot_id: _record_update_step("pre_update_backup", True, f"snapshot={snapshot_id}") return if _resolve_pre_update_backup_mode(args) == "off": reason = ("disabled by --no-backup" if getattr(args, "no_backup", False) else "disabled by updates.pre_update_backup (mode: off)") _record_update_skip("pre_update_backup", reason) return _record_update_step("pre_update_backup", False, "no snapshot captured") def _git_run(git_cmd, args, cwd=None, *, check=False, network=False): """Run git capturing utf-8 text (default cwd: checkout); ``network=True`` disables the terminal prompt so an HTTP 401 fails fast instead of hanging, and bounds the wait.""" try: return subprocess.run( git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", check=check, **({"timeout": NETWORK_GIT_TIMEOUT_SECONDS, **_no_prompt_git_kwargs()} if network else {})) except subprocess.TimeoutExpired as exc: # subprocess.run already killed the child; the checkout stays consistent because # fetch writes to tmp_pack_* and only renames on success. Report as a failed run # so every caller's existing stderr path prints one clear line. result = subprocess.CompletedProcess( exc.cmd, 124, stdout="", stderr=f"git {args[0]} timed out after {NETWORK_GIT_TIMEOUT_SECONDS}s with no response from the remote") if check: raise subprocess.CalledProcessError(124, exc.cmd, output="", stderr=result.stderr) from exc return result def _capture_head_sha(git_cmd, cwd) -> str | None: """Return the current HEAD SHA, or None if it can't be resolved.""" try: result = _git_run(git_cmd, ["rev-parse", "HEAD"], cwd, check=True) return result.stdout.strip() or None except (subprocess.CalledProcessError, OSError): return None def _validate_python_files_syntax(root, relpaths) -> tuple[bool, str | None, str | None]: """Compile *relpaths* under *root*; the .pyc goes to a temp dir, not ``__pycache__/`` (no race with test workers, no stale pyc for another interpreter).""" import py_compile import tempfile root = Path(root) with tempfile.TemporaryDirectory(prefix="hermes-syntax-check-") as tmpdir: for relpath in relpaths: path = root / relpath if not path.exists(): continue cfile = Path(tmpdir) / (str(relpath).replace("/", "__") + "c") try: py_compile.compile(str(path), cfile=str(cfile), doraise=True) except py_compile.PyCompileError as exc: return False, str(path), str(exc) except OSError as exc: return False, str(path), f"could not read: {exc}" return True, None, None def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]: """Compile ``_UPDATE_CRITICAL_FILES`` -> ``(ok, failing_path, error_message)``.""" return _validate_python_files_syntax(root, _UPDATE_CRITICAL_FILES) def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) -> str: """File-based IPC prompt for ``--gateway``: write a marker the gateway forwards to the messenger, poll for a response file, fall back to *default* on timeout.""" import json as _json import uuid as _uuid from hermes_constants import get_hermes_home # noqa: F811 (deliberate: constants variant) home = get_hermes_home() prompt_path, response_path = home / ".update_prompt.json", home / ".update_response" response_path.unlink(missing_ok=True) payload = {"prompt": prompt_text, "default": default, "id": str(_uuid.uuid4())} tmp = prompt_path.with_suffix(".tmp") tmp.write_text(_json.dumps(payload), encoding="utf-8") tmp.replace(prompt_path) deadline = _time.monotonic() + timeout while _time.monotonic() < deadline: if response_path.exists(): with suppress(OSError, ValueError): answer = response_path.read_text(encoding="utf-8").strip() response_path.unlink(missing_ok=True) prompt_path.unlink(missing_ok=True) return answer if answer else default _time.sleep(0.5) prompt_path.unlink(missing_ok=True) response_path.unlink(missing_ok=True) print(f" (no response after {int(timeout)}s, using default: {default!r})") return default def _called_process_error_cmd_parts(exc: subprocess.CalledProcessError) -> list[str]: """Normalize ``CalledProcessError.cmd`` into argv-style tokens.""" cmd = exc.cmd if cmd is None: return [] if isinstance(cmd, (str, bytes)): text = cmd.decode("utf-8", "replace") if isinstance(cmd, bytes) else cmd try: return shlex.split(text, posix=os.name != "nt") except ValueError: return text.split() return [str(part) for part in cmd] def _called_process_error_is_git(exc: subprocess.CalledProcessError) -> bool: """True when the failed subprocess was git itself.""" parts = _called_process_error_cmd_parts(exc) if not parts: return False # Windows argv may use backslashes; POSIX basename() would keep the whole path. name = os.path.basename(parts[0].replace("\\", "/")).lower() return name in {"git", "git.exe"} def _called_process_error_is_python_dep_install(exc: subprocess.CalledProcessError) -> bool: """True when the failed subprocess was a uv/pip (or ensurepip) install.""" parts = [part.lower() for part in _called_process_error_cmd_parts(exc)] if not parts: return False exe = os.path.basename(parts[0].replace("\\", "/")) return "ensurepip" in parts or ("install" in parts and ( "pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"})) def _format_update_failure_stage(exc: subprocess.CalledProcessError) -> str: """Name the failed stage: git pull and dep install share one ``try``, and calling every CalledProcessError a git failure misled users and keyed the ZIP overlay on exception *type* rather than on git actually failing. See #85840, #87304. """ if _called_process_error_is_python_dep_install(exc): return "Python dependency install failed" if _called_process_error_is_git(exc): return "Git update failed" return "Update step failed" def _shim_quarantine_error_type() -> "type[BaseException]": """Strict-quarantine refusal type via ``_m()``; falls back to a never-raised private type when main.py lacks it (torn mid-update tree) so the ``except`` stays valid.""" cls = getattr(_m(), "ShimQuarantineError", None) if isinstance(cls, type) and issubclass(cls, BaseException): return cls class _Never(Exception): pass return _Never def _refuse_update_for_contended_shims(exc: BaseException) -> None: """Fail closed when live shims could not be quarantined: a rename failing every retry proves a holder without FILE_SHARE_DELETE, and installing anyway strands the venv between versions. The code swap is already committed; only the dep install is deferred (via the update-incomplete marker). Exits 2 so the receipt records a refusal, not a failure. See #87331. """ print("✗ Cannot continue the update: live Hermes launcher(s) could not be") print(" moved aside:") for name in getattr(exc, "failed_shims", []) or ["hermes.exe"]: print(f" {name}") print(" Another process is holding this install's venv — typically Hermes") print(" Desktop, a gateway, or another hermes REPL — and mutating the venv") print(" now would strand it half-updated.") print(" The dependency install has been deferred: close the process(es)") print(" above, then run any `hermes` command to finish it automatically.") # Idempotent (git path already dropped it); covers ZIP/repair paths so the deferral is never silent. _write_update_incomplete_marker() sys.exit(2) def _should_zip_fallback_on_update_error(exc: BaseException) -> bool: """ZIP fallback is only for Windows git file-I/O breakage: after a dep-install failure the pull already succeeded, so a ZIP overlay can't fix it and would replace every top-level entry except venv/node_modules/.git/.env, deleting uncommitted and untracked files.""" return ( isinstance(exc, subprocess.CalledProcessError) and _m()._is_windows() and _called_process_error_is_git(exc)) def _print_called_process_error_tail(exc: subprocess.CalledProcessError, *, limit: int = 12) -> None: """Print a captured stderr/stdout tail when the failing call recorded one.""" blob = exc.stderr or exc.stdout or "" if isinstance(blob, bytes): blob = blob.decode("utf-8", "replace") lines = [line for line in str(blob).splitlines() if line.strip()] if not lines: return print(" Last output:") for line in lines[-limit:]: print(f" {line}") def _invalidate_update_cache(): """Delete the update-check cache for ALL profiles: the repo is shared, so one profile's update makes every profile current and a stale "commits behind" banner would linger.""" default_home = get_default_hermes_root() profiles_root = default_home / "profiles" homes = [default_home] if profiles_root.is_dir(): homes += [entry for entry in profiles_root.iterdir() if entry.is_dir()] for home in homes: with suppress(Exception): (home / ".update_check").unlink(missing_ok=True) 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") 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") def _format_concurrent_instances_message(matches: list[tuple[int, str]], scripts_dir: Path) -> str: """Explanation + remediation hint for the Windows concurrent-hermes.exe gate.""" shim = scripts_dir / "hermes.exe" lines = [ "✗ Another hermes.exe is running:", *(f" PID {pid} {name}" for pid, name in matches), "", f" Updating now would fail to overwrite {shim} because", " Windows blocks REPLACE on a running executable.", "", " Close Hermes Desktop, exit any open `hermes` REPLs, and", " stop the gateway (`hermes gateway stop`) before retrying.", ""] if matches: pid_args = " ".join(f"/PID {pid}" for pid, _ in matches) lines += [ " If you've already closed everything and these PIDs are", " stale, terminate them directly, then retry the update:", f" taskkill {pid_args} /F", ""] lines += [ " Override with `hermes update --force` if you've already", " confirmed those processes will not write to the venv."] return "\n".join(lines) def _classify_concurrent_instance(pid: int) -> str: """Classify ``pid`` as "gateway" / "non-gateway" / "unknown" (psutil can't read it). Uses ``_is_pausable_gateway`` (same matcher as the Desktop preflight and venv-holder guard) so "gateway" is exactly what the pause/restart machinery stops; "unknown" gates as non-gateway.""" try: import psutil # noqa: PLC0415 cmdline_list = psutil.Process(int(pid)).cmdline() except Exception: return "unknown" from hermes_cli._scan_venv_blockers import _is_pausable_gateway # noqa: PLC0415 return "gateway" if _is_pausable_gateway(" ".join(cmdline_list or [])) else "non-gateway" def _filter_non_gateway_concurrent_instances(matches: list[tuple[int, str]]) -> list[tuple[int, str]]: """Drop gateway matches (the pause + post-update restart machinery handles them); anything else (TUI, Desktop backend child, another REPL) has no pause path, so the gate aborts.""" return [(pid, name) for pid, name in matches if _classify_concurrent_instance(pid) != "gateway"] def _log_only_write(text: str) -> None: """Write to update.log only: reaches past the ``_UpdateOutputStream`` stdout mirror so loud, low-signal subprocess output stays debuggable without flooding the terminal.""" if not text: return stream = _m().sys.stdout log_file = getattr(stream, "_log", None) with suppress(Exception): if log_file is None: log_path = get_hermes_home() / "logs" / "update.log" log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8") as fallback: fallback.write(text) else: log_file.write(text) log_file.flush() def _run_logged_subprocess(cmd, *, cwd=None, env=None): """Stream combined build output to update.log, retaining it for failure reporting.""" import codecs import io from hermes_cli._subprocess_compat import kill_process_tree, windows_hide_flags child_env = dict(os.environ if env is None else env) child_env.setdefault("PYTHONUNBUFFERED", "1") spawn = {"creationflags": windows_hide_flags()} if os.name == "nt" else {"process_group": 0} proc = subprocess.Popen( cmd, cwd=cwd, env=child_env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **spawn) # read1 delivers partial lines too; incremental decoding preserves split UTF-8 # and the universal-newline behavior callers previously got from text=True. decoder = io.IncrementalNewlineDecoder(codecs.getincrementaldecoder("utf-8")("replace"), True) output = [] try: while True: chunk = proc.stdout.read1(8192) text = decoder.decode(chunk, final=not chunk) output.append(text) _log_only_write(text) if not chunk: break return subprocess.CompletedProcess(cmd, proc.wait(), stdout="".join(output)) except BaseException: # Unlike Popen.__exit__, do not wait for a cancelled build to finish. kill_process_tree(proc) with suppress(subprocess.TimeoutExpired): proc.wait(timeout=5) raise finally: proc.stdout.close() def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): """``hermes update --check``: fetch and report without installing. ``branch_explicit`` is True iff --branch was passed (Docker installs print a notice instead of dropping the flag).""" # Same marker-first admission gate as the apply path, so --check never reports git # state for an install whose real update mechanism is an image pull. from hermes_cli.update_contract import evaluate_update_admission, record_refusal_receipt refusal = evaluate_update_admission(_m().PROJECT_ROOT) if refusal is not None: print(refusal.message) record_refusal_receipt(refusal) sys.exit(2) git_dir = _m().PROJECT_ROOT / ".git" if not git_dir.exists(): print("✗ Not a git repository — cannot check for updates.") sys.exit(1) git_cmd = _base_git_cmd() # Interrupted fetches leave .git/*.lock behind ("File exists" forever); self-heal first. from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs for lock_path in clear_stale_git_locks(_m().PROJECT_ROOT): print(f" (removed stale git lock: {lock_path})") # Aborted fetches also strand tmp_pack_* debris (has reached 6 GB and corrupted the # pack dir); same age+process safety contract as the locks. swept = clear_stale_tmp_packs(_m().PROJECT_ROOT) if swept: print(f" (removed {len(swept)} aborted-fetch pack temp file(s))") # Fetch only (a bare fetch pulls thousands of auto-generated branches). Prefer # upstream only for main (a fork's other branches have no upstream counterpart). Installer # checkouts are shallow: a plain fetch would unshallow them and rev-list would report a # bogus huge "behind" count, so fetch --depth 1 and report presence-only. is_shallow = _is_shallow_checkout(git_cmd) depth_args = ["--depth", "1"] if is_shallow else [] # Probe locally for an 'upstream' remote before a network fetch non-forks always fail. fetch_result = None if branch == "main" and _git_run(git_cmd, ["remote", "get-url", "upstream"]).returncode == 0: print("→ Fetching from upstream...") fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["upstream", branch], network=True) if fetch_result is not None and fetch_result.returncode == 0: compare_branch = f"upstream/{branch}" else: print("→ Fetching from origin...") fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["origin", branch], network=True) compare_branch = f"origin/{branch}" if fetch_result.returncode != 0: _print_fetch_failure(fetch_result.stderr) sys.exit(1) if is_shallow: # The depth-1 fetch above leaves the previous tip behind as a ``.git/shallow`` graft # (git never removes old grafts); prune the stale ones so the file stops growing and # merge-base / the orphan-divergence heuristic keep working (#105951). from hermes_cli.gitlock import repair_broken_shallow_boundaries, prune_stale_shallow_grafts repaired = repair_broken_shallow_boundaries(_m().PROJECT_ROOT) if repaired: print(f" (restored {repaired} broken shallow boundary(ies))") pruned = prune_stale_shallow_grafts(_m().PROJECT_ROOT) if pruned: print(f" (pruned {pruned} stale shallow graft(s) left by past depth-1 checks)") # rev-list on a bogus ref exits 128 and (check=True) would traceback; verify first. verify_result = _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", compare_branch]) if verify_result.returncode != 0: print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.") sys.exit(1) if is_shallow: # No history across the shallow boundary: compare tip SHAs, then recover the # exact count via the GitHub compare API (complete graph). head_sha, target_sha = _tip_shas(git_cmd, compare_branch) if head_sha and target_sha and head_sha == target_sha: print("✓ Already up to date.") return from hermes_cli.banner import _github_compare_behind # counted == 0 means local-ahead, not behind; None means the API could not count. _print_update_check_result(_github_compare_behind(head_sha, target_sha), compare_branch) return rev_result = _git_run(git_cmd, ["rev-list", f"HEAD..{compare_branch}", "--count"], check=True) _print_update_check_result(int(rev_result.stdout.strip()), compare_branch) def _base_git_cmd() -> list[str]: """``git`` argv; Windows adds ``-c windows.appendAtomically=false`` (git can fail "unable to write loose object file: Invalid argument" on non-atomic appends).""" if sys.platform == "win32": return ["git", "-c", "windows.appendAtomically=false"] return ["git"] def _is_shallow_checkout(git_cmd) -> bool: return _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true" def _tip_shas(git_cmd, target_ref: str) -> tuple[str, str]: """``(HEAD sha, sha)`` as printed by rev-parse ("" when unresolvable).""" return tuple(_git_run(git_cmd, ["rev-parse", ref]).stdout.strip() for ref in ("HEAD", target_ref)) def _print_update_check_result(behind: int | None, compare_branch: str) -> None: """Report ``--check``'s verdict: up to date, N commits behind, or behind by an unknown count.""" if behind == 0: print("✓ Already up to date.") return if behind is not None: print(f"☤ Update available: {behind} {'commit' if behind == 1 else 'commits'} behind {compare_branch}.") else: print(f"☤ Update available (behind {compare_branch}).") from hermes_cli.config import recommended_update_command print(f" Run '{recommended_update_command()}' to install.") def _repair_venv_on_current_checkout( *, assume_yes, gateway_mode, pre_update_snapshot_id, had_desktop_app_before_update, active_lazy_features, active_tool_dependencies, _windows_gateway_resume) -> bool: """Reinstall ``.[all]`` + lazy/tool deps into an unhealthy (or handed-off) venv; returns whether the checkout can be reported complete.""" # Self-lock deferral: the repair rewrites the venv too (same mapped-extension hazard). # See #86735. # Self-lock deferral (relocated preflight — #86735): if THIS process holds a native extension the sync # must rewrite, defer NOW — after the code swap, so only the dependency install is pending and the next # fresh launch completes it via the marker. _m()._abort_dependency_sync_if_self_locked(_windows_gateway_resume) _write_update_incomplete_marker() from hermes_cli.managed_uv import ensure_uv repair_uv = ensure_uv() # Venv gone entirely (repair interrupted after the old one was moved aside): recreate. venv_dir = project_venv_dir(_m().PROJECT_ROOT) or _m().PROJECT_ROOT / "venv" venv_python_missing = not venv_python_path(venv_dir, windows=_m()._is_windows()).exists() if venv_python_missing and repair_uv: print("→ Recreating virtual environment...") subprocess.run([repair_uv, "venv", venv_dir.name], cwd=_m().PROJECT_ROOT, check=False) repair_prefix, repair_env = _pip_install_prefix(repair_uv) _m()._install_python_dependencies_with_optional_fallback(repair_prefix, env=repair_env, group="all") _m()._refresh_active_lazy_features(repair_prefix, env=repair_env, features=active_lazy_features) _m()._restore_active_tool_dependencies(active_tool_dependencies, repair_prefix, env=repair_env) # Same order as the pull and ZIP paths: the ``[all]`` reinstall above may have stripped the # active memory provider's bridge packages (torch, embedding stacks, ...). _m()._refresh_active_memory_provider_dependencies() _m()._reapply_plugin_python_dependencies() # 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() 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 print("✓ Dependencies repaired!") # The hand-off child never reaches the commits-pulled Node/web/Desktop # phase. Finish through the current-checkout repair path, whose npm digest # gate keeps this cheap when the pulled manifests did not change. 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="✓ Update complete!", had_desktop_app_before_update=had_desktop_app_before_update, ) def _pip_install_prefix(uv_bin) -> tuple[list[str], dict | None]: """``(install prefix, env)``: ``uv pip`` isolated from third-party UV env vars (so a foreign UV_PYTHON_INSTALL_DIR can't hijack it), else ``sys.executable -m pip`` (avoids PEP 668 errors).""" if uv_bin: # Same third-party UV-env isolation as the main update path (#83914): a user-level # UV_PYTHON_INSTALL_DIR / UV_PYTHON from unrelated software must not steer which interpreter uv # resolves here. # See #83914. from hermes_cli.managed_uv import managed_python_env env = managed_python_env() env["VIRTUAL_ENV"] = str(project_venv_dir(_m().PROJECT_ROOT) or _m().PROJECT_ROOT / "venv") return [uv_bin, "pip"], env return [sys.executable, "-m", "pip"], None def _repair_current_checkout( *, assume_yes, gateway_mode, pre_update_snapshot_id, had_desktop_app_before_update, active_lazy_features, active_tool_dependencies, 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.""" # "No new commits" != safe interpreter: uv can keep the same CPython patch while # python-build-standalone refreshes the embedded SQLite; keep the boundary hook here too. from hermes_cli.managed_uv import ensure_uv, update_managed_uv runtime_repairs = [] update_managed_uv(repair_observer=runtime_repairs.append) repair_uv = ensure_uv(repair_observer=runtime_repairs.append) runtime_repaired = next((result for result in runtime_repairs if result.repaired), None) # A current checkout does NOT imply a healthy install (a prior sync may have died # partway, e.g. Windows locked .pyd); probe or "Already up to date!" hides a bricked venv. healthy, detail = _venv_core_imports_healthy() # The Windows shim hand-off child is current BY DESIGN; its one job is the pending sync, # not venv health — without this it would print "Already up to date!" and skip it. handed_off_sync = os.environ.get(_m()._UPDATE_REEXEC_ENV) == "1" # Importable is not synced: a venv installed from an older release imports fine while its # pins lag the checkout (the sync after the pull was refused or died, #97208). stale, stale_detail = ( _venv_dependency_set_stale() if healthy and not handed_off_sync else (False, "")) 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...") elif stale: print("⚠ Checkout is current, but its dependencies were never synced after the last pull:") print(f" {stale_detail}") print("→ Syncing Python dependencies...") if handed_off_sync or not healthy or stale: current_checkout_complete = _repair_venv_on_current_checkout( assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, _windows_gateway_resume=_windows_gateway_resume) else: if runtime_repaired is not None: # A successful SQLite repair swaps in a venv built from uv.lock alone, so core # imports pass while lazily-installed backends and ``hermes tools`` dependencies # are gone (#112571). Restore the pre-cutover snapshots exactly as the pull path # and the unhealthy-venv repair do; the healthy core set needs no reinstall. repair_prefix, repair_env = _pip_install_prefix(repair_uv) # Same marker discipline as the pull path: an interrupted or failed lazy restore # must leave the breadcrumb so the next `hermes` run finishes the repair. _write_lazy_refresh_incomplete_marker() if _m()._refresh_active_lazy_features( repair_prefix, env=repair_env, features=active_lazy_features): _m()._clear_lazy_refresh_incomplete_marker() else: print(" ⚠ Lazy-refresh recovery incomplete — run `hermes` again " "to finish import-based venv repair.") _m()._restore_active_tool_dependencies( active_tool_dependencies, repair_prefix, env=repair_env) # Same order as the pull path: the swapped-in venv was built from uv.lock alone. _m()._refresh_active_memory_provider_dependencies() _m()._reapply_plugin_python_dependencies() current_checkout_complete = _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) if runtime_repaired is not None and not _m()._is_windows(): print() print("⚠ Restart required to finish the managed Python runtime repair.") print( " Any running Hermes gateways, Desktop backends, or other " "long-lived processes still use the previous runtime.") print(" Restart each of them to pick up the repaired runtime.") return current_checkout_complete def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: """Fast-forward failed: merge on a custom branch (local commits survive) or reset --hard on the same branch (rescue ref first when histories share no ancestor). ``sys.exit(1)`` on failure.""" # A custom branch (local commits atop origin/) also can't ff, and reset --hard # would discard that work: merge instead, stop on conflict. _cur_branch = (_git_run(git_cmd, ["branch", "--show-current"]).stdout or "").strip() if _cur_branch and _cur_branch != branch: print( f" ⚠ Checkout is on custom branch '{_cur_branch}' — " f"merging origin/{branch} instead of resetting so local commits survive...") # Best-effort safety tag as a recovery anchor. _git_run(git_cmd, ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"]) if _git_run(git_cmd, ["merge", "--no-edit", f"origin/{branch}"]).returncode != 0: _git_run(git_cmd, ["merge", "--abort"]) print("✗ Merge conflict between local commits and upstream — update stopped, nothing was changed.") print(f" Resolve manually: cd {_m().PROJECT_ROOT} && git merge origin/{branch}") print(" Then re-run the update. Local work is untouched.") sys.exit(1) return # Same branch: a true upstream force-push/rebase; local changes are stashed, so reset. # Orphan divergence (no common ancestor: corrupted HEAD, re-init) would lose the whole # local graph, so park pre_pull_sha behind a rescue ref first. merge_base_result = _git_run(git_cmd, ["merge-base", "HEAD", f"origin/{branch}"]) has_common_ancestor = merge_base_result.returncode == 0 and merge_base_result.stdout.strip() if not has_common_ancestor and pre_pull_sha: from datetime import datetime as _dt, timezone # SHA suffix so two updates in the same second get distinct refs. rescue_ref = ( f"refs/hermes-update-backups/orphan-{branch}-" f"{_dt.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{pre_pull_sha[:12]}") head = f" ⚠ Local history shares no common ancestor with origin/{branch} (orphan divergence) — " if _git_run(git_cmd, ["update-ref", rescue_ref, pre_pull_sha]).returncode == 0: print( f"{head}backed up current HEAD to {rescue_ref} before resetting. " f"This backup expires after {_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days.") else: # update-ref failure is intentionally non-fatal, but never claim a backup exists. print( f"{head}attempted to back up current HEAD to {rescue_ref} before resetting, " f"but the backup write failed (pre-reset SHA was {pre_pull_sha}).") _prune_orphan_rescue_refs(git_cmd, _m().PROJECT_ROOT, branch) print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...") reset_result = _git_run(git_cmd, ["reset", "--hard", f"origin/{branch}"]) if reset_result.returncode != 0: print(f"✗ Failed to reset to origin/{branch}.") if reset_result.stderr.strip(): print(f" {reset_result.stderr.strip()}") print(f" Try manually: git fetch origin && git reset --hard origin/{branch}") sys.exit(1) def _rollback_if_pulled_syntax_error(git_cmd, pre_pull_sha) -> None: """Post-pull syntax guard: roll back to *pre_pull_sha* and ``sys.exit(1)`` when a critical file no longer compiles (a bad admin-merge past CI must not brick the CLI).""" syntax_ok, failing_path, syntax_error = _validate_critical_files_syntax(_m().PROJECT_ROOT) if syntax_ok: return print() print("✗ Pulled code has a syntax error in a critical file:") print(f" {failing_path}") # py_compile errors can be multi-line; show enough for the SyntaxError text. for line in str(syntax_error).splitlines()[:6] if syntax_error else (): print(f" {line}") print() if pre_pull_sha: print(f"→ Rolling back to {pre_pull_sha[:10]}...") rollback_result = _git_run(git_cmd, ["reset", "--hard", pre_pull_sha]) if rollback_result.returncode == 0: print(" ✓ Rollback complete — your install is unchanged.") print(" Try ``hermes update`` again later once a fix lands.") else: print(" ✗ Rollback failed. Recover manually with:") print(f" cd {_m().PROJECT_ROOT} && git reset --hard {pre_pull_sha}") if rollback_result.stderr.strip(): print(f" ({rollback_result.stderr.strip().splitlines()[0]})") else: print(" Could not capture pre-pull SHA — recover manually with:") print(f" cd {_m().PROJECT_ROOT} && git reflog && git reset --hard ") sys.exit(1) def _pull_updates( git_cmd, branch, auto_stash_ref, *, prompt_for_restore, gw_input_fn, discard_local_changes, keep_stash): """Fast-forward onto ``origin/`` and settle the autostash. Divergence by shape: custom branch -> merge, same branch -> reset, orphan history -> rescue ref first; a post-pull syntax error in a critical file rolls back. Exits on failure; returns pre-pull SHA.""" update_succeeded = False # Pre-pull SHA for auto-rollback (stray conflict markers once bricked every updater). # Capture the pre-pull SHA so we can auto-roll-back if the new code has a syntax error in a # critical-path file (PR #28452 incident: orphan merge-conflict markers in hermes_cli/config.py bricked # every user who ran ``hermes update`` for the 7 minutes between the bad commit and the fix landing). pre_pull_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) try: # merge --ff-only the already-fetched ref instead of `git pull`, which would do a # SECOND network fetch; identical in effect given the fresh tracking ref. if _git_run(git_cmd, ["merge", "--ff-only", f"origin/{branch}"]).returncode != 0: _reconcile_diverged_checkout(git_cmd, branch, pre_pull_sha) _rollback_if_pulled_syntax_error(git_cmd, pre_pull_sha) update_succeeded = True finally: if auto_stash_ref is not None: # No stash restore if the update failed — tree state is unknown. if not update_succeeded: print(f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})") print(" Restore manually with: git stash apply") elif discard_local_changes: # Non-interactive + updates.non_interactive_local_changes: discard. _m()._discard_stashed_changes(git_cmd, _m().PROJECT_ROOT, auto_stash_ref) elif keep_stash: # --keep-stash (desktop updater): leave edits parked rather than re-apply silently. _m()._park_stashed_changes(auto_stash_ref) else: _m()._restore_stashed_changes( git_cmd, _m().PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore, input_fn=gw_input_fn) return pre_pull_sha @dataclass class _CheckoutPlan: """What the pre-pull checkout phase decided (see ``_prepare_checkout_for_update``).""" auto_stash_ref: "str | None" commit_count: int in_place_update: bool parked_branch_switched: bool prompt_for_restore: bool switch_block_reason: "str | None" upstream_checked: bool def _apply_parked_branch_guard( git_cmd, branch, current_branch, *, switch_branch, _windows_gateway_resume ) -> tuple[bool, bool, "str | None"]: """Decide how a checkout parked on another branch is brought to *branch* (stash-switch-pull- switch-back used to "update" main while the running code stayed behind). By branch contents + updates.parked_branch_strategy: fully merged -> switch back; unmerged -> "switch" (default; loud "kept" notice) or "update_in_place" (merge origin/ INTO the branch, checkout never moves; --switch-branch overrides once); dirty/unverifiable -> touch nothing, warn, ``sys.exit(1)`` with the code update SKIPPED (also when the target is missing). Returns ``(parked_branch_switched, in_place_update, switch_block_reason)``. """ if current_branch == branch or current_branch == "HEAD": return False, False, None switch_safe, switch_block_reason = _m()._assess_parked_branch_switch( git_cmd, _m().PROJECT_ROOT, current_branch, branch) if not switch_safe: _m()._print_parked_branch_skip_warning( git_cmd, _m().PROJECT_ROOT, current_branch, branch, switch_block_reason) print() print(f"⚠ Update finished — code update SKIPPED{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) if not switch_block_reason.startswith("unmerged:"): print(f" ⚠ Checkout was parked on '{current_branch}' (fully merged) — switching back to {branch}...") return True, False, switch_block_reason _in_place_configured = False with _best_effort('Could not read updates.parked_branch_strategy: %s'): _in_place_configured = ( _updates_config().get("parked_branch_strategy", "switch") == "update_in_place") if not _in_place_configured or switch_branch: _m()._print_parked_branch_kept_notice( current_branch, branch, switch_block_reason.split(":", 1)[1]) return True, False, switch_block_reason # --branch typos used to surface via the checkout failing, which this path skips. if _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", f"origin/{branch}"]).returncode != 0: print(f"✗ Branch '{branch}' does not exist locally or on origin.") sys.exit(1) print( f" ℹ On branch '{current_branch}' — updating it in place from " f"origin/{branch} (no branch switch; local commits preserved).") return False, True, switch_block_reason def _prepare_checkout_for_update( git_cmd, branch, current_branch, *, is_fork, assume_yes, gateway_mode, gw_input_fn, switch_branch, _windows_gateway_resume): """Parked-branch guard, land on the target, stash, count new commits. Exits when the checkout is unsafe to move or the target is missing. ``commit_count`` is 0 when up to date, -1 when tips differ but the shallow count is unrecoverable.""" parked_branch_switched, in_place_update, switch_block_reason = _apply_parked_branch_guard( git_cmd, branch, current_branch, switch_branch=switch_branch, _windows_gateway_resume=_windows_gateway_resume) if not in_place_update and current_branch == "HEAD" != branch: print(f" ⚠ Currently on detached HEAD — switching to {branch} for update...") auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT) if ( not in_place_update and current_branch != branch and _git_run(git_cmd, ["checkout", branch]).returncode != 0): track_result = _git_run(git_cmd, ["checkout", "-B", branch, f"origin/{branch}"]) if track_result.returncode != 0: # Restore the stash before bailing so the user isn't stranded. if auto_stash_ref is not None: _m()._restore_stashed_changes( git_cmd, _m().PROJECT_ROOT, auto_stash_ref, prompt_user=False, input_fn=gw_input_fn) print(f"✗ Branch '{branch}' does not exist locally or on origin.") if track_result.stderr.strip(): print(f" {track_result.stderr.strip().splitlines()[0]}") sys.exit(1) prompt_for_restore = ( auto_stash_ref is not None and not assume_yes and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))) # On shallow checkouts `rev-list --count` can report the entire remote ancestry. The # zero/nonzero gate is still sound; treat the shallow NUMBER as unknown and recover it # via the GitHub compare API when possible. result = _git_run(git_cmd, ["rev-list", f"HEAD..origin/{branch}", "--count"], check=True) commit_count = int(result.stdout.strip()) apply_is_shallow = _is_shallow_checkout(git_cmd) if commit_count > 0 and apply_is_shallow: from hermes_cli.banner import _github_compare_behind counted = _github_compare_behind(*_tip_shas(git_cmd, f"origin/{branch}")) # counted == 0 means local-ahead: falls through to the up-to-date path. commit_count = counted if counted is not None else -1 # A fork can match origin yet trail upstream, so the sync can move HEAD with # commit_count == 0; detect that BEFORE the no-update return so deps, restarts AND the # fleet matrix still run (it used to live in the early-return branch and verified nothing). # The sync can therefore advance HEAD even though the origin comparison found no commits. Detect that # BEFORE taking the no-update return so dependency refreshes, gateway restarts, AND the fleet version # matrix still run for the pulled code (#73108 — previously the sync lived inside the commit_count == 0 # branch, which returns immediately after: an update that pulled hundreds of upstream commits printed # "Already up to date!" and verified nothing). Non-fork checkouts have no upstream question: origin IS # the official repo, so "Already up to date!" is fully verified there. upstream_checked = True if commit_count == 0 and is_fork and branch == "main": pre_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) upstream_checked = _m()._sync_with_upstream_if_needed( git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn) post_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) if pre_sync_sha and post_sync_sha and pre_sync_sha != post_sync_sha: synced_count = _count_commits_between( git_cmd, _m().PROJECT_ROOT, pre_sync_sha, post_sync_sha) # HEAD moving is proof of an update even if the count can't be read. commit_count = max(1, synced_count) return _CheckoutPlan( auto_stash_ref=auto_stash_ref, commit_count=commit_count, in_place_update=in_place_update, parked_branch_switched=parked_branch_switched, prompt_for_restore=prompt_for_restore, switch_block_reason=switch_block_reason, upstream_checked=upstream_checked) @dataclass class _UpdateOptions: """Resolved ``hermes update`` inputs (flags, config, pre-update snapshots).""" active_lazy_features: object active_tool_dependencies: object pre_update_version: object gw_input_fn: object assume_yes: bool keep_stash: bool switch_branch: bool discard_local_changes: bool no_gateway_restart: bool = False 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() active_tool_dependencies = _m()._capture_active_tool_dependencies() # 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 # transition (prime-agent#630 port). # Snapshot the pre-update version before any code is pulled so the completion line can report the # transition (prime-agent#630 port). pre_update_version = _read_project_version() gw_input_fn = ( (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None) assume_yes = bool(getattr(args, "yes", False)) # --keep-stash (desktop updater): never re-apply the autostash; only when an update # landed — abort/no-op paths still restore since the tree is unchanged. keep_stash = bool(getattr(args, "keep_stash", False)) # --switch-branch: prefer switching over an in-place merge so an update never writes the # branch's history; only meaningful with parked_branch_strategy "update_in_place". # See #89507. switch_branch = bool(getattr(args, "switch_branch", False)) # --no-gateway-restart (cron inside the gateway's own cgroup): update code # and dependencies but defer the fleet restart so the updater is not killed # by its own restart. The pending-restart marker is kept for catch-up. no_gateway_restart = bool(getattr(args, "no_gateway_restart", False)) # Interactive terminals always stash-and-ask; only non-interactive updates consult # updates.non_interactive_local_changes (auto-restore vs discard). discard_local_changes = False if gateway_mode or assume_yes or not (sys.stdin.isatty() and sys.stdout.isatty()): # A config read failure must never change the safe default. with _best_effort("Could not read updates.non_interactive_local_changes: %s"): _mode = str(_updates_config().get("non_interactive_local_changes", "stash")).lower() discard_local_changes = _mode == "discard" return _UpdateOptions( active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, 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, no_gateway_restart=no_gateway_restart) 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.""" # 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. from hermes_cli.update_receipt import begin_update_receipt begin_update_receipt() # Plan phase: snapshot runtimes/supervisors/version (read-only; probe failure records # nothing). Re-read AFTER the restart phase to reconcile — the plan is the worklist. # Plan phase (#91277 Phase 2): snapshot the pre-update fleet — every running Hermes runtime, its # supervisor, and its running code version — into the receipt, so a post-mortem can compare what the # update SAW against what it did. ``_pre_update_plan`` is read again AFTER the restart phase to # reconcile every planned runtime against the phase's bookkeeping (restart via declared mechanism — the # plan is the worklist, not just a printout). _pre_update_plan = None with _best_effort('Update plan phase failed: %s'): from hermes_cli.update_inventory import collect_runtime_inventory, record_plan_in_receipt _pre_update_plan = collect_runtime_inventory() record_plan_in_receipt(_pre_update_plan) if _pre_update_plan.runtimes: _n = len(_pre_update_plan.runtimes) _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 def _prepare_git_command() -> tuple[bool, list, bool]: """Return ``(use_zip_update, git_cmd, is_fork)``; ``sys.exit(1)`` when not a git repo on a non-Windows host (Windows falls back to ZIP: broken git file I/O, AV, NTFS filters).""" git_dir = _m().PROJECT_ROOT / ".git" use_zip_update = not git_dir.exists() if use_zip_update and sys.platform != "win32": print("✗ Not a git repository. Please reinstall:") print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash") sys.exit(1) git_cmd = _base_git_cmd() if sys.platform == "win32" and git_dir.exists(): _git_run(git_cmd, ["config", "windows.appendAtomically", "false"]) # A broken Git-for-Windows trampoline refuses every call with a "BUG (fork bomb)" guard; # swap in a real binary up front so git survives instead of degrading to ZIP. # See #87876. git_cmd = _ensure_non_trampoline_git(git_cmd) # Before stash/branch logic: npm rewrites package-lock.json non-deterministically and # line-ending churn is machine-made dirt; both would otherwise force an autostash every update. _discard_lockfile_churn(git_cmd, _m().PROJECT_ROOT) _normalize_managed_eol(git_cmd, _m().PROJECT_ROOT) origin_url = _m()._get_origin_url(git_cmd, _m().PROJECT_ROOT) is_fork = _is_fork(origin_url) if is_fork: print("⚠ Updating from fork:") print(f" {origin_url}") print() return use_zip_update, git_cmd, is_fork def _verify_head_after_pull( git_cmd, branch: str, pre_pull_sha, *, in_place_update: bool, _windows_gateway_resume ) -> str | None: """Return the post-pull HEAD SHA; ``sys.exit(1)`` if the pull was a no-op or landed off-branch.""" # A detached checkout pinned to a SHA can report "N new commit(s)" and a successful # merge --ff-only yet stay put; surface the no-op instead of claiming "Code updated!". # Verify HEAD actually moved (issue #79678). ``merge --ff-only`` succeeding only means the merge # completed, not that the update applied: a checkout that is pinned to a raw SHA (detached HEAD) can # report "N new commit(s)" against origin yet still sit on the old commit afterward (the branch-switch # step re-detaches to the SHA). Before this guard, ``hermes update`` printed "✓ Code updated!" and # reinstalled deps + rebuilt the desktop app against the stale tree — no error, no warning, ``hermes # doctor`` healthy. Compare pre-pull and post-pull HEAD; if they match, surface the no-op instead of # claiming success. post_pull_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) if pre_pull_sha and post_pull_sha == pre_pull_sha: print() print("✗ Code did not move — update was a no-op.") print( f" HEAD is pinned to {pre_pull_sha[:10]} (detached checkout); " f"origin/{branch} advanced but the working tree stayed put.") print( " Reattach to the branch and retry: " f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) # HEAD must be on the target or "Code updated!" is a lie; an IN-PLACE update is the one # legitimate exception (origin/ merged INTO the checked-out branch). post_pull_branch = _current_branch_name(git_cmd) if not in_place_update and post_pull_branch and post_pull_branch not in {branch, "HEAD"}: print() print( f"✗ Update pulled origin/{branch}, but the checkout is on " f"'{post_pull_branch}' — not claiming success.") print( " Switch to the target branch and retry: " f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) return post_pull_sha def _current_branch_name(git_cmd, *, check: bool = False) -> str: """``rev-parse --abbrev-ref HEAD`` (literal "HEAD" when detached).""" return _git_run(git_cmd, ["rev-parse", "--abbrev-ref", "HEAD"], check=check).stdout.strip() def _handle_update_called_process_error( e, args, gateway_mode: bool, had_desktop_app_before_update: bool, _windows_gateway_resume=None) -> None: """Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``.""" stage = _format_update_failure_stage(e) if _should_zip_fallback_on_update_error(e): print(f"⚠ {stage}: {e}") print("→ Falling back to ZIP download...") print() desktop_build_ok = _update_via_zip( args, had_desktop_app_before_update=had_desktop_app_before_update, _windows_gateway_resume=_windows_gateway_resume) if gateway_mode: _write_gateway_update_exit_code(desktop_build_ok) else: if _called_process_error_is_python_dep_install(e): print(f"✗ {stage} (the code update itself succeeded).") _print_called_process_error_tail(e) print() print(" Hermes may not start until the dependencies are installed. Fix the error above") print(" (usually network or disk space), then run `hermes update` again.") if _m()._is_windows(): print(" If `hermes update` itself will not start, retry through the venv interpreter:") print( ' venv\\Scripts\\python.exe -c ' '"from hermes_cli.main import main; main()" update --yes') else: print(f"✗ {stage}.") print(f" Details: {e}") _print_called_process_error_tail(e) _finalize_receipt("failed", 'Update receipt finalize failed: %s') sys.exit(1) def _finalize_receipt(status: str, debug_message: str) -> None: """Best-effort ``finalize_update_receipt(status)``; the receipt must never break an update.""" with _best_effort(debug_message): from hermes_cli.update_receipt import finalize_update_receipt finalize_update_receipt(status) 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, had_desktop_app_before_update: bool, active_lazy_features, active_tool_dependencies, _windows_gateway_resume, no_gateway_restart: bool = False) -> 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() # Restore stash and switch back if we moved. EXCEPTION: a parked branch verified clean + # fully merged stays on the target — re-parking on the stale branch recreates the incident. if _plan.auto_stash_ref is not None: _m()._restore_stashed_changes( git_cmd, _m().PROJECT_ROOT, _plan.auto_stash_ref, prompt_user=_plan.prompt_for_restore, input_fn=gw_input_fn) if _plan.parked_branch_switched: if _plan.switch_block_reason.startswith("unmerged:"): _count = _plan.switch_block_reason.split(":", 1)[1] print( f" ✓ Checkout was parked on '{current_branch}' — switched back to {branch}; " f"{_count} unmerged commit(s) kept on '{current_branch}'.") else: print(f" ✓ Checkout was parked on '{current_branch}' (fully merged) — switched back to {branch}.") elif current_branch not in {branch, "HEAD"}: _git_run(git_cmd, ["checkout", current_branch]) current_checkout_complete = _repair_current_checkout( assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, upstream_checked=_plan.upstream_checked, _windows_gateway_resume=_windows_gateway_resume) # Same contract as the pull path's _resume_windows_gateways_and_merge_outcome: a failed # Windows gateway resume (e.g. the relaunch verification racing a Job-Object kill, #48820) # must demote this run to incomplete, never abort it. A bare call here let the identical # RuntimeError the pull path treats as a warning kill "Already up to date" outright (#115563). resume_outcome = _GatewayRestartOutcome( incomplete=False, phase_errors=[], pre_restart_gateway_pids=[], restarted_services=[], failed_or_stale_units=[], relaunched_profiles=[], externally_supervised_profiles=[], killed_pids=set(), ) _resume_windows_gateways_and_merge_outcome(resume_outcome, _windows_gateway_resume, gateway_mode) if resume_outcome.incomplete: current_checkout_complete = False # 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. # Catch up even on the "Already up to date" path — that early return is what left the gateway on stale # code for two days. Runs BEFORE the runtime-verification exit gate below: a vulnerable SQLite runtime # demotes the outcome to partial, but must not strand the fleet on stale code (#91277 fleet contract — # the pending-restart check always executes). Under --no-gateway-restart the # catch-up is deferred instead (executing it would kill the cron's own gateway). _apply_pending_fleet_restart_catchup(defer=no_gateway_restart) if not current_checkout_complete: if gateway_mode: _write_gateway_update_exit_code(False) _finalize_receipt("partial", 'Update receipt finalize (current checkout) failed: %s') sys.exit(1) def _apply_pulled_update( git_cmd, branch, pre_pull_sha, _plan, opts, *, gateway_mode, is_fork, desktop_dir, had_desktop_app_before_update, pre_update_snapshot_id, _pre_update_plan, _windows_gateway_resume, args) -> None: """Post-pull phase, pre-swap half: verify HEAD, arm the fleet marker, sweep bytecode, then hand the rest of the run to an interpreter born on the pulled code (never returns).""" _invalidate_update_cache() post_pull_sha = _verify_head_after_pull( git_cmd, branch, pre_pull_sha, in_place_update=_plan.in_place_update, _windows_gateway_resume=_windows_gateway_resume) # Gateways still serve pre-pull modules until the restart phase; an interrupt before a # completed restart leaves this marker so the next update catches up even when git is # current. Distinct from ``.update-incomplete`` (venv/install repair). # See #95294. _write_fleet_restart_pending_marker( expected_sha=post_pull_sha or "", runtimes=_pre_update_plan.to_dict().get("runtimes") if _pre_update_plan is not None else None, ) # Stale .pyc would ImportError on gateway restart when new source references new names. _sweep_bytecode_after_update(branch) _hand_off_post_swap( args, swap="git", branch=branch, pre_pull_sha=pre_pull_sha, is_fork=is_fork, opts=opts, gateway_mode=gateway_mode, had_desktop_app_before_update=had_desktop_app_before_update, pre_update_snapshot_id=pre_update_snapshot_id, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume) # ``store_true`` update flags the post-swap child must see exactly as the user passed them. _POST_SWAP_FORWARDED_FLAGS = ( ("gateway", "--gateway"), ("no_backup", "--no-backup"), ("backup", "--backup"), ("yes", "--yes"), ("keep_stash", "--keep-stash"), ("switch_branch", "--switch-branch"), ("force", "--force"), ("force_venv", "--force-venv"), ("no_gateway_restart", "--no-gateway-restart"), ) def _post_swap_argv_tail(args) -> list[str]: tail = [flag for attr, flag in _POST_SWAP_FORWARDED_FLAGS if getattr(args, attr, False)] branch = getattr(args, "branch", None) if branch: tail += ["--branch", str(branch)] return tail def _post_swap_payload( *, swap: str, branch: str, opts, gateway_mode: bool, had_desktop_app_before_update: bool, pre_pull_sha=None, is_fork: bool = False, pre_update_snapshot_id=None, _pre_update_plan=None, _windows_gateway_resume=None) -> dict: """Everything the post-swap tail needs that only the pre-swap process could observe: the open receipt (detached here — the child resumes it), the pre-update fleet plan, the pre-update version and active features, the Windows pause token. Flags cross as argv.""" from hermes_cli.update_receipt import detach_update_receipt return { "swap": swap, "branch": branch, "pre_pull_sha": pre_pull_sha, "is_fork": bool(is_fork), "gateway_mode": bool(gateway_mode), "had_desktop_app_before_update": bool(had_desktop_app_before_update), "pre_update_snapshot_id": pre_update_snapshot_id, "pre_update_version": opts.pre_update_version, "active_lazy_features": opts.active_lazy_features, "active_tool_dependencies": opts.active_tool_dependencies, "plan": _pre_update_plan.to_dict() if _pre_update_plan is not None else None, "windows_gateway_resume": _windows_gateway_resume, # {profile: snapshot_id} from the pre-update backup; the post-migration safety nets for # sibling profiles read it (update_cmd_config._LAST_SIBLING_SNAPSHOTS). "sibling_snapshots": dict(_sibling_snapshots_module()._LAST_SIBLING_SNAPSHOTS), "receipt": detach_update_receipt(), } def _sibling_snapshots_module(): # The backup phase REBINDS ``update_cmd_config._LAST_SIBLING_SNAPSHOTS``; read the module # attribute at call time, never this module's import-time copy of the empty dict. import hermes_cli.update_cmd_config as _cfg return _cfg def _hand_off_post_swap(args, **payload_kwargs) -> None: """Re-execute ``hermes update --post-swap`` on the pulled tree and exit with its code. The parent detaches from the receipt and its Windows resume hook — the child owns both — and only relays the exit code (``hermes_cli/update_handoff.py``). """ from hermes_cli.update_receipt import resume_update_receipt payload = _post_swap_payload(**payload_kwargs) code = _update_handoff.continue_update_in_fresh_interpreter( payload, argv_tail=_post_swap_argv_tail(args)) token = payload_kwargs.get("_windows_gateway_resume") if token and code is not None: # The child got its own copy (serialized before this flip) and owns the resume; every # parent-side hook (atexit, the ZIP path's ``finally``) reads this flag and stays out # of the way. When no child ran, the parent still resumes what it paused. token["resume_needed"] = False if code is None: # No child ran: take the receipt back so this failure is recorded, and leave the # install breadcrumb so the next launch finishes the dependency sync (new code, old deps). if payload["receipt"]: resume_update_receipt(payload["receipt"]) _record_update_step("post_swap_handoff", False, "child interpreter could not start") _m()._write_update_incomplete_marker() if payload_kwargs.get("gateway_mode"): _write_gateway_update_exit_code(False) code = 1 sys.exit(code) def _run_post_swap_phase(args, gateway_mode: bool) -> None: """Child half of the update (``--post-swap``): resume the receipt and finish the run on the pulled code.""" from hermes_cli.update_receipt import resume_update_receipt payload = _update_handoff.read_handoff(args.post_swap) with suppress(OSError): Path(args.post_swap).unlink() if payload.get("receipt"): resume_update_receipt(payload["receipt"]) _execute_post_swap(payload, args, gateway_mode) def _execute_post_swap(payload: dict, args, gateway_mode: bool) -> None: """The tail ``_apply_pulled_update`` / ``_update_via_zip`` used to run in the pre-pull interpreter, driven from a hand-off payload.""" from dataclasses import replace as _replace import hermes_cli.update_cmd_config as _cfg from hermes_cli.update_inventory import UpdatePlan _cfg._LAST_SIBLING_SNAPSHOTS = dict(payload.get("sibling_snapshots") or {}) _pre_update_plan = UpdatePlan.from_dict(payload["plan"]) if payload.get("plan") else None _windows_gateway_resume = payload.get("windows_gateway_resume") if _windows_gateway_resume: import atexit as _atexit _atexit.register(_m()._resume_windows_gateways_after_update, _windows_gateway_resume) # Flags and config resolve here exactly as they did pre-swap; the three pre-update # snapshots come from the payload (the new tree would report the NEW version). opts = _replace( _resolve_update_options(args, gateway_mode), pre_update_version=payload.get("pre_update_version"), active_lazy_features=payload.get("active_lazy_features"), active_tool_dependencies=payload.get("active_tool_dependencies")) had_desktop_app_before_update = bool(payload.get("had_desktop_app_before_update")) desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop" try: if payload.get("swap") == "zip": desktop_build_ok = _finish_zip_update( active_tool_dependencies=opts.active_tool_dependencies, pre_update_version=opts.pre_update_version, had_desktop_app_before_update=had_desktop_app_before_update, _windows_gateway_resume=_windows_gateway_resume) if gateway_mode: _write_gateway_update_exit_code(desktop_build_ok) return # The parent already ran the checkout preflight (fork banner, lockfile churn, EOL); the # child only needs a working git. git_cmd = _ensure_non_trampoline_git(_base_git_cmd()) _finish_pulled_update( git_cmd, payload["branch"], payload.get("pre_pull_sha"), opts, gateway_mode=gateway_mode, is_fork=bool(payload.get("is_fork")), desktop_dir=desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update, pre_update_snapshot_id=payload.get("pre_update_snapshot_id"), _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume) except _shim_quarantine_error_type() as e: _refuse_update_for_contended_shims(e) except subprocess.CalledProcessError as e: _handle_update_called_process_error( e, args, gateway_mode, had_desktop_app_before_update, _windows_gateway_resume=_windows_gateway_resume) def _finish_pulled_update( git_cmd, branch, pre_pull_sha, opts, *, gateway_mode, is_fork, desktop_dir, had_desktop_app_before_update, pre_update_snapshot_id, _pre_update_plan, _windows_gateway_resume) -> None: """Post-swap tail (git path): sync Python/Node/web/Desktop, maintenance, fleet restart.""" if is_fork and branch == "main": _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. _sync_python_dependencies_after_pull( git_cmd, branch, pre_pull_sha, active_lazy_features=opts.active_lazy_features, active_tool_dependencies=opts.active_tool_dependencies, _windows_gateway_resume=_windows_gateway_resume) 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() print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") update_complete = _run_post_update_maintenance( 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 # systemd cgroup and the systemctl-restart fallback SIGKILLs it (KillMode=mixed), so # the marker would never land and the new gateway's watcher would time out spuriously. if gateway_mode: _write_gateway_update_exit_code(update_complete) if opts.no_gateway_restart: # Cron inside the gateway's own cgroup: restarting the fleet now would # SIGUSR1-drain this updater's own gateway and systemd would SIGKILL the # updater with it. Defer instead — the pending marker written above is # kept for the next normal update. Skipping the restart phase also skips # its stale-module purge: this live interpreter still serves pre-update # code and must not have its sys.modules graph mutated mid-flight. # Windows pause/resume still runs (paused gateways must be resumed onto # pre-update code); only the fleet restart + verification are deferred. # The resume outcome is RETAINED (not discarded): a failed resume must # still make this update partial, exactly as on the normal path. resume_outcome = _GatewayRestartOutcome( incomplete=False, phase_errors=[], pre_restart_gateway_pids=[], restarted_services=[], failed_or_stale_units=[], relaunched_profiles=[], externally_supervised_profiles=[], killed_pids=set(), ) _resume_windows_gateways_and_merge_outcome( resume_outcome, _windows_gateway_resume, gateway_mode) _defer_fleet_restart_after_update( update_complete=update_complete, resume_incomplete=resume_outcome.incomplete) return _restart = _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode) _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) 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.""" opts = _resolve_update_options(args, gateway_mode) gw_input_fn, assume_yes = opts.gw_input_fn, opts.assume_yes # A child spawned off hermes.exe already outwaited its parent in ``cmd_update`` (before the # update lock, so the lock it now holds is its own — the parent's marker left with it). from hermes_cli.update_handoff import adopt_handed_off_gateway_resume if getattr(args, "post_swap", None): # Second half of a run whose pre-pull interpreter stopped at the code swap. _run_post_swap_phase(args, gateway_mode) return print("☤ Updating Hermes Agent...") print() _pre_update_plan = _begin_update_receipt_and_plan(args) # Backup before any git/file mutation; the snapshot id (None if disabled/failed) feeds # the post-update cron-jobs safety net. A deliberate opt-out is recorded as a skip with its # reason, not as a failed step (see _record_pre_update_backup_outcome). pre_update_snapshot_id = _m()._run_pre_update_backup(args) _record_pre_update_backup_outcome(args, pre_update_snapshot_id) # A legacy re-exec child resumes exactly the fleet its parent stopped; re-running discovery # here found the parent's just-relaunched gateway and force-killed it (#101600). _windows_gateway_resume = adopt_handed_off_gateway_resume() or _m()._pause_windows_gateways_for_update() if _windows_gateway_resume: 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) # After every fail-closed venv guard, before either path can remove the release tree. # Self-lock deferral moved: the venv-holder sweep above excludes this process by design (a CLI `hermes # update` IS the venv python), and an updater that has imported a native venv extension cannot rewrite # its own mapped .pyd (#83569). That check used to run HERE — before the fetch — but firing pre-fetch # meant a deferral stranded the user on the OLD checkout, and any startup path that eagerly loaded # cryptography turned every Windows update into an exit-2 loop (#86735/#86780/#86781). It now runs via # _abort_dependency_sync_if_self_locked() after the code swap, immediately before the dependency sync — # the only phase the lock can actually break — and only when the sync would truly rewrite the loaded # distribution. desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop" had_desktop_app_before_update = _desktop_app_present(desktop_dir) use_zip_update, git_cmd, is_fork = _prepare_git_command() if use_zip_update: try: desktop_build_ok = _update_via_zip( args, had_desktop_app_before_update=had_desktop_app_before_update, _windows_gateway_resume=_windows_gateway_resume) finally: # No-op once the post-swap child owns the token (``resume_needed`` flipped); # still resumes after a pre-swap refusal (dirty tree, --branch). _m()._resume_windows_gateways_after_update(_windows_gateway_resume) if gateway_mode: _write_gateway_update_exit_code(desktop_build_ok) return try: # Scoped fetch: a bare `git fetch origin` pulls thousands of branches and can stall. branch = _m()._resolve_update_branch(args) # Self-heal abandoned .git/*.lock files (crashed fetch) or the fetch fails "File exists". from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs cleared = clear_stale_git_locks(_m().PROJECT_ROOT) if cleared: print(" (removed stale git lock(s): %s)" % ", ".join(cleared)) swept = clear_stale_tmp_packs(_m().PROJECT_ROOT) if swept: print(" (removed %d aborted-fetch pack temp file(s))" % len(swept)) # Shallow installer checkouts collect one `.git/shallow` graft per past depth-1 fetch # (#105951); stale grafts break merge-base and push this run into the divergence path. from hermes_cli.gitlock import repair_broken_shallow_boundaries, prune_stale_shallow_grafts repaired = repair_broken_shallow_boundaries(_m().PROJECT_ROOT) if repaired: print(f" (restored {repaired} broken shallow boundary(ies))") pruned = prune_stale_shallow_grafts(_m().PROJECT_ROOT) if pruned: print(f" (pruned {pruned} stale shallow graft(s) left by past depth-1 checks)") # Surface autostashes left by earlier updates (--keep-stash, failed restores). # Surface autostash entries left behind by earlier updates (#63717 problem 6) — parked --keep-stash # runs and failed restores preserve the stash but nothing ever mentioned it again. _m()._warn_orphaned_update_autostashes(git_cmd, _m().PROJECT_ROOT) print("→ Fetching updates...") fetch_result = _git_run(git_cmd, ["fetch", "origin", branch], network=True) if fetch_result.returncode != 0: _print_fetch_failure(fetch_result.stderr) sys.exit(1) current_branch = _current_branch_name(git_cmd, check=True) _plan = _prepare_checkout_for_update( git_cmd, branch, current_branch, is_fork=is_fork, assume_yes=assume_yes, gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, switch_branch=opts.switch_branch, _windows_gateway_resume=_windows_gateway_resume) commit_count = _plan.commit_count if commit_count == 0: _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, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=opts.active_lazy_features, active_tool_dependencies=opts.active_tool_dependencies, _windows_gateway_resume=_windows_gateway_resume, no_gateway_restart=opts.no_gateway_restart) return if commit_count > 0: print(f"→ Found {commit_count} new commit(s)") else: # Shallow, exact count unrecoverable — but the tips differ, so there IS an update. print("→ Updates available (commit count unknown on this shallow checkout)") print("→ Pulling updates...") pre_pull_sha = _pull_updates( git_cmd, branch, _plan.auto_stash_ref, prompt_for_restore=_plan.prompt_for_restore, gw_input_fn=gw_input_fn, discard_local_changes=opts.discard_local_changes, keep_stash=opts.keep_stash) _apply_pulled_update( git_cmd, branch, pre_pull_sha, _plan, opts, gateway_mode=gateway_mode, is_fork=is_fork, desktop_dir=desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update, pre_update_snapshot_id=pre_update_snapshot_id, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume, args=args) except _shim_quarantine_error_type() as e: # Strict quarantine refused BEFORE any installer ran — defer via marker, exit 2, no ZIP. # See #87331. _refuse_update_for_contended_shims(e) except subprocess.CalledProcessError as e: _handle_update_called_process_error( e, args, gateway_mode, had_desktop_app_before_update, _windows_gateway_resume=_windows_gateway_resume) # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- # Names external plugins imported from this module before the Sep 2026 decomposition. # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). # The whole block is removed by reverting the commit that added it. from typing import Optional # noqa: F401,E402 from datetime import datetime # noqa: F401,E402 import hashlib # noqa: F401,E402 import json # noqa: F401,E402 # ---- END PLUGIN-COMPAT ----