Skip to content

fix(gateway): run state.db auto-prune on the housekeeping cadence, not just at startup - #79

Merged
pai-scaffolde merged 2 commits into
mainfrom
fix/state-db-maintenance-watcher
Aug 16, 2026
Merged

fix(gateway): run state.db auto-prune on the housekeeping cadence, not just at startup#79
pai-scaffolde merged 2 commits into
mainfrom
fix/state-db-maintenance-watcher

Conversation

@pai-scaffolde

@pai-scaffolde pai-scaffolde commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

~/.hermes/state.db already has a full row-level retention path — SessionDB.prune_sessions() deletes ended sessions' messages rows plus unreferenced system_prompts, and maybe_auto_prune_and_vacuum() wraps it with state_meta throttling and a VACUUM — but it only ever ran at process construction (CLI startup, gateway __init__). A gateway resident for weeks under launchd/systemd never pruned again, so state.db grew without bound even with sessions.auto_prune enabled. The 2026-08-15 disk-pressure remediation found a 3GB state.db of ~97% real rows (auto_vacuum=0, freelist only ~155MB) on exactly that profile.

This PR gives the existing pruner a recurring cadence, wired into the gateway's existing hourly housekeeping lane (revised per Codex review — the first cut used a separate supervised watcher, which double-scheduled auto-archive and risked live VACUUMs):

  • _start_gateway_housekeeping session-maintenance branch — the former AUTO_ARCHIVE_EVERY branch now runs archive and prune, hourly, per served profile. Each profile is swept under its own _profile_runtime_scope via profiles_to_serve(multiplex=gateway.multiplex_profiles), so a multiplexed gateway's profiles/<name>/state.db files each get retention gated by their own sessions config. The maybe_* helpers self-throttle via state_meta (min_interval_hours, default 24h), so the hourly tick is a cheap point-read almost every time. Live ticks always pass vacuum=FalseSessionDB.vacuum() holds an exclusive lock for a full DB rewrite and is only safe before traffic is served.
  • GatewayRunner._run_state_db_maintenance_once() — the construction-time maintenance block, extracted. Behavior unchanged; it remains the only pass that may VACUUM (it runs pre-serve and honors vacuum_after_prune / min_vacuum_interval_days).
  • The multiplex flag is threaded from runner.config at the housekeeping thread launch site.

Defaults are unchanged: sessions.auto_prune stays opt-in (false), retention stays 90d. Both sweeps remain never-raise.

Related Issue

Fixes the recurrence path of the 2026-08-15 disk-pressure incident (state.db unbounded growth on long-lived gateways). No pre-existing issue number.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • gateway/run.py — extended the housekeeping session-maintenance branch (archive + prune, per served profile, vacuum=False); threaded multiplex into _start_gateway_housekeeping; extracted _run_state_db_maintenance_once() for the startup pass.
  • hermes_cli/config_defaults.pysessions.auto_prune comment documents the recurring housekeeping cadence.
  • tests/gateway/test_state_db_periodic_maintenance.py — new: startup pass forwards config (vacuum allowed pre-serve); housekeeping tick prunes with vacuum=False; multiplex sweeps mint one profile-scoped SessionDB per home and close it; disabled profiles open no DB; never-raises on missing DB or broken config loader.

How to Test

  1. pytest tests/gateway/test_state_db_periodic_maintenance.py tests/test_session_vacuum_config.py tests/gateway/test_async_session_db.py -q → 14 passed.
  2. Enable sessions.auto_prune: true in config.yaml, start the gateway, and observe state.db auto-maintenance: pruned N session(s) in agent.log once per min_interval_hours without restarting the gateway.
  3. Verify the throttle: consecutive ticks inside the interval log nothing and do no work (state_meta last_auto_prune gate).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(gateway):)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature
  • Targeted tests pass (tests/gateway/test_state_db_periodic_maintenance.py, tests/test_session_vacuum_config.py, tests/gateway/test_async_session_db.py, housekeeping-related suite); full-suite run not performed in this environment
  • I've added tests for my changes
  • I've tested on my platform: Linux 6.18 (container)

