"""Post-update maintenance for ``hermes update``: pre-update backup snapshot, state-db verify/restore, curator/FTS notices, FHS path guard, completion summary, stale-module purge. Split out of ``update_cmd.py``, which re-imports every name so ``hermes_cli.update_cmd.`` still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; test patches on ``update_cmd`` stay effective). """ import logging from contextlib import suppress import os import shutil import subprocess import sys import time as _time from pathlib import Path from typing import Optional from hermes_constants import venv_python_path from hermes_cli.update_cmd_common import _best_effort # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.update_cmd") _UPDATE_RUNTIME_RELOAD_MODULES = "hermes_constants", "tools.environments.local", "tools.lazy_deps" #: Package prefixes whose cached modules go stale when the checkout changes under this #: process; purged (not reloaded) so any LATER import chain resolves against fresh source. _STALE_PURGE_PREFIXES = "hermes_cli", "gateway", "tools", "tui_gateway", "agent" #: Modules EXECUTING the update survive the purge: evicting them buys nothing (running frames #: keep them alive) and reloading them mid-flight is the one genuinely unsafe move. _STALE_PURGE_PROTECTED = frozenset( { "hermes_cli", "hermes_cli.main", "hermes_cli.update_cmd", "hermes_cli.hermes_logging", } # The updater's own split modules are executing too. | {f"hermes_cli.update_cmd_{c}" for c in ( "config", "deps", "fleet", "git", "maint", "stash", "windows", "zip", )} ) def _purge_stale_hermes_modules() -> None: """Evict every cached Hermes module after the checkout changed in-place. Never raises. The update runs in the pre-pull process; later phases lazily import NEW source into an OLD ``sys.modules`` world and die when new code references a symbol missing from a cached module. Purging (unlike reload) only drops the ``sys.modules`` entry — running frames keep their module objects — so later imports rebuild a self-consistent graph from the new tree. """ from hermes_cli.update_cmd import _m with _best_effort('Could not purge stale Hermes modules: %s'): import importlib importlib.invalidate_caches() purged = [] for name in list(_m().sys.modules): if name in _STALE_PURGE_PROTECTED: continue if not name.startswith(_STALE_PURGE_PREFIXES): continue root = name.split(".", 1)[0] if root not in _STALE_PURGE_PREFIXES: # startswith() also matches unrelated packages like ``gateway_foo``. continue if _m().sys.modules.pop(name, None) is not None: purged.append(name) if purged: logger.debug("Purged %d stale Hermes module(s) after checkout update", len(purged)) def _reload_updated_runtime_modules() -> None: """Reload the modules used by lazy-backend refresh: the pre-pull process's cached modules can expose old symbols despite new source on disk.""" from hermes_cli.update_cmd import _m with _best_effort('Could not refresh update runtime modules: %s'): import importlib importlib.invalidate_caches() for module_name in _UPDATE_RUNTIME_RELOAD_MODULES: module = _m().sys.modules.get(module_name) if module is None: continue try: importlib.reload(module) except Exception as exc: logger.debug("Could not reload updated module %s: %s", module_name, exc) def _print_curator_first_run_notice() -> None: """Curator heads-up after update. Fires only when enabled AND never run — the window where the first pass (deferred one ``interval_hours``) is pending, so the user can preview or disable it first. Silent on steady state.""" try: from agent import curator except Exception: return try: if not curator.is_enabled(): return state = curator.load_state() except Exception: return if state.get("last_run_at"): return try: hours = curator.get_interval_hours() except Exception: hours = 24 * 7 days = max(1, hours // 24) print() print("ℹ Skill curator") print( f" Background skill maintenance is enabled. First pass is deferred " f"~{days}d after installation; only agent-created skills are in " f"scope and nothing is ever auto-deleted (archive is recoverable)." ) print(" Preview now: hermes curator run --dry-run") print(" Pause it: hermes curator pause") print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator") def _print_fts_optimize_available_notice() -> None: """Advertise the opt-in v23 FTS optimization when state.db is still on the legacy layout. ``sessions.fts_optimize_notice``: ``advise`` (default), ``require`` (firmer), ``off``. """ mode = "advise" try: from hermes_cli.config import load_config mode = str( ((load_config() or {}).get("sessions") or {}).get( "fts_optimize_notice", "advise" ) ).strip().lower() except Exception: mode = "advise" if mode == "off": return try: from hermes_constants import get_hermes_home from hermes_state import SessionDB except Exception: return db_path = get_hermes_home() / "state.db" if not db_path.exists(): return try: size_gb = db_path.stat().st_size / (1024 ** 3) except OSError: return # Small DBs: the win isn't worth the nag. if size_gb < 0.5: return db = None interrupted = False try: db = SessionDB(db_path=db_path, read_only=True) # read_only opens skip schema init; probe the layout directly. row = db._conn.execute( "SELECT sql FROM sqlite_master " "WHERE type = 'table' AND name = 'messages_fts'" ).fetchone() # Interrupted optimize-storage: v23 table shape but backfill markers / trash # tables remain. Re-running resumes it, so offer the command again. interrupted = bool( db._conn.execute( "SELECT 1 FROM state_meta " "WHERE key = 'fts_rebuild_high_water' LIMIT 1" ).fetchone() or db._conn.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' " "AND name LIKE 'fts\\_v22\\_trash\\_%' ESCAPE '\\' LIMIT 1" ).fetchone() or db._conn.execute( "SELECT 1 FROM state_meta WHERE key IN " "('fts_cjk_rebuild_high_water', 'fts_cjk_stale') LIMIT 1" ).fetchone() ) except Exception: return finally: if db is not None: with suppress(Exception): db.close() sql = (row[0] if row else "") or "" if not sql or ("tool_name" in sql and not interrupted): return if interrupted: print() print("◆ Session database optimization incomplete") print( " A previous `hermes sessions optimize-storage` run was " "interrupted. Search still works; re-run the command to resume " "and finish reclaiming disk:" ) print(" hermes sessions optimize-storage") return est_reclaim = size_gb * 0.6 print() if mode == "require": print("◆ Session database upgrade required") print( f" Your search index uses the OLD storage layout and should be " f"upgraded. The new layout typically frees ~60% of state.db " f"(≈{est_reclaim:.1f} GB of your current {size_gb:.1f} GB) and is " f"required for continued optimal operation." ) else: print("◆ Reclaim ~60% of your session database disk") print( f" Your search index uses the old storage layout. Upgrading it " f"typically frees ~60% of state.db — about {est_reclaim:.1f} GB " f"of your current {size_gb:.1f} GB." ) print(" Run when convenient: hermes sessions optimize-storage") print( " It runs in the foreground with a progress bar, is safe to " "interrupt/re-run, and never changes your conversations." ) def _print_curator_recent_run_notice() -> None: """Print the latest background curator run summary (rename map) once, stamping ``last_run_summary_shown_at``. Silent when never run, already shown, or no rename info.""" try: from agent import curator except Exception: return try: state = curator.load_state() except Exception: return last_run_at = state.get("last_run_at") if not last_run_at: return # no curator run yet — first-run notice handles this case if state.get("last_run_summary_shown_at") == last_run_at: return # already shown for this run summary = state.get("last_run_summary") or "" if not summary: return # Only a multi-line summary (rename map) is worth showing; still stamp it shown. if "\n" not in summary: with suppress(Exception): state["last_run_summary_shown_at"] = last_run_at curator.save_state(state) return when = _format_time_ago(last_run_at) print() print(f"ℹ Skill curator — last run {when}") for line in summary.splitlines(): print(f" {line}") print(" (This message shows once per curator run. View anytime: hermes curator status)") with suppress(Exception): state["last_run_summary_shown_at"] = last_run_at curator.save_state(state) def _format_time_ago(iso_ts: str) -> str: """Render an ISO timestamp as `Xh ago` / `Xd ago` / `Xm ago`. Best effort.""" try: from datetime import datetime, timezone ts = datetime.fromisoformat(iso_ts.replace("Z", "+00:00")) if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) delta = datetime.now(timezone.utc) - ts secs = int(delta.total_seconds()) if secs < 60: return "just now" if secs < 3600: return f"{secs // 60}m ago" if secs < 86400: return f"{secs // 3600}h ago" return f"{secs // 86400}d ago" except Exception: return "recently" def _reload_process_scan_modules() -> None: """Reload the process-scan modules, dependency-first, so ``dashboard_procs`` binds against a fresh ``_subprocess_compat``: cleanup runs in the PRE-update process and a symbol the update added would otherwise ImportError after the code update succeeded. Called from the cleanup entry point so every caller (git path, ZIP fallback) is covered.""" import importlib importlib.invalidate_caches() for mod_name in ( "hermes_cli._subprocess_compat", "hermes_cli.dashboard_procs", ): mod = sys.modules.get(mod_name) if mod is not None: try: importlib.reload(mod) except Exception as exc: # warning, not debug: a failed reload surfaces as ImportError seconds later. logger.warning("Could not reload %s for post-update cleanup: %s", mod_name, exc) def _finish_dashboard_update_cleanup( node_failures: list[str], already_restarted_units: "set[str] | None" = None ) -> None: """Refresh managed dashboards or stop stale manual ones after an update. *already_restarted_units*: systemd unit names (no ``.service``) the fleet-restart loop already restarted, so a Serve-only install isn't restarted a second time here. """ from hermes_cli.update_cmd import _m, _reload_process_scan_modules if node_failures: print() print(" ℹ Leaving running dashboard process(es) untouched because the") print(" Node.js dependency refresh did not complete.") return _reload_process_scan_modules() stop_result = _m()._kill_stale_dashboard_processes( restart_managed=True, already_restarted_units=already_restarted_units ) if not stop_result.get("unrecovered"): return print() print( "⚠ A web dashboard/serve process was stopped during update and could " "not be auto-restarted." ) print(" Re-launch it when you want the web UI back:") print(" hermes dashboard --port ") def _print_update_completion(message: str) -> None: """Print the outcome (with branch @ sha so drift is visible) plus, when launched by the dashboard with an action id, a receipt line the Desktop matches after restart.""" from hermes_cli.update_cmd import _branch_head_suffix print(f"{message}{_branch_head_suffix()}") action_id = os.environ.get("HERMES_ACTION_ID", "") if len(action_id) == 32 and all(char in "0123456789abcdef" for char in action_id): print(f"=== hermes-update completed {action_id} ===") def _read_project_version() -> str | None: """``version`` from the checkout's pyproject.toml (not importlib.metadata, which still describes the OLD version after a pull). None on any failure — cosmetic, never breaks.""" from hermes_cli.update_cmd import _m try: import tomllib with open(_m().PROJECT_ROOT / "pyproject.toml", "rb") as fh: # windows-footgun: ok — binary mode, tomllib requires bytes version = tomllib.load(fh).get("project", {}).get("version") return str(version) if version else None except Exception: return None def _update_complete_message(pre_version: str | None) -> str: """Completion line with ``vA → vB`` when known; plain when either side is unknown or the version did not change.""" post_version = _read_project_version() if pre_version and post_version and pre_version != post_version: return f"✓ Update complete! (v{pre_version} → v{post_version})" if post_version: return f"✓ Update complete! (v{post_version})" return "✓ Update complete!" def _post_update_sqlite_runtime_status(): """Return whether the interpreter used after update has safe SQLite.""" from hermes_cli.update_cmd import _m from hermes_constants import project_venv_dir from hermes_cli.sqlite_runtime import probe_sqlite_runtime venv_dir = project_venv_dir(_m().PROJECT_ROOT) python = ( venv_python_path(venv_dir, windows=_m()._is_windows()) if venv_dir is not None else Path(sys.executable) ) info = probe_sqlite_runtime(python) return info is not None and not info.wal_reset_vulnerable, info def _print_verified_update_completion(message: str) -> bool: """Print a success completion only after probing the next Hermes runtime.""" from hermes_cli.update_cmd import _post_update_sqlite_runtime_status if not message.startswith("✓"): _print_update_completion(message) return False sqlite_runtime_ok, sqlite_info = _post_update_sqlite_runtime_status() if sqlite_info is None: # Grace path: an unprobeable interpreter (dev checkout, no probe subprocess) must not # fail the update — only a POSITIVE vulnerable probe withholds success. logger.debug("Post-update SQLite runtime probe unavailable; not blocking") _print_update_completion(message) return True if sqlite_runtime_ok: _print_update_completion(message) return True print() detail = ( f"SQLite {sqlite_info.sqlite_version_string} still has the " "WAL-reset corruption bug" ) print(f"⚠ Update partially complete — {detail}.") print( " Rebuild the Hermes venv with a uv-managed Python, restart Hermes, " "then verify with `hermes doctor`." ) return False def _clear_stale_sqlite_sidecars(db_path: Path) -> None: """Delete -wal/-shm/-journal next to *db_path*, immediately before overwriting it with a snapshot image. Snapshots are checkpointed ``sqlite3.backup()`` images with no WAL; copying replaces only the main file, so a leftover WAL from the OLD database would be replayed over the fresh image on next open (passes integrity_check while serving old contents). Safe because the caller has already declared that database corrupt. """ for suffix in ("-wal", "-shm", "-journal"): db_path.with_name(db_path.name + suffix).unlink(missing_ok=True) def _print_update_summary( *, node_failures: list, desktop_build_ok: bool, pre_update_version: str | None, ) -> bool: """Final banner. A failed Desktop rebuild is non-fatal but must not print ``✓ Update complete!``.""" from hermes_cli.update_cmd import _post_update_sqlite_runtime_status, _update_complete_message sqlite_runtime_ok, sqlite_info = _post_update_sqlite_runtime_status() if sqlite_info is None: # Grace path: only a POSITIVE vulnerable probe demotes success to partial. sqlite_runtime_ok = True print() if node_failures or not desktop_build_ok or not sqlite_runtime_ok: parts = [] if node_failures: parts.append(f"Node.js dependencies for {', '.join(node_failures)} did not refresh") if not desktop_build_ok: parts.append("the desktop app was not rebuilt and is still on the previous build") if not sqlite_runtime_ok and sqlite_info is not None: parts.append( f"SQLite {sqlite_info.sqlite_version_string} still has the " "WAL-reset corruption bug" ) print("⚠ Update partially complete — " + "; ".join(parts) + ".") if node_failures: print(" Code and Python deps are updated, but the dashboard/TUI may") print(" be in a mixed state until the Node deps are rebuilt.") if not desktop_build_ok: print(" Run `hermes desktop` to retry the desktop rebuild.") if not sqlite_runtime_ok: print( " The Python runtime remediation did not complete. Run `hermes " "update` again; if SQLite is unchanged, rebuild the Hermes venv " "with a uv-managed Python, restart Hermes, then verify with " "`hermes doctor`." ) else: _print_update_completion(_update_complete_message(pre_update_version)) return desktop_build_ok and sqlite_runtime_ok def _restore_state_db_from_snapshot(state_path: Path, snap_state: Path) -> bool: """Replace *state_path* with the snapshot image; True when the result passes integrity. Stale sidecars are cleared first so the corrupt DB's WAL can't replay over the image. Refuses (False) while another process — or a live connection in THIS process — holds the DB: copying over a live writer's inode desyncs its page cache and its next checkpoint clobbers pages. Holder scan ``None`` proceeds (gateways drained; refusing on unknown would disable auto-restore on non-Linux). Raises OSError if the copy fails. """ from hermes_cli.backup import _foreign_db_holder_pids, verify_sqlite_integrity from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access holders = _foreign_db_holder_pids(state_path) if holders: print( f" ✗ Auto-restore refused: process(es) {holders} still hold " "state.db or its WAL open. Stop them (hermes gateway stop), " "then restore manually with /snapshot restore." ) return False # The foreign-pid scan excludes THIS process; an in-process SessionDB handle is just as # live (it would checkpoint through deleted-inode sidecars). offline_file_access fails # CLOSED on any tracked connection and holds the lock across clear + copy. try: with offline_file_access(state_path, what="restore a snapshot over"): _clear_stale_sqlite_sidecars(state_path) shutil.copy2(snap_state, state_path) except LiveConnectionError as exc: print( f" ✗ Auto-restore refused: {exc} Close the in-process database " "handles (or restart Hermes) and retry." ) return False restored = verify_sqlite_integrity(state_path, check_header=True, run_pragma=True) return bool(restored.get("valid")) def _verify_and_restore_one_state_db(home: Path, *, label: str) -> None: """Integrity check + auto-restore for ONE home's state.db from its newest valid snapshot. Never raises: a guard that crashes the update tail is worse than what it detects.""" try: from hermes_cli.backup import _quick_snapshot_root, verify_sqlite_integrity state_path = home / "state.db" if not state_path.exists(): return ok = verify_sqlite_integrity(state_path, check_header=True, run_pragma=True) if ok.get("valid"): logger.debug("Post-update state.db integrity OK (%s): %s", label, ok.get("message")) return print() print( f"⚠ state.db is corrupted after update ({label}): " + ok.get("message", "unknown error") ) snap_root = _quick_snapshot_root(home) if not snap_root.exists(): print(" ⚠ No pre-update snapshot for this home") return for snap_dir in sorted( (d for d in snap_root.iterdir() if d.is_dir()), reverse=True ): snap_state = snap_dir / "state.db" if not snap_state.exists(): continue snap_ok = verify_sqlite_integrity(snap_state, check_header=True, run_pragma=True) if not snap_ok.get("valid"): continue try: if _restore_state_db_from_snapshot(state_path, snap_state): print(f" ✓ Auto-restored from snapshot {snap_dir.name} ({label})") else: print(" ✗ Auto-restore FAILED — restored copy also failed integrity") except OSError as exc: print(f" ✗ Auto-restore file copy failed: {exc}") return print(" ⚠ No valid pre-update snapshot found for this home") except Exception as exc: logger.debug("Post-update state.db guard (%s) failed: %s", label, exc) def _verify_and_restore_state_dbs_post_update() -> None: """Integrity guard for the ROOT state.db AND every sibling profile's (the snapshot covers siblings, so the guard must too or a corrupt profile DB goes undetected).""" from hermes_cli.update_cmd import get_hermes_home home = get_hermes_home() _verify_and_restore_one_state_db(home, label="default home") with _best_effort('Sibling-profile state.db guard sweep failed: %s'): from hermes_cli.backup import _sibling_profile_homes for name, profile_home in _sibling_profile_homes(home): _verify_and_restore_one_state_db(profile_home, label=f"profile {name}") def _print_bundled_skills_sync_report() -> None: """Run ``sync_skills`` (copies new, updates changed, respects user deletions) and print its summary.""" from tools.skills_sync import sync_skills result = sync_skills(quiet=True) if result["copied"]: print(f" + {len(result['copied'])} new: {', '.join(result['copied'])}") if result.get("updated"): print(f" ↑ {len(result['updated'])} updated: {', '.join(result['updated'])}") if result.get("user_modified"): print(f" ~ {len(result['user_modified'])} user-modified (kept)") print(" → see them: hermes skills list-modified (diff/reset to resume updates)") if result.get("cleaned"): print(f" − {len(result['cleaned'])} removed from manifest") if result.get("relocated"): print( f" → {len(result['relocated'])} moved to new upstream paths: " f"{', '.join(result['relocated'])}" ) if not result["copied"] and not result.get("updated"): print(" ✓ Skills are up to date") def _ensure_fhs_path_guard() -> None: """Ensure /usr/local/bin is on PATH for RHEL-family root non-login shells (su, sudo -s, tmux), where neither /etc/bashrc nor .bash_profile adds it. Mirrors install.sh. Idempotent; no-op on non-Linux/non-root/non-FHS or when ``bash -i -c 'command -v hermes'`` resolves.""" from hermes_cli.update_cmd import _m if _m().sys.platform != "linux": return try: if os.geteuid() != 0: # windows-footgun: ok — Linux FHS helper, guarded by sys.platform == "linux" above + AttributeError catch return except AttributeError: return # Only for FHS-layout installs (link at /usr/local/bin/hermes). fhs_link = Path("/usr/local/bin/hermes") if not fhs_link.is_symlink() and not fhs_link.exists(): return # ``bash -i -c`` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile — the exact # scenario where RHEL root loses /usr/local/bin. home = os.environ.get("HOME") or "/root" try: probe = subprocess.run( [ "env", "-i", f"HOME={home}", f"TERM={os.environ.get('TERM', 'dumb')}", "bash", "-i", "-c", "command -v hermes", ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, ) except (FileNotFoundError, subprocess.TimeoutExpired): return # no bash or probe hung — don't block update on this if probe.returncode == 0: return # already on PATH, nothing to do path_line = 'export PATH="/usr/local/bin:$PATH"' path_comment = "# Hermes Agent — ensure /usr/local/bin is on PATH (RHEL non-login shells)" wrote_any = False for candidate in (".bashrc", ".bash_profile"): cfg = Path(home) / candidate if not cfg.is_file(): continue try: existing = cfg.read_text(errors="replace", encoding="utf-8") except OSError: continue # Idempotency: any uncommented PATH line referencing /usr/local/bin (install.sh grep). already_guarded = any( "/usr/local/bin" in line and "PATH" in line and not line.lstrip().startswith("#") for line in existing.splitlines() ) if already_guarded: continue try: with cfg.open("a", encoding="utf-8") as f: f.write("\n" + path_comment + "\n" + path_line + "\n") except OSError as e: print(f" ⚠ Could not update {cfg}: {e}") continue print(f" ✓ Added /usr/local/bin to PATH in {cfg}") wrote_any = True if wrote_any: print(" (reload your shell or run 'source ~/.bashrc' to pick it up)") def _ensure_acp_launcher() -> None: r"""Self-heal a ``hermes-acp`` launcher next to ``hermes`` (mirrors install.sh): ACP hosts resolve it on the login-shell PATH but the console script lives in the venv. The shim delegates to the sibling ``hermes acp``, correct for every layout. No-op on Windows (install.ps1 stages launchers into ``$HermesHome\bin``, never ``venv\Scripts`` which would shadow the user's python) and where it already exists. Unwritable dirs are skipped. Idempotent. """ from hermes_cli.update_cmd import _m if _m().sys.platform == "win32": # Windows launcher repair lives in _install_repair, not here. return for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")): hermes_cmd = bin_dir / "hermes" acp_cmd = bin_dir / "hermes-acp" try: if not (hermes_cmd.is_file() or hermes_cmd.is_symlink()): continue # is_symlink() catches broken symlinks exists() misses; never follow-and-overwrite. if acp_cmd.exists() or acp_cmd.is_symlink(): continue shim = ( "#!/usr/bin/env bash\n" "# Hermes Agent — ACP launcher (written by `hermes update`).\n" "# ACP hosts (Zed, JetBrains, Buzz) resolve the agent by this\n" "# command name on the login-shell PATH.\n" f'exec "{hermes_cmd}" acp "$@"\n' ) acp_cmd.write_text(shim, encoding="utf-8") acp_cmd.chmod(acp_cmd.stat().st_mode | 0o755) except OSError: continue print(f" ✓ Installed hermes-acp launcher → {acp_cmd}") _PRE_UPDATE_SNAPSHOT_KEEP = 1 # Per-file cap for the quick snapshot (larger files skipped with a warning): it protects # small hard-to-regenerate state, not a multi-GB state.db (24 GB cost ~60s + 24 GB/update). _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE = 1 << 30 # 1 GiB def _resolve_pre_update_backup_mode(args) -> str: """Backup mode ``off``/``quick``/``full``. CLI flags win (``--no-backup`` beats ``--backup``); config accepts mode strings plus legacy booleans (true→full, false→off, which also disables the quick snapshot). Default ``quick``.""" if getattr(args, "no_backup", False): return "off" if getattr(args, "backup", False): return "full" try: from hermes_cli.config import load_config cfg = load_config() except Exception as exc: logger.debug("Could not load config for pre-update backup: %s", exc) cfg = {} updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {} raw = updates_cfg.get("pre_update_backup", "quick") if raw is True: return "full" if raw is False: return "off" mode = str(raw).strip().lower() if mode in ("off", "false", "none", "disabled"): return "off" if mode in ("full", "zip", "true"): return "full" if mode == "quick": return "quick" logger.warning("Unknown updates.pre_update_backup value %r — using 'quick'", raw) return "quick" def _run_pre_update_backup(args) -> Optional[str]: """Run the pre-update backup; return the quick-snapshot id (None when off/failed). Never raises. ``off`` — nothing. ``quick`` (default) — snapshot of critical small files under ``state-snapshots/``, files over 1 GiB skipped so a bloated state.db can't stall the update. ``full`` — quick snapshot PLUS a zip of HERMES_HOME under ``backups/`` (``hermes import``). """ from hermes_cli.update_cmd import _record_update_step mode = _resolve_pre_update_backup_mode(args) if mode == "off": if getattr(args, "no_backup", False): print("◆ Pre-update backup: skipped (--no-backup)") print() # Config-level off is silent: the user opted out. return None snapshot_id = None with _best_effort('Pre-update snapshot failed: %s'): from hermes_cli.backup import ( _quick_snapshot_root, create_quick_snapshot, verify_sqlite_integrity, ) # A later `from hermes_constants import get_hermes_home` in this function makes that # name function-local (unbound here), so alias explicitly. from hermes_cli.config import get_hermes_home as _get_home snapshot_id = create_quick_snapshot( label="pre-update", keep=_PRE_UPDATE_SNAPSHOT_KEEP, max_file_size=_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, ) # Verify live state.db after the snapshot: a concurrent process (antivirus, killed # gateway, Windows filter driver) can corrupt it and we'd otherwise exit 0 silently. if snapshot_id: _src_path = _get_home() / "state.db" if _src_path.exists(): _integrity = verify_sqlite_integrity( _src_path, check_header=True, run_pragma=True, max_bytes=_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, ) if not _integrity.get("valid"): _msg = _integrity.get("message", "unknown error") print(f" ⚠ state.db integrity check FAILED after snapshot: {_msg}") _snap_root = _quick_snapshot_root(_get_home()) _snap_state = _snap_root / snapshot_id / "state.db" if _snap_state.exists(): _snap_ok = verify_sqlite_integrity( _snap_state, check_header=True, run_pragma=True ) if _snap_ok.get("valid"): print(" ✓ Snapshot copy is valid — continuing update.") print(" If state.db is lost after update it will be auto-restored.") else: print( " ✗ Snapshot copy ALSO failed integrity — " "the source was already corrupted before the backup." ) else: print(" ⚠ Snapshot does not contain state.db (was skipped or too large).") print() if snapshot_id: print(f"◆ Pre-update snapshot: {snapshot_id}") # The code swap + fleet restart touch EVERY profile, so each gets the same snapshot # under its own state-snapshots/. Best-effort per profile. with _best_effort('Sibling profile snapshots failed: %s'): from hermes_cli.backup import create_pre_update_snapshots_all_profiles _sibling_snaps = create_pre_update_snapshots_all_profiles( keep=_PRE_UPDATE_SNAPSHOT_KEEP, max_file_size=_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, ) if _sibling_snaps: print(f"◆ Sibling profile snapshot(s): " + ", ".join(sorted(_sibling_snaps))) _record_update_step( "sibling_profile_snapshots", True, ", ".join( f"{k}={v}" for k, v in sorted(_sibling_snaps.items()) ), ) import hermes_cli.update_cmd_config as _cfg # The reader lives in update_cmd_config; write ITS module global, not ours. _cfg._LAST_SIBLING_SNAPSHOTS = _sibling_snaps if mode != "full": if snapshot_id: print() return snapshot_id try: from hermes_cli.backup import create_pre_update_backup except Exception as exc: print(f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update.") print() return snapshot_id try: from hermes_cli.config import load_config _keep = (load_config() or {}).get("updates", {}).get("backup_keep", 5) except Exception: _keep = 5 print("◆ Creating pre-update backup...") t0 = _time.monotonic() try: out_path = create_pre_update_backup(keep=int(_keep)) except Exception as exc: # defensive — helper already swallows, but just in case print(f" ⚠ Backup failed: {exc}") print(" Continuing with update.") print() return snapshot_id elapsed = _time.monotonic() - t0 if out_path is None: print(" ⚠ Backup skipped (no files found or write failed); continuing update.") print() return snapshot_id try: size_bytes = out_path.stat().st_size except OSError: size_bytes = 0 from hermes_cli.sizefmt import format_bytes size_str = format_bytes(size_bytes) # display_hermes_home so the user sees ~/.hermes/... try: from hermes_constants import get_hermes_home, display_hermes_home home = get_hermes_home() try: display_path = f"{display_hermes_home()}/{out_path.relative_to(home)}" except ValueError: display_path = str(out_path) except Exception: display_path = str(out_path) print(f" Saved: {display_path} ({size_str}, {elapsed:.1f}s)") print(f" Restore: hermes import {out_path}") print(" Disable: set updates.pre_update_backup: quick (or off) in config.yaml") print() return snapshot_id def _sweep_bytecode_after_update(branch: str) -> None: """Clear stale ``__pycache__`` (else gateway restart ImportErrors on names absent from old bytecode), re-stamp the fingerprint, refresh the bootstrap cache scripts.""" from hermes_cli.update_cmd import _m removed = _m()._clear_bytecode_cache(_m().PROJECT_ROOT) if removed: print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") _m()._record_bytecode_fingerprint() _m()._refresh_bootstrap_cache_scripts(branch) def _sync_profiles_after_update() -> None: """Best-effort per-profile syncs: bundled skills, ``.env`` backfill, Honcho profiles.""" # All profiles incl. the active one: seed_profile_skills() subprocesses with an explicit # HERMES_HOME, so sync_skills()'s module-level HERMES_HOME cache can't skew it. with suppress(Exception): from hermes_cli.profiles import ( list_profiles, seed_profile_skills, ) all_profiles = list_profiles() if all_profiles: print() print("→ Syncing bundled skills to all profiles...") for p in all_profiles: try: r = seed_profile_skills(p.path, quiet=True) if r and r.get("skipped_opt_out"): status = "opted out (--no-skills)" elif r: copied = len(r.get("copied", [])) updated = len(r.get("updated", [])) modified = len(r.get("user_modified", [])) parts = [] if copied: parts.append(f"+{copied} new") if updated: parts.append(f"↑{updated} updated") if modified: parts.append(f"~{modified} user-modified") status = ", ".join(parts) if parts else "up to date" else: status = "sync failed" print(f" {p.name}: {status}") except Exception as pe: print(f" {p.name}: error ({pe})") # Backfill .env for profiles created before .env seeding (copy the default's) so they # keep the credentials they were effectively using. with suppress(Exception): from hermes_cli.profiles import backfill_profile_envs backfilled = backfill_profile_envs(quiet=True) if backfilled: print() print( f"→ Seeded .env for {len(backfilled)} profile(s) " f"(copied from default): {', '.join(backfilled)}" ) with suppress(Exception): from plugins.memory.honcho.cli import sync_honcho_profiles_quiet synced = sync_honcho_profiles_quiet() if synced: print(f"\n-> Honcho: synced {synced} profile(s)") def _print_post_update_notices_and_self_heals() -> None: """Best-effort notices (FTS optimize, curator) and self-heals (FHS PATH, ACP launcher, Windows bin launchers, cua-driver refresh) that run after the summary.""" from hermes_cli.update_cmd import _m, _print_curator_first_run_notice, _print_curator_recent_run_notice # v23 FTS layout is opt-in (existing indexes untouched); surface the command here. with _best_effort('FTS optimize notice failed: %s'): _print_fts_optimize_available_notice() with _best_effort('Curator first-run notice failed: %s'): _print_curator_first_run_notice() with _best_effort('Curator recent-run notice failed: %s'): _print_curator_recent_run_notice() with _best_effort('FHS PATH guard check failed: %s'): _ensure_fhs_path_guard() with _best_effort('hermes-acp launcher self-heal failed: %s'): _ensure_acp_launcher() # Windows launchers into the managed bin dir: in-checkout launchers were swept by the # autostash (--include-untracked) and updates never run install.ps1. No-op on POSIX. with _best_effort('Windows bin launcher migration failed: %s'): from hermes_cli._install_repair import migrate_windows_bin_path migrate_windows_bin_path(_m().PROJECT_ROOT) # cua-driver refresh, no-op unless on PATH; tied to update for a predictable cadence # without a per-launch GitHub API call. with _best_effort('cua-driver refresh failed: %s'): refresh_cua_driver = True with _best_effort('Could not read updates.refresh_cua_driver: %s'): from hermes_cli.config import load_config _update_cfg = (load_config() or {}).get("updates", {}) if isinstance(_update_cfg, dict): refresh_cua_driver = bool(_update_cfg.get("refresh_cua_driver", True)) if ( refresh_cua_driver and sys.platform in ("darwin", "win32", "linux") and shutil.which("cua-driver") ): from hermes_cli.tools_config import install_cua_driver print() print("→ Refreshing cua-driver (Computer Use)...") # require_confirmed_update: install only when check-update positively reports a # newer release (update must stay fast; `computer-use install --upgrade` forces). # Windows defers even confirmed updates (installer may need console/UAC consent). install_cua_driver( upgrade=True, require_confirmed_update=True, show_installer_progress=False, ) def _run_post_update_maintenance( *, assume_yes, gateway_mode, pre_update_snapshot_id, had_desktop_app_before_update, node_failures, desktop_build_ok, pre_update_version, ) -> bool: """Post-pull housekeeping: state.db restore, catalog/skills/profile syncs, config migration, the update summary (verdict returned), and best-effort notices/self-heals. Every step is isolated so none can fail the update.""" from hermes_cli.update_cmd import _check_and_apply_config_migration, _m # macOS TCC: Desktop bundles are re-signed each update, so old grants can go stale # (toggle ON, yet macOS re-prompts with no Allow button). Tell users how to re-grant. if sys.platform == "darwin" and had_desktop_app_before_update: print() print( " ℹ macOS: if Hermes re-prompts for permissions you already " "granted (toggle shows ON), the stored grant is stale — run " "`tccutil reset ScreenCapture com.nousresearch.hermes` (repeat " "per affected service), toggle it ON in System Settings, then " "fully quit & relaunch once." ) # macOS TCC interpreter anchor; boot-gated — a failed probe leaves the venv untouched. try: from hermes_cli.macos_tcc_anchor import ensure_tcc_anchor ensure_tcc_anchor() except Exception: logger.debug("macOS TCC anchor refresh skipped", exc_info=True) # state.db integrity guard for root home AND every profile; restore from own snapshot. with _best_effort('Post-update state.db integrity check failed: %s'): _verify_and_restore_state_dbs_post_update() # Seed the model-catalog cache from the checkout instead of a bot-gated, flaky fetch. with _best_effort('Model catalog seed during update failed: %s'): from hermes_cli.model_catalog import seed_cache_from_checkout if seed_cache_from_checkout(_m().PROJECT_ROOT): print(" ✓ Model catalog cache refreshed from checkout") with _best_effort('Skills sync during update failed: %s'): print() print("→ Syncing bundled skills...") _print_bundled_skills_sync_report() _sync_profiles_after_update() _check_and_apply_config_migration( assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, ) update_complete = _print_update_summary( node_failures=node_failures, desktop_build_ok=desktop_build_ok, pre_update_version=pre_update_version, ) _print_post_update_notices_and_self_heals() return update_complete