Files
hermes-agent/hermes_cli/update_cmd_maint.py

1102 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.<name>``
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 <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