Files
hermes-agent/tools/checkpoint_maintenance.py
ethernet 6716b72ef2 refactor: separate checkpoint store maintenance from snapshots
Move retention, orphan pruning, status and clear operations into a topical sibling and route callers and tests directly to their defining module. Keep the shared store paths and git execution in checkpoint_manager; shorten the local child-env WHAT docstring.
2026-09-24 01:55:51 -04:00

609 lines
24 KiB
Python
Raw Permalink 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.

"""Checkpoint store retention, orphan pruning, status and cleanup."""
import logging
import os
import time
from pathlib import Path
from typing import Dict, Optional
from hermes_cli.gitlock import clear_stale_tmp_packs
from utils import rmtree_readonly
from tools.checkpoint_manager import (
_GIT_TIMEOUT, _LEGACY_PREFIX, _PRUNE_MARKER_NAME, _REFS_PREFIX, _STORE_DIRNAME,
_dir_size_bytes, _index_path, _list_projects, _pre_v2_shadow_repos,
_project_meta_path, _ref_name, _resolve_checkpoint_base, _run_git, _store_path,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Auto-maintenance
# ---------------------------------------------------------------------------
#
# v2 rewrite. The sweep now operates on per-project refs inside the shared
# store rather than per-project shadow repos. Legacy-archive dirs
# (``legacy-<ts>/``) are swept with the same retention policy.
def _delete_ref(store: Path, ref: str) -> bool:
"""Delete a ref from the store. Returns True on success."""
ok, _, _ = _run_git(
["update-ref", "-d", ref], store, str(store.parent),
allowed_returncodes={128},
)
return ok
def _workdir_is_observably_gone(
workdir: str,
parent_dev: Optional[int] = None,
parent_ino: Optional[int] = None,
require_parent_identity: bool = True,
) -> bool:
"""True only when we can positively observe that ``workdir`` was removed.
``Path.exists()`` returns False for a deleted directory AND for one whose
storage simply is not attached right now — an unplugged external drive, a
network share behind a downed VPN, a bind-mount absent from this
container, an offline Windows mapped drive. Orphan pruning deletes the
project's entire checkpoint history, so treating that ambiguity as
"deleted" throws away the user's restore points over a transient mount
state, unattended, at startup.
Require corroboration, in three steps.
First, the parent directory must be present, so the absence of the project
inside it is something we actually observed. When the parent is missing
too, the volume is not there and we know nothing.
Second, the present parent must be the directory we knew — not merely a
directory at the same path. Unmounting swaps the directory visible at a
mount point: while the volume is attached the path resolves to the
mounted filesystem's root; after detach it resolves to the *underlying*
(underlay) directory, which may carry entries of its own (a ``.keep``
placeholder, sibling mount points). Those entries were never next to the
project and prove nothing about the volume being attached. So the
parent's ``(st_dev, st_ino)`` must match the identity recorded in the
project's metadata while the project was observably live
(``parent_dev``/``parent_ino``). A mismatch means a different directory
is visible at that path — a detached volume, not an observed deletion.
When no identity was ever recorded (metadata written by an older
version) and ``require_parent_identity`` is True, stay conservative and
do not classify as orphan. Callers that have no identity channel at all
(the frozen pre-v2 layout) pass ``require_parent_identity=False`` to
keep the structural checks only.
Third, the (identity-confirmed) parent must actually carry information.
Unmounting leaves classic static mount points (``/mnt/volume/proj``, an
fstab entry, a container bind-mount) behind as *empty* directories, so an
empty parent is the signature of a detached volume just as much as of a
deleted project. Prune only when the parent holds something else (we
observed a populated directory that does not contain the project) or is
itself a live mount point (the volume is demonstrably attached and the
project is demonstrably not on it).
Genuinely abandoned projects are still reclaimed by the retention/stale
rule, which runs off ``last_touch`` rather than a filesystem probe.
"""
if not workdir:
return False
path = Path(workdir)
try:
if path.exists():
return False
parent = path.parent
# A path whose parent is itself (a filesystem root) gives us nothing
# to corroborate against.
if parent == path:
return False
if not parent.is_dir():
return False
if parent_dev is not None and parent_ino is not None:
st = parent.stat()
if (st.st_dev, st.st_ino) != (parent_dev, parent_ino):
# A different directory is visible at the parent's path than
# the one the project lived in — the volume is detached (its
# underlay showing through) or was swapped. Not a deletion.
return False
elif require_parent_identity:
# No recorded identity to check against — we cannot tell the
# project's real parent from an underlay directory exposed by an
# unmount. Unsure never deletes; retention still reclaims.
return False
if _dir_has_any_entry(parent):
return True
# Empty parent: only evidence if that directory is a mount point, i.e.
# the volume is attached right now and simply does not hold the
# project. An empty plain directory is an unmounted mount point as
# readily as an emptied project root.
return os.path.ismount(parent)
except OSError:
# Probe failed (permission, I/O error) — not evidence of deletion.
return False
def _dir_has_any_entry(directory: Path) -> bool:
"""True when ``directory`` contains at least one entry.
Stops after the first entry rather than materializing the listing; a
project root can hold a large tree.
"""
with os.scandir(directory) as entries:
for _ in entries:
return True
return False
def prune_checkpoints(
retention_days: int = 7,
delete_orphans: bool = True,
checkpoint_base: Optional[Path] = None,
max_total_size_mb: int = 0,
orphan_allowlist: Optional[set] = None,
) -> Dict[str, int]:
"""Delete stale/orphan checkpoints and reclaim store space.
A project entry is deleted when either:
* ``delete_orphans=True`` and its ``workdir`` no longer exists on disk
(the original project was deleted / moved); OR
* its ``last_touch`` is older than ``retention_days`` days.
``orphan_allowlist``, when not ``None``, restricts orphan deletion to
the given identities (v2 project ``_hash`` strings and/or pre-v2 shadow
repo paths as ``str``). This lets a caller that showed the user a
confirmation preview (built from ``store_status()``) bind the resulting
deletion to exactly what was displayed — a project that only becomes
orphaned *after* the preview (e.g. its workdir vanishes while the human
is answering the prompt) is skipped rather than swept up under the
earlier confirmation. Pass ``None`` (the default) to delete every
currently-orphaned project, e.g. for ``--force`` or unattended callers
that never show a preview.
Additionally, if ``max_total_size_mb > 0`` and the store exceeds that
after orphan/stale pruning, the oldest commit per remaining project is
dropped until the store is under the cap.
Legacy-archive dirs (``legacy-*``) older than ``retention_days`` are
also deleted.
Returns a dict with counts ``{"scanned", "deleted_orphan",
"deleted_stale", "errors", "bytes_freed"}``.
Never raises — maintenance must never block interactive startup.
"""
base = checkpoint_base or _resolve_checkpoint_base()
result = {
"scanned": 0,
"deleted_orphan": 0,
"deleted_stale": 0,
"errors": 0,
"bytes_freed": 0,
}
if not base.exists():
return result
from tools.checkpoint_pruning import PruneError, store_lock
try:
with store_lock(base):
return _prune_checkpoints(base, result, retention_days, delete_orphans, max_total_size_mb, orphan_allowlist)
except (PruneError, OSError) as exc:
result["errors"] += 1
logger.warning("Checkpoint maintenance stopped: %s", exc)
return result
def _prune_checkpoints(
base: Path, result: Dict[str, int], retention_days: int, delete_orphans: bool,
max_total_size_mb: int, orphan_allowlist: Optional[set],
) -> Dict[str, int]:
size_before = _dir_size_bytes(base)
# --- Legacy pre-v2 per-project shadow repos (kept directly under base) ---
# Pre-v2 layout: ``base/<hash>/HEAD`` etc. We treat these exactly as the
# v1 pruner did so behaviour is unchanged for anyone still on that layout
# or sitting on a mid-migration system.
cutoff = 0.0
if retention_days > 0:
cutoff = time.time() - retention_days * 86400
for child in base.iterdir():
if not child.is_dir():
continue
if child.name == _STORE_DIRNAME:
continue
if child.name.startswith(_LEGACY_PREFIX):
# Legacy archive: prune by dir mtime using same retention rule.
if retention_days <= 0:
continue
try:
m = child.stat().st_mtime
except OSError:
continue
if m >= cutoff:
continue
try:
size = _dir_size_bytes(child)
rmtree_readonly(child)
result["bytes_freed"] += size
result["deleted_stale"] += 1
except OSError as exc:
result["errors"] += 1
logger.warning("Failed to delete legacy archive %s: %s", child, exc)
# Pre-v2 per-project shadow repos. Scanned via the same helper
# `store_status()` uses for its orphan preview, so a confirmation prompt
# built from that preview always matches what gets deleted here.
for repo in _pre_v2_shadow_repos(base):
child = repo["path"]
result["scanned"] += 1
reason: Optional[str] = None
if (
delete_orphans
and not repo["marker_unreadable"]
and (
repo["workdir"] is None
# The frozen pre-v2 layout has no metadata channel to carry a
# recorded parent identity, so only the structural checks
# (parent present + populated / live mount point) apply here.
or _workdir_is_observably_gone(
repo["workdir"], require_parent_identity=False,
)
)
and (orphan_allowlist is None or str(child) in orphan_allowlist)
):
reason = "orphan"
if reason is None and retention_days > 0:
newest = 0.0
try:
for p in child.rglob("*"):
try:
mt = p.stat().st_mtime
newest = max(newest, mt)
except OSError:
continue
except OSError:
pass
if newest > 0 and newest < cutoff:
reason = "stale"
if reason is None:
continue
try:
size = _dir_size_bytes(child)
rmtree_readonly(child)
result["bytes_freed"] += size
if reason == "orphan":
result["deleted_orphan"] += 1
else:
result["deleted_stale"] += 1
except OSError as exc:
result["errors"] += 1
logger.warning("Failed to prune checkpoint repo %s: %s", child.name, exc)
# --- v2 shared store: per-project ref pruning via metadata ---
store = _store_path(base)
if (store / "HEAD").exists():
# A gc killed by the store timeout strands tmp_pack_* files that gc.auto=0 means git
# itself never reclaims; sweep them even when no ref moved (a sweep is a directory
# listing, unlike the pack-rewriting gc gated on refs below).
clear_stale_tmp_packs(store)
for meta in _list_projects(store):
dir_hash = meta.get("_hash") or ""
workdir = meta.get("workdir") or ""
if not dir_hash:
continue
result["scanned"] += 1
reason = None
parent_dev = meta.get("workdir_parent_dev")
parent_ino = meta.get("workdir_parent_ino")
if not isinstance(parent_dev, int) or isinstance(parent_dev, bool):
parent_dev = None
if not isinstance(parent_ino, int) or isinstance(parent_ino, bool):
parent_ino = None
if (
delete_orphans
and (
not workdir
or _workdir_is_observably_gone(
workdir,
parent_dev=parent_dev,
parent_ino=parent_ino,
)
)
and (orphan_allowlist is None or dir_hash in orphan_allowlist)
):
reason = "orphan"
elif retention_days > 0:
last_touch = float(meta.get("last_touch", 0) or 0)
if last_touch > 0 and last_touch < cutoff:
reason = "stale"
if reason is None:
continue
ref = _ref_name(dir_hash)
if not _delete_ref(store, ref):
result["errors"] += 1
return result
# Drop per-project index and metadata.
try:
idx = _index_path(store, dir_hash)
if idx.exists():
idx.unlink()
except OSError:
pass
try:
mp = _project_meta_path(store, dir_hash)
if mp.exists():
mp.unlink()
except OSError:
pass
if reason == "orphan":
result["deleted_orphan"] += 1
else:
result["deleted_stale"] += 1
from tools.checkpoint_pruning import Pruner, PruneError
pruner = Pruner(_run_git, store, str(base), _GIT_TIMEOUT, _dir_size_bytes, _REFS_PREFIX)
try:
if result["deleted_orphan"] + result["deleted_stale"] or pruner.gc_pending():
pruner.reclaim()
if not pruner.enforce_size(max_total_size_mb * 1024 * 1024):
result["errors"] += 1
logger.warning("Checkpoint store remains over its size cap; minimum history retained")
except (PruneError, OSError) as exc:
result["errors"] += 1
logger.warning("Checkpoint pruning stopped: %s", exc)
size_after = _dir_size_bytes(base)
delta = size_before - size_after
result["bytes_freed"] = max(result["bytes_freed"], delta)
return result
def maybe_auto_prune_checkpoints(
retention_days: int = 7,
min_interval_hours: int = 24,
delete_orphans: bool = True,
checkpoint_base: Optional[Path] = None,
max_total_size_mb: int = 0,
) -> Dict[str, object]:
"""Idempotent wrapper around ``prune_checkpoints`` for startup hooks.
Writes ``CHECKPOINT_BASE/.last_prune`` on completion so subsequent
calls within ``min_interval_hours`` short-circuit.
Returns ``{"skipped": bool, "result": prune_checkpoints-dict,
"error": optional str}``.
"""
base = checkpoint_base or _resolve_checkpoint_base()
out: Dict[str, object] = {"skipped": False}
try:
if not base.exists():
out["result"] = {
"scanned": 0, "deleted_orphan": 0, "deleted_stale": 0,
"errors": 0, "bytes_freed": 0,
}
return out
marker = base / _PRUNE_MARKER_NAME
now = time.time()
try:
if marker.exists() and now - float(marker.read_text(encoding="utf-8-sig").strip()) < min_interval_hours * 3600:
out["skipped"] = True
return out
except (OSError, ValueError):
pass # corrupt marker — treat as no prior run
# Claim the interval before pruning: callers run on a periodic tick, and a prune that
# dies mid-way must cost one skipped day, not a git gc every tick.
try:
marker.write_text(str(now), encoding="utf-8")
except OSError as exc:
logger.debug("Could not write checkpoint prune marker: %s", exc)
result = out["result"] = prune_checkpoints(retention_days=retention_days, delete_orphans=delete_orphans,
checkpoint_base=base, max_total_size_mb=max_total_size_mb)
total = result["deleted_orphan"] + result["deleted_stale"]
if total > 0:
logger.info(
"checkpoint auto-maintenance: pruned %d entry(ies) "
"(%d orphan, %d stale), reclaimed %.1f MB",
total,
result["deleted_orphan"],
result["deleted_stale"],
result["bytes_freed"] / (1024 * 1024),
)
except Exception as exc:
logger.warning("checkpoint auto-maintenance failed: %s", exc)
out["error"] = str(exc)
return out
def auto_prune_from_config() -> Dict[str, object]:
"""``maybe_auto_prune_checkpoints`` driven by the ``checkpoints:`` config section — the one
startup/housekeeping entry point for the CLI and the gateway. ``delete_orphans`` is never
honoured unattended: a missing workdir is ambiguous (deleted vs. unmounted share); orphan
cleanup is only via explicit ``hermes checkpoints prune``. Never raises."""
try:
from hermes_cli.config import load_config
cfg = load_config().get("checkpoints") or {}
if not cfg.get("auto_prune", False):
return {"skipped": True}
return maybe_auto_prune_checkpoints(
retention_days=int(cfg.get("retention_days", 7)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
delete_orphans=False,
max_total_size_mb=int(cfg.get("max_total_size_mb", 500)))
except Exception as exc:
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
return {"skipped": True, "error": str(exc)}
def checkpoint_footprint_notice() -> Optional[str]:
"""One-line notice when ``/rollback`` checkpoints are on and their store sits at or above
``checkpoints.max_total_size_mb``, else None. Checkpoints were on by default for a while
(Mar–May 2026) and that ``enabled: true`` persisted into user configs; many users carry a
GB-scale store for a feature they never invoke. The cap is a floor of one snapshot per
project, so a big store is expected, not broken — the notice names the opt-out. Never raises."""
try:
from hermes_cli.config import load_config
cfg = load_config().get("checkpoints") or {}
if not cfg.get("enabled", False):
return None
cap_mb = int(cfg.get("max_total_size_mb", 500) or 0)
status = store_status()
size = int(status["total_size_bytes"])
if cap_mb <= 0 or size < cap_mb * 1024 * 1024:
return None
from hermes_cli.sizefmt import format_bytes
return (f"Filesystem checkpoints (/rollback) are on: {format_bytes(size)} across "
f"{status['project_count']} project(s), above the {cap_mb} MB cap (one snapshot per project is "
f"always kept). Not using /rollback? `hermes config set checkpoints.enabled false` then "
f"`hermes checkpoints clear`; or lower `checkpoints.retention_days`.")
except Exception as exc:
logger.debug("checkpoint footprint notice skipped: %s", exc)
return None
# ---------------------------------------------------------------------------
# Public helpers for `hermes checkpoints` CLI
# ---------------------------------------------------------------------------
def store_status(checkpoint_base: Optional[Path] = None) -> Dict:
"""Return a summary of the shadow store.
``{"base": path, "store_size_bytes": N, "legacy_size_bytes": N,
"total_size_bytes": N, "project_count": N, "projects": [...],
"pre_v2_projects": [...], "legacy_archives": [...]}``
``pre_v2_projects`` covers shadow repos still on the pre-v2 per-project
layout (``base/<hash>/HEAD``) — distinct from ``legacy_archives``, which
are already-migrated ``legacy-<ts>/`` dirs. Callers that preview an
orphan-deletion sweep must include both ``projects`` and
``pre_v2_projects``, since ``prune_checkpoints`` deletes orphans from
both layouts.
"""
base = checkpoint_base or _resolve_checkpoint_base()
out: Dict = {
"base": str(base),
"store_size_bytes": 0,
"legacy_size_bytes": 0,
"total_size_bytes": 0,
"project_count": 0,
"projects": [],
"pre_v2_projects": [],
"legacy_archives": [],
}
if not base.exists():
return out
store = _store_path(base)
if store.exists():
out["store_size_bytes"] = _dir_size_bytes(store)
if (store / "HEAD").exists():
for meta in _list_projects(store):
dir_hash = meta.get("_hash") or ""
workdir = meta.get("workdir") or ""
ref = _ref_name(dir_hash)
ok, count_out, _ = _run_git(
["rev-list", "--count", ref], store, str(base),
allowed_returncodes={128},
)
try:
commits = int(count_out) if ok else 0
except ValueError:
commits = 0
out["projects"].append({
"hash": dir_hash,
"workdir": workdir,
"exists": bool(workdir) and Path(workdir).exists(),
"created_at": meta.get("created_at"),
"last_touch": meta.get("last_touch"),
"commits": commits,
})
out["project_count"] = len(out["projects"])
out["pre_v2_projects"] = [
{
"path": str(r["path"]),
"workdir": r["workdir"],
"exists": r["exists"],
}
for r in _pre_v2_shadow_repos(base)
]
for child in base.iterdir():
if child.is_dir() and child.name.startswith(_LEGACY_PREFIX):
try:
size = _dir_size_bytes(child)
except OSError:
size = 0
out["legacy_size_bytes"] += size
try:
mt = child.stat().st_mtime
except OSError:
mt = 0
out["legacy_archives"].append({
"name": child.name,
"size_bytes": size,
"mtime": mt,
})
out["total_size_bytes"] = _dir_size_bytes(base)
return out
def _rmtree_force(path: Path) -> None:
rmtree_readonly(path)
def clear_all(checkpoint_base: Optional[Path] = None) -> Dict[str, int]:
"""Nuke the entire checkpoint base (store + legacy). Irreversible.
Returns ``{"bytes_freed": N, "deleted": bool}``.
"""
base = checkpoint_base or _resolve_checkpoint_base()
out = {"bytes_freed": 0, "deleted": False}
if not base.exists():
return out
size = _dir_size_bytes(base)
try:
from tools.checkpoint_pruning import store_lock
with store_lock(base):
_rmtree_force(base)
out["bytes_freed"] = size
out["deleted"] = True
except (OSError, RuntimeError) as exc:
logger.warning("Could not clear checkpoint base %s: %s", base, exc)
return out
def clear_legacy(checkpoint_base: Optional[Path] = None) -> Dict[str, int]:
"""Delete all ``legacy-*`` archive directories and report any failures."""
base = checkpoint_base or _resolve_checkpoint_base()
out = {"bytes_freed": 0, "deleted": 0, "errors": 0}
if not base.exists():
return out
from tools.checkpoint_pruning import store_lock
try:
with store_lock(base):
for child in list(base.iterdir()):
if not child.is_dir() or not child.name.startswith(_LEGACY_PREFIX):
continue
try:
size = _dir_size_bytes(child)
_rmtree_force(child)
out["bytes_freed"] += size
out["deleted"] += 1
except OSError as exc:
out["errors"] += 1
logger.warning("Could not delete legacy archive %s: %s", child, exc)
except (OSError, RuntimeError) as exc:
out["errors"] += 1
logger.warning("Could not clear legacy archives: %s", exc)
return out