diff --git a/cli.py b/cli.py index 26a94f0b1e48..6d581f32386a 100644 --- a/cli.py +++ b/cli.py @@ -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: diff --git a/gateway/run.py b/gateway/run.py index 5f9219bcd643..5ed80459140c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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: diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py index 2975553ae495..758af6cb22bf 100644 --- a/hermes_cli/checkpoints.py +++ b/hermes_cli/checkpoints.py @@ -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( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5f27dee3bcbe..56aee57ddcf5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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, }, diff --git a/website/docs/user-guide/checkpoints-and-rollback.md b/website/docs/user-guide/checkpoints-and-rollback.md index 1393060612e2..a71ec2fe5b79 100644 --- a/website/docs/user-guide/checkpoints-and-rollback.md +++ b/website/docs/user-guide/checkpoints-and-rollback.md @@ -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 ``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/checkpoints-and-rollback.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/checkpoints-and-rollback.md index 472b30f930ab..dec3ddb259d9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/checkpoints-and-rollback.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/checkpoints-and-rollback.md @@ -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 ```