NousResearch/hermes-agent

Session transcript files accumulating unbounded on disk

Cluster 3015 · gateway. Data as of

Nothing open on GitHub. Fix merged in #13861. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #13861. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #16286. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs address a leak where on-disk session transcript files are never deleted during pruning or reset, causing unbounded disk growth. Fixes involve deleting transcript files during auto-prune, session deletion, and gateway reset operations.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #13861 feat(state): auto-prune old sessions + VACUUM state.db at startup

    merged to main

    files: cli.py, gateway/run.py, hermes_cli/config.py, hermes_state.py, tests/test_hermes_state.py, website/docs/user-guide/sessions.md

    Show the diff
    • cli.py
      @@ -914,6 +914,32 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
           print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
       
       
      +def _run_state_db_auto_maintenance(session_db) -> None:
      +    """Call ``SessionDB.maybe_auto_prune_and_vacuum`` using current config.
      +
      +    Reads the ``sessions:`` section from config.yaml via
      +    :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.
      +    """
      +    if session_db is None:
      +        return
      +    try:
      +        from hermes_cli.config import load_config as _load_full_config
      +        cfg = (_load_full_config().get("sessions") or {})
      +        if not cfg.get("auto_prune", False):
      +            return
      +        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)),
      +            vacuum=bool(cfg.get("vacuum_after_prune", True)),
      +        )
      +    except Exception as exc:
      +        logger.debug("state.db auto-maintenance skipped: %s", exc)
      +
      +
       def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
           """Remove stale worktrees and orphaned branches on startup.
       
      @@ -1961,7 +1987,13 @@ def __init__(
                   self._session_db = SessionDB()
               except Exception as e:
                   logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e)
      -        
      +
      +        # Opportunistic state.db maintenance — runs at most once per
      +        # min_interval_hours, tracked via state_meta in state.db itself so
      +        # it's shared across all Hermes processes for this HERMES_HOME.
      +        # Never blocks startup on failure.
      +        _run_state_db_auto_maintenance(self._session_db)
      +
               # Deferred title: stored in memory until the session is created in the DB
               self._pending_title: Optional[str] = None
               
    • gateway/run.py
      @@ -710,7 +710,26 @@ def __init__(self, config: Optional[GatewayConfig] = None):
                   self._session_db = SessionDB()
               except Exception as e:
                   logger.debug("SQLite session store not available: %s", e)
      -        
      +
      +        # Opportunistic state.db maintenance: prune ended sessions older
      +        # than sessions.retention_days + optional VACUUM. Tracks last-run
      +        # in state_meta so it only actually executes once per
      +        # sessions.min_interval_hours.  Gateway is long-lived so blocking
      +        # a few seconds once per day is acceptable; failures are logged
      +        # but never raised.
      +        if self._session_db is not None:
      +            try:
      +                from hermes_cli.config import load_config as _load_full_config
      +                _sess_cfg = (_load_full_config().get("sessions") or {})
      +                if _sess_cfg.get("auto_prune", False):
      +                    self._session_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)),
      +                        vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
      +                    )
      +            except Exception as exc:
      +                logger.debug("state.db auto-maintenance skipped: %s", exc)
      +
               # DM pairing store for code-based user authorization
               from gateway.pairing import PairingStore
               self.pairing_store = PairingStore()
    • hermes_cli/config.py
      @@ -893,6 +893,34 @@ def _ensure_hermes_home_managed(home: Path):
               "force_ipv4": False,
           },
       
      +    # Session storage — controls automatic cleanup of ~/.hermes/state.db.
      +    # state.db accumulates every session, message, tool call, and FTS5 index
      +    # entry forever.  Without auto-pruning, a heavy user (gateway + cron)
      +    # reports 384MB+ databases with 68K+ messages, which slows down FTS5
      +    # inserts, /resume listing, and insights queries.
      +    "sessions": {
      +        # When true, prune ended sessions older than retention_days once
      +        # per (roughly) min_interval_hours at CLI/gateway/cron startup.
      +        # Only touches ended sessions — active sessions are always preserved.
      +        # Default false: session history is valuable for search recall, and
      +        # silently deleting it could surprise users.  Opt in explicitly.
      +        "auto_prune": False,
      +        # How many days of ended-session history to keep.  Matches the
      +        # default of ``hermes sessions prune``.
      +        "retention_days": 90,
      +        # VACUUM after a prune that actually deleted rows.  SQLite does not
      +        # reclaim disk space on DELETE — freed pages are just reused on
      +        # subsequent INSERTs — so without VACUUM the file stays bloated
      +        # even after pruning.  VACUUM blocks writes for a few seconds per
      +        # 100MB, so it only runs at startup, and only when prune deleted
      +        # ≥1 session.
      +        "vacuum_after_prune": True,
      +        # 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.
      +        "min_interval_hours": 24,
      +    },
      +
           # Config schema version - bump this when adding new required fields
           "_config_version": 22,
       }
      @@ -2102,6 +2130,7 @@ def check_config_version() -> Tuple[int, int]:
           "fallback_providers", "credential_pool_strategies", "toolsets",
           "agent", "terminal", "display", "compression", "delegation",
           "auxiliary", "custom_providers", "context", "memory", "gateway",
      +    "sessions",
       }
       
       # Valid fields inside a custom_providers list entry
    • hermes_state.py
      @@ -84,6 +84,11 @@
           codex_reasoning_items TEXT
       );
       
      +CREATE TABLE IF NOT EXISTS state_meta (
      +    key TEXT PRIMARY KEY,
      +    value TEXT
      +);
      +
       CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
       CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
       CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
      @@ -1441,3 +1446,116 @@ def _do(conn):
                   return len(session_ids)
       
               return self._execute_write(_do)
      +
      +    # ── Meta key/value (for scheduler bookkeeping) ──
      +
      +    def get_meta(self, key: str) -> Optional[str]:
      +        """Read a value from the state_meta key/value store."""
      +        with self._lock:
      +            row = self._conn.execute(
      +                "SELECT value FROM state_meta WHERE key = ?", (key,)
      +            ).fetchone()
      +        if row is None:
      +            return None
      +        return row["value"] if isinstance(row, sqlite3.Row) else row[0]
      +
      +    def set_meta(self, key: str, value: str) -> None:
      +        """Write a value to the state_meta key/value store."""
      +        def _do(conn):
      +            conn.execute(
      +                "INSERT INTO state_meta (key, value) VALUES (?, ?) "
      +                "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
      +                (key, value),
      +            )
      +        self._execute_write(_do)
      +
      +    # ── Space reclamation ──
      +
      +    def vacuum(self) -> None:
      +        """Run VACUUM to reclaim disk space after large deletes.
      +
      +        SQLite does not shrink the database file when rows are deleted —
      +        freed pages just get reused on the next insert. After a prune that
      +        removed hundreds of sessions, the file stays bloated unless we
      +        explicitly VACUUM.
      +
      +        VACUUM rewrites the entire DB, so it's expensive (seconds per
      +        100MB) and cannot run inside a transaction. It also acquires an
      +        exclusive lock, so callers must ensure no other writers are
      +        active. Safe to call at startup before the gateway/CLI starts
      +        serving traffic.
      +        """
      +        # VACUUM cannot be executed inside a transaction.
      +        with self._lock:
      +            # Best-effort WAL checkpoint first, then VACUUM.
      +            try:
      +                self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
      +            except Exception:
      +                pass
      +            self._conn.execute("VACUUM")
      +
      +    def maybe_auto_prune_and_vacuum(
      +        self,
      +        retention_days: int = 90,
      +        min_interval_hours: int = 24,
      +        vacuum: bool = True,
      +    ) -> Dict[str, Any]:
      +        """Idempotent auto-maintenance: prune old 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).
      +
      +        Never raises. On any failure, logs a warning and returns a dict
      +        with ``"error"`` set.
      +
      +        Returns a dict with keys:
      +          - ``"skipped"`` (bool) — true if within min_interval_hours of last run
      +          - ``"pruned"`` (int)   — number of sessions deleted
      +          - ``"vacuumed"`` (bool) — true if VACUUM ran
      +          - ``"error"`` (str, optional) — present only on failure
      +        """
      +        result: Dict[str, Any] = {"skipped": False, "pruned": 0, "vacuumed": False}
      +        try:
      +            # Skip if another process/call did maintenance recently.
      +            last_raw = self.get_meta("last_auto_prune")
      +            now = time.time()
      +            if last_raw:
      +                try:
      +                    last_ts = float(last_raw)
      +                    if now - last_ts < min_interval_hours * 3600:
      +                        result["skipped"] = True
      +                        return result
      +                except (TypeError, ValueError):
      +                    pass  # corrupt meta; treat as no prior run
      +
      +            pruned = self.prune_sessions(older_than_days=retention_days)
      +            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:
      +                try:
      +                    self.vacuum()
      +                    result["vacuumed"] = True
      +                except Exception as exc:
      +                    logger.warning("state.db VACUUM failed: %s", exc)
      +
      +            # Record the attempt even if pruned == 0, so we don't retry
      +            # every startup within the min_interval_hours window.
      +            self.set_meta("last_auto_prune", str(now))
      +
      +            if pruned > 0:
      +                logger.info(
      +                    "state.db auto-maintenance: pruned %d session(s) older than %d days%s",
      +                    pruned,
      +                    retention_days,
      +                    " + VACUUM" if result["vacuumed"] else "",
      +                )
      +        except Exception as exc:
      +            # Maintenance must never block startup. Log and return error marker.
      +            logger.warning("state.db auto-maintenance failed: %s", exc)
      +            result["error"] = str(exc)
      +
      +        return result
      +
    • tests/test_hermes_state.py
      @@ -1732,3 +1732,124 @@ def test_sqlite_timeout_is_at_least_30s(self, db):
               assert "30" in src, (
                   "SQLite timeout should be at least 30s to handle CLI/gateway lock contention"
               )
      +
      +
      +# =========================================================================
      +# Auto-maintenance: state_meta + vacuum + maybe_auto_prune_and_vacuum
      +# =========================================================================
      +
      +class TestStateMeta:
      +    def test_get_meta_missing_returns_none(self, db):
      +        assert db.get_meta("nonexistent") is None
      +
      +    def test_set_then_get_meta(self, db):
      +        db.set_meta("foo", "bar")
      +        assert db.get_meta("foo") == "bar"
      +
      +    def test_set_meta_upsert(self, db):
      +        """set_meta overwrites existing value (ON CONFLICT DO UPDATE)."""
      +        db.set_meta("key", "v1")
      +        db.set_meta("key", "v2")
      +        assert db.get_meta("key") == "v2"
      +
      +
      +class TestVacuum:
      +    def test_vacuum_runs_without_error(self, db):
      +        """VACUUM must succeed on a fresh DB (no rows to reclaim)."""
      +        db.create_session(session_id="s1", source="cli")
      +        db.append_message(session_id="s1", role="user", content="hi")
      +        # Should not raise, even though there's nothing significant to reclaim.
      +        db.vacuum()
      +
      +
      +class TestAutoMaintenance:
      +    def _make_old_ended(self, db, sid: str, days_old: int = 100):
      +        """Create a session that is ended and was started `days_old` days ago."""
      +        db.create_session(session_id=sid, source="cli")
      +        db.end_session(sid, end_reason="done")
      +        db._conn.execute(
      +            "UPDATE sessions SET started_at = ? WHERE id = ?",
      +            (time.time() - days_old * 86400, sid),
      +        )
      +        db._conn.commit()
      +
      +    def test_first_run_prunes_and_vacuums(self, db):
      +        self._make_old_ended(db, "old1", days_old=100)
      +        self._make_old_ended(db, "old2", days_old=100)
      +        db.create_session(session_id="new", source="cli")  # active, must survive
      +
      +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
      +        assert result["skipped"] is False
      +        assert result["pruned"] == 2
      +        assert result["vacuumed"] is True
      +        assert result.get("error") is None
      +        assert db.get_session("old1") is None
      +        assert db.get_session("old2") is None
      +        assert db.get_session("new") is not None
      +
      +    def test_second_call_within_interval_skips(self, db):
      +        self._make_old_ended(db, "old", days_old=100)
      +        first = db.maybe_auto_prune_and_vacuum(
      +            retention_days=90, min_interval_hours=24
      +        )
      +        assert first["skipped"] is False
      +        assert first["pruned"] == 1
      +
      +        # Create another prunable session; a second call within
      +        # min_interval_hours should still skip without touching it.
      +        self._make_old_ended(db, "old2", days_old=100)
      +        second = db.maybe_auto_prune_and_vacuum(
      +            retention_days=90, min_interval_hours=24
      +        )
      +        assert second["skipped"] is True
      +        assert second["pruned"] == 0
      +        assert db.get_session("old2") is not None  # untouched
      +
      +    def test_second_call_after_interval_runs_again(self, db):
      +        self._make_old_ended(db, "old", days_old=100)
      +        db.maybe_auto_prune_and_vacuum(retention_days=90, min_interval_hours=24)
      +
      +        # Backdate the last-run marker to force another run.
      +        db.set_meta("last_auto_prune", str(time.time() - 48 * 3600))
      +
      +        self._make_old_ended(db, "old2", days_old=100)
      +        result = db.maybe_auto_prune_and_vacuum(
      +            retention_days=90, min_interval_hours=24
      +        )
      +        assert result["skipped"] is False
      +        assert result["pruned"] == 1
      +        assert db.get_session("old2") is None
      +
      +    def test_no_prunable_sessions_no_vacuum(self, db):
      +        """When prune deletes 0 rows, VACUUM is skipped (wasted I/O)."""
      +        db.create_session(session_id="fresh", source="cli")  # too recent
      +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
      +        assert result["skipped"] is False
      +        assert result["pruned"] == 0
      +        assert result["vacuumed"] is False
      +        # But last-run is still recorded so we don't retry immediately.
      +        assert db.get_meta("last_auto_prune") is not None
      +
      +    def test_vacuum_disabled_via_flag(self, db):
      +        self._make_old_ended(db, "old", days_old=100)
      +        result = db.maybe_auto_prune_and_vacuum(retention_days=90, vacuum=False)
      +        assert result["pruned"] == 1
      +        assert result["vacuumed"] is False
      +
      +    def test_corrupt_last_run_marker_treated_as_no_prior_run(self, db):
      +        """A non-numeric marker must not break maintenance."""
      +        db.set_meta("last_auto_prune", "not-a-timestamp")
      +        self._make_old_ended(db, "old", days_old=100)
      +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
      +        assert result["skipped"] is False
      +        assert result["pruned"] == 1
      +
      +    def test_state_meta_survives_vacuum(self, db):
      +        """Marker written just before VACUUM must still be readable after."""
      +        self._make_old_ended(db, "old", days_old=100)
      +        db.maybe_auto_prune_and_vacuum(retention_days=90)
      +        marker = db.get_meta("last_auto_prune")
      +        assert marker is not None
      +        # Should parse as a float timestamp close to now.
      +        assert abs(float(marker) - time.time()) < 60
      +
    • website/docs/user-guide/sessions.md
      @@ -386,7 +386,21 @@ 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
      -- Ended sessions remain in the database until pruned
      +- Opt-in auto-pruning: when `sessions.auto_prune` is `true`, ended sessions older than `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)
      +- 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`:
      +
      +```yaml
      +sessions:
      +  auto_prune: true          # opt in — default is false
      +  retention_days: 90        # keep ended sessions this many days
      +  vacuum_after_prune: true  # reclaim disk space after a pruning sweep
      +  min_interval_hours: 24    # don't re-run the sweep more often than this
      +```
      +
      +Active sessions are never auto-pruned, regardless of age.
       
       ### Manual Cleanup
       
      @@ -403,5 +417,5 @@ hermes sessions prune --older-than 30 --yes
       ```
       
       :::tip
      -The database grows slowly (typical: 10-15 MB for hundreds of sessions). Pruning is mainly useful for removing old conversations you no longer need for search recall.
      +The database grows slowly (typical: 10-15 MB for hundreds of sessions) and session history powers `session_search` recall across past conversations, so auto-prune ships disabled. Enable it if you're running a heavy gateway/cron workload where `state.db` is meaningfully affecting performance (observed failure mode: 384 MB state.db with ~1000 sessions slowing down FTS5 inserts and `/resume` listing). Use `hermes sessions prune` for one-off cleanup without turning on the automatic sweep.
       :::
  • #16286 fix(sessions): delete transcript files during auto-prune (salvage #6613, closes #3015)

    merged to main

    files: cli.py, gateway/run.py, hermes_cli/main.py, hermes_state.py, tests/hermes_cli/test_sessions_delete.py, tests/test_hermes_state.py

    Show the diff
    • cli.py
      @@ -974,6 +974,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
               return
           try:
               from hermes_cli.config import load_config as _load_full_config
      +        from hermes_constants import get_hermes_home as _get_hermes_home
               cfg = (_load_full_config().get("sessions") or {})
               if not cfg.get("auto_prune", False):
                   return
      @@ -981,6 +982,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
                   retention_days=int(cfg.get("retention_days", 90)),
                   min_interval_hours=int(cfg.get("min_interval_hours", 24)),
                   vacuum=bool(cfg.get("vacuum_after_prune", True)),
      +            sessions_dir=_get_hermes_home() / "sessions",
               )
           except Exception as exc:
               logger.debug("state.db auto-maintenance skipped: %s", exc)
    • gateway/run.py
      @@ -763,6 +763,7 @@ def __init__(self, config: Optional[GatewayConfig] = None):
                               retention_days=int(_sess_cfg.get("retention_days", 90)),
                               min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
                               vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
      +                        sessions_dir=self.config.sessions_dir,
                           )
                   except Exception as exc:
                       logger.debug("state.db auto-maintenance skipped: %s", exc)
    • hermes_cli/main.py
      @@ -9230,7 +9230,8 @@ def cmd_sessions(args):
                       ):
                           print("Cancelled.")
                           return
      -            if db.delete_session(resolved_session_id):
      +            sessions_dir = get_hermes_home() / "sessions"
      +            if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
                       print(f"Deleted session '{resolved_session_id}'.")
                   else:
                       print(f"Session '{args.session_id}' not found.")
      @@ -9244,7 +9245,9 @@ def cmd_sessions(args):
                       ):
                           print("Cancelled.")
                           return
      -            count = db.prune_sessions(older_than_days=days, source=args.source)
      +            sessions_dir = get_hermes_home() / "sessions"
      +            count = db.prune_sessions(older_than_days=days, source=args.source,
      +                                      sessions_dir=sessions_dir)
                   print(f"Pruned {count} session(s).")
       
               elif action == "rename":
    • hermes_state.py
      @@ -1512,12 +1512,45 @@ def _do(conn):
                   )
               self._execute_write(_do)
       
      -    def delete_session(self, session_id: str) -> bool:
      +    @staticmethod
      +    def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
      +        """Remove on-disk transcript files for a session.
      +
      +        Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
      +        ``request_dump_{session_id}_*.json`` files left by the gateway.
      +        Silently skips files that don't exist and swallows OSError so a
      +        filesystem hiccup never blocks a DB operation.
      +        """
      +        if sessions_dir is None:
      +            return
      +        for suffix in (".json", ".jsonl"):
      +            p = sessions_dir / f"{session_id}{suffix}"
      +            try:
      +                p.unlink(missing_ok=True)
      +            except OSError:
      +                pass
      +        # request_dump files use session_id as a prefix component
      +        try:
      +            for p in sessions_dir.glob(f"request_dump_{session_id}_*.json"):
      +                try:
      +                    p.unlink(missing_ok=True)
      +                except OSError:
      +                    pass
      +        except OSError:
      +            pass
      +
      +    def delete_session(
      +        self,
      +        session_id: str,
      +        sessions_dir: Optional[Path] = None,
      +    ) -> bool:
               """Delete a session and all its messages.
       
               Child sessions are orphaned (parent_session_id set to NULL) rather
               than cascade-deleted, so they remain accessible independently.
      -        Returns True if the session was found and deleted.
      +        When *sessions_dir* is provided, also removes on-disk transcript
      +        files (``.json`` / ``.jsonl`` / ``request_dump_*``) for the deleted
      +        session. Returns True if the session was found and deleted.
               """
               def _do(conn):
                   cursor = conn.execute(
      @@ -1534,16 +1567,29 @@ def _do(conn):
                   conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
                   conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
                   return True
      -        return self._execute_write(_do)
       
      -    def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
      +        deleted = self._execute_write(_do)
      +        if deleted:
      +            self._remove_session_files(sessions_dir, session_id)
      +        return deleted
      +
      +    def prune_sessions(
      +        self,
      +        older_than_days: int = 90,
      +        source: str = None,
      +        sessions_dir: Optional[Path] = None,
      +    ) -> int:
               """Delete sessions older than N days. Returns count of deleted sessions.
       
               Only prunes ended sessions (not active ones).  Child sessions outside
               the prune window are orphaned (parent_session_id set to NULL) rather
      -        than cascade-deleted.
      +        than cascade-deleted.  When *sessions_dir* is provided, also removes
      +        on-disk transcript files (``.json`` / ``.jsonl`` /
      +        ``request_dump_*``) for every pruned session, outside the DB
      +        transaction.
               """
               cutoff = time.time() - (older_than_days * 86400)
      +        removed_ids: list[str] = []
       
               def _do(conn):
                   if source:
      @@ -1573,9 +1619,14 @@ def _do(conn):
                   for sid in session_ids:
                       conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
                       conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
      +                removed_ids.append(sid)
                   return len(session_ids)
       
      -        return self._execute_write(_do)
      +        count = self._execute_write(_do)
      +        # Clean up on-disk files outside the DB transaction
      +        for sid in removed_ids:
      +            self._remove_session_files(sessions_dir, sid)
      +        return count
       
           # ── Meta key/value (for scheduler bookkeeping) ──
       
      @@ -1629,6 +1680,7 @@ def maybe_auto_prune_and_vacuum(
               retention_days: int = 90,
               min_interval_hours: int = 24,
               vacuum: bool = True,
      +        sessions_dir: Optional[Path] = None,
           ) -> Dict[str, Any]:
               """Idempotent auto-maintenance: prune old sessions + optional VACUUM.
       
      @@ -1636,6 +1688,10 @@ def maybe_auto_prune_and_vacuum(
               within ``min_interval_hours`` no-op. 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
      +        are removed as part of the same sweep (issue #3015).
      +
               Never raises. On any failure, logs a warning and returns a dict
               with ``"error"`` set.
       
      @@ -1659,7 +1715,10 @@ def maybe_auto_prune_and_vacuum(
                       except (TypeError, ValueError):
                           pass  # corrupt meta; treat as no prior run
       
      -            pruned = self.prune_sessions(older_than_days=retention_days)
      +            pruned = self.prune_sessions(
      +                older_than_days=retention_days,
      +                sessions_dir=sessions_dir,
      +            )
                   result["pruned"] = pruned
       
                   # Only VACUUM if we actually freed rows — VACUUM on a tight DB
    • tests/hermes_cli/test_sessions_delete.py
      @@ -12,7 +12,7 @@ def resolve_session_id(self, session_id):
                   captured["resolved_from"] = session_id
                   return "20260315_092437_c9a6ff"
       
      -        def delete_session(self, session_id):
      +        def delete_session(self, session_id, **kwargs):
                   captured["deleted"] = session_id
                   return True
       
      @@ -45,7 +45,7 @@ class FakeDB:
               def resolve_session_id(self, session_id):
                   return None
       
      -        def delete_session(self, session_id):
      +        def delete_session(self, session_id, **kwargs):
                   raise AssertionError("delete_session should not be called when resolution fails")
       
               def close(self):
      @@ -73,7 +73,7 @@ class FakeDB:
               def resolve_session_id(self, session_id):
                   return "20260315_092437_c9a6ff"
       
      -        def delete_session(self, session_id):
      +        def delete_session(self, session_id, **kwargs):
                   raise AssertionError("delete_session should not be called when cancelled")
       
               def close(self):
    • tests/test_hermes_state.py
      @@ -1981,3 +1981,58 @@ def test_state_meta_survives_vacuum(self, db):
               # Should parse as a float timestamp close to now.
               assert abs(float(marker) - time.time()) < 60
       
      +    def test_auto_prune_deletes_transcript_files(self, db, tmp_path):
      +        """Issue #3015: auto-prune must also delete on-disk transcript files."""
      +        sessions_dir = tmp_path / "sessions"
      +        sessions_dir.mkdir()
      +
      +        self._make_old_ended(db, "old1", days_old=100)
      +        self._make_old_ended(db, "old2", days_old=100)
      +        db.create_session(session_id="new", source="cli")  # active
      +
      +        # Transcript files mimicking real gateway/CLI layout
      +        (sessions_dir / "old1.json").write_text("{}")
      +        (sessions_dir / "old1.jsonl").write_text("{}\n")
      +        (sessions_dir / "old2.jsonl").write_text("{}\n")
      +        (sessions_dir / "request_dump_old1_001.json").write_text("{}")
      +        (sessions_dir / "new.jsonl").write_text("{}\n")  # active, must survive
      +
      +        result = db.maybe_auto_prune_and_vacuum(
      +            retention_days=90, sessions_dir=sessions_dir
      +        )
      +        assert result["pruned"] == 2
      +
      +        # Pruned transcript files are gone
      +        assert not (sessions_dir / "old1.json").exists()
      +        assert not (sessions_dir / "old1.jsonl").exists()
      +        assert not (sessions_dir / "old2.jsonl").exists()
      +        assert not (sessions_dir / "request_dump_old1_001.json").exists()
      +        # Active session's transcript is untouched
      +        assert (sessions_dir / "new.jsonl").exists()
      +
      +    def test_auto_prune_without_sessions_dir_preserves_files(self, db, tmp_path):
      +        """Backward-compat: no sessions_dir = DB-only cleanup (legacy behavior)."""
      +        sessions_dir = tmp_path / "sessions"
      +        sessions_dir.mkdir()
      +        self._make_old_ended(db, "old", days_old=100)
      +        (sessions_dir / "old.jsonl").write_text("{}\n")
      +
      +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
      +        assert result["pruned"] == 1
      +        # File stays — caller didn't opt in
      +        assert (sessions_dir / "old.jsonl").exists()
      +
      +    def test_prune_sessions_deletes_files_for_pruned_only(self, db, tmp_path):
      +        """Active-session transcripts must never be deleted by prune."""
      +        sessions_dir = tmp_path / "sessions"
      +        sessions_dir.mkdir()
      +        self._make_old_ended(db, "old", days_old=100)
      +        db.create_session(session_id="active", source="cli")  # not ended
      +        (sessions_dir / "old.jsonl").write_text("{}\n")
      +        (sessions_dir / "active.jsonl").write_text("{}\n")
      +
      +        count = db.prune_sessions(older_than_days=90, sessions_dir=sessions_dir)
      +        assert count == 1
      +        assert not (sessions_dir / "old.jsonl").exists()
      +        assert (sessions_dir / "active.jsonl").exists()
      +

2 PRs closed without merging

From the close comments: merged via another change 1 · other 1

  • #3028 fix(gateway): delete legacy session transcript files on reset (#3015) · other
  • #6613 fix(sessions): delete on-disk transcript files during prune and delete · merged via another change → #16286, #13861
Closed issues · 1
  • Closed#3015Session File Leak: JSON sessions never deleted, causing unbounded disk growth and cost explosion
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#13861, #16286
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
27 Apr 2026

For your agent

The same facts, typed.