fix(sessions): prune keeps the compressed-away start of a chat still in use

Retention prune (the default-on startup auto-prune, `hermes sessions prune`
and the dashboard prune) aged every session row on its own. A conversation
that rotating compression split into segments has an ended, old root by
construction, so once that root passed retention_days it was deleted while
the conversation's live tip was still being written: the pre-compression
turns vanished from the resume/Desktop history and from session_search, and
the tip was orphaned.

Prune now deletes a compression ancestor only together with every
continuation after it (`whole_lineages`), so a lineage ages through its
newest segment and goes as a unit once the whole conversation qualifies.
Branch, delegate, reset and tool children do not count as continuations.
The CLI and dashboard prune previews pass the same flag, so they list what
prune deletes; bulk export keeps its current selection.

(cherry picked from commit b082fc6ffa02607cba219a5dfa361d4f3bf4c266)
This commit is contained in:
John Paul Soliva
2026-09-26 18:55:48 +09:00
committed by kshitij
parent c59c4b4118
commit 7d49b46e15
6 changed files with 78 additions and 7 deletions

View File

@@ -8,7 +8,8 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from hermes_state_common import (
AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _placeholders, _sql_session_last_active, escape_like as _escape_like
AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _placeholders, _sql_json_extract, _sql_session_last_active,
escape_like as _escape_like
)
from hermes_startup_watchdog import report_startup_progress
@@ -74,6 +75,27 @@ _PRUNE_FILTERS = (
)
_PRUNE_FILTER_NAMES = frozenset(name for name, _, _ in _PRUNE_FILTERS) | {"archived", "include_pinned", "lineage_tips_only"}
# Child ``c`` continues compression-ended ``p``; a fork names ``p`` in its marker (compression copies
# ``model_config``, so a marker naming another row is inherited, not a fork of ``p``).
_CONTINUATION_EDGE_SQL = " AND ".join([
"p.end_reason = 'compression'",
*(f"COALESCE({_sql_json_extract('c.model_config', f'$.{marker}')}, '') != p.id"
for marker in ("_branched_from", "_delegate_from", "_reset_from")),
"COALESCE(c.source, '') != 'tool'",
])
def _continued_ancestors_sql(candidates_where: str) -> str:
"""Compression ancestors of every row *candidates_where* (alias ``s``) does not select."""
return ("WITH RECURSIVE kept(id) AS ("
" SELECT p.id FROM sessions c JOIN sessions p ON p.id = c.parent_session_id"
f" WHERE {_CONTINUATION_EDGE_SQL}"
f" AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.id = c.id AND {candidates_where})"
" UNION"
" SELECT p.id FROM kept k JOIN sessions c ON c.id = k.id JOIN sessions p ON p.id = c.parent_session_id"
f" WHERE {_CONTINUATION_EDGE_SQL}"
") SELECT id FROM kept")
class SessionMaintenanceMixin:
"""Retention pruning, stale-session archiving and VACUUM policy for SessionDB."""
@@ -205,7 +227,10 @@ class SessionMaintenanceMixin:
return " AND ".join(clauses), params
def _prune_where(self, older_than_days, source, filters) -> Tuple[str, list]:
"""Translate the legacy age window into the shared activity filter, then build WHERE."""
"""Translate the legacy age window into the shared activity filter, then build WHERE.
``whole_lineages`` (prune) keeps a compression ancestor while any continuation after it
is unmatched."""
whole_lineages = filters.pop("whole_lineages", False)
if (older_than_days is not None and filters.get("last_active_before") is None
and filters.get("started_before") is None):
if older_than_days < 0:
@@ -213,7 +238,12 @@ class SessionMaintenanceMixin:
f"older_than_days must be >= 0, got {older_than_days!r}: a negative "
"retention builds a future cutoff that matches every ended session.")
filters["last_active_before"] = time.time() - (older_than_days * 86400)
return self._prune_filter_where(source=source, **filters)
where, params = self._prune_filter_where(source=source, **filters)
if not whole_lineages:
return where, params
# A compressed-away segment ages with its conversation, not on its own: while any later
# segment stays, deleting it would cut the start off a chat that is still in use.
return f"{where} AND s.id NOT IN ({_continued_ancestors_sql(where)})", [*params, *params]
def list_prune_candidates(self, older_than_days: Optional[float] = None, source: str = None,
**filters) -> List[Dict[str, Any]]:
@@ -277,7 +307,9 @@ class SessionMaintenanceMixin:
Children outside the window are orphaned (parent NULLed), not cascade-deleted. With
*sessions_dir*, transcript files are removed outside the DB transaction.
``exclude_active_write_guards`` (automatic maintenance) skips rows under a live turn lease
or compression lock while expired/dead holders are reclaimed and fenced."""
or compression lock while expired/dead holders are reclaimed and fenced. A compression
ancestor is deleted only together with every continuation after it (``whole_lineages``)."""
filters["whole_lineages"] = True
where, where_params = self._prune_where(older_than_days, source, filters)
removed_ids: list[str] = []
def _do(conn):