Files
hermes-agent/hermes_cli/subcommands/update.py
teknium1 94ced1a2b2 fix(update): finish hermes update in an interpreter born on the pulled code
`hermes update` started in an interpreter that had imported the PRE-pull tree, then
kept running every post-swap phase (dependency sync, Node/web/Desktop builds,
maintenance, config migration, fleet restart, verification, receipt) in that same
process, lazily importing NEW source into an OLD `sys.modules` graph. Any rename
between the two commits surfaced as an ImportError/AttributeError inside the updater
after the code swap had already succeeded (#87134, #111271, #112465, #112558, #112604).
Each incident added another purge, reload list or per-step isolation, and each moved
the crash to the next module nobody had listed.

The pre-pull process now stops at the swap: it writes the open receipt, the pre-update
fleet plan, the pre-update version/active features and the Windows pause token to a
hand-off file and re-executes `hermes update <same flags> --post-swap <file>` under the
venv interpreter. The child imports exclusively from the pulled tree, resumes the
receipt and owns the rest of the run; the parent relays its exit code. Git and ZIP paths
both hand off. On Windows, when the updater runs from `hermes.exe`, the child is spawned
detached exactly as the shim hand-off already did (the shim cannot be awaited while the
sync must replace it).

With no pulled code ever executing in a pre-pull interpreter, the stale-module layer is
dead and removed: `_purge_stale_hermes_modules`, `_stale_purge_prefixes`,
`_evict_module`, `_STALE_PURGE_*`, `_reload_updated_runtime_modules`,
`_reload_process_scan_modules`, `_reload_config_modules`, `_UPDATE_RUNTIME_RELOAD_MODULES`
and their tests. `_run_config_check_fresh` / `_run_migrate_config_fresh` keep their names
and simply call the config API.

Tests: the hand-off boundary (child argv/env, detached receipt + plan in the payload,
exit-code relay) and the child side (receipt resumed with its history, plan rebuilt,
pre-update snapshots taken from the payload). Mocked updater flows run the tail
in-process through the same payload round-trip (autouse fixture; opt out with
`@pytest.mark.real_post_swap_handoff`).
2026-09-17 00:02:09 -07:00

83 lines
4.9 KiB
Python

"""``hermes update`` subcommand parser."""
from __future__ import annotations
import argparse
from typing import Callable
def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
"""Attach the ``update`` subcommand to ``subparsers``."""
update_parser = subparsers.add_parser(
"update", help="Update Hermes Agent to the latest version",
description="Pull the latest changes from git and reinstall dependencies")
update_parser.add_argument(
"--gateway", action="store_true", default=False,
help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)",
)
update_parser.add_argument(
"--check", action="store_true", default=False,
help="Check whether an update is available without installing anything")
update_parser.add_argument(
"--plan", action="store_true", default=False,
help="Show the update plan and exit without changing anything: install "
"kind (git/docker/nix), every running Hermes service across all "
"profiles with its supervisor and running code version, and how "
"each will be restarted. Read-only; safe on a live fleet.")
update_parser.add_argument(
"--no-backup", action="store_true", default=False,
help="Skip ALL pre-update backups for this run (both the quick state snapshot and the full zip; overrides updates.pre_update_backup)",
)
update_parser.add_argument(
"--backup", action="store_true", default=False,
help="Force a FULL pre-update backup (quick state snapshot + HERMES_HOME zip) for this run, regardless of updates.pre_update_backup",
)
update_parser.add_argument(
"--yes", "-y", action="store_true", default=False,
help="Run without blocking on prompts: accepts the config-migration and stash-restore prompts, skips the fork-upstream prompt without adding a remote. API-key entry is skipped; run 'hermes config migrate' separately for those.",
)
update_parser.add_argument(
"--keep-stash", action="store_true", default=False,
help="Do NOT re-apply local changes after the update. Uncommitted "
"changes are still stashed so the update can proceed, but they "
"stay parked in git stash instead of being restored onto the "
"updated code. Used by the desktop updater so local source edits "
"never silently ride along across updates.")
update_parser.add_argument(
"--branch", default=None, metavar="NAME",
help="Update against this branch instead of the default (main). "
"If the local checkout is on a different branch, hermes will "
"switch to the requested branch first (auto-stashing any "
"uncommitted changes).")
update_parser.add_argument(
"--switch-branch", action="store_true", default=False,
help="With updates.parked_branch_strategy: update_in_place configured, "
"override it for this run: switch to the update target and update "
"THERE instead of merging the target into the checked-out branch. "
"The branch is left exactly as it was — no merge commit is written "
"into its history. Use on long-lived feature branches where an "
"update-driven merge commit would pollute the branch. No effect "
"under the default strategy (switch), which already switches. "
"Still refuses to touch a dirty tree.")
update_parser.add_argument(
"--force", action="store_true", default=False,
help="Windows: proceed with the update even when another hermes.exe is detected. The concurrent process will likely cause WinError 32 warnings. Does NOT bypass the venv-process guard (see --force-venv).",
)
update_parser.add_argument(
"--force-venv", action="store_true", default=False,
help="Windows: mutate the venv even while other processes are running from its interpreter (desktop backend, gateway, terminals). Those processes keep native .pyd files locked, so the dependency sync will likely fail partway and strand the install half-updated. Use only if you know the detected holders are false positives.",
)
update_parser.add_argument(
"--no-gateway-restart", action="store_true", default=False,
help="Update code and dependencies but skip the final gateway restart. "
"Use for cron/automated updates that run inside the gateway process: "
"the gateway would otherwise restart its own cgroup and kill the updater. "
"Pair with a separate restart step (e.g. a cron that runs 10-15 min later).",
)
update_parser.add_argument(
"--post-swap", default=None, metavar="FILE", help=argparse.SUPPRESS,
# Internal: the pre-pull interpreter re-executes itself here after the code swap so the
# rest of the update runs on the pulled code (hermes_cli/update_handoff.py).
)
update_parser.set_defaults(func=cmd_update)