Documentation & Housekeeping

  • I've updated relevant documentation (config_defaults docstrings) — the knobs themselves are unchanged
  • cli-config.yaml.example — N/A (no new/changed config keys)
  • CONTRIBUTING.md / AGENTS.md — N/A
  • Cross-platform impact considered — pure threading/stdlib, no platform-specific paths
  • Tool descriptions/schemas — N/A

🤖 Generated with Claude Code

https://claude.ai/code/session_01WNiHNUntm1Sa9ob2KdCz49

… at startup

The sessions auto-maintenance sweep (archive + prune ended sessions +
throttled VACUUM) only ever ran at process construction. A gateway that
stays resident for weeks under launchd/systemd therefore never pruned
again, so ~/.hermes/state.db grew without bound even with
sessions.auto_prune enabled — the 2026-08-15 disk-pressure incident
found a 3GB state.db of ~97% real rows on exactly that profile.

- Extract the construction-time maintenance block into
  GatewayRunner._run_state_db_maintenance_once(): same behavior, but
  config is re-read on every call so operators can flip
  sessions.auto_prune on a live gateway without restarting it.
- Add a supervised _state_db_maintenance_watcher spawned from start():
  hourly tick, each tick delegates to the SessionDB maybe_* helpers,
  which self-throttle via state_meta (min_interval_hours, default 24h;
  VACUUM additionally via min_vacuum_interval_days) — so the tick is a
  cheap point-read almost every time, the real work runs at most once
  per configured interval, and the throttle is shared across every
  hermes process. The sweep runs in a worker thread (asyncio.to_thread)
  because prune takes SessionDB's write lock and a due VACUUM can hold
  it for seconds per 100MB.
