Files
hermes-agent/hermes_cli/update_cmd.py
Tyler Lyon 18f4b29daf fix(cli): symmetric Windows resume-failure handling + atexit disarm
Two distinct defects in the Windows gateway pause/resume path, one
symptom: "hermes update" aborting exit 1 on a checkout that was
already current and needed no code change.

1. Asymmetric error handling between the two finish paths. The pull
   path wraps the Windows gateway resume in
   _resume_windows_gateways_and_merge_outcome, whose contract is
   explicit: "Must never abort the update" -- it catches the failure,
   marks the outcome incomplete, warns, and continues (receipt status
   "partial", exit 1 only via the existing incomplete-repair gate).
   _finish_already_up_to_date ("Already up to date" path) called
   _resume_windows_gateways_after_update bare, so the identical
   RuntimeError -- e.g. the relaunch-verification race with a parent
   Job Object kill, #48820 -- killed the run as an unhandled exception
   instead. Fixed by routing through the same merge helper and folding
   a failed resume into current_checkout_complete, landing on the
   pre-existing "partial" finalize + sys.exit(1) contract the repair
   path already uses for other incomplete states.

2. The atexit callback could replay the same failure a second time.
   Every foreground call site registers
   _resume_windows_gateways_after_update via atexit.register as a
   dead-process safety net, but the function only cleared
   token["resume_needed"] on its OWN success path -- a raise from
   _verify_relaunched_gateways_alive left the registration live, so
   interpreter teardown called the function again with the same
   still-armed token, repeated the whole relaunch + verify, failed
   identically, and printed "Exception ignored in atexit callback".
   Fixed by unregistering the function from atexit as soon as
   execution reaches it (foreground or the atexit fallback itself) --
   ownership transfers to whichever call site got there first, and a
   failure below is reported exactly once. atexit.unregister is a
   no-op when the function was never registered, so a non-Windows or
   no-resume-needed call is unaffected.

Both fixes are surgical: they leave the "a failed relaunch keeps
resume_needed=True so the token is retryable" contract completely
untouched (see the existing
test_resume_windows_gateway_service_failure_stays_retryable /
test_resume_windows_gateway_launcher_refresh_failure_stays_retryable
tests, which this PR does not modify) -- that design is intentional,
not the bug.

Closes #115563.

Testing: Windows-specific behavior verified by code trace + full
resolution-chain tests against the real functions (mocked I/O, real
control flow) on Linux/macOS, per the "Don't fake the host OS" rule
this repo's own AGENTS.md sets -- no sys.platform patching, and the
Windows-only branch (_is_windows() == True) is exercised exactly as
the existing test suite for this module already does (monkeypatching
_is_windows on the shared test fixtures, not faking the platform).
True live-Windows process-topology proof is out of scope for this PR
(the wine2e lane is reserved for process-topology E2E per AGENTS.md);
this fix is control-flow/bookkeeping, not host-specific syscalls.

- 2 new regression tests, both proven red on the pre-fix code via
  git stash (both fail with the exact defect signature: the "Already
  up to date" path re-raises instead of demoting to partial; the
  atexit unregister call never happens) and green on the fix
- scripts/run_tests.sh across the full affected surface (Windows
  update/resume/reconciliation + related pause/resume/venv-repair
  test files): 228 passed, 0 failed, 4 skipped (pre-existing,
  unrelated to this change)
2026-09-20 10:40:12 -07:00

1704 lines
90 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: dispatchers (``_cmd_update_impl``/``_cmd_update_check``) + git plumbing.
Each concern lives in ``update_cmd_<concern>.py`` and is re-imported here so
``hermes_cli.update_cmd.<name>`` 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.<name>.
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 <branch> (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, <target_ref> 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 (hindsight-embed, torch, ...).
_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/<branch>) 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 <prev-sha>")
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/<branch>`` 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/<target>
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/<target> 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 ----