feat: session garbage collection (#315)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 14s
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 14s
Add garbage_collect() method to SessionDB that cleans up empty and trivial sessions based on age: - Empty sessions (0 messages) older than 24h - Trivial sessions (1-5 messages) older than 7 days - Sessions with >5 messages kept indefinitely Add `hermes sessions gc` CLI command with: - --empty-hours (default: 24) - --trivial-days (default: 7) - --trivial-max (default: 5) - --source filter - --dry-run preview mode - --yes skip confirmation The dry-run flow: preview what would be deleted, ask for confirmation, then execute. Handles child session FK constraints properly. 7 tests covering: empty/trivial deletion, active session protection, substantial session preservation, dry-run, source filtering, and child session handling. Closes #315
This commit is contained in:
@@ -665,6 +665,127 @@ class TestPruneSessions:
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# =========================================================================
|
||||
# Garbage Collect
|
||||
# =========================================================================
|
||||
|
||||
class TestGarbageCollect:
|
||||
def test_gc_deletes_empty_old_sessions(self, db):
|
||||
"""Empty sessions (0 messages) older than 24h should be deleted."""
|
||||
db.create_session(session_id="empty_old", source="cli")
|
||||
db.end_session("empty_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "empty_old"), # 48 hours ago
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
# Recent empty session should be kept
|
||||
db.create_session(session_id="empty_new", source="cli")
|
||||
db.end_session("empty_new", end_reason="done")
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["empty"] == 1
|
||||
assert result["trivial"] == 0
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("empty_old") is None
|
||||
assert db.get_session("empty_new") is not None
|
||||
|
||||
def test_gc_deletes_trivial_old_sessions(self, db):
|
||||
"""Sessions with 1-5 messages older than 7 days should be deleted."""
|
||||
db.create_session(session_id="trivial_old", source="cli")
|
||||
for i in range(3):
|
||||
db.append_message("trivial_old", role="user", content=f"msg {i}")
|
||||
db.end_session("trivial_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 10 * 86400, "trivial_old"), # 10 days ago
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["trivial"] == 1
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("trivial_old") is None
|
||||
|
||||
def test_gc_keeps_active_sessions(self, db):
|
||||
"""Active (not ended) sessions should never be deleted."""
|
||||
db.create_session(session_id="active_old", source="cli")
|
||||
# Backdate but don't end
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "active_old"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["total"] == 0
|
||||
assert db.get_session("active_old") is not None
|
||||
|
||||
def test_gc_keeps_substantial_sessions(self, db):
|
||||
"""Sessions with >5 messages should never be deleted."""
|
||||
db.create_session(session_id="big_old", source="cli")
|
||||
for i in range(10):
|
||||
db.append_message("big_old", role="user", content=f"msg {i}")
|
||||
db.end_session("big_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 365 * 86400, "big_old"), # 1 year ago
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["total"] == 0
|
||||
assert db.get_session("big_old") is not None
|
||||
|
||||
def test_gc_dry_run_does_not_delete(self, db):
|
||||
"""dry_run=True should return counts but not delete anything."""
|
||||
db.create_session(session_id="empty_old", source="cli")
|
||||
db.end_session("empty_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "empty_old"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect(dry_run=True)
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("empty_old") is not None # Still exists
|
||||
|
||||
def test_gc_with_source_filter(self, db):
|
||||
"""--source should only GC sessions from that source."""
|
||||
for sid, src in [("old_cli", "cli"), ("old_tg", "telegram")]:
|
||||
db.create_session(session_id=sid, source=src)
|
||||
db.end_session(sid, end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, sid),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect(source="cli")
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("old_cli") is None
|
||||
assert db.get_session("old_tg") is not None
|
||||
|
||||
def test_gc_handles_child_sessions(self, db):
|
||||
"""Child sessions should be deleted when parent is GC'd."""
|
||||
db.create_session(session_id="parent_old", source="cli")
|
||||
db.end_session("parent_old", end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 48 * 3600, "parent_old"),
|
||||
)
|
||||
# Create child session
|
||||
db.create_session(session_id="child", source="cli", parent_session_id="parent_old")
|
||||
db.end_session("child", end_reason="done")
|
||||
db._conn.commit()
|
||||
|
||||
result = db.garbage_collect()
|
||||
assert result["total"] == 1
|
||||
assert db.get_session("parent_old") is None
|
||||
assert db.get_session("child") is None
|
||||
|
||||
# Schema and WAL mode
|
||||
# =========================================================================
|
||||
|
||||
|
||||
Reference in New Issue
Block a user