Files
hermes-agent/hermes_cli/update_cmd.py
T

2022 lines
84 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Hermes update pipeline — extracted from ``hermes_cli/main.py``.
Mechanical move (main.py decomposition): ``_cmd_update_impl``, ``_cmd_update_check``
and every module-level helper used only by the update path, plus the update-only
constants they read. Function bodies are lifted verbatim; the only mechanical
change is that references to helpers/constants that STAY in ``hermes_cli.main``
(and to moved-but-test-patched siblings) are routed through ``_m()`` — a lazy
``hermes_cli.main`` reference — so existing call sites and test monkeypatches
that target ``hermes_cli.main.<name>`` (``PROJECT_ROOT``, ``_is_windows``,
``_run_pre_update_backup``, ...) keep working unchanged. ``main.py`` re-imports
every public-ish name from here (``# noqa: F401``) so the argparse wiring and
the test-patch surface still resolve on ``hermes_cli.main``.
The closures that used to be nested inside ``_cmd_update_impl`` (``_print_items``,
``_wait_for_service_active``, ``_service_restart_sec``, ``_resolve_manage_cmd``,
``_restart_one_systemd_gateway_unit``) now live at module level or inside the
phase helpers ``_cmd_update_impl`` calls in order: ``_pull_updates`` ->
``_sync_python_dependencies_after_pull`` -> ``_run_post_update_maintenance`` ->
``_restart_gateway_fleet_after_update`` -> ``_verify_fleet_after_update``.
Imports are one-way: ``hermes_cli.main`` imports this module, never the reverse
at import time (``_m()`` resolves lazily at call time, when main.py is fully
loaded, so there is no import cycle).
"""
import logging
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
# Both re-exported: the split update_cmd_* modules import them lazily from
# here so tests that monkeypatch ``update_cmd.get_hermes_home`` stay effective.
from hermes_cli.config import get_hermes_home # noqa: F401
from hermes_constants import get_default_hermes_root, venv_python_path
# Abort recovery lives in its own bounded module (review on #96235). Re-exported
# here because `hermes_cli.main` and the split update modules address these
# names through `update_cmd` (tests patch them here).
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 (re-exported; tests patch hermes_cli.update_cmd.<name>)
_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 (re-exported; tests patch hermes_cli.update_cmd.<name>)
_FLEET_RESTART_PENDING_NAME,
_FRESH_RESTART_SUPERVISORS,
_GatewayRestartOutcome,
_apply_pending_fleet_restart_catchup,
_clear_fleet_restart_pending_marker,
_current_checkout_sha,
_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 (re-exported; tests patch hermes_cli.update_cmd.<name>)
_ZIP_PRESERVED_TOP_LEVEL,
_ZIP_STAGING_ARTIFACT_SUFFIXES,
_abort_zip_update_if_dirty_tree,
_atomic_replace_dir,
_commit_staged_replacements,
_discard_staged,
_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 (re-exported; tests patch hermes_cli.update_cmd.<name>)
_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 (re-exported; tests patch hermes_cli.update_cmd.<name>)
_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 (re-exported; tests patch hermes_cli.update_cmd.<name>)
_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,
_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_foreign_owned_paths,
_web_build_toolchain_ready,
_web_toolchain_roots,
)
from hermes_cli.update_cmd_git import ( # noqa: F401 (re-exported; tests patch hermes_cli.update_cmd.<name>)
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 (re-exported; tests patch hermes_cli.update_cmd.<name>)
_PRE_UPDATE_SNAPSHOT_KEEP,
_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE,
_STALE_PURGE_PREFIXES,
_STALE_PURGE_PROTECTED,
_UPDATE_RUNTIME_RELOAD_MODULES,
_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,
_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,
_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`` reference.
Keeps ``hermes_cli.main.<helper>`` test patches effective in this code
path and keeps the ``main`` -> ``update_cmd`` import one-way at import time.
"""
from hermes_cli import main
return main
def _no_prompt_git_kwargs() -> dict:
"""``subprocess.run`` kwargs for the updater's network git calls.
GitHub answers anonymous fetches with HTTP 401 during outages (and for
unreachable repos); git then prompts ``Username for 'https://github.com':``
on the inherited terminal and the update sits there forever. Disable the
prompt so the fetch fails fast into ``_classify_fetch_failure``. Only the
*prompt* is disabled — a configured credential helper / askpass still
runs, so a private-fork origin keeps authenticating non-interactively.
"""
env = dict(os.environ)
env["GIT_TERMINAL_PROMPT"] = "0"
env["GCM_INTERACTIVE"] = "Never"
return {"stdin": subprocess.DEVNULL, "env": env}
# Files that must parse right after an update/install (CLI startup imports;
# ``web_server.py`` is the desktop backend a fresh Windows install launches).
# The post-pull syntax guard validates these and auto-rolls-back on failure.
_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."""
try:
from hermes_cli.update_receipt import record_step
record_step(step, ok, detail)
except Exception:
pass
def _git_run(git_cmd, args, cwd=None, *, check=False, network=False):
"""Run ``git_cmd + args`` (default cwd: the checkout), capturing utf-8 text.
``network=True`` (fetch/pull/push) disables git's terminal prompt so an
HTTP 401 fails fast instead of hanging on ``Username for ...``.
"""
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,
**(_no_prompt_git_kwargs() if network else {}),
)
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* without writing bytecode into the tree."""
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 each file in ``_UPDATE_CRITICAL_FILES`` to catch SyntaxErrors.
These are imported on every ``hermes`` startup; a syntax error (orphan
conflict markers, etc.) means the CLI can't bootstrap, so we validate
after ``git pull`` and auto-roll-back instead of leaving a bricked install.
The ``.pyc`` goes to a temp dir, not the tree's ``__pycache__/``: avoids
racing concurrent test workers and leaving a stale pyc behind when the
next interpreter run uses a different Python. Only the compile-or-not
signal matters.
Returns ``(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 mode.
Writes a prompt marker file for the gateway to forward to the user, then
polls for a response file; falls back to *default* on timeout. Lets
``hermes update --gateway`` forward prompts (stash restore, config
migration) to the messenger instead of silently skipping them.
"""
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 = home / ".update_prompt.json"
response_path = 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():
try:
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
except (OSError, ValueError):
pass
_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; basename() on POSIX would otherwise
# keep the whole path. Normalize separators before taking the name.
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("\\", "/"))
if "ensurepip" in parts:
return True
if "install" in parts and (
"pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"}
):
return True
return False
def _format_update_failure_stage(exc: subprocess.CalledProcessError) -> str:
"""Name the update stage that actually failed.
The git pull and the Python-dependency install share one ``try`` in
``_cmd_update_impl``. Calling every ``CalledProcessError`` a git failure
(the historical Windows message) sent users hunting in the wrong place
and, worse, keyed the ZIP overlay on exception *type* rather than on git
actually having failed (#87304, #85840).
"""
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]":
"""The strict-quarantine refusal type, resolved lazily through ``_m()``.
Falls back to a never-raised private type when main.py lacks it (torn
mid-update tree), so the ``except`` clause 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:
"""Refuse the dependency sync when live shims could not be quarantined.
#87331 fail-closed half: a shim rename that failed every retry proves a
process holds the venv without FILE_SHARE_DELETE — running the installer
anyway is exactly how the venv ends up stranded between versions. The
code swap (when one happened) is already committed; only the dependency
install is deferred, via the update-incomplete marker, to the next fresh
launch after the holder exits. Exits 2 (refused) so the command-boundary
receipt net records it as a refusal, not a failure.
"""
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: the git path already dropped the marker before the sync;
# this covers the 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 for Windows git file-I/O breakage, not later stages.
A dependency-install failure (locked ``hermes.exe`` / ``uv pip install``
exit 2) is not a git failure. The pull has already succeeded by then, so
re-downloading the source ZIP cannot fix the install and would replace
every top-level entry except ``venv`` / ``node_modules`` / ``.git`` /
``.env`` — permanently deleting uncommitted edits 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 git repo is shared, so one profile's update makes every profile
current; a per-profile cache would show a stale "commits behind" banner.
"""
homes = []
# Default profile home (Docker-aware — uses /opt/data in Docker)
default_home = get_default_hermes_root()
homes.append(default_home)
# Named profiles under <root>/profiles/
profiles_root = default_home / "profiles"
if profiles_root.is_dir():
for entry in profiles_root.iterdir():
if entry.is_dir():
homes.append(entry)
for home in homes:
try:
cache_file = home / ".update_check"
if cache_file.exists():
cache_file.unlink()
except Exception:
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")
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:
"""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 used by the Desktop preflight exemption and the venv-holder guard
— so a PID classified ``"gateway"`` here is exactly the set the downstream
pause/kill+restart machinery will stop. That symmetry lets the pre-update
concurrent gate skip the abort for gateway-only matches instead of making
the user kill a gateway that is about to be paused anyway.
Returns ``"non-gateway"`` when the cmdline doesn't match and ``"unknown"``
when psutil can't read it; the gate treats ``"unknown"`` as non-gateway
(better to block than proceed against an unidentified process).
"""
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.
If every concurrent instance is a gateway, the pause machinery and the
post-update kill+restart handle it and the update proceeds. Anything else
(TUI shell, Desktop backend child, another ``hermes`` REPL) has no pause
machinery downstream, so the gate still aborts.
"""
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
def _log_only_write(text: str) -> None:
"""Write ``text`` to ``~/.hermes/logs/update.log`` only, never the terminal.
During ``hermes update`` ``sys.stdout`` is an ``_UpdateOutputStream``
mirroring to terminal and log; this reaches past it to the log handle so
loud, low-signal subprocess output (npm, Electron/vite, cua-driver "Next
steps") stays debuggable without flooding the terminal.
"""
if not text:
return
stream = _m().sys.stdout
log_file = getattr(stream, "_log", None)
if log_file is None:
return
try:
log_file.write(text if text.endswith("\n") else text + "\n")
log_file.flush()
except Exception:
pass
def _run_logged_subprocess(cmd, *, cwd=None, env=None):
"""Run ``cmd`` capturing combined output into update.log (not the terminal).
Returns the ``CompletedProcess`` (with ``stdout`` populated) so the caller
can decide whether to surface the captured output on failure.
"""
result = subprocess.run(
cmd,
cwd=cwd,
env=env,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
_log_only_write(result.stdout or "")
return result
def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
"""Implement ``hermes update --check``: fetch and report without installing.
``branch`` selects which branch the check compares against. Default is
"main"; callers can pass another branch to ask "are there new commits
on origin/<branch>?" without performing the update.
``branch_explicit`` is True iff the caller passed --branch on the CLI.
Installs that can't honor non-default branches (e.g. Docker) surface a
one-line notice instead of silently dropping the flag.
"""
# Shared admission gate (#91277 Phase 3): same marker-first decision as
# the apply path, so --check can never report 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 = ["git"]
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
# An interrupted fetch can leave .git/shallow.lock (or another lock) behind,
# making every later fetch fail with "File exists". Self-heal before fetching.
from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs
cleared = clear_stale_git_locks(_m().PROJECT_ROOT)
for lock_path in cleared:
print(f" (removed stale git lock: {lock_path})")
# Aborted fetches on flaky lines also strand tmp_pack_* debris in
# .git/objects/pack — unchecked it reached 6 GB and corrupted the pack
# dir outright (#93732). 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 <branch>: a bare `git fetch <remote>` pulls thousands of
# auto-generated branches. Prefer upstream as canonical, but only for main
# (a fork's non-default branch has no upstream counterpart). Installer
# checkouts are shallow (`--depth 1`); a plain fetch would unshallow them
# and rev-list would report a huge bogus "behind" count, so fetch with
# --depth 1 and report presence-only.
is_shallow = (
_git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip()
== "true"
)
depth_args = ["--depth", "1"] if is_shallow else []
if branch == "main":
# Probe locally (~6 ms) for an 'upstream' remote before spending a
# network fetch (~0.3-1 s) that non-fork installs would always fail.
has_upstream_remote = (
_git_run(git_cmd, ["remote", "get-url", "upstream"]).returncode
== 0
)
fetch_result = None
if has_upstream_remote:
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:
# No upstream remote, or the upstream fetch failed — use origin.
print("→ Fetching from origin...")
fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["origin", branch], network=True)
compare_branch = f"origin/{branch}"
else:
# Non-default branch: compare against origin/<branch> directly.
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)
# Verify the compare ref exists first: rev-list on a bogus ref exits 128
# and (with check=True) would surface a Python traceback.
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 (like the
# banner's _check_via_local_git), then recover the exact count via the
# GitHub compare API, whose graph is complete.
head_sha = _git_run(git_cmd, ["rev-parse", "HEAD"]).stdout.strip()
target_sha = _git_run(git_cmd, ["rev-parse", compare_branch]).stdout.strip()
if head_sha and target_sha and head_sha == target_sha:
print("✓ Already up to date.")
else:
from hermes_cli.banner import _github_compare_behind
from hermes_cli.config import recommended_update_command
counted = _github_compare_behind(head_sha, target_sha)
if counted == 0:
# Local commits on top of the remote tip — not behind.
print("✓ Already up to date.")
return
if counted is not None:
commits_word = "commit" if counted == 1 else "commits"
print(f"⚕ Update available: {counted} {commits_word} behind {compare_branch}.")
else:
print(f"⚕ Update available (behind {compare_branch}).")
print(f" Run '{recommended_update_command()}' to install.")
return
rev_result = _git_run(git_cmd, ["rev-list", f"HEAD..{compare_branch}", "--count"], check=True)
behind = int(rev_result.stdout.strip())
if behind == 0:
print("✓ Already up to date.")
else:
commits_word = "commit" if behind == 1 else "commits"
print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.")
from hermes_cli.config import recommended_update_command
print(f" Run '{recommended_update_command()}' to install.")
def _repair_current_checkout(
*,
assume_yes,
gateway_mode,
pre_update_snapshot_id,
desktop_dir,
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 and repair a broken venv.
A current checkout does not imply a healthy install (a prior dependency
sync may have died partway), and the Windows shim hand-off child lands
here BY DESIGN to run the sync its parent could not. Returns whether the
checkout can be reported as complete.
"""
# "No new commits" does not mean the managed interpreter is safe.
# uv can retain the same CPython patch while python-build-standalone
# refreshes the embedded SQLite underneath it. Keep the existing
# update-boundary hook active on this retry path too.
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
runtime_repairs = []
update_managed_uv(repair_observer=runtime_repairs.append)
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 (Windows: locked .pyd → uv/pip access-denied, venv
# stranded between versions). Probe core imports and repair, or
# "Already up to date!" hides a bricked install.
healthy, detail = _venv_core_imports_healthy()
# The Windows shim hand-off child exists to run the sync its parent
# could not; the checkout is current BY DESIGN, so the pending sync —
# not venv health — is the question. Without this it would print
# "Already up to date!" and skip its one job.
handed_off_sync = os.environ.get(_m()._UPDATE_REEXEC_ENV) == "1"
current_checkout_complete = True
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:
# Self-lock deferral (#86735): the repair rewrites the venv
# too — same mapped-extension hazard as the update sync.
_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()
# A managed install whose venv is gone entirely (interrupted
# repair after the old venv was moved aside) needs the venv
# recreated before dependencies can be installed into it.
venv_python_missing = not (
venv_python_path(
_m().PROJECT_ROOT / "venv", windows=_m()._is_windows()
)
).exists()
if venv_python_missing and repair_uv:
print("→ Recreating virtual environment...")
subprocess.run(
[repair_uv, "venv", "venv"],
cwd=_m().PROJECT_ROOT,
check=False,
)
if repair_uv:
# Isolated from third-party UV env vars (#83914), same as
# the main-path and git-path dependency syncs.
from hermes_cli.managed_uv import managed_python_env
repair_env = managed_python_env()
repair_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv")
_m()._install_python_dependencies_with_optional_fallback(
[repair_uv, "pip"], env=repair_env, group="all"
)
_m()._refresh_active_lazy_features(
[repair_uv, "pip"],
env=repair_env,
features=active_lazy_features,
)
_m()._restore_active_tool_dependencies(
active_tool_dependencies,
[repair_uv, "pip"],
env=repair_env,
)
else:
_m()._install_python_dependencies_with_optional_fallback(
[sys.executable, "-m", "pip"], group="all"
)
_m()._refresh_active_lazy_features(
[sys.executable, "-m", "pip"],
features=active_lazy_features,
)
_m()._restore_active_tool_dependencies(
active_tool_dependencies,
[sys.executable, "-m", "pip"],
)
_m()._clear_update_incomplete_marker()
healthy_after, detail_after = _venv_core_imports_healthy()
if healthy_after:
print("✓ Dependencies repaired!")
_check_and_apply_config_migration(
assume_yes=assume_yes,
gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
)
# The hand-off child never reaches the commits-pulled rebuild,
# so rebuild the Desktop app here or it stays on the old build (#97343).
if _rebuild_desktop_after_update(
desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update,
):
current_checkout_complete = _print_verified_update_completion(
"✓ Update complete!"
)
else:
current_checkout_complete = False
_print_update_completion(
"⚠ Update partially complete — the desktop app was "
"not rebuilt and is still on the previous build."
)
else:
current_checkout_complete = False
print(f"⚠ Venv still unhealthy after repair: {detail_after}")
print(" Close all Hermes windows/gateways and re-run: hermes update")
else:
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 _pull_updates(
git_cmd,
branch,
auto_stash_ref,
*,
prompt_for_restore,
gw_input_fn,
discard_local_changes,
keep_stash,
):
"""Fast-forward the checkout onto ``origin/<branch>`` and settle the autostash.
Divergence is handled by shape (custom branch -> merge, same branch ->
reset, orphan history -> rescue ref first); a post-pull syntax error in a
critical file rolls back to the pre-pull SHA. Exits the process on
failure. Returns the pre-pull HEAD SHA (or None).
"""
update_succeeded = False
# Pre-pull SHA for auto-rollback when pulled code has a syntax error in
# a critical file (PR #28452: stray conflict markers in config.py
# bricked every updater for 7 minutes).
pre_pull_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
try:
# Merge the ref we already fetched above (→ Fetching updates...)
# instead of `git pull`, which performs a SECOND network fetch of
# the same branch (~0.5-1.5 s of redundant round-trip per update).
# `merge --ff-only origin/<branch>` is byte-identical in effect to
# `pull --ff-only origin <branch>` given the fresh tracking ref;
# the divergence fallback below is unchanged.
pull_result = _git_run(git_cmd, ["merge", "--ff-only", f"origin/{branch}"])
if pull_result.returncode != 0:
# ff-only failed — local and remote have diverged. Before
# assuming an upstream force-push, check WHY: a checkout on a
# custom branch (local commits on top of origin/<branch>) also
# cannot fast-forward, and `reset --hard` here would silently
# discard that work. Merge instead and stop cleanly on
# conflict — an update must never destroy local commits.
_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; recovery anchor if anything goes wrong.
subprocess.run(
git_cmd
+ ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"],
cwd=_m().PROJECT_ROOT,
capture_output=True,
check=False,
)
merge_result = _git_run(git_cmd, ["merge", "--no-edit", f"origin/{branch}"])
if merge_result.returncode != 0:
subprocess.run(
git_cmd + ["merge", "--abort"],
cwd=_m().PROJECT_ROOT,
capture_output=True,
check=False,
)
print(
"✗ Merge conflict between local commits and upstream — "
"update stopped, nothing was changed."
)
print(
f" Resolve manually: cd {_m().PROJECT_ROOT} && "
f"git merge origin/{branch}"
)
print(
" Then re-run the update. Local work is untouched."
)
sys.exit(1)
else:
# Same branch as the target — a true upstream force-push/
# rebase; local changes are stashed, so reset to the remote.
# Orphan divergence (no common ancestor: corrupted HEAD, repo
# re-init — #87694) would lose the whole local commit 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 = bool(
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 (not just a 1s timestamp) so two updates in
# the same second get distinct refs instead of overwriting.
rescue_ref = (
f"refs/hermes-update-backups/orphan-{branch}-"
f"{_dt.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
f"-{pre_pull_sha[:12]}"
)
update_ref_result = _git_run(git_cmd, ["update-ref", rescue_ref, pre_pull_sha])
if update_ref_result.returncode == 0:
print(
" ⚠ Local history shares no common ancestor with "
f"origin/{branch} (orphan divergence) — backed up "
f"current HEAD to {rescue_ref} before resetting. "
f"This backup expires after "
f"{_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days."
)
else:
# update-ref's return code is intentionally not
# fatal (disk full, permissions) — but don't tell
# the user a backup exists when the write failed.
print(
" ⚠ Local history shares no common ancestor with "
f"origin/{branch} (orphan divergence) — attempted "
f"to back up current HEAD to {rescue_ref} before "
"resetting, but the backup write failed "
f"(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)
# Post-pull syntax guard: a bad commit that slipped past CI
# (admin-merge bypass) is caught here and rolled back so the CLI
# stays bootable until a fix lands.
syntax_ok, failing_path, syntax_error = _validate_critical_files_syntax(
_m().PROJECT_ROOT
)
if not syntax_ok:
print()
print("✗ Pulled code has a syntax error in a critical file:")
print(f" {failing_path}")
if syntax_error:
# py_compile errors can be multi-line; show the first
# ~6 lines so the user sees the actual SyntaxError text.
for line in str(syntax_error).splitlines()[:6]:
print(f" {line}")
if pre_pull_sha:
print()
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()
print(" Could not capture pre-pull SHA — recover manually with:")
print(f" cd {_m().PROJECT_ROOT} && git reflog && git reset --hard <prev-sha>")
sys.exit(1)
update_succeeded = True
finally:
if auto_stash_ref is not None:
# Don't attempt stash restore if the code update itself failed —
# working tree is in an unknown state.
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 update + user opted into discarding local
# source edits (updates.non_interactive_local_changes:
# discard). Throw the stash away instead of re-applying it.
_m()._discard_stashed_changes(
git_cmd,
_m().PROJECT_ROOT,
auto_stash_ref,
)
elif keep_stash:
# --keep-stash (desktop updater): the update landed; leave
# local edits parked in the stash instead of silently
# re-applying them onto the updated code.
_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 _prepare_checkout_for_update(
git_cmd,
branch,
current_branch,
*,
is_fork,
assume_yes,
gateway_mode,
gw_input_fn,
switch_branch,
_windows_gateway_resume,
):
"""Apply the parked-branch guard, land on the update target, stash, and count new commits.
Exits the process when the checkout is unsafe to move or the target branch
does not exist. ``commit_count`` is 0 when up to date, -1 when tips differ
but the shallow count is unrecoverable.
"""
switch_block_reason = None # only meaningful when parked_branch_switched
# Parked-branch guard: a checkout parked on a stale feature branch
# used to stash-switch-pull-switch-back, "updating" main while the
# running code stayed behind. Routing by branch contents +
# updates.parked_branch_strategy:
# fully merged -> switch back to the target.
# unmerged: N -> "switch" (default): switch anyway (commits are
# safe on the branch) with a loud "kept" notice;
# deterministic for non-interactive callers.
# "update_in_place": merge origin/<target> INTO
# the branch — checkout never moves, local commits
# survive. --switch-branch overrides for one run.
# anything else -> dirty/unverifiable/opted out: touch nothing,
# warn, mark the code update SKIPPED, stop.
parked_branch_switched = False
in_place_update = False
if current_branch != branch and current_branch != "HEAD":
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(
"⚠ Update finished — code update SKIPPED"
f"{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}"
)
_m()._resume_windows_gateways_after_update(
_windows_gateway_resume
)
sys.exit(1)
if switch_block_reason.startswith("unmerged:"):
_in_place_configured = False
try:
from hermes_cli.config import load_config as _load_cfg
_upd_cfg = (_load_cfg() or {}).get("updates", {})
_in_place_configured = (
isinstance(_upd_cfg, dict)
and _upd_cfg.get("parked_branch_strategy", "switch")
== "update_in_place"
)
except Exception as exc:
logger.debug(
"Could not read updates.parked_branch_strategy: %s", exc
)
if _in_place_configured and not switch_branch:
# The merge source must exist upstream; --branch typos
# previously surfaced through the checkout failing, which
# does not run on this path.
verify_ref = _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", f"origin/{branch}"])
if verify_ref.returncode != 0:
print(f"✗ Branch '{branch}' does not exist locally or on origin.")
sys.exit(1)
in_place_update = True
print(
f" ℹ On branch '{current_branch}' — updating it in place from "
f"origin/{branch} (no branch switch; local commits preserved)."
)
else:
parked_branch_switched = True
_m()._print_parked_branch_kept_notice(
current_branch,
branch,
switch_block_reason.split(":", 1)[1],
)
else:
parked_branch_switched = True
print(
f" ⚠ Checkout was parked on '{current_branch}' "
f"(fully merged) — switching back to {branch}..."
)
if not in_place_update and current_branch != branch:
if current_branch == "HEAD":
print(
f" ⚠ Currently on detached HEAD — switching to {branch} "
"for update..."
)
# Stash before checkout so uncommitted work isn't lost
auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT)
checkout_result = _git_run(git_cmd, ["checkout", branch])
if checkout_result.returncode != 0:
# Branch not local yet — set it up tracking origin/<branch>.
track_result = _git_run(git_cmd, ["checkout", "-B", branch, f"origin/{branch}"])
if track_result.returncode != 0:
# Restore the user's prior stash before bailing
# so we don't leave them stranded in a weird state.
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)
else:
auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT)
prompt_for_restore = (
auto_stash_ref is not None
and not assume_yes
and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))
)
# Check if there are updates. On shallow checkouts `rev-list --count`
# walks the truncated graph and can report the entire remote ancestry
# (e.g. "Found 9980 new commit(s)" on a depth-1 install — #53479).
# The zero/nonzero gate is still sound (HEAD == origin/<branch> counts
# 0), so keep it, but treat the shallow NUMBER as unknown and recover
# the real one 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 = (
_git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip()
== "true"
)
if commit_count > 0 and apply_is_shallow:
from hermes_cli.banner import _github_compare_behind
head_sha = _git_run(git_cmd, ["rev-parse", "HEAD"]).stdout.strip()
target_sha = _git_run(git_cmd, ["rev-parse", f"origin/{branch}"]).stdout.strip()
counted = _github_compare_behind(head_sha, target_sha)
# counted == 0 means local-ahead (remote tip reachable from HEAD):
# not behind, fall 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 upstream sync can
# move HEAD with commit_count == 0. Detect that BEFORE the no-update
# return so deps, restarts AND the fleet matrix still run (#73108 —
# the sync used to live inside the early-return branch and verified
# nothing). Non-forks have no upstream question.
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 itself proof of an update. Keep the update
# path active even if the informational count cannot 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
def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions:
"""Snapshot pre-update state and resolve the flags/config ``_cmd_update_impl`` runs on."""
# A managed-runtime refresh can replace site-packages before the normal
# ``.[all]`` install runs. Snapshot while the old environment can still
# prove which optional backends the user had activated.
active_lazy_features = _m()._capture_active_lazy_features()
active_tool_dependencies = _m()._capture_active_tool_dependencies()
# 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()
# In gateway mode, use file-based IPC for prompts instead of stdin
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): stash local changes so the update can
# proceed, but never re-apply them afterward — they stay parked in git
# stash. Only applies when an update actually landed; abort/no-op paths
# still restore, since the tree they restore onto is unchanged.
keep_stash = bool(getattr(args, "keep_stash", False))
# --switch-branch: on a branch carrying unmerged commits, prefer switching
# to the update target over an in-place merge, so the branch's history is
# never written to by an update (#89507 review feedback). Only meaningful
# when updates.parked_branch_strategy is "update_in_place".
switch_branch = bool(getattr(args, "switch_branch", False))
# Whether this update is running without a human at the keyboard.
# Interactive terminal updates always stash-and-ask (unchanged behavior);
# only non-interactive updates (desktop/chat app, gateway, `--yes`) consult
# the `updates.non_interactive_local_changes` config setting to decide
# whether to auto-restore stashed local source changes or throw them away.
_non_interactive_update = (
gateway_mode
or assume_yes
or not (sys.stdin.isatty() and sys.stdout.isatty())
)
discard_local_changes = False
if _non_interactive_update:
try:
from hermes_cli.config import load_config
_update_cfg = (load_config() or {}).get("updates", {})
if isinstance(_update_cfg, dict):
_mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower()
discard_local_changes = _mode == "discard"
except Exception as exc:
# Never let a config read failure change the safe default.
logger.debug("Could not read updates.non_interactive_local_changes: %s", exc)
discard_local_changes = False
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,
)
def _begin_update_receipt_and_plan(args):
"""Open the update receipt, snapshot the running fleet, refuse on Windows shim holders.
Returns the pre-update runtime plan (or None when the probe failed);
``sys.exit(2)`` when a non-gateway hermes.exe still holds the venv shim.
"""
# Phase 1 (#91277): structured update receipt — record what this run
# discovers, does, and skips, so silent-failure classes (#88848,
# #74973, #85753, #81193) become diagnosable from disk.
try:
from hermes_cli.update_receipt import begin_update_receipt
begin_update_receipt()
except Exception as _receipt_exc:
logger.debug("Update receipt unavailable: %s", _receipt_exc)
# Plan phase (#91277): snapshot every running runtime, supervisor and
# code version into the receipt (read-only; probe failure records
# nothing). Re-read AFTER the restart phase to reconcile planned
# runtimes against bookkeeping — the plan is the worklist.
_pre_update_plan = None
try:
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}")
except Exception as _plan_exc:
logger.debug("Update plan phase failed: %s", _plan_exc)
# Windows: abort if another hermes.exe holds the venv shim — continuing
# yields WinError 32 spam and a deferred-rename leftover or silent ZIP
# fallback (#26670). Exception (#37039): instances positively identified
# as gateways are paused by ``_pause_windows_gateways_for_update`` below
# and restarted afterwards; anything else (TUI, Desktop backend,
# unreadable cmdline) still aborts.
if _m()._is_windows() and not getattr(args, "force", False):
scripts_dir = _m()._venv_scripts_dir()
if scripts_dir is not None:
concurrent = _m()._detect_concurrent_hermes_instances(scripts_dir)
if concurrent:
non_gateway = _m()._filter_non_gateway_concurrent_instances(
concurrent
)
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]:
"""Decide git-vs-ZIP, build the git argv and detect a fork origin.
Returns ``(use_zip_update, git_cmd, is_fork)``; ``sys.exit(1)`` when the
checkout is not a git repo on a non-Windows host.
"""
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
use_zip_update = False
git_dir = _m().PROJECT_ROOT / ".git"
if not git_dir.exists():
if sys.platform == "win32":
use_zip_update = True
else:
print("✗ Not a git repository. Please reinstall:")
print(
" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
)
sys.exit(1)
# On Windows, git can fail with "unable to write loose object file: Invalid argument"
# due to filesystem atomicity issues. Set the recommended workaround.
if sys.platform == "win32" and git_dir.exists():
subprocess.run(
[
"git",
"-c",
"windows.appendAtomically=false",
"config",
"windows.appendAtomically",
"false",
],
cwd=_m().PROJECT_ROOT,
check=False,
capture_output=True,
)
# Build git command once — reused for fork detection and the update itself.
git_cmd = ["git"]
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
# A broken Git-for-Windows trampoline refuses every git call with a
# "BUG (fork bomb)" guard instead of running; swap in a real binary up
# front so the normal git path survives instead of degrading to ZIP
# (#87876).
git_cmd = _ensure_non_trampoline_git(git_cmd)
# Discard npm lockfile churn before stash/branch logic: npm rewrites
# package-lock.json non-deterministically, which is never an intentional
# edit on a managed install but forces an autostash every update.
_discard_lockfile_churn(git_cmd, _m().PROJECT_ROOT)
# Same rationale, different generator: line-ending churn is machine-made
# dirt on a managed checkout, so clear it (and stop generating it) before
# the stash/branch logic rather than autostashing the entire tree.
_normalize_managed_eol(git_cmd, _m().PROJECT_ROOT)
# Detect if we're updating from a fork (before any branch logic)
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."""
# Verify HEAD moved (#79678): a detached checkout pinned to a SHA can
# report "N new commit(s)" and a successful ``merge --ff-only`` yet
# stay on the old commit, so the old code reinstalled deps and
# claimed "✓ Code updated!". Surface the no-op instead.
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)
# Verify HEAD is on the target branch; otherwise "✓ Code updated!"
# would be a lie. An IN-PLACE update is the one legitimate way to end
# elsewhere: origin/<target> was merged INTO the checked-out branch,
# so the running code *is* current.
post_pull_branch = _git_run(git_cmd, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
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 _handle_update_called_process_error(
e, args, gateway_mode: bool, had_desktop_app_before_update: bool
) -> 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,
)
if gateway_mode:
_write_gateway_update_exit_code(desktop_build_ok)
else:
print(f"✗ {stage}: {e}")
_print_called_process_error_tail(e)
if _called_process_error_is_python_dep_install(e):
print(
" The git update already finished. Re-downloading the source "
"ZIP cannot fix a dependency install error and would overwrite "
"local files."
)
if _m()._is_windows():
print(" Retry through the venv interpreter:")
print(
' venv\\Scripts\\python.exe -c '
'"from hermes_cli.main import main; main()" update --yes'
)
try:
from hermes_cli.update_receipt import finalize_update_receipt
finalize_update_receipt("failed")
except Exception:
pass
sys.exit(1)
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``."""
opts = _resolve_update_options(args, gateway_mode)
active_lazy_features = opts.active_lazy_features
active_tool_dependencies = opts.active_tool_dependencies
pre_update_version = opts.pre_update_version
gw_input_fn = opts.gw_input_fn
assume_yes = opts.assume_yes
keep_stash = opts.keep_stash
switch_branch = opts.switch_branch
discard_local_changes = opts.discard_local_changes
print("⚕ Updating Hermes Agent...")
print()
_pre_update_plan = _begin_update_receipt_and_plan(args)
# Pre-update backup — runs before any git/file mutation so users can
# always roll back to the exact state they had before this update.
# Returns the quick-snapshot id (or None when disabled/failed); the
# post-update cron-jobs safety net uses it to detect job loss.
pre_update_snapshot_id = _m()._run_pre_update_backup(args)
_record_update_step(
"pre_update_backup",
pre_update_snapshot_id is not None,
f"snapshot={pre_update_snapshot_id}" if pre_update_snapshot_id else "disabled or failed",
)
_windows_gateway_resume = _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,
)
# With gateways paused, any venv python still running (typically the
# Desktop `hermes serve` backend) keeps .pyd files locked and would
# corrupt the sync; refuse rather than race (the app respawns a killed
# backend). NOT bypassed by --force: the desktop updater passes it to
# skip the shim guard but only probes the shim and app.asar.
# --force-venv is the explicit escape hatch.
if _m()._is_windows() and not getattr(args, "force_venv", False):
_clear_windows_venv_holders_or_exit(args, gateway_mode, _windows_gateway_resume)
# 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.
# Capture this after every fail-closed venv guard, but before either
# update path can remove the ignored release tree.
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:
# ZIP-based update for Windows when git is broken
try:
desktop_build_ok = _update_via_zip(
args,
had_desktop_app_before_update=had_desktop_app_before_update,
)
finally:
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
if gateway_mode:
_write_gateway_update_exit_code(desktop_build_ok)
return
# Fetch and pull
try:
# Resolve the branch first so the fetch is scoped: a bare `git fetch
# origin` pulls thousands of auto-generated branches and can stall for minutes.
branch = _m()._resolve_update_branch(args)
# Self-heal abandoned git lock files (e.g. .git/shallow.lock left by a
# crashed fetch) before the fetch — otherwise the update fails with
# "Unable to create .../shallow.lock: File exists" and never reaches
# the network.
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))
# 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)
# Get current branch (returns literal "HEAD" when detached)
result = _git_run(git_cmd, ["rev-parse", "--abbrev-ref", "HEAD"], check=True)
current_branch = result.stdout.strip()
_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=switch_branch,
_windows_gateway_resume=_windows_gateway_resume,
)
auto_stash_ref = _plan.auto_stash_ref
commit_count = _plan.commit_count
in_place_update = _plan.in_place_update
parked_branch_switched = _plan.parked_branch_switched
prompt_for_restore = _plan.prompt_for_restore
switch_block_reason = _plan.switch_block_reason
upstream_checked = _plan.upstream_checked
if commit_count == 0:
_invalidate_update_cache()
# Restore stash and switch back to original branch if we moved.
# EXCEPTION: a parked feature branch we verified clean + fully
# merged stays on the target — re-parking the checkout on the
# stale branch is the 2026-08-17 incident all over again.
if auto_stash_ref is not None:
_m()._restore_stashed_changes(
git_cmd,
_m().PROJECT_ROOT,
auto_stash_ref,
prompt_user=prompt_for_restore,
input_fn=gw_input_fn,
)
if parked_branch_switched:
if switch_block_reason.startswith("unmerged:"):
_count = switch_block_reason.split(":", 1)[1]
print(
f" ✓ Checkout was parked on '{current_branch}' — "
f"switched back to {branch}; {_count} unmerged "
f"commit(s) kept on '{current_branch}'."
)
else:
print(
f" ✓ Checkout was parked on '{current_branch}' (fully "
f"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,
desktop_dir=desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update,
active_lazy_features=active_lazy_features,
active_tool_dependencies=active_tool_dependencies,
upstream_checked=upstream_checked,
_windows_gateway_resume=_windows_gateway_resume,
)
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
# A prior pull may still owe the fleet a restart (#95294); catch
# up even on the "Already up to date" path, and BEFORE the exit
# gate below so a partial outcome can't strand the fleet on stale
# code (#91277 fleet contract).
_apply_pending_fleet_restart_catchup()
if not current_checkout_complete:
if gateway_mode:
_write_gateway_update_exit_code(False)
try:
from hermes_cli.update_receipt import finalize_update_receipt
finalize_update_receipt("partial")
except Exception as _receipt_exc:
logger.debug(
"Update receipt finalize (current checkout) failed: %s",
_receipt_exc,
)
sys.exit(1)
return
if commit_count > 0:
print(f"→ Found {commit_count} new commit(s)")
else:
# Shallow checkout, exact count unrecoverable (offline/rate-limited
# compare API) — 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,
auto_stash_ref,
prompt_for_restore=prompt_for_restore,
gw_input_fn=gw_input_fn,
discard_local_changes=discard_local_changes,
keep_stash=keep_stash,
)
_invalidate_update_cache()
post_pull_sha = _verify_head_after_pull(
git_cmd,
branch,
pre_pull_sha,
in_place_update=in_place_update,
_windows_gateway_resume=_windows_gateway_resume,
)
# #95294: HEAD advanced; running gateways still serve pre-pull
# modules until the restart phase below. Any interrupt between here
# and a completed (or no-op) restart leaves this marker so the next
# ``hermes update`` can catch up even when git is already up to date.
# Distinct from ``.update-incomplete`` (venv/install repair).
_write_fleet_restart_pending_marker(expected_sha=post_pull_sha or "")
# Clear stale .pyc bytecode cache — prevents ImportError on gateway
# restart when updated source references names that didn't exist in
# the old bytecode (e.g. get_hermes_home added to hermes_constants).
_sweep_bytecode_after_update(branch)
# Fork upstream sync logic (only for main branch on forks)
if is_fork and branch == "main":
_m()._sync_with_upstream_if_needed(
git_cmd,
_m().PROJECT_ROOT,
assume_yes=assume_yes,
input_fn=gw_input_fn,
)
# Reinstall deps: .[all], falling back to base + remaining extras
# individually so a broken extra doesn't strip working capabilities.
# Ownership preflight (#83529) refuses first if the venv has
# foreign-owned (sudo-pip) files that would brick the install mid-mutation.
_sync_python_dependencies_after_pull(
git_cmd,
branch,
pre_pull_sha,
active_lazy_features=active_lazy_features,
active_tool_dependencies=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=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=pre_update_version,
)
# Write the exit code *before* the restart attempt: under ``update
# --gateway`` this process lives in the gateway's systemd cgroup, and
# the ``systemctl restart`` fallback SIGKILLs the cgroup (KillMode=
# mixed) — us and the wrapping shell included — so the marker would
# never be written and the new gateway's watcher would poll 30 min
# and report a spurious timeout. The verified summary already folds
# in Desktop and SQLite-runtime health (gateway/run.py).
if gateway_mode:
_write_gateway_update_exit_code(update_complete)
_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,
)
except _shim_quarantine_error_type() as e:
# Fail-closed shim contention (#87331): strict quarantine refused
# BEFORE any installer ran — defer via marker, exit 2, no ZIP.
_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)
# --- Hoisted from the body of _cmd_update_impl (self-contained, no closure state) ---