- Unit tests for the extracted pass (config forwarding, gates,
  never-raises) and the watcher (re-runs on cadence, stops cleanly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNiHNUntm1Sa9ob2KdCz49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: da0723319d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread gateway/run.py
min_vacuum_interval_days=int(
_sess_cfg.get("min_vacuum_interval_days", 30)
),
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid VACUUM during live maintenance ticks

When sessions.auto_prune is enabled, the default vacuum_after_prune: true now lets an hourly watcher invoke SessionDB.vacuum() while the gateway is serving traffic. That method explicitly requires callers to ensure no writers are active and holds an exclusive SQLite lock for the complete rewrite; on the multi-GB database motivating this change, it can outlast the 60-second transcript-write retry budget and cause active turns to fail persistence. Running it through to_thread keeps the event loop responsive but does not prevent database starvation, so periodic passes should prune without VACUUM or coordinate an idle/drained window while retaining VACUUM for startup. The added tests mock _db and therefore do not exercise this locking path against a real temporary store.

AGENTS.md reference: AGENTS.md:L84-L87

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three findings adopted in 83e0f35 — the review was right that the housekeeping lane was the existing scheduler to extend, not duplicate. The separate asyncio watcher is gone; the hourly prune now lives in _start_gateway_housekeeping's session-maintenance branch (the former AUTO_ARCHIVE_EVERY branch, so archive keeps exactly one scheduler), always with vacuum=False on live ticks — VACUUM stays owned by the pre-serve construction pass — and the branch iterates profiles_to_serve(multiplex=…) under _profile_runtime_scope so every multiplexed profile's state.db gets its own config-gated sweep. Tests rewritten accordingly (test_state_db_periodic_maintenance.py).


Generated by Claude Code

Comment thread gateway/run.py Outdated
await asyncio.sleep(self._STATE_DB_MAINTENANCE_TICK_SECS)
if not self._running:
return
await asyncio.to_thread(self._run_state_db_maintenance_once)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sweep every multiplexed profile database

In a gateway with gateway.multiplex_profiles enabled, this periodic call only operates on self._session_db, which was constructed for the process's active/default HERMES_HOME, and _run_state_db_maintenance_once() also reloads only that profile's config. Secondary-profile turns run under their own profile runtime scopes and persist to separate profiles/<name>/state.db files, so even when those profiles enable sessions.auto_prune, their databases still receive no recurring maintenance and retain the same unbounded-growth failure this change is intended to fix. Iterate the served profile homes and run each profile's configured sweep against its own SessionDB.

AGENTS.md reference: AGENTS.md:L54-L57

Useful? React with 👍 / 👎.

Comment thread gateway/run.py
Comment on lines +11578 to +11579
if _sess_cfg.get("auto_archive", False):
self._session_db._db.maybe_auto_archive(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid scheduling auto-archive twice

The gateway already runs maybe_auto_archive() hourly from _start_gateway_housekeeping via its AUTO_ARCHIVE_EVERY branch, but the new hourly watcher calls it again here. Because the state_meta check and archive operation are not one atomic transaction, coincident due ticks can both select the same stale rows and repeat the full per-session update sweep, doubling write contention and logging duplicate archive counts on large stores. Keep auto-archive in the existing housekeeping path and limit this new periodic path to the previously missing prune operation, rather than maintaining two independent schedulers for the same chore.

AGENTS.md reference: AGENTS.md:L75-L79

Useful? React with 👍 / 👎.

Addresses the Codex review on the periodic-maintenance change:

- One scheduler, not two: the new hourly prune now lives in
  _start_gateway_housekeeping's existing session-maintenance branch
  (formerly the AUTO_ARCHIVE_EVERY branch) instead of a second
  supervised asyncio watcher that double-scheduled maybe_auto_archive
  against the housekeeping tick. The duplicate watcher is removed.
- No VACUUM on live ticks: the housekeeping sweep always passes
  vacuum=False. SessionDB.vacuum() holds an exclusive lock for a full
  DB rewrite and is only safe before traffic is served, so VACUUM stays
  owned by the construction-time pass (_run_state_db_maintenance_once,
  which runs pre-serve and keeps honoring vacuum_after_prune).
- Multiplexed profiles are swept too: the branch iterates
  profiles_to_serve(multiplex=gateway.multiplex_profiles) and runs each
  profile's archive + prune under its own _profile_runtime_scope, so
  every profiles/<name>/state.db gets recurring retention gated by that
  profile's own sessions config. The multiplex flag is threaded from
  runner.config at the housekeeping thread launch site.
- Tests rewritten around the housekeeping branch: live ticks prune with
  vacuum=False, multiplex sweeps mint one profile-scoped SessionDB per
  home and close it, disabled profiles open no DB, and the startup pass
  still forwards vacuum config. File renamed to
  test_state_db_periodic_maintenance.py to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNiHNUntm1Sa9ob2KdCz49
@pai-scaffolde pai-scaffolde changed the title fix(gateway): re-run state.db auto-maintenance periodically, not just at startup fix(gateway): run state.db auto-prune on the housekeeping cadence, not just at startup Aug 15, 2026
@pai-scaffolde
pai-scaffolde merged commit dd73d9c into main Aug 16, 2026
37 checks passed
@pai-scaffolde
pai-scaffolde deleted the fix/state-db-maintenance-watcher branch August 16, 2026 03:46
pai-scaffolde added a commit that referenced this pull request Aug 20, 2026
… (#81)

PR #64 was replayed onto main with the wrong merge base (its parent
branch's TIP rather than the true fork point). Files main had gained
after that branch forked therefore looked like deletions, so merging #64
silently reverted #79:

  * gateway/run.py — _run_state_db_maintenance_once() refactor undone
  * hermes_cli/config_defaults.py — housekeeping comment reverted
  * tests/gateway/test_state_db_periodic_maintenance.py — deleted

This re-applies #79 verbatim onto current main. The LSP work from #63
and #64 (including sessions.max_clients) is untouched. Audited: #63 and
#65 match their original diffstats exactly; #64 was the only bad replay.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants