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
- 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
Open PRs0
Fixes on record
-
#13861 feat(state): auto-prune old sessions + VACUUM state.db at startup
files:
cli.py,gateway/run.py,hermes_cli/config.py,hermes_state.py,tests/test_hermes_state.py,website/docs/user-guide/sessions.mdShow 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)
files:
cli.py,gateway/run.py,hermes_cli/main.py,hermes_state.py,tests/hermes_cli/test_sessions_delete.py,tests/test_hermes_state.pyShow 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
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.00means 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