Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 119 additions & 20 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10131,6 +10131,62 @@ def _cmd_update_pip(args):
print("✓ Update complete! Restart hermes to use the new version.")


def _should_handoff_after_pull(finalize_only: bool) -> bool:
"""Whether to hand the post-pull steps off to a fresh subprocess on new
code.

Returns False (finish in-process) when:
- this IS already the finalize subprocess (avoid an infinite loop),
- under pytest, so the test runner's interpreter never spawns a real
recursive update mid-suite,
- the ``HERMES_UPDATE_NO_HANDOFF`` escape hatch is set.

Cross-platform: unlike an ``os.exec*`` replacement (which on Windows
spawns a *new* PID and breaks the desktop installer's exit-code wait on
the original process), a child subprocess + exit-code forwarding keeps the
parent PID intact everywhere, so this stays on for Windows too.
"""
if finalize_only or os.environ.get("HERMES_UPDATE_FINALIZE") == "1":
return False
if os.environ.get("HERMES_UPDATE_NO_HANDOFF") == "1":
return False
if "pytest" in sys.modules:
return False
return True


def _handoff_update_to_refreshed_code():
"""Finish the update in a fresh subprocess running the just-pulled code.

Called after a successful git pull + dependency install (so the new source
AND its deps are on disk). Spawns ``hermes update`` again with
``HERMES_UPDATE_FINALIZE=1`` set; that child skips the fetch/pull/snapshot
work and this hand-off, and runs only the post-pull finalize steps with
new code. stdin/stdout/stderr are inherited, so interactive prompts and a
parent streaming our output both keep working.

Returns the child's exit code, or ``None`` if the subprocess could not be
launched at all — in which case the caller finishes the update in-process,
the historical behavior.
"""
try:
env = dict(os.environ)
env["HERMES_UPDATE_FINALIZE"] = "1"
cmd = [sys.executable, "-m", "hermes_cli.main", *sys.argv[1:]]
print("→ Finishing update with refreshed code...")
sys.stdout.flush()
sys.stderr.flush()
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env)
return result.returncode
except Exception as exc: # pragma: no cover - spawn almost never fails
logger.warning(
"update: could not hand off to refreshed code (%s); "
"finishing in-process",
exc,
)
return None


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``."""
Expand All @@ -10142,7 +10198,18 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
assume_yes = bool(getattr(args, "yes", False))

print("⚕ Updating Hermes Agent...")
# Self-update hand-off marker. ``hermes update`` runs from the *old*
# install, so its post-pull steps (dep install, config migration, gateway
# restart) would otherwise execute stale in-memory code even though the new
# source is already on disk. After a successful pull we re-exec into the
# refreshed code with HERMES_UPDATE_FINALIZE=1; ``finalize_only`` is True on
# that second pass and makes us skip the fetch/pull work and the re-exec.
finalize_only = os.environ.get("HERMES_UPDATE_FINALIZE") == "1"

if finalize_only:
print("⚕ Finalizing update with refreshed code...")
else:
print("⚕ Updating Hermes Agent...")
print()

# On Windows, abort early if another hermes.exe is holding the venv shim
Expand All @@ -10158,8 +10225,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
sys.exit(2)

# Pre-update backup — runs before any git/file mutation so users can
# always roll back to the exact state they had before this update.
_run_pre_update_backup(args)
# always roll back to the exact state they had before this update. Skipped
# on the finalize re-exec (the original pass already took it).
if not finalize_only:
_run_pre_update_backup(args)

# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
Expand Down Expand Up @@ -10363,7 +10432,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
commit_count = int(result.stdout.strip())

if commit_count == 0:
# On the finalize re-exec the pull already happened in the original
# pass, so origin is level with HEAD (count == 0). Don't take the
# "Already up to date" early return — fall through and run the
# post-pull steps (now with refreshed code).
if commit_count == 0 and not finalize_only:
_invalidate_update_cache()

# Even if origin is up to date, the fork may be behind upstream
Expand All @@ -10390,24 +10463,30 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("✓ Already up to date!")
return

print(f"→ Found {commit_count} new commit(s)")

# Snapshot critical state (state.db, config, pairing JSONs, etc.)
# before pulling so a user can recover if something goes wrong.
# Issue #15733 reported missing pairing data after an update; even
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
# The "found N commits" notice and pre-update snapshot belong to the
# original pass only — the finalize re-exec sees count == 0 and the
# snapshot was already taken before the pull.
pre_update_snapshot_id = None
try:
from hermes_cli.backup import create_quick_snapshot
if not finalize_only:
print(f"→ Found {commit_count} new commit(s)")

# Snapshot critical state (state.db, config, pairing JSONs, etc.)
# before pulling so a user can recover if something goes wrong.
# Issue #15733 reported missing pairing data after an update; even
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
try:
from hermes_cli.backup import create_quick_snapshot

pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)
pre_update_snapshot_id = create_quick_snapshot(
label="pre-update", keep=1
)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)

print("→ Pulling updates...")
update_succeeded = False
Expand Down Expand Up @@ -10579,6 +10658,26 @@ def _cmd_update_impl(args, gateway_mode: bool):

_refresh_active_lazy_features()

# Hand off the remaining post-pull work (node deps, web/desktop build,
# config migration, skills sync, gateway restart) to the freshly-pulled
# code. These are the most frequently-changed and historically most
# fragile update steps, yet they used to run from the modules this
# process imported at startup — so a bug fixed in the pulled version
# still crashed here, forcing users to run ``hermes update`` twice. The
# git pull AND dependency install are done, so the new source and its
# deps are on disk; finish in a fresh subprocess running new code and
# forward its exit code. On the finalize pass (or under pytest /
# opt-out) we skip the hand-off and finish in-process — see
# _should_handoff_after_pull.
if _should_handoff_after_pull(finalize_only):
handoff_rc = _handoff_update_to_refreshed_code()
if handoff_rc is not None:
# The child ran every remaining post-pull step on new code;
# forward its result and stop (cmd_update's finally still
# restores stdio on the way out).
sys.exit(handoff_rc)
# else: spawning the child failed — fall through and finish here.

_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")

Expand Down
Loading
Loading