fix(checkpoints): never auto-delete orphans on unattended startup sweep

Builds on this PR's diagnosis by @Frowtek: a missing workdir is
ambiguous (deleted project vs. an unmounted external volume / network
share / VPN not yet up), so it's not safe evidence for a destructive
GC sweep — especially one that runs unattended at startup.

- cli.py / gateway/run.py: the startup auto-maintenance sweep now
  always passes delete_orphans=False to maybe_auto_prune_checkpoints().
  It still prunes by retention_days, size cap, and legacy archives —
  none of which require guessing whether a project was deleted or is
  just temporarily unreachable.
- hermes_cli/config.py: drop the now-unused delete_orphans default.
- hermes_cli/checkpoints.py: `hermes checkpoints prune` (the explicit,
  human-invoked path) now previews the orphan project list and asks
  for confirmation before deleting, unless -f/--force is passed.
- Docs updated (EN + zh-Hans) to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
joaomarcos 2026-07-22 03:11:12 -03:00 committed by Teknium
parent 3960315af7
commit cd3f653b4e
6 changed files with 64 additions and 18 deletions

7
cli.py
View file

@ -2008,10 +2008,15 @@ def _run_checkpoint_auto_maintenance() -> None:
if not cfg.get("auto_prune", False):
return
from tools.checkpoint_manager import maybe_auto_prune_checkpoints
# delete_orphans is intentionally never honoured here: a missing
# workdir at startup is ambiguous (deleted project vs. an unmounted
# external volume / network share / VPN not yet up) and this sweep
# runs unattended. Orphan cleanup is only ever done via the explicit
# `hermes checkpoints prune` command, which the user has to invoke.
maybe_auto_prune_checkpoints(
retention_days=int(cfg.get("retention_days", 7)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
delete_orphans=bool(cfg.get("delete_orphans", True)),
delete_orphans=False,
max_total_size_mb=int(cfg.get("max_total_size_mb", 500)),
)
except Exception as exc:

View file

@ -3619,18 +3619,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as exc:
logger.debug("state.db auto-maintenance skipped: %s", exc)
# Opportunistic shadow-repo cleanup — deletes orphan/stale
# checkpoint repos under ~/.hermes/checkpoints/. Opt-in via
# checkpoints.auto_prune, idempotent via .last_prune marker.
# Opportunistic shadow-repo cleanup — deletes stale checkpoint repos
# under ~/.hermes/checkpoints/. Opt-in via checkpoints.auto_prune,
# idempotent via .last_prune marker.
try:
from hermes_cli.config import load_config as _load_full_config
_ckpt_cfg = (_load_full_config().get("checkpoints") or {})
if _ckpt_cfg.get("auto_prune", False):
from tools.checkpoint_manager import maybe_auto_prune_checkpoints
# delete_orphans is intentionally never honoured here: a
# missing workdir at startup is ambiguous (deleted project
# vs. an unmounted external volume / network share / VPN
# not yet up) and this sweep runs unattended. Orphan cleanup
# is only ever done via the explicit `hermes checkpoints
# prune` command, which the user has to invoke.
maybe_auto_prune_checkpoints(
retention_days=int(_ckpt_cfg.get("retention_days", 7)),
min_interval_hours=int(_ckpt_cfg.get("min_interval_hours", 24)),
delete_orphans=bool(_ckpt_cfg.get("delete_orphans", True)),
delete_orphans=False,
max_total_size_mb=int(_ckpt_cfg.get("max_total_size_mb", 500)),
)
except Exception as exc:

View file

@ -109,20 +109,41 @@ def cmd_list(args: argparse.Namespace) -> int:
def cmd_prune(args: argparse.Namespace) -> int:
from tools.checkpoint_manager import prune_checkpoints
from tools.checkpoint_manager import prune_checkpoints, store_status
retention_days = args.retention_days
max_size_mb = args.max_size_mb
delete_orphans = not args.keep_orphans
if delete_orphans and not args.force:
orphans = [
p for p in store_status().get("projects", [])
if not p.get("exists")
]
if orphans:
print(f"This will permanently delete {len(orphans)} orphan checkpoint "
"project(s) whose workdir is not currently reachable:")
print()
for p in orphans:
wd = p.get("workdir") or "(unknown)"
print(f" {wd} ({p.get('commits', 0)} commit(s))")
print()
print("A workdir can be unreachable because the project was deleted,")
print("or because an external volume / network share / VPN is down.")
print("Pass --keep-orphans to prune stale entries only.")
if not _confirm("Delete these orphan projects?"):
print("Aborted.")
return 1
print("Pruning checkpoint store…")
print(f" retention_days: {retention_days}")
print(f" delete_orphans: {not args.keep_orphans}")
print(f" delete_orphans: {delete_orphans}")
print(f" max_total_size_mb: {max_size_mb}")
print()
result = prune_checkpoints(
retention_days=retention_days,
delete_orphans=not args.keep_orphans,
delete_orphans=delete_orphans,
max_total_size_mb=max_size_mb,
)
print(f"Scanned: {result['scanned']}")
@ -225,6 +246,8 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
"per project until total size <= this (default 500)")
p_prune.add_argument("--keep-orphans", action="store_true",
help="Skip deleting projects whose workdir no longer exists")
p_prune.add_argument("-f", "--force", action="store_true",
help="Skip the orphan-deletion confirmation prompt")
p_prune.set_defaults(func=cmd_prune)
p_clear = subs.add_parser(

View file

@ -1304,15 +1304,22 @@ DEFAULT_CONFIG = {
"max_file_size_mb": 10,
# Auto-maintenance: hermes sweeps the checkpoint base at startup
# (at most once per ``min_interval_hours``) and:
# * deletes project entries whose workdir no longer exists (orphan)
# * deletes project entries whose last_touch is older than
# ``retention_days``
# * GCs the single shared store to reclaim unreachable objects
# * enforces ``max_total_size_mb`` across remaining projects
# * deletes ``legacy-*`` archives older than ``retention_days``
#
# NOTE: this automatic sweep never deletes "orphan" entries (workdir
# no longer found on disk). A missing workdir at startup is
# ambiguous — it can mean the project was deleted, or that an
# external volume / network share / VPN is simply not mounted yet —
# and this sweep runs unattended, so it must never guess. Orphan
# cleanup is only available via the explicit
# ``hermes checkpoints prune`` command (add ``--keep-orphans`` to
# skip it), where a human is looking at the output.
"auto_prune": True,
"retention_days": 7,
"delete_orphans": True,
"min_interval_hours": 24,
},

View file

@ -94,12 +94,15 @@ checkpoints:
max_file_size_mb: 10 # skip any single file larger than this
# Auto-maintenance (on by default): sweep ~/.hermes/checkpoints/ at startup
# and delete project entries whose working directory no longer exists
# (orphans) or whose last_touch is older than retention_days. Runs at most
# once per min_interval_hours, tracked via a .last_prune marker.
# and delete project entries whose last_touch is older than retention_days.
# Runs at most once per min_interval_hours, tracked via a .last_prune
# marker. This sweep never deletes "orphan" entries (working directory not
# found) — a missing workdir at startup is ambiguous (deleted project vs.
# an unmounted external volume / network share / VPN not yet up), so
# orphan cleanup is only ever done via the explicit
# `hermes checkpoints prune` command below, with a confirmation prompt.
auto_prune: true
retention_days: 7
delete_orphans: true
min_interval_hours: 24
```

View file

@ -94,12 +94,14 @@ checkpoints:
max_file_size_mb: 10 # 跳过大于此值的单个文件
# 自动维护(默认开启):启动时扫描 ~/.hermes/checkpoints/
# 删除工作目录已不存在的项目条目(孤立项)或 last_touch 超过
# retention_days 的条目。通过 .last_prune 标记控制,
# 最多每 min_interval_hours 运行一次。
# 删除 last_touch 超过 retention_days 的条目。通过 .last_prune
# 标记控制,最多每 min_interval_hours 运行一次。此扫描不会删除
# “孤立”条目(工作目录未找到)——启动时工作目录缺失含义模糊
# (项目被删除,还是外部卷/网络共享/VPN 尚未挂载),因此孤立项
# 清理只能通过下方的 `hermes checkpoints prune` 命令显式触发,
# 并会要求确认。
auto_prune: true
retention_days: 7
delete_orphans: true
min_interval_hours: 24
```