mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(update): consolidate pre-update backups into one gated mechanism (#65754)
hermes update ran TWO separate pre-update backup mechanisms: the config-gated full zip (updates.pre_update_backup, default off) and an unconditional quick state snapshot added for #15733 that ignored the user's setting entirely. On a large state.db (observed: 24 GB) the 'cheap' snapshot silently added ~60s to every update and ate 24 GB of disk in state-snapshots/. Now there is ONE mechanism, gated by updates.pre_update_backup with three modes: - quick (new default): state snapshot of critical small files (pairing JSONs, cron jobs, config, auth, per-profile DBs). Files over 1 GiB are skipped with a warning so a bloated state.db can never stall the update again. - full: the quick snapshot plus the HERMES_HOME zip (old 'true' behavior; --backup forces it for one run). - off: nothing runs — an explicit opt-out now disables the quick snapshot too (--no-backup does the same per-run). Legacy booleans are honored: true -> full, false -> off. _run_pre_update_backup() now returns the quick-snapshot id so the post-update cron-jobs restore safety net (#34600) keeps working; the snapshot moved from the post-fetch site to the pre-mutation site, which also covers the zip-fallback update path it previously missed.
This commit is contained in:
parent
8462764367
commit
d0dcb9a5fd
9 changed files with 343 additions and 161 deletions
|
|
@ -794,18 +794,51 @@ def create_quick_snapshot(
|
|||
label: Optional[str] = None,
|
||||
hermes_home: Optional[Path] = None,
|
||||
keep: Optional[int] = None,
|
||||
max_file_size: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
"""Create a quick state snapshot of critical files.
|
||||
|
||||
Copies STATE_FILES to a timestamped directory under state-snapshots/.
|
||||
Auto-prunes old snapshots beyond the keep limit.
|
||||
|
||||
Args:
|
||||
max_file_size: When set, individual files larger than this many bytes
|
||||
are skipped (with a printed warning) instead of copied. Used by
|
||||
the pre-update safety snapshot so a multi-GB ``state.db`` can
|
||||
never stall ``hermes update`` or silently eat disk — the small
|
||||
pairing/cron/config files the snapshot exists to protect are
|
||||
always captured. ``None`` (default) copies everything, which
|
||||
preserves manual ``/snapshot`` and ``hermes backup --quick``
|
||||
behavior.
|
||||
|
||||
Returns:
|
||||
Snapshot ID (timestamp-based), or None if no files found.
|
||||
"""
|
||||
home = hermes_home or get_hermes_home()
|
||||
root = _quick_snapshot_root(home)
|
||||
|
||||
def _too_large(path: Path, rel_name: str) -> bool:
|
||||
"""True (and warn) when ``path`` exceeds the max_file_size cap."""
|
||||
if max_file_size is None:
|
||||
return False
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
if size <= max_file_size:
|
||||
return False
|
||||
print(
|
||||
f" ⚠ Snapshot: skipping {rel_name} "
|
||||
f"({_format_size(size)} exceeds {_format_size(max_file_size)} limit)"
|
||||
)
|
||||
logger.warning(
|
||||
"Quick snapshot skipped %s: %d bytes exceeds %d byte limit",
|
||||
rel_name,
|
||||
size,
|
||||
max_file_size,
|
||||
)
|
||||
return True
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
snap_id = f"{ts}-{label}" if label else ts
|
||||
snap_dir = root / snap_id
|
||||
|
|
@ -831,6 +864,8 @@ def create_quick_snapshot(
|
|||
# the board databases + their metadata to restore a board.
|
||||
if "/workspaces/" in f"/{sub_rel}/" or "/attachments/" in f"/{sub_rel}/":
|
||||
continue
|
||||
if _too_large(sub, sub_rel):
|
||||
continue
|
||||
dst = snap_dir / sub_rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
|
|
@ -850,6 +885,9 @@ def create_quick_snapshot(
|
|||
if not src.is_file():
|
||||
continue
|
||||
|
||||
if _too_large(src, rel):
|
||||
continue
|
||||
|
||||
dst = snap_dir / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
@ -1239,7 +1277,7 @@ def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int:
|
|||
than no backup at all (and the wrapper in ``main.py`` would still print
|
||||
a misleading ``Saved: <path>`` line for a file that no longer exists).
|
||||
Operators who genuinely don't want a backup should set
|
||||
``updates.pre_update_backup: false`` in config — that gates creation.
|
||||
``updates.pre_update_backup: off`` in config — that gates creation.
|
||||
"""
|
||||
keep = max(keep, 1)
|
||||
if not backup_dir.exists():
|
||||
|
|
|
|||
|
|
@ -3110,21 +3110,29 @@ DEFAULT_CONFIG = {
|
|||
|
||||
# ``hermes update`` behaviour.
|
||||
"updates": {
|
||||
# Run a full ``hermes backup``-style zip of HERMES_HOME before every
|
||||
# ``hermes update``. Backups land in ``<HERMES_HOME>/backups/`` and
|
||||
# can be restored with ``hermes import <path>``. Off by default:
|
||||
# zipping a large HERMES_HOME (sessions DB, caches, skills) can add
|
||||
# minutes to every update. The #48200 incident — a ``hermes update
|
||||
# --yes`` run that computed a wrong path and silently wiped the
|
||||
# user's ``.env``, ``MEMORY.md``, ``kanban.db``, custom skills, and
|
||||
# scripts — is the reason this knob exists; enable it (here, or via
|
||||
# ``--backup`` for a single run) if you want that safety net.
|
||||
"pre_update_backup": False,
|
||||
# How many pre-update backup zips to retain. Older ones are pruned
|
||||
# automatically after each successful backup. Values below 1 are
|
||||
# floored to 1 — the backup just created is always preserved. To
|
||||
# disable backups entirely, set ``pre_update_backup: false`` above
|
||||
# rather than ``backup_keep: 0``.
|
||||
# Pre-update safety backup — ONE consolidated mechanism, three modes:
|
||||
#
|
||||
# quick (default) — snapshot critical small state files (pairing
|
||||
# JSONs, cron jobs, config.yaml, .env, auth.json, per-profile
|
||||
# DBs) into <HERMES_HOME>/state-snapshots/ before the update.
|
||||
# Files over 1 GiB (e.g. a bloated state.db) are skipped with a
|
||||
# warning so the snapshot stays fast. Restore via ``/snapshot``.
|
||||
# This is the #15733 (lost pairing data) / #34600 (emptied cron
|
||||
# jobs) safety net.
|
||||
# full — the quick snapshot PLUS a full ``hermes backup``-style zip
|
||||
# of HERMES_HOME into <HERMES_HOME>/backups/, restorable with
|
||||
# ``hermes import``. Can add minutes on large homes. This is the
|
||||
# #48200 (wrong-path wipe) safety net. ``--backup`` forces this
|
||||
# for a single run.
|
||||
# off — no pre-update backup of any kind. ``--no-backup`` forces
|
||||
# this for a single run.
|
||||
#
|
||||
# Legacy boolean values are honored: true -> full, false -> off.
|
||||
"pre_update_backup": "quick",
|
||||
# How many full pre-update backup zips to retain (mode ``full``).
|
||||
# Older ones are pruned automatically after each successful backup.
|
||||
# Values below 1 are floored to 1 — the backup just created is
|
||||
# always preserved. The quick snapshot always keeps exactly 1.
|
||||
"backup_keep": 5,
|
||||
# What `hermes update` does with uncommitted local changes to the
|
||||
# source tree when it runs NON-interactively — i.e. triggered from
|
||||
|
|
|
|||
|
|
@ -8908,23 +8908,30 @@ def _ensure_fhs_path_guard() -> None:
|
|||
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
|
||||
|
||||
|
||||
def _run_pre_update_backup(args) -> None:
|
||||
"""Create a full zip backup of HERMES_HOME before running the update.
|
||||
_PRE_UPDATE_SNAPSHOT_KEEP = 1
|
||||
|
||||
Gated on ``updates.pre_update_backup`` in config (default false). Off
|
||||
by default because the zip can add minutes to every update on large
|
||||
HERMES_HOME directories. The ``--backup`` flag on ``hermes update``
|
||||
opts in for a single run; ``--no-backup`` forces it off when config
|
||||
has it enabled. Never raises — a backup failure should not block the
|
||||
update itself.
|
||||
# Per-file size cap for the pre-update quick snapshot. Anything larger is
|
||||
# skipped with a warning: the snapshot exists to protect small, hard-to-
|
||||
# regenerate state (pairing JSONs, cron jobs, config, auth) — not to copy a
|
||||
# multi-GB state.db on every update (observed: a 24 GB state.db added ~60s
|
||||
# of wall time and silently ate 24 GB of disk per update).
|
||||
_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE = 1 << 30 # 1 GiB
|
||||
|
||||
|
||||
def _resolve_pre_update_backup_mode(args) -> str:
|
||||
"""Resolve the pre-update backup mode: ``"off"``, ``"quick"``, or ``"full"``.
|
||||
|
||||
CLI flags win over config; ``--no-backup`` beats ``--backup`` when both
|
||||
are set. Config accepts the mode strings plus legacy booleans:
|
||||
``true`` → ``full`` (the old zip behavior), ``false`` → ``off``
|
||||
(an explicit opt-out now disables the quick snapshot too — previously
|
||||
it ran unconditionally, ignoring the user's setting). A missing key
|
||||
defaults to ``quick``.
|
||||
"""
|
||||
# CLI flags win over config. --no-backup beats --backup if both are set.
|
||||
if getattr(args, "no_backup", False):
|
||||
print("◆ Pre-update backup: skipped (--no-backup)")
|
||||
print()
|
||||
return
|
||||
|
||||
force_backup = bool(getattr(args, "backup", False))
|
||||
return "off"
|
||||
if getattr(args, "backup", False):
|
||||
return "full"
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
|
@ -8937,19 +8944,76 @@ def _run_pre_update_backup(args) -> None:
|
|||
cfg = {}
|
||||
|
||||
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
|
||||
# The default config ships with ``pre_update_backup: false`` (see
|
||||
# ``hermes_cli/config.py``). Fall back to false if the key is missing
|
||||
# so the default behaviour matches the shipped config: zipping a large
|
||||
# HERMES_HOME can add minutes to every update. Users who want the
|
||||
# #48200 safety net opt in via the config knob or ``--backup``.
|
||||
enabled = updates_cfg.get("pre_update_backup", False)
|
||||
keep = updates_cfg.get("backup_keep", 5)
|
||||
raw = updates_cfg.get("pre_update_backup", "quick")
|
||||
|
||||
if not enabled and not force_backup:
|
||||
# Silent by default — the backup is off, most users don't need to
|
||||
# hear about it on every update. They can opt in via --backup
|
||||
# or by flipping the config knob.
|
||||
return
|
||||
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"
|
||||
logging.getLogger(__name__).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 safety backup and return the quick-snapshot id.
|
||||
|
||||
Single consolidated mechanism gated on ``updates.pre_update_backup``:
|
||||
|
||||
- ``off`` — nothing runs. Explicit user opt-out is honored fully.
|
||||
- ``quick`` (default) — a state snapshot of critical small files
|
||||
(pairing JSONs, cron jobs, config, auth; see ``_QUICK_STATE_FILES``)
|
||||
under ``state-snapshots/``. Files over 1 GiB are skipped with a
|
||||
warning so a bloated state.db can never stall the update
|
||||
(issues #15733, #34600 are the reason this safety net exists).
|
||||
- ``full`` — the quick snapshot PLUS a full zip of HERMES_HOME under
|
||||
``backups/`` (restorable via ``hermes import``; the #48200 wrong-path
|
||||
wipe is the reason this level exists).
|
||||
|
||||
``--backup`` forces ``full`` for one run; ``--no-backup`` forces ``off``.
|
||||
Never raises — a backup failure should not block the update itself.
|
||||
|
||||
Returns the quick-snapshot id (used by the post-update cron-jobs
|
||||
restore safety net), or ``None`` when mode is ``off`` or the snapshot
|
||||
failed.
|
||||
"""
|
||||
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; don't spam them
|
||||
# on every update.
|
||||
return None
|
||||
|
||||
snapshot_id = None
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
snapshot_id = create_quick_snapshot(
|
||||
label="pre-update",
|
||||
keep=_PRE_UPDATE_SNAPSHOT_KEEP,
|
||||
max_file_size=_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE,
|
||||
)
|
||||
if snapshot_id:
|
||||
print(f"◆ Pre-update snapshot: {snapshot_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logging.getLogger(__name__).debug("Pre-update snapshot failed: %s", exc)
|
||||
|
||||
if mode != "full":
|
||||
if snapshot_id:
|
||||
print()
|
||||
return snapshot_id
|
||||
|
||||
try:
|
||||
from hermes_cli.backup import create_pre_update_backup
|
||||
|
|
@ -8958,24 +9022,31 @@ def _run_pre_update_backup(args) -> None:
|
|||
f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update."
|
||||
)
|
||||
print()
|
||||
return
|
||||
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))
|
||||
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
|
||||
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
|
||||
return snapshot_id
|
||||
|
||||
try:
|
||||
size_bytes = out_path.stat().st_size
|
||||
|
|
@ -9004,9 +9075,9 @@ def _run_pre_update_backup(args) -> None:
|
|||
|
||||
print(f" Saved: {display_path} ({size_str}, {elapsed:.1f}s)")
|
||||
print(f" Restore: hermes import {out_path}")
|
||||
print(" Disable: omit --backup (backups are off by default)")
|
||||
print(" set updates.pre_update_backup: false in config.yaml")
|
||||
print(" Disable: set updates.pre_update_backup: quick (or off) in config.yaml")
|
||||
print()
|
||||
return snapshot_id
|
||||
|
||||
|
||||
def _write_update_planned_stop_marker(profile_path: Path, pid: int) -> bool:
|
||||
|
|
@ -9711,7 +9782,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
|
||||
# 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)
|
||||
# Returns the quick-snapshot id (or None when disabled/failed); the
|
||||
# post-update cron-jobs safety net uses it to detect job loss.
|
||||
pre_update_snapshot_id = _run_pre_update_backup(args)
|
||||
|
||||
_windows_gateway_resume = _pause_windows_gateways_for_update()
|
||||
if _windows_gateway_resume:
|
||||
|
|
@ -9996,23 +10069,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
|
||||
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>`.
|
||||
pre_update_snapshot_id = None
|
||||
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)
|
||||
|
||||
print("→ Pulling updates...")
|
||||
update_succeeded = False
|
||||
# Capture the pre-pull SHA so we can auto-roll-back if the new code
|
||||
|
|
|
|||
|
|
@ -35,13 +35,13 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
|
|||
"--no-backup",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip the pre-update backup for this run (overrides updates.pre_update_backup)",
|
||||
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 pre-update backup for this run (off by default; overrides updates.pre_update_backup=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",
|
||||
|
|
|
|||
|
|
@ -1449,6 +1449,40 @@ class TestQuickSnapshot:
|
|||
empty.mkdir()
|
||||
assert create_quick_snapshot(hermes_home=empty) is None
|
||||
|
||||
def test_max_file_size_skips_oversized_file(self, hermes_home, capsys):
|
||||
"""Files above the cap are skipped with a warning; small files
|
||||
(the pairing/cron data the snapshot exists for) still land."""
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
# state.db in the fixture is a few KB — cap below it
|
||||
cap = 1024
|
||||
snap_id = create_quick_snapshot(
|
||||
hermes_home=hermes_home, max_file_size=cap
|
||||
)
|
||||
assert snap_id is not None
|
||||
snap_dir = hermes_home / "state-snapshots" / snap_id
|
||||
assert not (snap_dir / "state.db").exists()
|
||||
# Small files still captured
|
||||
assert (snap_dir / "cron" / "jobs.json").exists()
|
||||
with open(snap_dir / "manifest.json") as f:
|
||||
meta = json.load(f)
|
||||
assert "state.db" not in meta["files"]
|
||||
out = capsys.readouterr().out
|
||||
assert "skipping state.db" in out
|
||||
assert "exceeds" in out
|
||||
|
||||
def test_max_file_size_none_copies_everything(self, hermes_home):
|
||||
"""Default (no cap) preserves manual /snapshot behavior."""
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
snap_id = create_quick_snapshot(hermes_home=hermes_home, max_file_size=None)
|
||||
assert (hermes_home / "state-snapshots" / snap_id / "state.db").exists()
|
||||
|
||||
def test_max_file_size_under_cap_copies(self, hermes_home):
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
snap_id = create_quick_snapshot(
|
||||
hermes_home=hermes_home, max_file_size=1 << 30
|
||||
)
|
||||
assert (hermes_home / "state-snapshots" / snap_id / "state.db").exists()
|
||||
|
||||
def test_list_snapshots(self, hermes_home):
|
||||
from hermes_cli.backup import create_quick_snapshot, list_quick_snapshots
|
||||
id1 = create_quick_snapshot(label="first", hermes_home=hermes_home)
|
||||
|
|
@ -2030,7 +2064,8 @@ class TestPreUpdateBackup:
|
|||
|
||||
class TestRunPreUpdateBackup:
|
||||
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
|
||||
covers config gate, ``--no-backup`` flag, and user-facing output."""
|
||||
covers the consolidated off/quick/full mode gate, CLI flags, and
|
||||
user-facing output."""
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(self, tmp_path, monkeypatch):
|
||||
|
|
@ -2047,102 +2082,145 @@ class TestRunPreUpdateBackup:
|
|||
del __import__("sys").modules[mod]
|
||||
return root
|
||||
|
||||
def test_backup_flag_creates_backup(self, hermes_home, capsys):
|
||||
"""--backup forces the pre-update backup for one run even when config is off."""
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
_run_pre_update_backup(Namespace(no_backup=False, backup=True))
|
||||
out = capsys.readouterr().out
|
||||
assert "Creating pre-update backup" in out
|
||||
assert "Saved:" in out
|
||||
assert "Restore:" in out
|
||||
assert "hermes import" in out
|
||||
assert "Disable:" in out
|
||||
# Actual backup was created
|
||||
backups = list((hermes_home / "backups").glob("pre-update-*.zip"))
|
||||
assert len(backups) == 1
|
||||
@staticmethod
|
||||
def _set_mode(hermes_home, value):
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
|
||||
"_config_version": 22,
|
||||
"updates": {"pre_update_backup": value},
|
||||
}))
|
||||
import sys as _sys
|
||||
for mod in list(_sys.modules.keys()):
|
||||
if mod.startswith("hermes_cli.config"):
|
||||
del _sys.modules[mod]
|
||||
|
||||
def test_default_disabled_is_silent(self, hermes_home, capsys):
|
||||
"""With the default (``pre_update_backup: false``), ``hermes update``
|
||||
does NOT create a backup and stays silent — zipping a large
|
||||
HERMES_HOME can add minutes to every update. Users who want the
|
||||
#48200 safety net opt in via the config knob or ``--backup``.
|
||||
"""
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
_run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert out == ""
|
||||
assert not list((hermes_home / "backups").glob("pre-update-*.zip")) \
|
||||
if (hermes_home / "backups").exists() else True
|
||||
@staticmethod
|
||||
def _zips(hermes_home):
|
||||
d = hermes_home / "backups"
|
||||
return list(d.glob("pre-update-*.zip")) if d.exists() else []
|
||||
|
||||
def test_no_backup_flag_skips(self, hermes_home, capsys):
|
||||
@staticmethod
|
||||
def _snaps(hermes_home):
|
||||
d = hermes_home / "state-snapshots"
|
||||
return [p for p in d.iterdir() if p.is_dir()] if d.exists() else []
|
||||
|
||||
def test_default_creates_quick_snapshot_only(self, hermes_home, capsys):
|
||||
"""With no config, the default mode is ``quick``: a state snapshot is
|
||||
created but NOT the full zip."""
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
_run_pre_update_backup(Namespace(no_backup=True, backup=False))
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert "skipped (--no-backup)" in out
|
||||
assert snap_id is not None
|
||||
assert "Pre-update snapshot" in out
|
||||
assert "Creating pre-update backup" not in out
|
||||
# No backup written
|
||||
assert not (hermes_home / "backups").exists() or not list(
|
||||
(hermes_home / "backups").glob("pre-update-*.zip")
|
||||
)
|
||||
|
||||
def test_config_enabled_creates_backup(self, hermes_home, capsys):
|
||||
"""Users who explicitly set updates.pre_update_backup: true still get
|
||||
a backup on every update — this is the opt-in legacy behavior."""
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
|
||||
"_config_version": 22,
|
||||
"updates": {"pre_update_backup": True},
|
||||
}))
|
||||
import sys as _sys
|
||||
for mod in list(_sys.modules.keys()):
|
||||
if mod.startswith("hermes_cli.config"):
|
||||
del _sys.modules[mod]
|
||||
assert self._snaps(hermes_home)
|
||||
assert not self._zips(hermes_home)
|
||||
|
||||
def test_backup_flag_forces_full(self, hermes_home, capsys):
|
||||
"""--backup forces the full zip (plus quick snapshot) for one run."""
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
_run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=True))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is not None
|
||||
assert "Pre-update snapshot" in out
|
||||
assert "Creating pre-update backup" in out
|
||||
assert "Saved:" in out
|
||||
backups = list((hermes_home / "backups").glob("pre-update-*.zip"))
|
||||
assert len(backups) == 1
|
||||
|
||||
def test_config_disabled_is_silent(self, hermes_home, capsys):
|
||||
"""Explicit pre_update_backup: false behaves the same as the default —
|
||||
silent no-op, no message spam."""
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
|
||||
"_config_version": 22,
|
||||
"updates": {"pre_update_backup": False},
|
||||
}))
|
||||
# Ensure config module re-reads
|
||||
import sys as _sys
|
||||
for mod in list(_sys.modules.keys()):
|
||||
if mod.startswith("hermes_cli.config"):
|
||||
del _sys.modules[mod]
|
||||
assert "hermes import" in out
|
||||
assert len(self._zips(hermes_home)) == 1
|
||||
|
||||
def test_no_backup_flag_skips_everything(self, hermes_home, capsys):
|
||||
"""--no-backup skips BOTH the quick snapshot and the zip."""
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
_run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert out == ""
|
||||
assert not list((hermes_home / "backups").glob("pre-update-*.zip")) \
|
||||
if (hermes_home / "backups").exists() else True
|
||||
|
||||
def test_cli_flag_overrides_enabled_config(self, hermes_home, capsys):
|
||||
"""--no-backup wins even when config says pre_update_backup: true."""
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
|
||||
"_config_version": 22,
|
||||
"updates": {"pre_update_backup": True},
|
||||
}))
|
||||
import sys as _sys
|
||||
for mod in list(_sys.modules.keys()):
|
||||
if mod.startswith("hermes_cli.config"):
|
||||
del _sys.modules[mod]
|
||||
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
_run_pre_update_backup(Namespace(no_backup=True, backup=False))
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=True, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is None
|
||||
assert "skipped (--no-backup)" in out
|
||||
assert "Pre-update snapshot" not in out
|
||||
assert not self._snaps(hermes_home)
|
||||
assert not self._zips(hermes_home)
|
||||
|
||||
def test_config_off_disables_everything_silently(self, hermes_home, capsys):
|
||||
"""pre_update_backup: off — an explicit opt-out disables the quick
|
||||
snapshot too (it previously ran unconditionally), with no output."""
|
||||
self._set_mode(hermes_home, "off")
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is None
|
||||
assert out == ""
|
||||
assert not self._snaps(hermes_home)
|
||||
assert not self._zips(hermes_home)
|
||||
|
||||
def test_legacy_false_maps_to_off(self, hermes_home, capsys):
|
||||
"""Legacy boolean ``false`` (the old zip opt-out) now means off."""
|
||||
self._set_mode(hermes_home, False)
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
assert snap_id is None
|
||||
assert capsys.readouterr().out == ""
|
||||
assert not self._snaps(hermes_home)
|
||||
assert not self._zips(hermes_home)
|
||||
|
||||
def test_legacy_true_maps_to_full(self, hermes_home, capsys):
|
||||
"""Legacy boolean ``true`` (the old always-zip opt-in) means full."""
|
||||
self._set_mode(hermes_home, True)
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is not None
|
||||
assert "Creating pre-update backup" in out
|
||||
assert "Saved:" in out
|
||||
assert len(self._zips(hermes_home)) == 1
|
||||
|
||||
def test_config_full_mode(self, hermes_home, capsys):
|
||||
self._set_mode(hermes_home, "full")
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is not None
|
||||
assert "Pre-update snapshot" in out
|
||||
assert "Creating pre-update backup" in out
|
||||
assert len(self._zips(hermes_home)) == 1
|
||||
|
||||
def test_config_quick_mode(self, hermes_home, capsys):
|
||||
self._set_mode(hermes_home, "quick")
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is not None
|
||||
assert "Pre-update snapshot" in out
|
||||
assert "Creating pre-update backup" not in out
|
||||
assert not self._zips(hermes_home)
|
||||
|
||||
def test_unknown_mode_falls_back_to_quick(self, hermes_home, capsys):
|
||||
self._set_mode(hermes_home, "bogus-mode")
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is not None
|
||||
assert "Pre-update snapshot" in out
|
||||
assert not self._zips(hermes_home)
|
||||
|
||||
def test_no_backup_flag_overrides_full_config(self, hermes_home, capsys):
|
||||
"""--no-backup wins even when config says full."""
|
||||
self._set_mode(hermes_home, "full")
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=True, backup=False))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is None
|
||||
assert "skipped (--no-backup)" in out
|
||||
assert not self._snaps(hermes_home)
|
||||
assert not self._zips(hermes_home)
|
||||
|
||||
def test_backup_flag_overrides_off_config(self, hermes_home, capsys):
|
||||
"""--backup wins over config off for a single run."""
|
||||
self._set_mode(hermes_home, "off")
|
||||
from hermes_cli.main import _run_pre_update_backup
|
||||
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=True))
|
||||
out = capsys.readouterr().out
|
||||
assert snap_id is not None
|
||||
assert "Creating pre-update backup" in out
|
||||
assert len(self._zips(hermes_home)) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ This pulls the latest code from `main`, updates dependencies, and prompts you to
|
|||
|
||||
When you run `hermes update`, the following steps occur:
|
||||
|
||||
1. **Pairing-data snapshot** — a lightweight pre-update state snapshot is saved (covers `~/.hermes/pairing/`, Feishu comment rules, and other state files that get modified at runtime). Recoverable via the snapshot restore flow described under [Snapshots and rollback](../user-guide/checkpoints-and-rollback.md), or by extracting the most recent quick-snapshot zip Hermes wrote next to your `~/.hermes/` directory.
|
||||
1. **Pre-update snapshot** — a lightweight state snapshot is saved by default (covers pairing data, cron jobs, `config.yaml`, `.env`, `auth.json`, and other state files that get modified at runtime; individual files over 1 GiB are skipped so a large sessions DB never slows the update down). Controlled by `updates.pre_update_backup` (`quick` by default, `full` for a zip of all of `HERMES_HOME`, `off` to disable). Recoverable via the snapshot restore flow described under [Snapshots and rollback](../user-guide/checkpoints-and-rollback.md).
|
||||
2. **Git pull** — pulls the latest code from the `main` branch and updates submodules
|
||||
3. **Post-pull syntax validation + auto-rollback** — after the pull, Hermes compiles the eight critical files every `hermes` invocation imports at startup. If any fails to parse (e.g. an orphan merge-conflict marker, an accidentally truncated file), Hermes runs `git reset --hard <pre-pull-sha>` to roll the install back so your shell stays bootable. Re-run `hermes update` once the upstream fix lands.
|
||||
4. **Dependency install** — runs `uv pip install -e ".[all]"` to pick up new or changed dependencies
|
||||
|
|
@ -77,10 +77,10 @@ Or make it the default for every run:
|
|||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
updates:
|
||||
pre_update_backup: true
|
||||
pre_update_backup: full
|
||||
```
|
||||
|
||||
`--backup` was the always-on behavior in earlier builds, but it was adding minutes to every update on large homes, so it's now opt-in. The lightweight pairing-data snapshot above still runs unconditionally.
|
||||
`updates.pre_update_backup` is a single knob with three modes: `quick` (default — the lightweight state snapshot described above), `full` (the quick snapshot plus a complete `HERMES_HOME` zip; can add minutes on large homes), and `off` (no pre-update backup at all — `--no-backup` does the same for a single run). Legacy boolean values still work: `true` means `full`, `false` means `off`.
|
||||
|
||||
### Windows: another `hermes.exe` is running
|
||||
|
||||
|
|
|
|||
|
|
@ -1562,8 +1562,8 @@ Pulls the latest `hermes-agent` code and reinstalls dependencies in the managed
|
|||
|--------|-------------|
|
||||
| `--gateway` | Internal mode used by the messaging `/update` command. Uses file-based IPC for prompts and progress streaming instead of reading from terminal stdin. Not a gateway restart flag. |
|
||||
| `--check` | Check whether an update is available without pulling, installing dependencies, or restarting anything. |
|
||||
| `--no-backup` | Skip the pre-update backup for this run, even if `updates.pre_update_backup` is enabled in `config.yaml`. |
|
||||
| `--backup` | Create a labeled pre-update snapshot of `HERMES_HOME` (config, auth, sessions, skills, pairing data) before pulling. Default is **off** — the previous always-backup behavior was adding minutes to every update on large homes. Flip it on permanently via `updates.pre_update_backup: true` in `config.yaml`. |
|
||||
| `--no-backup` | Skip all pre-update backups for this run (both the quick state snapshot and the full zip), regardless of `updates.pre_update_backup`. |
|
||||
| `--backup` | Force a **full** pre-update backup for this run: the quick state snapshot plus a complete zip of `HERMES_HOME` (config, auth, sessions, skills, pairing data). The default mode is `quick` — a lightweight state snapshot only. Set the permanent mode via `updates.pre_update_backup: quick | full | off` in `config.yaml`. |
|
||||
| `--yes`, `-y` | Assume yes for interactive prompts such as config migration and stash restore. API-key entry is skipped; run `hermes config migrate` separately for those. |
|
||||
|
||||
Additional behavior:
|
||||
|
|
|
|||
|
|
@ -101,11 +101,13 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM
|
|||
|
||||
```yaml
|
||||
updates:
|
||||
pre_update_backup: false # Create a full HERMES_HOME zip before every update
|
||||
backup_keep: 5 # Keep this many pre-update backup zips
|
||||
pre_update_backup: quick # quick (state snapshot, default) | full (snapshot + HERMES_HOME zip) | off
|
||||
backup_keep: 5 # Keep this many full pre-update backup zips
|
||||
non_interactive_local_changes: stash # stash | discard
|
||||
```
|
||||
|
||||
`pre_update_backup` is the single pre-update safety knob: `quick` (default) snapshots critical state files (pairing data, cron jobs, config, auth; files over 1 GiB are skipped) into `state-snapshots/`; `full` additionally zips all of `HERMES_HOME` into `backups/` and can add minutes on large homes; `off` disables both. Legacy booleans are honored (`true` → `full`, `false` → `off`).
|
||||
|
||||
For git installs, Hermes auto-stashes dirty tracked files and untracked files before checking out the update branch or pulling. Interactive terminal updates prompt before restoring that stash. Non-interactive updates (desktop/chat app, gateway, or `--yes`) use `updates.non_interactive_local_changes`: `stash` restores local source edits after a successful pull, while `discard` drops the update-created stash after a successful pull. Use `discard` only on managed installs where local source edits are never meant to persist.
|
||||
|
||||
Before that stash step, Hermes also restores tracked `package-lock.json` diffs left by npm install/build churn. Commit or manually stash intentional lockfile edits before updating.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ hermes update
|
|||
|
||||
运行 `hermes update` 时,将依次执行以下步骤:
|
||||
|
||||
1. **配对数据快照** — 保存一份轻量级的更新前状态快照(涵盖 `~/.hermes/pairing/`、飞书评论规则及其他运行时修改的状态文件)。可通过 [快照与回滚](../user-guide/checkpoints-and-rollback.md) 中描述的快照恢复流程进行恢复,或从 Hermes 写入 `~/.hermes/` 目录旁的最新快速快照 zip 文件中提取。
|
||||
1. **更新前快照** — 默认保存一份轻量级状态快照(涵盖配对数据、cron 任务、`config.yaml`、`.env`、`auth.json` 及其他运行时修改的状态文件;单个超过 1 GiB 的文件会被跳过,因此大型会话数据库不会拖慢更新)。由 `updates.pre_update_backup` 控制(默认 `quick`,`full` 为整个 `HERMES_HOME` 的 zip 备份,`off` 为禁用)。可通过 [快照与回滚](../user-guide/checkpoints-and-rollback.md) 中描述的快照恢复流程进行恢复。
|
||||
2. **Git pull** — 从 `main` 分支拉取最新代码并更新子模块
|
||||
3. **依赖安装** — 运行 `uv pip install -e ".[all]"` 以获取新增或变更的依赖项
|
||||
4. **配置迁移** — 检测自当前版本以来新增的配置选项并提示设置
|
||||
|
|
@ -47,10 +47,10 @@ hermes update --backup
|
|||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
updates:
|
||||
pre_update_backup: true
|
||||
pre_update_backup: full
|
||||
```
|
||||
|
||||
`--backup` 在早期版本中是始终开启的行为,但在大型 home 目录上会给每次更新增加数分钟时间,因此现已改为按需启用。上述轻量级配对数据快照仍会无条件执行。
|
||||
`updates.pre_update_backup` 是单一开关,有三种模式:`quick`(默认 — 上述轻量级状态快照)、`full`(快速快照加上完整的 `HERMES_HOME` zip 备份;在大型 home 目录上可能增加数分钟)、`off`(完全不做更新前备份 — `--no-backup` 对单次运行有相同效果)。旧版布尔值仍然有效:`true` 等同于 `full`,`false` 等同于 `off`。
|
||||
|
||||
### Windows:另一个 `hermes.exe` 正在运行
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue