mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(sessions): preserve recently active sessions during pruning
This commit is contained in:
parent
769dba1758
commit
a228b81501
12 changed files with 224 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue