diff --git a/cli.py b/cli.py index f9d0abae02445..559094a527d89 100644 --- a/cli.py +++ b/cli.py @@ -2114,8 +2114,9 @@ def _run_state_db_auto_maintenance(session_db) -> None: :func:`hermes_cli.config.load_config` (the authoritative loader that deep-merges DEFAULT_CONFIG, so unmigrated configs still get default values). Honours ``auto_prune`` / ``retention_days`` / - ``vacuum_after_prune`` / ``min_interval_hours``, and delegates to the - DB. Never raises — maintenance must never block interactive startup. + ``vacuum_after_prune`` / ``min_vacuum_interval_days`` / + ``min_interval_hours``, and delegates to the DB. Never raises — + maintenance must never block interactive startup. """ if session_db is None: return @@ -2164,6 +2165,7 @@ def _run_state_db_auto_maintenance(session_db) -> None: session_db.maybe_auto_prune_and_vacuum( retention_days=int(cfg.get("retention_days", 90)), min_interval_hours=int(cfg.get("min_interval_hours", 24)), + min_vacuum_interval_days=int(cfg.get("min_vacuum_interval_days", 30)), vacuum=bool(cfg.get("vacuum_after_prune", True)), sessions_dir=_hermes_home_maint / "sessions", ) diff --git a/gateway/run.py b/gateway/run.py index 905a7763c5f9d..532ff9c2a942b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6001,6 +6001,9 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._session_db._db.maybe_auto_prune_and_vacuum( retention_days=int(_sess_cfg.get("retention_days", 90)), min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)), + min_vacuum_interval_days=int( + _sess_cfg.get("min_vacuum_interval_days", 30) + ), vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)), sessions_dir=self.config.sessions_dir, ) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 80adf77ff45c1..4545575db26cd 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2625,6 +2625,9 @@ # 100MB, so it only runs at startup, and only when prune deleted # ≥1 session. "vacuum_after_prune": True, + # Minimum days between successful VACUUM rewrites. Pruning can still + # run on its normal cadence while SQLite reuses the freed pages. + "min_vacuum_interval_days": 30, # Minimum hours between auto-maintenance runs (avoids repeating # the sweep on every CLI invocation). Tracked via state_meta in # state.db itself, so it's shared across all processes. diff --git a/hermes_state.py b/hermes_state.py index 4f7b6453461a9..90a8b43c9515f 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -8529,12 +8529,16 @@ def maybe_auto_prune_and_vacuum( min_interval_hours: int = 24, vacuum: bool = True, sessions_dir: Optional[Path] = None, + min_vacuum_interval_days: int = 30, ) -> Dict[str, Any]: """Idempotent auto-maintenance: prune inactive sessions + optional VACUUM. Records the last run timestamp in state_meta so subsequent calls - within ``min_interval_hours`` no-op. Designed to be called once at - startup from long-lived entrypoints (CLI, gateway, cron scheduler). + within ``min_interval_hours`` no-op. VACUUM has its own, typically + longer, throttle controlled by ``min_vacuum_interval_days`` so routine + pruning does not repeatedly rewrite the database. Designed to be + called once at startup from long-lived entrypoints (CLI, gateway, cron + scheduler). When *sessions_dir* is provided, on-disk transcript files (``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions @@ -8569,12 +8573,25 @@ def maybe_auto_prune_and_vacuum( ) result["pruned"] = pruned - # Only VACUUM if we actually freed rows — VACUUM on a tight DB - # is wasted I/O. Threshold keeps small DBs from paying the cost. - if vacuum and pruned > 0: + # Only VACUUM if we actually freed rows, and no more often than + # once every min_vacuum_interval_days -- a large prune (e.g. the + # first one to cross retention_days on a DB with tens of + # thousands of rows) can free enough pages that pruned > 0 fires + # on every subsequent startup even though a VACUUM already ran + # recently. VACUUM on this DB's size (FTS5 shadow tables) is not + # cheap -- it holds an exclusive lock for the full rewrite. + last_vacuum_raw = self.get_meta("last_vacuum") + vacuum_due = True + if last_vacuum_raw: + try: + vacuum_due = (now - float(last_vacuum_raw)) >= min_vacuum_interval_days * 86400 + except (TypeError, ValueError): + vacuum_due = True + if vacuum and pruned > 0 and vacuum_due: try: self.vacuum() result["vacuumed"] = True + self.set_meta("last_vacuum", str(now)) except Exception as exc: logger.warning("state.db VACUUM failed: %s", exc) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index dd54682ef5d26..4c27c3adac3b2 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1744,6 +1744,67 @@ def test_vacuum_runs_without_error(self, db): # Should not raise, even though there's nothing significant to reclaim. db.vacuum() + def test_auto_maintenance_records_successful_vacuum(self, db, monkeypatch): + monkeypatch.setattr(db, "prune_sessions", lambda **_kwargs: 3) + vacuum_calls = [] + monkeypatch.setattr(db, "vacuum", lambda: vacuum_calls.append(True)) + + result = db.maybe_auto_prune_and_vacuum(min_interval_hours=0) + + assert result["vacuumed"] is True + assert vacuum_calls == [True] + assert db.get_meta("last_vacuum") is not None + + def test_auto_maintenance_skips_recent_vacuum(self, db, monkeypatch): + monkeypatch.setattr(db, "prune_sessions", lambda **_kwargs: 3) + db.set_meta("last_vacuum", str(time.time())) + vacuum_calls = [] + monkeypatch.setattr(db, "vacuum", lambda: vacuum_calls.append(True)) + + result = db.maybe_auto_prune_and_vacuum( + min_interval_hours=0, + min_vacuum_interval_days=30, + ) + + assert result["vacuumed"] is False + assert vacuum_calls == [] + + def test_auto_maintenance_retries_after_vacuum_interval(self, db, monkeypatch): + monkeypatch.setattr(db, "prune_sessions", lambda **_kwargs: 3) + db.set_meta("last_vacuum", str(time.time() - 31 * 86400)) + vacuum_calls = [] + monkeypatch.setattr(db, "vacuum", lambda: vacuum_calls.append(True)) + + result = db.maybe_auto_prune_and_vacuum( + min_interval_hours=0, + min_vacuum_interval_days=30, + ) + + assert result["vacuumed"] is True + assert vacuum_calls == [True] + + def test_auto_maintenance_retries_after_failed_vacuum(self, db, monkeypatch): + monkeypatch.setattr(db, "prune_sessions", lambda **_kwargs: 3) + vacuum_calls = [] + + def fail_first_vacuum(): + vacuum_calls.append(True) + if len(vacuum_calls) == 1: + raise RuntimeError("vacuum failed") + + monkeypatch.setattr(db, "vacuum", fail_first_vacuum) + + first = db.maybe_auto_prune_and_vacuum(min_interval_hours=0) + + assert first["vacuumed"] is False + assert db.get_meta("last_vacuum") is None + + second = db.maybe_auto_prune_and_vacuum(min_interval_hours=0) + + assert second["vacuumed"] is True + assert vacuum_calls == [True, True] + assert db.get_meta("last_vacuum") is not None + class TestOptimizeFts: def test_optimize_returns_index_count(self, db): diff --git a/tests/test_session_vacuum_config.py b/tests/test_session_vacuum_config.py new file mode 100644 index 0000000000000..d231996b59887 --- /dev/null +++ b/tests/test_session_vacuum_config.py @@ -0,0 +1,41 @@ +from pathlib import Path +from unittest.mock import MagicMock + + +def test_default_config_exposes_vacuum_interval(): + from hermes_cli.config import DEFAULT_CONFIG + + assert DEFAULT_CONFIG["sessions"]["min_vacuum_interval_days"] == 30 + + +def test_cli_auto_maintenance_forwards_vacuum_interval(monkeypatch, tmp_path: Path): + import cli + import hermes_cli.config + import hermes_constants + + session_db = MagicMock() + session_db.get_meta.return_value = "already-done" + monkeypatch.setattr( + hermes_cli.config, + "load_config", + lambda: { + "sessions": { + "auto_prune": True, + "retention_days": 90, + "vacuum_after_prune": True, + "min_interval_hours": 24, + "min_vacuum_interval_days": 17, + } + }, + ) + monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path) + + cli._run_state_db_auto_maintenance(session_db) + + session_db.maybe_auto_prune_and_vacuum.assert_called_once_with( + retention_days=90, + min_interval_hours=24, + min_vacuum_interval_days=17, + vacuum=True, + sessions_dir=tmp_path / "sessions", + ) diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 71b53a23101a6..91a63a2bed4f2 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -714,7 +714,7 @@ Key tables in `state.db`: - Gateway sessions auto-reset based on the configured reset policy - Before reset, the agent saves memories and skills from the expiring session - Opt-in auto-pruning: when `sessions.auto_prune` is `true`, ended sessions inactive for `sessions.retention_days` (default 90) are pruned at CLI/gateway startup -- After a prune that actually removed rows, `state.db` is `VACUUM`ed to reclaim disk space (SQLite does not shrink the file on plain DELETE) +- After a prune that actually removed rows, `state.db` is `VACUUM`ed to reclaim disk space when at least `sessions.min_vacuum_interval_days` (default 30) have elapsed since the last successful `VACUUM` (SQLite does not shrink the file on plain DELETE) - Pruning runs at most once per `sessions.min_interval_hours` (default 24); the last-run timestamp is tracked inside `state.db` itself so it's shared across every Hermes process in the same `HERMES_HOME` Default is **off** — session history is valuable for `session_search` recall, and silently deleting it could surprise users. Enable in `~/.hermes/config.yaml`: @@ -724,6 +724,7 @@ sessions: auto_prune: true # opt in — default is false retention_days: 90 # keep ended sessions active within this window vacuum_after_prune: true # reclaim disk space after a pruning sweep + min_vacuum_interval_days: 30 # don't rewrite the DB more often than this min_interval_hours: 24 # don't re-run the sweep more often than this ``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index 3bc6692de231d..19c7b710ebf7d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -579,7 +579,7 @@ state.db 后可安全删除。 - Gateway session 根据配置的重置策略自动重置 - 重置前,agent 保存即将过期 session 中的记忆和技能 - 可选自动清理:当 `sessions.auto_prune` 为 `true` 时,在 CLI/gateway 启动时清理早于 `sessions.retention_days`(默认 90)天的已结束 session -- 实际删除了行的清理操作完成后,`state.db` 会执行 `VACUUM` 以回收磁盘空间(SQLite 在普通 DELETE 后不会缩小文件) +- 实际删除了行的清理操作完成后,如果距离上次成功执行 `VACUUM` 已达到 `sessions.min_vacuum_interval_days`(默认 30)天,`state.db` 会执行 `VACUUM` 以回收磁盘空间(SQLite 在普通 DELETE 后不会缩小文件) - 清理最多每 `sessions.min_interval_hours`(默认 24)小时运行一次;上次运行时间戳记录在 `state.db` 内部,因此在同一 `HERMES_HOME` 下的所有 Hermes 进程间共享 默认为**关闭**——session 历史对 `session_search` 召回很有价值,静默删除可能会让用户感到意外。在 `~/.hermes/config.yaml` 中启用: @@ -589,6 +589,7 @@ sessions: auto_prune: true # 选择启用——默认为 false retention_days: 90 # 保留已结束 session 的天数 vacuum_after_prune: true # 清理后回收磁盘空间 + min_vacuum_interval_days: 30 # 数据库重写的最短间隔天数 min_interval_hours: 24 # 清理间隔不短于此值 ```