Files
hermes-agent/tools/checkpoint_manager.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

1539 lines
61 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 Manager — Transparent filesystem snapshots via a single shared
shadow git store.
Creates automatic snapshots of working directories before file-mutating
operations (``write_file``, ``patch``, ``terminal`` with destructive flags),
triggered once per conversation turn. Provides rollback to any previous
checkpoint.
This is NOT a tool — the LLM never sees it. It's transparent infrastructure
controlled by the ``checkpoints`` config flag or ``--checkpoints`` CLI flag.
Storage layout (single shared store, git objects deduplicated across projects)
-----------------------------------------------------------------------------
~/.hermes/checkpoints/
store/ — single bare-ish git repo
HEAD, config, objects/ — standard git internals (shared)
refs/hermes/<hash16> — per-project branch tip
indexes/<hash16> — per-project git index
projects/<hash16>.json — {workdir, created_at, last_touch}
info/exclude — default excludes (shared)
.last_prune — auto-prune idempotency marker
legacy-<timestamp>/ — archived pre-v2 per-project shadow
repos (auto-migrated on first init)
Why a single store?
-------------------
The pre-v2 design kept a full shadow repo per working directory. Each one
re-stored most of the project's files under its own ``objects/`` tree, with
zero sharing across worktrees of the same project. A single user with a
dozen worktrees of the same repo burned ~40 MB each (~500 MB total) storing
the same blobs over and over. A single shared store lets git's content-
addressable object DB deduplicate across projects and across turns, so adding
a new worktree costs near-zero.
The shadow store uses ``GIT_DIR`` + ``GIT_WORK_TREE`` + ``GIT_INDEX_FILE``
so no git state leaks into the user's project directory.
Auto-maintenance (``tools.checkpoint_maintenance``)
---------------------------------------------------
Shadow state accumulates over time. ``prune_checkpoints`` deletes refs whose
recorded working directory no longer exists (orphan) or whose last touch is
older than ``retention_days`` (stale), then runs ``git gc --prune=now`` to
reclaim object storage. A size-cap pass drops the oldest checkpoints per
project until total store size is under ``max_total_size_mb``.
"""
import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli._subprocess_compat import selected_git_env, windows_hide_flags
from hermes_cli.gitlock import clear_stale_tmp_packs
from typing import Dict, List, Optional, Set, Tuple
from utils import env_int, rmtree_readonly
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CHECKPOINT_BASE = get_hermes_home() / "checkpoints"
_CHECKPOINT_BASE_AT_IMPORT = CHECKPOINT_BASE
def _resolve_checkpoint_base() -> Path:
"""Active profile's checkpoint root at call time: the patched ``CHECKPOINT_BASE`` when a test
changed it, else live profile-scoped HERMES_HOME — under the multiplexed gateway one process
serves every profile, so the import-time constant would write every profile's code-edit
checkpoints into the launch profile's store."""
return CHECKPOINT_BASE if CHECKPOINT_BASE != _CHECKPOINT_BASE_AT_IMPORT else get_hermes_home() / "checkpoints"
# Single shared store directory under CHECKPOINT_BASE.
_STORE_DIRNAME = "store"
_REFS_PREFIX = "refs/hermes"
_INDEXES_DIRNAME = "indexes"
_PROJECTS_DIRNAME = "projects"
_LEDGERS_DIRNAME = "ledgers"
_LEGACY_PREFIX = "legacy-"
_PRUNE_MARKER_NAME = ".last_prune"
# Agent-write ledger cap: newest entries retained per project.
_LEDGER_MAX_ENTRIES = 2000
DEFAULT_EXCLUDES = [
# Dependency / build output
"node_modules/",
"dist/",
"build/",
"target/",
"out/",
".next/",
".nuxt/",
# Caches
"__pycache__/",
"*.pyc",
"*.pyo",
".cache/",
".pytest_cache/",
".mypy_cache/",
".ruff_cache/",
"coverage/",
".coverage",
# Virtualenvs
".venv/",
"venv/",
"env/",
# VCS
".git/",
".hg/",
".svn/",
# Worktrees (Hermes convention — don't recursively snapshot siblings)
".worktrees/",
# Native / compiled binaries
"*.so",
"*.dylib",
"*.dll",
"*.o",
"*.a",
"*.jar",
"*.class",
"*.exe",
"*.obj",
# Media / large binaries
"*.mp4",
"*.mov",
"*.mkv",
"*.webm",
"*.zip",
"*.tar",
"*.tar.gz",
"*.tgz",
"*.7z",
"*.rar",
"*.iso",
# Secrets
".env",
".env.*",
".env.local",
".env.*.local",
# OS junk
".DS_Store",
"Thumbs.db",
# Logs
"*.log",
]
# Git subprocess timeout (seconds).
_GIT_TIMEOUT: int = max(10, min(60, env_int("HERMES_CHECKPOINT_TIMEOUT", 30)))
# Max files to snapshot — skip huge directories to avoid slowdowns.
_MAX_FILES = 50_000
# Valid git commit hash pattern: 4–40 hex chars (short or full SHA-1/SHA-256).
_COMMIT_HASH_RE = re.compile(r'^[0-9a-fA-F]{4,64}$')
# ---------------------------------------------------------------------------
# Input validation helpers
# ---------------------------------------------------------------------------
def _validate_commit_hash(commit_hash: str) -> Optional[str]:
"""Validate a commit hash to prevent git argument injection.
Returns an error string if invalid, None if valid.
Values starting with '-' would be interpreted as git flags
(e.g., '--patch', '-p') instead of revision specifiers.
"""
if not commit_hash or not commit_hash.strip():
return "Empty commit hash"
if commit_hash.startswith("-"):
return f"Invalid commit hash (must not start with '-'): {commit_hash!r}"
if not _COMMIT_HASH_RE.match(commit_hash):
return f"Invalid commit hash (expected 4-64 hex characters): {commit_hash!r}"
return None
def _validate_file_path(file_path: str, working_dir: str) -> Optional[str]:
"""Validate a file path to prevent path traversal outside the working directory.
Returns an error string if invalid, None if valid.
"""
if not file_path or not file_path.strip():
return "Empty file path"
if os.path.isabs(file_path):
return f"File path must be relative, got absolute path: {file_path!r}"
abs_workdir = _normalize_path(working_dir)
resolved = (abs_workdir / file_path).resolve()
try:
resolved.relative_to(abs_workdir)
except ValueError:
return f"File path escapes the working directory via traversal: {file_path!r}"
return None
# ---------------------------------------------------------------------------
# Path / hash helpers
# ---------------------------------------------------------------------------
def _normalize_path(path_value: str) -> Path:
"""Return a canonical absolute path for checkpoint operations."""
return Path(path_value).expanduser().resolve()
def _project_hash(working_dir: str) -> str:
"""Deterministic per-project hash: sha256(abs_path)[:16]."""
abs_path = str(_normalize_path(working_dir))
return hashlib.sha256(abs_path.encode()).hexdigest()[:16]
def _store_path(base: Optional[Path] = None) -> Path:
"""Return the single shared shadow store path."""
return (base or _resolve_checkpoint_base()) / _STORE_DIRNAME
def _store_has_head(store: Path) -> bool:
return (store / "HEAD").exists()
def _index_path(store: Path, dir_hash: str) -> Path:
return store / _INDEXES_DIRNAME / dir_hash
def _ledger_path(store: Path, dir_hash: str) -> Path:
return store / _LEDGERS_DIRNAME / f"{dir_hash}.json"
def _hash_file(path: Path) -> Optional[str]:
"""Streaming sha256 of a file's bytes. None if unreadable/missing."""
try:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
except OSError:
return None
def _load_ledger(store: Path, dir_hash: str) -> Dict[str, Dict]:
"""Load the agent-write ledger: {relpath: {"sha256": ..., "ts": ...}}.
The ledger records the content hash of every file the last successful
``write_file`` / ``patch`` produced, so restores can tell "Hermes wrote
this" apart from "the user hand-edited this afterwards".
"""
try:
raw = _ledger_path(store, dir_hash).read_text(encoding="utf-8-sig")
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _save_ledger(store: Path, dir_hash: str, ledger: Dict[str, Dict]) -> None:
"""Persist the agent-write ledger, capped to the newest entries."""
try:
if len(ledger) > _LEDGER_MAX_ENTRIES:
newest = sorted(
ledger.items(),
key=lambda kv: kv[1].get("ts", 0) if isinstance(kv[1], dict) else 0,
reverse=True,
)[:_LEDGER_MAX_ENTRIES]
ledger = dict(newest)
path = _ledger_path(store, dir_hash)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(ledger), encoding="utf-8")
tmp.replace(path)
except OSError:
logger.debug("Failed to save agent-write ledger for %s", dir_hash, exc_info=True)
def _ref_name(dir_hash: str) -> str:
return f"{_REFS_PREFIX}/{dir_hash}"
def _project_meta_path(store: Path, dir_hash: str) -> Path:
return store / _PROJECTS_DIRNAME / f"{dir_hash}.json"
# ---------------------------------------------------------------------------
# Git env
# ---------------------------------------------------------------------------
def _git_env(
store: Path,
working_dir: str,
index_file: Optional[Path] = None,
) -> dict:
"""Build env dict that redirects git to the shared store.
The shared store is internal Hermes infrastructure — it must NOT inherit
the user's global or system git config. User-level settings like
``commit.gpgsign = true``, signing hooks, or credential helpers would
either break background snapshots or, worse, spawn interactive prompts
(pinentry GUI windows) mid-session every time a file is written.
Isolation strategy:
* ``GIT_CONFIG_GLOBAL=<os.devnull>`` — ignore ``~/.gitconfig`` (git 2.32+).
* ``GIT_CONFIG_SYSTEM=<os.devnull>`` — ignore ``/etc/gitconfig`` (git 2.32+).
* ``GIT_CONFIG_NOSYSTEM=1`` — legacy belt-and-suspenders for older git.
``index_file``, if given, forces git to use a per-project index under
``store/indexes/<hash>`` so projects don't race on a shared index.
"""
normalized_working_dir = _normalize_path(working_dir)
# git child with hand-isolated config env; exact preservation — a HOME
# rewrite would change which ~/.gitconfig the isolation vars are hiding.
from tools.environments.local import build_subprocess_env
env = selected_git_env(build_subprocess_env(scrub_secrets=False, inherit_profile_home=False))
env["GIT_DIR"] = str(store)
env["GIT_WORK_TREE"] = str(normalized_working_dir)
env.pop("GIT_NAMESPACE", None)
env.pop("GIT_ALTERNATE_OBJECT_DIRECTORIES", None)
if index_file is not None:
env["GIT_INDEX_FILE"] = str(index_file)
else:
env.pop("GIT_INDEX_FILE", None)
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_SYSTEM"] = os.devnull
env["GIT_CONFIG_NOSYSTEM"] = "1"
return env
def _run_git(
args: List[str],
store: Path,
working_dir: str,
timeout: int = _GIT_TIMEOUT,
allowed_returncodes: Optional[Set[int]] = None,
index_file: Optional[Path] = None,
extra_env: Optional[Dict[str, str]] = None,
) -> Tuple[bool, str, str]:
"""Run a git command against the shared store. Returns (ok, stdout, stderr).
``allowed_returncodes`` suppresses error logging for known/expected non-zero
exits while preserving the normal ``ok = (returncode == 0)`` contract.
Example: ``git diff --cached --quiet`` returns 1 when changes exist.
"""
normalized_working_dir = _normalize_path(working_dir)
if not normalized_working_dir.exists():
msg = f"working directory not found: {normalized_working_dir}"
logger.error("Git command skipped: %s (%s)", " ".join(["git"] + list(args)), msg)
return False, "", msg
if not normalized_working_dir.is_dir():
msg = f"working directory is not a directory: {normalized_working_dir}"
logger.error("Git command skipped: %s (%s)", " ".join(["git"] + list(args)), msg)
return False, "", msg
env = _git_env(store, str(normalized_working_dir), index_file=index_file)
if extra_env:
env.update(extra_env)
git = shutil.which("git", path=env.get("PATH", ""))
if git is None:
return False, "", "git is not installed or not on PATH"
cmd = [git, *args]
allowed_returncodes = allowed_returncodes or set()
try:
# NUL-delimited git output contains literal filenames, not text lines.
text_options = {} if "-z" in args else {"text": True, "encoding": "utf-8", "errors": "replace"}
result = subprocess.run(
cmd,
capture_output=True,
**text_options,
timeout=timeout,
env=env,
cwd=str(normalized_working_dir),
stdin=subprocess.DEVNULL,
# Checkpoints fire several bare git calls per turn from the
# console-less desktop/gateway backend; suppress the per-call
# conhost flash on Windows (no-op on POSIX).
creationflags=windows_hide_flags(),
)
ok = result.returncode == 0
stdout = os.fsdecode(result.stdout) if "-z" in args else result.stdout.strip()
stderr = result.stderr.decode("utf-8", errors="replace").strip() if "-z" in args else result.stderr.strip()
if not ok and result.returncode not in allowed_returncodes:
logger.error(
"Git command failed: %s (rc=%d) stderr=%s",
" ".join(cmd), result.returncode, stderr,
)
return ok, stdout, stderr
except subprocess.TimeoutExpired:
msg = f"git timed out after {timeout}s: {' '.join(cmd)}"
logger.error(msg, exc_info=True)
return False, "", msg
except FileNotFoundError as exc:
missing_target = getattr(exc, "filename", None)
if missing_target == "git":
logger.error("Git executable not found: %s", " ".join(cmd), exc_info=True)
return False, "", "git not found"
msg = f"working directory not found: {normalized_working_dir}"
logger.error("Git command failed before execution: %s (%s)", " ".join(cmd), msg, exc_info=True)
return False, "", msg
except Exception as exc:
logger.error("Unexpected git error running %s: %s", " ".join(cmd), exc, exc_info=True)
return False, "", str(exc)
def _git_out(args: List[str], store: Path, working_dir: str, rc: Optional[Set[int]] = None) -> str:
"""stdout of a successful git call, else ``""``."""
ok, out, _ = _run_git(args, store, working_dir, allowed_returncodes=rc)
return out if ok else ""
def _ref_tip(store: Path, working_dir: str, ref: str) -> Optional[str]:
"""Commit sha at ``ref``, or None when the ref does not exist yet."""
return _git_out(["rev-parse", "--verify", ref + "^{commit}"], store, working_dir, {128}) or None
def _list_project_refs(store: Path, working_dir: str) -> List[str]:
out = _git_out(["for-each-ref", "--format=%(refname)", _REFS_PREFIX], store, working_dir, {128})
return [r for r in out.splitlines() if r.strip()]
def _unlink_quiet(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
pass
# ---------------------------------------------------------------------------
# Store initialisation + legacy migration
# ---------------------------------------------------------------------------
def _migrate_legacy_store(base: Path) -> Optional[Path]:
"""Move pre-v2 per-project shadow repos into a ``legacy-<ts>/`` dir.
The pre-v2 layout had one shadow git repo per working directory directly
under ``CHECKPOINT_BASE``. The v2 layout wants a single ``store/`` dir.
Rather than delete the old data (users might want to recover), rename
everything except our own v2 entries into ``legacy-<timestamp>/``. The
legacy dir is subject to the same retention sweep and can be manually
cleared with ``hermes checkpoints clear-legacy``.
Returns the legacy-archive path, or None if nothing to migrate.
"""
if not base.exists():
return None
store = _store_path(base)
legacy_root: Optional[Path] = None
# Reserved top-level entries managed by v2.
reserved = {_STORE_DIRNAME, _PRUNE_MARKER_NAME}
for child in list(base.iterdir()):
name = child.name
if name in reserved or name.startswith(_LEGACY_PREFIX):
continue
# Candidate: pre-v2 shadow repo (has HEAD) OR stray dir. Either way
# we archive it so v2 starts clean.
if legacy_root is None:
stamp = time.strftime("%Y%m%d-%H%M%S")
legacy_root = base / f"{_LEGACY_PREFIX}{stamp}"
try:
legacy_root.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning("Could not create legacy archive dir: %s", exc)
return None
dest = legacy_root / name
try:
shutil.move(str(child), str(dest))
except OSError as exc:
logger.warning("Could not archive legacy checkpoint %s: %s", child, exc)
# If the store still hasn't been created, create it here.
_ = store
if legacy_root is not None:
logger.info(
"Migrated pre-v2 checkpoint repos to %s. "
"Clear with `hermes checkpoints clear-legacy` when safe.",
legacy_root,
)
return legacy_root
def _init_store(store: Path, working_dir: str) -> Optional[str]:
"""Initialise the shared shadow store if needed. Returns error or None.
Also performs one-time migration of pre-v2 per-directory shadow repos
into ``legacy-<timestamp>/``.
"""
base = store.parent
# One-time legacy migration before we create the store.
if not store.exists():
try:
base.mkdir(parents=True, exist_ok=True)
except OSError as exc:
return f"Could not create checkpoint base: {exc}"
# Only migrate if the base dir has pre-existing content that isn't
# our own v2 layout.
_migrate_legacy_store(base)
if (store / "HEAD").exists():
return None
store.mkdir(parents=True, exist_ok=True)
(store / _INDEXES_DIRNAME).mkdir(exist_ok=True)
(store / _PROJECTS_DIRNAME).mkdir(exist_ok=True)
# ``git init --bare`` rejects GIT_WORK_TREE, so we can't use _run_git
# here (which always sets GIT_DIR + GIT_WORK_TREE). Use a raw
# subprocess with just the config-isolation env vars.
from tools.environments.local import build_subprocess_env
init_env = selected_git_env(build_subprocess_env(scrub_secrets=False, inherit_profile_home=False))
init_env["GIT_CONFIG_GLOBAL"] = os.devnull
init_env["GIT_CONFIG_SYSTEM"] = os.devnull
init_env["GIT_CONFIG_NOSYSTEM"] = "1"
# Drop any inherited GIT_* that would interfere.
for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_NAMESPACE",
"GIT_ALTERNATE_OBJECT_DIRECTORIES"):
init_env.pop(k, None)
try:
git = shutil.which("git", path=init_env.get("PATH", ""))
if git is None:
return "Shadow store init failed: git is not installed or not on PATH"
result = subprocess.run(
[git, "init", "--bare", str(store)],
capture_output=True, text=True, encoding='utf-8', errors='replace',
env=init_env, timeout=_GIT_TIMEOUT,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
if result.returncode != 0:
return f"Shadow store init failed: {result.stderr.strip()}"
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
return f"Shadow store init failed: {exc}"
# Per-store config (isolated by env vars above, but belt-and-suspenders).
# Use the base dir as the working_dir for config commands — it always
# exists since we just created the store inside it.
cfg_wd = str(base)
_run_git(["config", "user.email", "hermes@local"], store, cfg_wd)
_run_git(["config", "user.name", "Hermes Checkpoint"], store, cfg_wd)
_run_git(["config", "commit.gpgsign", "false"], store, cfg_wd)
_run_git(["config", "tag.gpgSign", "false"], store, cfg_wd)
_run_git(["config", "gc.auto", "0"], store, cfg_wd)
info_dir = store / "info"
info_dir.mkdir(exist_ok=True)
(info_dir / "exclude").write_text(
"\n".join(DEFAULT_EXCLUDES) + "\n", encoding="utf-8"
)
logger.debug("Initialised checkpoint store at %s", store)
return None
def _volume_evidence(workdir: Path) -> Dict:
"""Record the identity of ``workdir``'s parent while the project is live.
``(st_dev, st_ino)`` of the parent directory, captured at a moment when
the workdir itself is reachable, identifies the *directory* — not just
the path. A mount point resolves to the mounted filesystem's root while
the volume is attached and to the underlying (underlay) directory after
unmount: same path, different directory, different ``(st_dev, st_ino)``.
Orphan pruning uses this to distinguish "the project was deleted out of
the directory we knew" from "a different directory is now visible at
that path because the volume is detached".
Returns ``{}`` when the workdir is not currently reachable, when the
filesystem does not provide a usable directory identity (a zero
``st_dev`` or ``st_ino`` — e.g. Windows filesystems without file IDs and
some network shares), or when the probe fails — callers treat all of
these as "no evidence recorded" and orphan pruning stays conservative
for the project (never classified as orphan; retention still applies).
"""
try:
if not workdir.exists():
return {}
st = workdir.parent.stat()
if not st.st_dev or not st.st_ino:
return {}
return {
"workdir_parent_dev": st.st_dev,
"workdir_parent_ino": st.st_ino,
}
except OSError:
return {}
def _register_project(store: Path, working_dir: str) -> None:
"""Create or update ``projects/<hash>.json`` with workdir + timestamps."""
dir_hash = _project_hash(working_dir)
meta_path = _project_meta_path(store, dir_hash)
now = time.time()
meta: Dict = {"workdir": str(_normalize_path(working_dir)),
"created_at": now, "last_touch": now}
evidence = _volume_evidence(_normalize_path(working_dir))
if evidence:
meta.update(evidence)
if meta_path.exists():
try:
existing = json.loads(meta_path.read_text(encoding="utf-8-sig"))
if isinstance(existing, dict):
meta["created_at"] = existing.get("created_at", now)
if not evidence:
# Fresh probe failed — keep the previously recorded
# parent identity rather than dropping it. Stale evidence
# only makes pruning MORE conservative (mismatch => not
# an orphan).
for key in ("workdir_parent_dev", "workdir_parent_ino"):
if key in existing:
meta[key] = existing[key]
except (OSError, ValueError):
pass
try:
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(meta), encoding="utf-8")
except OSError as exc:
logger.debug("Could not write project metadata %s: %s", meta_path, exc)
def _touch_project(store: Path, working_dir: str) -> None:
"""Update last_touch for a project, preserving created_at."""
dir_hash = _project_hash(working_dir)
meta_path = _project_meta_path(store, dir_hash)
if not meta_path.exists():
_register_project(store, working_dir)
return
try:
meta = json.loads(meta_path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
meta = {}
if not isinstance(meta, dict):
meta = {}
meta["workdir"] = str(_normalize_path(working_dir))
meta["last_touch"] = time.time()
meta.setdefault("created_at", meta["last_touch"])
# Refresh the parent-directory identity while the project is observably
# live — a remount can legitimately change it (new device, new inode).
# On probe failure the previous evidence is kept: stale evidence can only
# make pruning MORE conservative (mismatch => not an orphan).
evidence = _volume_evidence(_normalize_path(working_dir))
if evidence:
meta.update(evidence)
try:
meta_path.write_text(json.dumps(meta), encoding="utf-8")
except OSError as exc:
logger.debug("Could not update project metadata %s: %s", meta_path, exc)
def _list_projects(store: Path) -> List[Dict]:
"""Return all registered projects under the store."""
projects_dir = store / _PROJECTS_DIRNAME
if not projects_dir.exists():
return []
out: List[Dict] = []
for meta_path in projects_dir.glob("*.json"):
dir_hash = meta_path.stem
try:
meta = json.loads(meta_path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
continue
if not isinstance(meta, dict):
continue
meta["_hash"] = dir_hash
out.append(meta)
return out
def _pre_v2_shadow_repos(base: Path) -> List[Dict]:
"""Return pre-v2 per-project shadow repos still directly under ``base``.
Pre-v2 layout kept one shadow git repo per working directory directly
under ``CHECKPOINT_BASE`` (identified by a ``HEAD`` file). This is the
single source of truth for that scan so a preview built from it (e.g.
``store_status``) always matches what ``prune_checkpoints`` deletes.
"""
out: List[Dict] = []
if not base.exists():
return out
for child in base.iterdir():
if not child.is_dir():
continue
if child.name == _STORE_DIRNAME or child.name.startswith(_LEGACY_PREFIX):
continue
if not (child / "HEAD").exists():
continue
workdir: Optional[str] = None
marker_unreadable = False
wd_marker = child / "HERMES_WORKDIR"
if wd_marker.exists():
try:
workdir = wd_marker.read_text(encoding="utf-8-sig").strip()
except (OSError, UnicodeDecodeError):
# The marker is there, we just could not read it. That is
# not evidence the project is gone — never delete on it.
workdir = None
marker_unreadable = True
out.append({
"path": child,
"workdir": workdir,
"exists": bool(workdir) and Path(workdir).exists(),
"marker_unreadable": marker_unreadable,
})
return out
def _dir_file_count(path: str) -> int:
"""Quick file count estimate (stops early if over _MAX_FILES)."""
count = 0
try:
for _ in Path(path).rglob("*"):
count += 1
if count > _MAX_FILES:
return count
except (PermissionError, OSError):
pass
return count
def _dir_size_bytes(path: Path) -> int:
"""Best-effort recursive size in bytes. Returns 0 on error."""
total = 0
try:
for p in path.rglob("*"):
try:
if p.is_file():
total += p.stat().st_size
except OSError:
continue
except OSError:
pass
return total
# ---------------------------------------------------------------------------
# CheckpointManager
# ---------------------------------------------------------------------------
class CheckpointManager:
"""Manages automatic filesystem checkpoints.
Designed to be owned by AIAgent. Call ``new_turn()`` at the start of
each conversation turn and ``ensure_checkpoint(dir, reason)`` before
any file-mutating tool call. The manager deduplicates so at most one
snapshot is taken per directory per turn.
Parameters
----------
enabled : bool
Master switch (from config / CLI flag).
max_snapshots : int
Keep at most this many checkpoints per directory.
max_total_size_mb : int
Hard ceiling on total store size. Oldest checkpoints per project
are dropped when the store exceeds this after a commit.
max_file_size_mb : int
Skip adding any single file larger than this to a checkpoint.
(Implemented via ``.gitignore`` excludes + a post-stage size check.)
"""
def __init__(
self,
enabled: bool = False,
max_snapshots: int = 20,
max_total_size_mb: int = 500,
max_file_size_mb: int = 10,
):
self.enabled = enabled
self.max_snapshots = max(1, int(max_snapshots))
self.max_total_size_mb = max(0, int(max_total_size_mb))
self.max_file_size_mb = max(0, int(max_file_size_mb))
self._checkpointed_dirs: Set[str] = set()
# ------------------------------------------------------------------
# Turn lifecycle
# ------------------------------------------------------------------
def new_turn(self) -> None:
"""Reset per-turn dedup. Call at the start of each agent iteration."""
self._checkpointed_dirs.clear()
def unsupported_backend_reason(self, task_id: str = "default") -> Optional[str]:
"""Explain why host checkpoints are off limits for a container-backed session.
Classifies the task's backend at call time (nothing is remembered), so /rollback is
refused before the first mutation and follows a backend change within the session."""
from tools.file_tools_paths import container_backend_for_task
backend = container_backend_for_task(task_id)
if backend is None:
return None
return (
f"Checkpoints are not taken for terminal.backend={backend}: "
"file paths belong to the container, not this host."
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def record_agent_write(self, file_path: str) -> None:
"""Record the content hash of a file Hermes just successfully wrote.
Feeds the agent-write ledger used by :meth:`restore` in safe mode:
at restore time, a file whose current content no longer matches the
recorded hash was hand-edited by the user after Hermes last touched
it, and is skipped instead of clobbered.
Never raises — the ledger is best-effort bookkeeping.
"""
if not self.enabled:
return
try:
from tools.checkpoint_pruning import store_lock
path = _normalize_path(file_path)
digest = _hash_file(path)
if digest is None:
return
with store_lock(_resolve_checkpoint_base()):
store = _store_path()
dir_hash = self._ledger_key(str(path))
ledger = _load_ledger(store, dir_hash)
ledger[str(path)] = {"sha256": digest, "ts": time.time()}
_save_ledger(store, dir_hash, ledger)
except Exception as exc:
logger.debug("record_agent_write failed for %s: %s", file_path, exc)
def _safe_restore_plan(self, working_dir: str, commit_hash: str) -> Dict:
"""Classify files changed since ``commit_hash`` for a safe restore.
Returns ``{"success", "restore": [rel...], "skipped": [rel...],
"error"?}`` where ``restore`` lists files whose current content
still matches what Hermes last wrote (per the agent-write ledger)
and ``skipped`` lists files the user hand-edited after Hermes'
last write or that Hermes never wrote at all.
"""
hash_err = _validate_commit_hash(commit_hash)
if hash_err:
return {"success": False, "error": hash_err}
abs_dir = str(_normalize_path(working_dir))
store = _store_path()
if not (store / "HEAD").exists():
return {"success": False, "error": "No checkpoints exist for this directory"}
dir_hash = _project_hash(abs_dir)
index_file = _index_path(store, dir_hash)
# Stage the current tree so the name-only diff sees new files too.
_run_git(["add", "-A"], store, abs_dir,
timeout=_GIT_TIMEOUT * 2, index_file=index_file)
ok, names_out, err = _run_git(
["diff", "--name-only", "-z", commit_hash, "--cached"],
store, abs_dir, index_file=index_file,
)
# Reset the index back to the project ref so it doesn't drift.
_run_git(["read-tree", _ref_name(dir_hash)], store, abs_dir,
index_file=index_file, allowed_returncodes={128})
if not ok:
return {"success": False, "error": f"Could not compute changed files: {err}"}
# Read the same marker-walked project key as record_agent_write.
ledger = _load_ledger(store, self._ledger_key(abs_dir))
if not ledger:
# No agent-write ledger yet (pre-existing store, or Hermes has
# not written any files here since the ledger was introduced).
# Signal callers to fall back to a full restore rather than
# skipping every file.
return {"success": True, "restore": [], "skipped": [],
"ledger_empty": True}
restore: List[str] = []
skipped: List[str] = []
for rel in filter(None, names_out.split("\x00")):
abs_path = Path(abs_dir) / rel
entry = ledger.get(str(abs_path))
recorded = entry.get("sha256") if isinstance(entry, dict) else None
if recorded is None:
# Hermes never wrote this file (or the ledger predates it) —
# do not touch it in safe mode.
skipped.append(rel)
continue
current = _hash_file(abs_path)
if current is None:
# File deleted since Hermes wrote it: restoring it back is
# safe — its last content was Hermes-authored.
restore.append(rel)
elif current == recorded:
restore.append(rel)
else:
skipped.append(rel)
return {"success": True, "restore": restore, "skipped": skipped}
def ensure_checkpoint(self, working_dir: str, reason: str = "auto") -> bool:
"""Take a checkpoint if enabled and not already done this turn.
Returns True if a checkpoint was taken, False otherwise.
Never raises — all errors are silently logged.
"""
if not self.enabled:
return False
abs_dir = str(_normalize_path(working_dir))
# Skip root, home, and other overly broad directories
if abs_dir in {"/", str(Path.home())}:
logger.debug("Checkpoint skipped: directory too broad (%s)", abs_dir)
return False
if abs_dir in self._checkpointed_dirs:
return False
self._checkpointed_dirs.add(abs_dir)
try:
from tools.checkpoint_pruning import store_lock
with store_lock(_resolve_checkpoint_base()):
return self._take(abs_dir, reason)
except Exception as e:
logger.debug("Checkpoint failed (non-fatal): %s", e)
return False
def list_checkpoints(self, working_dir: str) -> List[Dict]:
"""List available checkpoints for a directory (most recent first)."""
abs_dir = str(_normalize_path(working_dir))
store = _store_path()
if not (store / "HEAD").exists():
return []
ref = _ref_name(_project_hash(abs_dir))
ok, stdout, _ = _run_git(
["log", ref, "--format=%H|%h|%aI|%s", "-n", str(self.max_snapshots)],
store, abs_dir,
allowed_returncodes={128, 129},
)
if not ok or not stdout:
return []
results: List[Dict] = []
for line in stdout.splitlines():
parts = line.split("|", 3)
if len(parts) == 4:
entry = {
"hash": parts[0],
"short_hash": parts[1],
"timestamp": parts[2],
"reason": parts[3],
"files_changed": 0,
"insertions": 0,
"deletions": 0,
}
stat_ok, stat_out, _ = _run_git(
["diff", "--shortstat", f"{parts[0]}~1", parts[0]],
store, abs_dir,
allowed_returncodes={128, 129},
)
if stat_ok and stat_out:
self._parse_shortstat(stat_out, entry)
results.append(entry)
return results
def list_all_checkpoints(self) -> List[Dict]:
"""List checkpoints across every registered project (most recent first).
Surgical reapply of PR #10633 by @nightq (#10505) onto the v2
single-store layout: iterate ``projects/<hash>.json`` metadata via
``_list_projects`` instead of the pre-v2 per-shadow-dir scan. Each
entry carries the extra ``workdir`` key so callers can label which
project a checkpoint belongs to.
"""
store = _store_path()
if not (store / "HEAD").exists():
return []
results: List[Dict] = []
for meta in _list_projects(store):
workdir = meta.get("workdir") or ""
if not workdir:
continue
for entry in self.list_checkpoints(workdir):
entry["workdir"] = workdir
results.append(entry)
results.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
return results
@staticmethod
def _parse_shortstat(stat_line: str, entry: Dict) -> None:
"""Parse git --shortstat output into entry dict."""
m = re.search(r'(\d+) file', stat_line)
if m:
entry["files_changed"] = int(m.group(1))
m = re.search(r'(\d+) insertion', stat_line)
if m:
entry["insertions"] = int(m.group(1))
m = re.search(r'(\d+) deletion', stat_line)
if m:
entry["deletions"] = int(m.group(1))
def diff(self, working_dir: str, commit_hash: str) -> Dict:
"""Show diff between a checkpoint and the current working tree."""
from tools.checkpoint_pruning import PruneError, store_lock
try:
with store_lock(_resolve_checkpoint_base()):
return self._diff(working_dir, commit_hash)
except (PruneError, OSError) as exc:
return {"success": False, "error": str(exc)}
def _diff(self, working_dir: str, commit_hash: str) -> Dict:
hash_err = _validate_commit_hash(commit_hash)
if hash_err:
return {"success": False, "error": hash_err}
abs_dir = str(_normalize_path(working_dir))
store = _store_path()
if not (store / "HEAD").exists():
return {"success": False, "error": "No checkpoints exist for this directory"}
ok, _, err = _run_git(
["cat-file", "-t", commit_hash], store, abs_dir,
)
if not ok:
return {"success": False, "error": f"Checkpoint '{commit_hash}' not found"}
dir_hash = _project_hash(abs_dir)
index_file = _index_path(store, dir_hash)
# Stage current state into the per-project index to compare.
_run_git(["add", "-A"], store, abs_dir,
timeout=_GIT_TIMEOUT * 2, index_file=index_file)
ok_stat, stat_out, _ = _run_git(
["diff", "--stat", commit_hash, "--cached"],
store, abs_dir, index_file=index_file,
)
ok_diff, diff_out, _ = _run_git(
["diff", commit_hash, "--cached", "--no-color"],
store, abs_dir, index_file=index_file,
)
# Reset staged tree back to the project's last checkpoint so the
# index doesn't drift out of sync with the ref.
ref = _ref_name(dir_hash)
_run_git(["read-tree", ref], store, abs_dir,
index_file=index_file,
allowed_returncodes={128})
if not ok_stat and not ok_diff:
return {"success": False, "error": "Could not generate diff"}
return {
"success": True,
"stat": stat_out if ok_stat else "",
"diff": diff_out if ok_diff else "",
}
def session_diff(self, working_dir: str) -> Dict:
"""Show the cumulative diff of everything changed in this directory.
This powers ``/diff session``. It answers "what has Hermes changed
here?" by diffing the *earliest retained checkpoint* — the snapshot
taken before the first recorded edit — against the current working
tree. Because checkpoints are captured just before each file-mutating
tool call, that baseline is the pre-edit state, so the diff covers the
first edit and everything after it.
Note: checkpoints are a persistent per-project ref, so the earliest
*retained* checkpoint may predate the current session (or, after
pruning, postdate its true start). It is an approximation of "what
Hermes changed", not an exact per-session ledger.
Returns the same shape as :meth:`diff` (``{"success", "stat",
"diff"}``). When no checkpoints exist yet — nothing has been edited —
the call still *succeeds* with empty output and ``"empty": True`` so
callers can show a friendly "no changes" message rather than an error.
"""
checkpoints = self.list_checkpoints(working_dir)
if not checkpoints:
return {"success": True, "stat": "", "diff": "", "empty": True}
baseline = checkpoints[-1].get("hash") or ""
result = self.diff(working_dir, baseline)
if result.get("success"):
result.setdefault("baseline", baseline)
if not result.get("stat") and not result.get("diff"):
result["empty"] = True
return result
def restore(
self,
working_dir: str,
commit_hash: str,
file_path: str = None,
safe: bool = False,
) -> Dict:
"""Restore files to a checkpoint state.
With ``safe=True`` (full-directory restores only), files the user
hand-edited after Hermes' last write — per the agent-write ledger —
are left untouched, and only Hermes-authored changes are reverted.
The result gains ``skipped_user_edits`` listing the preserved paths,
``skipped_oversize`` listing paths kept because the size cap excluded
them from every checkpoint, and — only when a delete failed —
``failed_deletes`` listing paths that could not be removed.
"""
from tools.checkpoint_pruning import PruneError, store_lock
try:
with store_lock(_resolve_checkpoint_base()):
return self._restore(working_dir, commit_hash, file_path, safe)
except (PruneError, OSError) as exc:
return {"success": False, "error": str(exc)}
def _restore(self, working_dir: str, commit_hash: str, file_path: str | None, safe: bool) -> Dict:
hash_err = _validate_commit_hash(commit_hash)
if hash_err:
return {"success": False, "error": hash_err}
abs_dir = str(_normalize_path(working_dir))
if file_path:
path_err = _validate_file_path(file_path, abs_dir)
if path_err:
return {"success": False, "error": path_err}
store = _store_path()
if not (store / "HEAD").exists():
return {"success": False, "error": "No checkpoints exist for this directory"}
ok, _, err = _run_git(
["cat-file", "-t", commit_hash], store, abs_dir,
)
if not ok:
return {"success": False, "error": f"Checkpoint '{commit_hash}' not found",
"debug": err or None}
skipped_user_edits: List[str] = []
kept_oversize: List[str] = []
failed_deletes: List[str] = []
restore_paths: Optional[List[str]] = None
if safe and not file_path:
plan = self._safe_restore_plan(abs_dir, commit_hash)
if not plan.get("success"):
return {"success": False, "error": plan.get("error", "Safe-restore plan failed")}
if plan.get("ledger_empty"):
# No agent-write history to compare against — fall back to
# the classic full restore rather than restoring nothing.
restore_paths = None
else:
restore_paths = plan["restore"]
skipped_user_edits = plan["skipped"]
if not restore_paths:
return {
"success": True,
"restored_to": commit_hash[:8],
"reason": "nothing to restore (all changed files were user-edited)",
"directory": abs_dir,
"restored_files": [],
"skipped_user_edits": skipped_user_edits,
"skipped_oversize": [],
}
# Take a pre-rollback snapshot so you can undo the undo.
self._take(abs_dir, f"pre-rollback snapshot (restoring to {commit_hash[:8]})", prune=False)
dir_hash = _project_hash(abs_dir)
index_file = _index_path(store, dir_hash)
if restore_paths is not None:
# Split into files present in the checkpoint (checkout) and
# Hermes-created files absent from it (delete to restore state).
checkout_targets: List[str] = []
delete_targets: List[str] = []
for rel in restore_paths:
ok_in_commit, _, _ = _run_git(
["cat-file", "-e", f"{commit_hash}:{rel}"],
store, abs_dir, allowed_returncodes={1, 128},
)
if ok_in_commit:
checkout_targets.append(rel)
elif self._exceeds_size_cap(Path(abs_dir) / rel):
# Absent from the checkpoint because ``max_file_size_mb``
# kept it out (_drop_oversize_from_index), not because
# Hermes created it. Deleting it would not restore a prior
# state — no checkpoint holds one — it would destroy the
# only copy. The ledger records a content hash, not whether
# a write created or modified the file, so an oversize path
# cannot be proven agent-created; leaving it costs a stale
# file, deleting it costs the file.
kept_oversize.append(rel)
else:
delete_targets.append(rel)
for rel in delete_targets:
try:
target = Path(abs_dir) / rel
if target.is_file() or target.is_symlink():
target.unlink()
except OSError as exc:
logger.warning(
"Safe restore: could not remove %s: %s", rel, exc,
)
failed_deletes.append(rel)
if not checkout_targets:
ok, stdout, err = True, "", ""
else:
ok, stdout, err = _run_git(
["checkout", commit_hash, "--", *checkout_targets],
store, abs_dir, timeout=_GIT_TIMEOUT * 2,
index_file=index_file,
)
else:
ok, stdout, err = _run_git(
["checkout", commit_hash, "--", file_path if file_path else "."],
store, abs_dir, timeout=_GIT_TIMEOUT * 2,
index_file=index_file,
)
if not ok:
return {"success": False, "error": f"Restore failed: {err}",
"debug": err or None}
ok2, reason_out, _ = _run_git(
["log", "--format=%s", "-1", commit_hash], store, abs_dir,
)
reason = reason_out if ok2 else "unknown"
result = {
"success": True,
"restored_to": commit_hash[:8],
"reason": reason,
"directory": abs_dir,
}
if file_path:
result["file"] = file_path
if restore_paths is not None:
# Only what was actually acted on. A kept oversize path was not
# restored (and a failed unlink left the file in place), and
# reporting either as restored is how the data loss above stayed
# silent: the user was told "Restored" for a file that had just
# been unlinked.
not_restored = set(kept_oversize) | set(failed_deletes)
result["restored_files"] = [
rel for rel in restore_paths if rel not in not_restored
]
result["skipped_user_edits"] = skipped_user_edits
result["skipped_oversize"] = kept_oversize
if failed_deletes:
result["failed_deletes"] = failed_deletes
# The selected tree was needed until checkout completed. Only now may
# the safety snapshot's count/size budget make that tree unreachable.
self._prune(store, abs_dir, _ref_name(dir_hash))
return result
def _ledger_key(self, path: str) -> str:
"""Agent-write ledger key: hash of the marker-walked project dir, for writer and reader alike."""
return _project_hash(self.get_working_dir_for_path(path))
def get_working_dir_for_path(self, file_path: str) -> str:
"""Resolve a file path to its working directory for checkpointing."""
path = _normalize_path(file_path)
if path.is_dir():
candidate = path
else:
candidate = path.parent
# An explicitly checkpointed root owns its ledger even when an
# ancestor carries a project marker. Prefer the nearest owner.
roots = [Path(meta["workdir"]) for meta in _list_projects(_store_path()) if meta.get("workdir")]
owners = [root for root in roots if candidate.is_relative_to(root)]
if owners:
return str(max(owners, key=lambda root: len(root.parts)))
markers = {".git", "pyproject.toml", "package.json", "Cargo.toml",
"go.mod", "Makefile", "pom.xml", ".hg", "Gemfile"}
home = Path.home().resolve()
broad = {home, *home.parents}
check = candidate
while check != check.parent and check not in broad:
if any((check / m).exists() for m in markers):
return str(check)
check = check.parent
return str(candidate)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _take(self, working_dir: str, reason: str, *, prune: bool = True) -> bool:
"""Take a snapshot. Returns True on success."""
store = _store_path()
err = _init_store(store, working_dir)
if err:
logger.debug("Checkpoint store init failed: %s", err)
return False
_touch_project(store, working_dir)
# Quick size guard — don't try to snapshot enormous directories
if _dir_file_count(working_dir) > _MAX_FILES:
logger.debug("Checkpoint skipped: >%d files in %s", _MAX_FILES, working_dir)
return False
dir_hash = _project_hash(working_dir)
index_file = _index_path(store, dir_hash)
ref = _ref_name(dir_hash)
# Seed the per-project index from the last checkpoint, if any, so the
# diff/commit machinery sees only changes since then. On first call,
# clear the index so ``git add -A`` produces a clean tree.
if index_file.exists():
# Reset index to current ref tip to avoid accumulating stale paths.
ok_ref, ref_commit, _ = _run_git(
["rev-parse", "--verify", ref + "^{commit}"],
store, working_dir,
allowed_returncodes={128},
)
if ok_ref and ref_commit:
_run_git(
["read-tree", ref_commit],
store, working_dir,
index_file=index_file,
allowed_returncodes={128},
)
else:
try:
index_file.unlink()
except OSError:
pass
else:
# First snapshot for this project.
index_file.parent.mkdir(parents=True, exist_ok=True)
# Stage with per-project index. Include a per-stage file-size filter
# via ``core.bigFileThreshold`` is not what we want — instead, we
# rely on the exclude file for broad patterns and post-stage prune
# any path whose size exceeds max_file_size_mb.
ok, _, err = _run_git(
["add", "-A"], store, working_dir,
timeout=_GIT_TIMEOUT * 2, index_file=index_file,
)
if not ok:
logger.debug("Checkpoint git-add failed: %s", err)
return False
if self.max_file_size_mb > 0:
self._drop_oversize_from_index(store, working_dir, index_file)
# Compare against the current ref tip (not HEAD — HEAD points to a
# branch that doesn't exist on a bare store, so ``diff --cached``
# against HEAD would always show "new file" for every staged path).
ok_ref, ref_commit, _ = _run_git(
["rev-parse", "--verify", ref + "^{commit}"],
store, working_dir,
allowed_returncodes={128},
)
has_ref = ok_ref and bool(ref_commit)
if has_ref:
ok_diff, _, _ = _run_git(
["diff-index", "--cached", "--quiet", ref_commit],
store, working_dir,
allowed_returncodes={1},
index_file=index_file,
)
if ok_diff:
logger.debug("Checkpoint skipped: no changes in %s", working_dir)
return False
else:
# No ref yet — skip only if the index is empty.
ok_ls, ls_out, _ = _run_git(
["ls-files", "--cached"],
store, working_dir,
index_file=index_file,
)
if ok_ls and not ls_out.strip():
logger.debug("Checkpoint skipped: empty tree in %s", working_dir)
return False
# Write tree from per-project index.
ok_tree, tree_sha, err = _run_git(
["write-tree"], store, working_dir,
index_file=index_file,
)
if not ok_tree or not tree_sha:
logger.debug("Checkpoint write-tree failed: %s", err)
return False
# Build commit (parent = current ref tip, if any).
commit_args = ["commit-tree", tree_sha, "-m", reason, "--no-gpg-sign"]
if has_ref:
commit_args = ["commit-tree", tree_sha, "-p", ref_commit, "-m", reason, "--no-gpg-sign"]
ok_commit, new_sha, err = _run_git(
commit_args, store, working_dir,
index_file=index_file,
)
if not ok_commit or not new_sha:
logger.debug("Checkpoint commit-tree failed: %s", err)
return False
# Update the per-project ref.
update_args = ["update-ref", ref, new_sha]
if has_ref:
update_args = ["update-ref", ref, new_sha, ref_commit]
ok_update, _, err = _run_git(
update_args, store, working_dir,
)
if not ok_update:
logger.debug("Checkpoint update-ref failed: %s", err)
return False
logger.debug("Checkpoint taken in %s: %s (%s)", working_dir, reason, new_sha[:8])
# Count and size budgets share one failure boundary.
if prune:
self._prune(store, working_dir, ref)
return True
def _exceeds_size_cap(self, path: Path) -> bool:
"""Whether *path* is larger than ``max_file_size_mb``.
The same test :meth:`_drop_oversize_from_index` applies when building a
checkpoint, so "excluded from the checkpoint" and "refused deletion at
restore" agree on one definition. A cap of 0 disables it, and an
unstattable path is not claimed to be oversize.
"""
cap = self.max_file_size_mb * 1024 * 1024
if cap <= 0:
return False
try:
return path.stat().st_size > cap
except OSError:
return False
def _drop_oversize_from_index(
self, store: Path, working_dir: str, index_file: Path,
) -> None:
"""Remove any staged file larger than ``max_file_size_mb`` from the index.
Lets the agent keep snapshotting source code while refusing to
swallow generated assets (datasets, model weights, logs, videos).
"""
if self.max_file_size_mb <= 0:
return
ok, stdout, _ = _run_git(
["ls-files", "--cached", "-z"],
store, working_dir, index_file=index_file,
)
if not ok or not stdout:
return
# NUL separators preserve whitespace within each literal filename.
paths = [p for p in stdout.split("\x00") if p]
abs_workdir = _normalize_path(working_dir)
# Same predicate safe restore consults, called rather than restated:
# a threshold that drifted between the two would make a file both
# absent from the checkpoint and not recognised as capped at restore,
# which is precisely the deletion this change exists to prevent.
oversize = [
rel for rel in paths if self._exceeds_size_cap(abs_workdir / rel)
]
if not oversize:
return
logger.debug(
"Checkpoint: dropping %d oversize file(s) (>%d MB) from index",
len(oversize), self.max_file_size_mb,
)
# Use --pathspec-from-file for safety with many paths.
# Chunk into manageable batches.
BATCH = 200
for i in range(0, len(oversize), BATCH):
chunk = oversize[i:i + BATCH]
_run_git(
["rm", "--cached", "--quiet", "--"] + chunk,
store, working_dir, index_file=index_file,
allowed_returncodes={128},
)
def _prune(self, store: Path, working_dir: str, ref: str) -> None:
"""Checkpoint-take path: snapshot-count budget plus one size round, gc deferred to the
periodic prune — a repack here held the tool call for the whole gc on a large store."""
from tools.checkpoint_pruning import Pruner, PruneError
pruner = Pruner(_run_git, store, working_dir, _GIT_TIMEOUT, _dir_size_bytes, _REFS_PREFIX)
try:
pruner.trim(ref, self.max_snapshots)
if pruner.drop_one_round(self.max_total_size_mb * 1024 * 1024):
logger.info("Checkpoint store exceeded %d MB — dropped the oldest snapshot per project; "
"space is reclaimed by the next prune", self.max_total_size_mb)
except (PruneError, OSError) as exc:
logger.warning("Checkpoint pruning stopped: %s", exc)
def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str:
"""Format checkpoint list for display to user."""
if not checkpoints:
return f"No checkpoints found for {directory}"
lines = [f"📸 Checkpoints for {directory}:\n"]
for i, cp in enumerate(checkpoints, 1):
ts = cp["timestamp"]
if "T" in ts:
ts = ts.split("T")[1].split("+")[0].split("-")[0][:5]
date = cp["timestamp"].split("T")[0]
ts = f"{date} {ts}"
files = cp.get("files_changed", 0)
ins = cp.get("insertions", 0)
dele = cp.get("deletions", 0)
if files:
stat = f" ({files} file{'s' if files != 1 else ''}, +{ins}/-{dele})"
else:
stat = ""
# Label per-project entries when showing the cross-project view
# (workdir key only present on list_all_checkpoints results).
workdir = cp.get("workdir", "")
if workdir and directory == "all directories":
workdir_short = Path(workdir).name or workdir
lines.append(
f" {i}. {cp['short_hash']} {ts} [{workdir_short}] {cp['reason']}{stat}"
)
else:
lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}")
lines.append("\n /rollback <N> restore to checkpoint N")
lines.append(" /rollback diff <N> preview changes since checkpoint N")
lines.append(" /rollback <N> <file> restore a single file from checkpoint N")
return "\n".join(lines)