fix(sessions): preserve recently active sessions during pruning

This commit is contained in:
Frowtek 2026-07-25 00:45:09 +03:00 committed by Teknium
parent 769dba1758
commit a228b81501
12 changed files with 224 additions and 68 deletions

View file

@ -3655,8 +3655,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# hermes_state.get_last_init_error() for slash-command error strings.
logger.warning("SQLite session store not available: %s", e)
# Opportunistic state.db maintenance: prune ended sessions older
# than sessions.retention_days + optional VACUUM. Tracks last-run
# Opportunistic state.db maintenance: prune ended sessions inactive
# for 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

View file

@ -3308,14 +3308,15 @@ DEFAULT_CONFIG = {
# 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
# When true, prune ended sessions inactive for retention_days once
# per (roughly) min_interval_hours at CLI/gateway/cron startup.
# Only touches ended sessions — active sessions are always preserved.
# Activity is the latest message timestamp, falling back to creation
# time for empty 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``.
# How many inactive days of ended-session history to keep. Matches
# the default of ``hermes sessions prune``.
"retention_days": 90,
# When true, auto-archive (soft-hide, never delete) sessions that
# haven't been touched in ``auto_archive_days`` days, once per

View file

@ -16201,7 +16201,7 @@ def main():
p.add_argument(
"--newer-than",
metavar="AGE",
help="Only match sessions started within the last AGE "
help="Only match sessions active within the last AGE "
"(e.g. '5h', '2d') or after an ISO timestamp",
)
p.add_argument(
@ -17358,12 +17358,14 @@ def main():
print(f"No sessions match ({describe_filters(filters)}).")
return
# Candidates are ordered oldest-first — surface the age span so
# the confirmation makes the blast radius obvious.
_oldest = candidates[0].get("started_at")
_newest = candidates[-1].get("started_at")
# Candidates are ordered by activity oldest-first. Surface that
# span so a long-lived but recently used conversation cannot look
# old merely because of its creation date.
_oldest = candidates[0].get("last_active")
_newest = candidates[-1].get("last_active")
_span = (
f"oldest {format_epoch(_oldest)}, newest {format_epoch(_newest)}"
f"oldest activity {format_epoch(_oldest)}, "
f"newest activity {format_epoch(_newest)}"
)
if args.dry_run or not args.yes:
@ -17376,7 +17378,7 @@ def main():
title = (s.get("title") or "")[:36]
model = (s.get("model") or "-").split("/")[-1][:24]
print(
f" {s['id']} {format_epoch(s['started_at']):<17} "
f" {s['id']} {format_epoch(s.get('last_active')):<17} "
f"{s['source']:<10} {model:<24} "
f"{s['message_count']:>4} msgs {title}"
)

View file

@ -86,13 +86,15 @@ def build_prune_filters(args: Any) -> Dict[str, Any]:
``--after``, ``--source``, ``--title``, ``--end-reason``, ``--cwd``,
``--min-messages``, ``--max-messages``, ``--archived``/``--no-archived``.
``--before``/``--older-than`` both set the upper bound (started_before);
``--after``/``--newer-than`` both set the lower bound (started_after).
When both a duration flag and an absolute flag target the same bound,
the tighter (more restrictive) bound wins.
``--older-than`` / ``--newer-than`` bound last activity, while
``--before`` / ``--after`` explicitly bound session start time. Last
activity is the latest message timestamp, falling back to ``started_at``
for empty sessions.
Raises ``ValueError`` on unparseable values or an empty/inverted window.
"""
last_active_before: Optional[float] = None
last_active_after: Optional[float] = None
started_before: Optional[float] = None
started_after: Optional[float] = None
@ -103,19 +105,23 @@ def build_prune_filters(args: Any) -> Dict[str, Any]:
older_than = getattr(args, "older_than", None)
if older_than is not None:
started_before = _tighter(
started_before, parse_point_in_time(older_than, "--older-than"), True
last_active_before = _tighter(
last_active_before,
parse_point_in_time(older_than, "--older-than"),
True,
)
newer_than = getattr(args, "newer_than", None)
if newer_than is not None:
last_active_after = _tighter(
last_active_after,
parse_point_in_time(newer_than, "--newer-than"),
False,
)
before = getattr(args, "before", None)
if before is not None:
started_before = _tighter(
started_before, parse_point_in_time(before, "--before"), True
)
newer_than = getattr(args, "newer_than", None)
if newer_than is not None:
started_after = _tighter(
started_after, parse_point_in_time(newer_than, "--newer-than"), False
)
after = getattr(args, "after", None)
if after is not None:
started_after = _tighter(
@ -128,9 +134,19 @@ def build_prune_filters(args: Any) -> Dict[str, Any]:
and started_after >= started_before
):
raise ValueError(
"Empty time window: the --after/--newer-than bound "
"Empty start-time window: the --after bound "
f"({format_epoch(started_after)}) is not earlier than the "
f"--before/--older-than bound ({format_epoch(started_before)})."
f"--before bound ({format_epoch(started_before)})."
)
if (
last_active_before is not None
and last_active_after is not None
and last_active_after >= last_active_before
):
raise ValueError(
"Empty activity window: the --newer-than bound "
f"({format_epoch(last_active_after)}) is not earlier than the "
f"--older-than bound ({format_epoch(last_active_before)})."
)
filters: Dict[str, Any] = {
@ -138,6 +154,8 @@ def build_prune_filters(args: Any) -> Dict[str, Any]:
# Without this, prune_sessions' default 90-day cutoff would silently
# cap an --after/--newer-than-only window.
"older_than_days": None,
"last_active_before": last_active_before,
"last_active_after": last_active_after,
"started_before": started_before,
"started_after": started_after,
"source": getattr(args, "source", None),
@ -165,6 +183,14 @@ def build_prune_filters(args: Any) -> Dict[str, Any]:
def describe_filters(filters: Dict[str, Any]) -> str:
"""Human-readable summary of active filters for confirmation prompts."""
parts = []
if filters.get("last_active_before") is not None:
parts.append(
f"last active before {format_epoch(filters['last_active_before'])}"
)
if filters.get("last_active_after") is not None:
parts.append(
f"last active after {format_epoch(filters['last_active_after'])}"
)
if filters.get("started_before") is not None:
parts.append(f"started before {format_epoch(filters['started_before'])}")
if filters.get("started_after") is not None:

View file

@ -12022,9 +12022,15 @@ def _prune_sessions(body: SessionPrune):
"ok": True,
"removed": 0,
"matched": len(rows),
# Rows are ordered oldest-first.
"oldest_started_at": rows[0]["started_at"] if rows else None,
"newest_started_at": rows[-1]["started_at"] if rows else None,
# Rows are ordered by last activity, not creation time.
"oldest_last_active": rows[0]["last_active"] if rows else None,
"newest_last_active": rows[-1]["last_active"] if rows else None,
"oldest_started_at": (
min(r["started_at"] for r in rows) if rows else None
),
"newest_started_at": (
max(r["started_at"] for r in rows) if rows else None
),
"sessions": [
{
"id": r["id"],
@ -12032,6 +12038,7 @@ def _prune_sessions(body: SessionPrune):
"title": r.get("title"),
"model": r.get("model"),
"started_at": r["started_at"],
"last_active": r["last_active"],
"message_count": r["message_count"],
}
for r in rows

View file

@ -9615,6 +9615,8 @@ class SessionDB:
@staticmethod
def _prune_filter_where(
*,
last_active_before: Optional[float] = None,
last_active_after: Optional[float] = None,
started_before: Optional[float] = None,
started_after: Optional[float] = None,
source: Optional[str] = None,
@ -9657,6 +9659,24 @@ class SessionDB:
"""
clauses = ["s.ended_at IS NOT NULL"]
params: list = []
if last_active_before is not None:
clauses.append(
"""COALESCE(
(SELECT MAX(m.timestamp) FROM messages m
WHERE m.session_id = s.id),
s.started_at
) < ?"""
)
params.append(last_active_before)
if last_active_after is not None:
clauses.append(
"""COALESCE(
(SELECT MAX(m.timestamp) FROM messages m
WHERE m.session_id = s.id),
s.started_at
) >= ?"""
)
params.append(last_active_after)
if started_before is not None:
clauses.append("s.started_at < ?")
params.append(started_before)
@ -9744,18 +9764,31 @@ class SessionDB:
Backs ``--dry-run`` and pre-confirmation counts. Accepts the same
keyword filters as :meth:`_prune_filter_where` (unknown names raise
``TypeError`` there). Rows are ordered oldest-first and carry
``id, source, title, model, started_at, ended_at, message_count,
archived``.
``id, source, title, model, started_at, last_active, ended_at,
message_count, archived``. ``older_than_days`` is an inactivity
threshold: it uses the latest message timestamp, falling back to
``started_at`` for sessions without messages.
"""
if filters.get("started_before") is None and older_than_days is not None:
filters["started_before"] = time.time() - (older_than_days * 86400)
if (
filters.get("last_active_before") is None
and filters.get("started_before") is None
and older_than_days is not None
):
filters["last_active_before"] = time.time() - (
older_than_days * 86400
)
where, params = self._prune_filter_where(source=source, **filters)
with self._lock:
cursor = self._conn.execute(
f"""SELECT s.id, s.source, s.title, s.model, s.started_at,
COALESCE(
(SELECT MAX(m.timestamp) FROM messages m
WHERE m.session_id = s.id),
s.started_at
) AS last_active,
s.ended_at, s.message_count, s.archived
FROM sessions s WHERE {where}
ORDER BY s.started_at ASC""",
ORDER BY last_active ASC, s.started_at ASC""",
params,
)
return [dict(row) for row in cursor.fetchall()]
@ -9794,8 +9827,8 @@ class SessionDB:
"Touched" is the latest message timestamp (falling back to
``started_at``) i.e. real recency, not creation time so a session
created long ago but active yesterday is spared, while an old
abandoned one (even a still-open one) is swept. This differs from
:meth:`archive_sessions`, which ages on ``started_at`` and only ended
abandoned one (even a still-open one) is swept. Unlike
:meth:`archive_sessions`, this method can also archive unended
sessions.
Guards:
@ -9844,15 +9877,19 @@ class SessionDB:
) -> int:
"""Delete sessions matching the filters. Returns count deleted.
Default behavior (no keyword filters) is unchanged: delete ended
sessions older than ``older_than_days`` days, optionally restricted
to ``source``. Additional keyword filters AND together the full
set is defined by :meth:`_prune_filter_where`:
By default, delete ended sessions inactive for
``older_than_days`` days, optionally restricted to ``source``.
Activity is the latest message timestamp, falling back to
``started_at`` for sessions without messages. Additional keyword
filters AND together the full set is defined by
:meth:`_prune_filter_where`:
* ``last_active_before`` / ``last_active_after`` epoch bounds on
the latest message timestamp (falling back to ``started_at``).
* ``started_before`` / ``started_after`` epoch bounds on
``started_at``. ``started_before`` overrides ``older_than_days``;
pass ``older_than_days=None`` for no upper age bound (e.g. when
only pruning a recent window via ``started_after``).
``started_at``. An explicit ``started_before`` overrides the
default ``older_than_days`` inactivity cutoff; pass
``older_than_days=None`` for no implicit upper age bound.
* ``title_like`` / ``model_like`` / ``branch_like``
case-insensitive substring matches.
* ``end_reason`` / ``provider`` / ``user_id`` / ``chat_id`` /
@ -9874,8 +9911,14 @@ class SessionDB:
``request_dump_*``) for every pruned session, outside the DB
transaction.
"""
if filters.get("started_before") is None and older_than_days is not None:
filters["started_before"] = time.time() - (older_than_days * 86400)
if (
filters.get("last_active_before") is None
and filters.get("started_before") is None
and older_than_days is not None
):
filters["last_active_before"] = time.time() - (
older_than_days * 86400
)
where, where_params = self._prune_filter_where(source=source, **filters)
removed_ids: list[str] = []
@ -10613,7 +10656,7 @@ class SessionDB:
vacuum: bool = True,
sessions_dir: Optional[Path] = None,
) -> Dict[str, Any]:
"""Idempotent auto-maintenance: prune old sessions + optional VACUUM.
"""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
@ -10667,7 +10710,7 @@ class SessionDB:
if pruned > 0:
logger.info(
"state.db auto-maintenance: pruned %d session(s) older than %d days%s",
"state.db auto-maintenance: pruned %d session(s) inactive for %d days%s",
pruned,
retention_days,
" + VACUUM" if result["vacuumed"] else "",

View file

@ -740,6 +740,8 @@ class TestSessionManagementEndpoints:
body = r.json()
assert body["matched"] >= 1
assert "oldest_started_at" in body and "newest_started_at" in body
assert "oldest_last_active" in body and "newest_last_active" in body
assert all("last_active" in session for session in body["sessions"])
def test_prune_explicit_older_than_kept_with_attr_filter(self):
# Explicit older_than_days is honored even alongside attribute filters.

View file

@ -72,14 +72,18 @@ class TestBuildPruneFilters:
def test_newer_than_sets_lower_bound_only(self):
f = build_prune_filters(_ns(newer_than="5h"))
assert f["started_before"] is None
assert f["started_after"] == pytest.approx(time.time() - 18000, abs=5)
assert f["started_after"] is None
assert f["last_active_after"] == pytest.approx(
time.time() - 18000, abs=5
)
assert f["older_than_days"] is None # no implicit 90d cap
def test_older_than_bare_days(self):
f = build_prune_filters(_ns(older_than="90"))
assert f["started_before"] == pytest.approx(
assert f["last_active_before"] == pytest.approx(
time.time() - 90 * 86400, abs=5
)
assert f["started_before"] is None
assert f["started_after"] is None
def test_window_before_and_after(self):
@ -87,14 +91,19 @@ class TestBuildPruneFilters:
assert f["started_after"] < f["started_before"]
def test_inverted_window_rejected(self):
with pytest.raises(ValueError, match="Empty time window"):
with pytest.raises(ValueError, match="Empty start-time window"):
build_prune_filters(_ns(after="2h", before="10h"))
def test_tighter_bound_wins(self):
# --older-than 1d and --before 5h both set the upper bound;
# 1d ago is earlier (tighter for "older than") so it wins.
def test_inverted_activity_window_rejected(self):
with pytest.raises(ValueError, match="Empty activity window"):
build_prune_filters(_ns(newer_than="2h", older_than="10h"))
def test_activity_and_start_bounds_are_independent(self):
f = build_prune_filters(_ns(older_than="1d", before="5h"))
assert f["started_before"] == pytest.approx(
time.time() - 5 * 3600, abs=5
)
assert f["last_active_before"] == pytest.approx(
time.time() - 86400, abs=5
)
@ -141,7 +150,7 @@ class TestBuildPruneFilters:
def test_describe_filters_mentions_active_parts(self):
f = build_prune_filters(_ns(newer_than="5h", source="cli"))
desc = describe_filters(f)
assert "started after" in desc
assert "last active after" in desc
assert "source 'cli'" in desc
def test_describe_filters_empty(self):

View file

@ -145,6 +145,7 @@ def _run_prune(monkeypatch, capsys, argv_tail, candidates=None):
"source": "cron",
"title": "oldest run",
"started_at": 1_600_000_000.0,
"last_active": 1_600_000_050.0,
"ended_at": 1_600_000_100.0,
"message_count": 2,
"archived": 0,
@ -154,6 +155,7 @@ def _run_prune(monkeypatch, capsys, argv_tail, candidates=None):
"source": "cron",
"title": "newest run",
"started_at": 1_700_000_000.0,
"last_active": 1_700_000_050.0,
"ended_at": 1_700_000_100.0,
"message_count": 4,
"archived": 0,
@ -185,8 +187,8 @@ def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys):
import time as _time
filters, _out = _run_prune(monkeypatch, capsys, [])
assert filters["started_before"] is not None
assert filters["started_before"] == pytest.approx(
assert filters["last_active_before"] is not None
assert filters["last_active_before"] == pytest.approx(
_time.time() - 90 * 86400, abs=60
)
@ -194,6 +196,8 @@ def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys):
def test_sessions_prune_source_matches_all_ages(monkeypatch, capsys):
"""--source alone suppresses the implicit 90-day cutoff (all ages)."""
filters, _out = _run_prune(monkeypatch, capsys, ["--source", "cron"])
assert filters["last_active_before"] is None
assert filters["last_active_after"] is None
assert filters["started_before"] is None
assert filters["started_after"] is None
assert filters["source"] == "cron"
@ -206,7 +210,7 @@ def test_sessions_prune_source_with_explicit_time_respected(monkeypatch, capsys)
filters, _out = _run_prune(
monkeypatch, capsys, ["--source", "cron", "--older-than", "30"]
)
assert filters["started_before"] == pytest.approx(
assert filters["last_active_before"] == pytest.approx(
_time.time() - 30 * 86400, abs=60
)
assert filters["source"] == "cron"
@ -218,5 +222,5 @@ def test_sessions_prune_preview_shows_oldest_newest(monkeypatch, capsys):
_filters, out = _run_prune(monkeypatch, capsys, ["--source", "cron"])
assert "2 session(s) match" in out
assert f"oldest {format_epoch(1_600_000_000.0)}" in out
assert f"newest {format_epoch(1_700_000_000.0)}" in out
assert f"oldest activity {format_epoch(1_600_000_050.0)}" in out
assert f"newest activity {format_epoch(1_700_000_050.0)}" in out

View file

@ -189,11 +189,11 @@ def test_sessions_export_md_bulk_dry_run_lists_candidates(monkeypatch, tmp_path,
class FakeDB:
def list_prune_candidates(self, **kwargs):
# Export flows through the shared prune-filter machinery:
# --older-than 30 becomes a started_before epoch bound, source
# --older-than 30 becomes a last-active epoch bound, source
# passes through, and archived is tri-state None (export includes
# archived sessions).
assert kwargs.get("source") == "cron"
assert kwargs.get("started_before") is not None
assert kwargs.get("last_active_before") is not None
assert kwargs.get("archived") is None
return [{"id": "s1", "source": "cron"}, {"id": "s2", "source": "cron"}]
@ -444,7 +444,7 @@ def test_sessions_export_md_accepts_duration_age_grammar(monkeypatch, tmp_path,
class FakeDB:
def list_prune_candidates(self, **kwargs):
assert kwargs.get("started_before") is not None
assert kwargs.get("last_active_before") is not None
return [{"id": "s1", "source": "cli"}]
def close(self):

View file

@ -3008,6 +3008,31 @@ class TestPruneSessions:
assert session is not None
assert session["id"] == "new"
def test_age_preview_and_prune_use_last_activity(self, db):
old_ts = time.time() - 100 * 86400
for sid in ("inactive", "recently-active"):
db.create_session(session_id=sid, source="telegram")
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id = ?",
(old_ts, sid),
)
db.end_session("inactive", end_reason="agent_close")
db.append_message(
"recently-active",
role="user",
content="A recent message in a long-lived conversation.",
)
db.end_session("recently-active", end_reason="agent_close")
db._conn.commit()
candidates = db.list_prune_candidates(older_than_days=90)
assert [row["id"] for row in candidates] == ["inactive"]
assert candidates[0]["last_active"] == pytest.approx(old_ts)
assert db.prune_sessions(older_than_days=90) == 1
assert db.get_session("inactive") is None
assert db.get_session("recently-active") is not None
def test_prune_skips_active_sessions(self, db):
db.create_session(session_id="active", source="cli")
# Backdate but don't end
@ -5548,6 +5573,39 @@ class TestAutoMaintenance:
# Active session's transcript is untouched
assert (sessions_dir / "new.jsonl").exists()
def test_auto_prune_preserves_old_session_with_recent_activity(self, db, tmp_path):
"""Retention is based on activity, not when a conversation began."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
db.create_session(session_id="long-lived", source="telegram")
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id = ?",
(time.time() - 100 * 86400, "long-lived"),
)
db._conn.commit()
db.append_message(
"long-lived",
role="user",
content="This conversation was active today.",
)
db.end_session("long-lived", end_reason="agent_close")
transcript = sessions_dir / "long-lived.jsonl"
transcript.write_text('{"role":"user","content":"recent"}\n')
result = db.maybe_auto_prune_and_vacuum(
retention_days=90,
vacuum=False,
sessions_dir=sessions_dir,
)
assert result["pruned"] == 0
assert db.get_session("long-lived") is not None
assert [m["content"] for m in db.get_messages("long-lived")] == [
"This conversation was active today."
]
assert transcript.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"

View file

@ -419,7 +419,7 @@ If the title is already in use by another session, an error is shown.
### Prune Old Sessions
```bash
# Delete ended sessions older than 90 days (default)
# Delete ended sessions inactive for 90 days (default)
hermes sessions prune
# Custom age threshold — bare numbers are days
@ -461,8 +461,10 @@ hermes sessions prune --older-than 30 --yes
Time values (`--older-than`, `--newer-than`, `--before`, `--after`) accept a
duration (`5h`, `30m`, `2d`, `1w`), a bare number of days, or an ISO
timestamp (`2026-07-05`, `2026-07-05 14:30`). `--older-than`/`--before` set
the upper bound; `--newer-than`/`--after` set the lower bound. Combine both
for a window.
the upper bound; `--newer-than`/`--after` set the lower bound. The
`--older-than`/`--newer-than` pair uses latest message activity (falling back
to session start for empty sessions); `--before`/`--after` explicitly uses
session start time. Combine either pair for a window.
Attribute filters: `--source` (platform, exact), `--title` / `--model` /
`--branch` (case-insensitive substring), `--provider` (billing provider,
@ -688,7 +690,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 older than `sessions.retention_days` (default 90) are pruned at CLI/gateway startup
- 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)
- 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`
@ -697,12 +699,14 @@ Default is **off** — session history is valuable for `session_search` recall,
```yaml
sessions:
auto_prune: true # opt in — default is false
retention_days: 90 # keep ended sessions this many days
retention_days: 90 # keep ended sessions active within this window
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.
Active sessions are never auto-pruned, regardless of age. Ended sessions are
aged from their latest message, so a long-lived conversation used recently is
not deleted merely because it began before the retention window.
### Manual Cleanup