Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
)
Expand Down
3 changes: 3 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 22 additions & 5 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
61 changes: 61 additions & 0 deletions tests/test_hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
41 changes: 41 additions & 0 deletions tests/test_session_vacuum_config.py
Original file line number Diff line number Diff line change
@@ -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",
)
3 changes: 2 additions & 1 deletion website/docs/user-guide/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -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
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` 中启用:
Expand All @@ -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 # 清理间隔不短于此值
```

Expand Down
Loading