fix(sessions): bulk archive never hides a live chat because its old history matched

`hermes sessions archive --older-than N` (SessionDB.archive_sessions) selected
every ENDED row matching the filters, which includes the compression ancestors
of a long conversation: they are ended (`end_reason='compression'`) and old by
construction. `set_session_archived` then flipped `archived` across the whole
lineage — including the OPEN, actively written, lease-holding live tip — and
the default `archived=exclude` listing (Desktop sidebar, `hermes sessions
list`, /resume) lost the chat while messages kept flowing.

A lineage is now matched through its tip only: `_prune_filter_where` gains
`lineage_tips_only`, which `archive_sessions` always sets and the CLI sets in
archive mode so the dry-run preview and the confirmation count show exactly
the rows the archive will touch. An idle, ended tip still archives its whole
chain (the lineage stays one unit — the listed row is the root, projected to
the tip, so sparing only the tip would leave the chat hidden anyway).
Prune is unchanged. Docs: the bulk-archive section says how compacted
conversations are matched.

Direction from #115500 by @whyyagswhy (automatic archives must not hide the
open live tip); the mechanism differs because the listing keys on the root.

Co-authored-by: whyyagswhy <166958865+whyyagswhy@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-19 23:49:22 -07:00
committed by Teknium
parent 2edbd4ed5c
commit ad1707e570
5 changed files with 54 additions and 7 deletions

View File

@@ -72,7 +72,7 @@ _PRUNE_FILTERS = (
("min_tool_calls", "notnone", _one("COALESCE(s.tool_call_count, 0) >= ?")),
("max_tool_calls", "notnone", _one("COALESCE(s.tool_call_count, 0) <= ?")),
)
_PRUNE_FILTER_NAMES = frozenset(name for name, _, _ in _PRUNE_FILTERS) | {"archived", "include_pinned"}
_PRUNE_FILTER_NAMES = frozenset(name for name, _, _ in _PRUNE_FILTERS) | {"archived", "include_pinned", "lineage_tips_only"}
class SessionMaintenanceMixin:
@@ -176,15 +176,20 @@ class SessionMaintenanceMixin:
@staticmethod
def _prune_filter_where(*, archived: Optional[bool] = None, include_pinned: bool = False,
**filters) -> Tuple[str, list]:
lineage_tips_only: bool = False, **filters) -> Tuple[str, list]:
"""Shared WHERE clause for bulk prune/archive selection (alias ``s``): ``_PRUNE_FILTERS``
AND together, only ended sessions are ever candidates, ``archived`` is tri-state
(None = both), ``*_like`` are case-insensitive substrings, the rest exact."""
(None = both), ``*_like`` are case-insensitive substrings, the rest exact.
``lineage_tips_only`` (bulk archive) drops compression ancestors: they are archived with
their tip, never on their own age — matching an old ancestor would fan out over the lineage
and hide its OPEN, recently active tip (#115489)."""
unknown = set(filters) - _PRUNE_FILTER_NAMES
if unknown:
raise TypeError("SessionMaintenanceMixin._prune_filter_where() got an unexpected "
f"keyword argument {sorted(unknown)[0]!r}")
clauses = ["s.ended_at IS NOT NULL"]
if lineage_tips_only:
clauses.append("COALESCE(s.end_reason, '') <> 'compression'")
params: list = []
for name, applies, build in _PRUNE_FILTERS:
value = filters.get(name)