diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index ff11dcc26e..c1d02e416e 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -657,11 +657,14 @@ def _cmd_prune_or_archive(db, args, action): # Prune skips archived rows unless --include-archived; archive only targets not-yet-archived rows. filters["archived"] = None if prune and getattr(args, "include_archived", False) else False filters["include_pinned"] = getattr(args, "include_pinned", False) + # Archive flips a compression lineage as a unit, matched through its tip (an old ancestor alone + # never qualifies); the preview must show the same rows the archive will touch. + filters["lineage_tips_only"] = not prune if not filters["include_pinned"]: _note_pinned_skipped(db, filters, action) candidates = db.list_prune_candidates(**filters) - # Archive expands each row to its compression lineage (may include open continuations), so a - # direct-open count would misdescribe its effect. + # Archive expands each matched tip to its compression lineage, so a direct-open count would + # misdescribe its effect. skipped_open = db.count_open_prune_matches(**filters) if prune else 0 if skipped_open: print(f"Note: {skipped_open} open session{'' if skipped_open == 1 else 's'} also match these filters but " diff --git a/hermes_state_maintenance.py b/hermes_state_maintenance.py index 84b3b41b2f..3414e42267 100644 --- a/hermes_state_maintenance.py +++ b/hermes_state_maintenance.py @@ -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) diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index ef5a9ed4b0..061502af4c 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -1651,8 +1651,11 @@ class SessionSessionsMixin: self, older_than_days: Optional[float] = None, source: str = None, **filters, ) -> int: """Bulk soft-hide with prune_sessions' filter surface, via set_session_archived so each lineage - flips as a unit; idempotent. Returns matches.""" + flips as a unit; idempotent. Returns matches. A lineage is matched through its TIP only: an + old compression ancestor never qualifies on its own age, or the fan-out would hide an open, + recently active continuation (#115489).""" filters.setdefault("archived", False) + filters["lineage_tips_only"] = True rows = self.list_prune_candidates(older_than_days=older_than_days, source=source, **filters) for row in rows: self.set_session_archived(row["id"], True) diff --git a/tests/hermes_state/test_session_archiving.py b/tests/hermes_state/test_session_archiving.py index 2d5fbac1eb..f3eb44af74 100644 --- a/tests/hermes_state/test_session_archiving.py +++ b/tests/hermes_state/test_session_archiving.py @@ -64,3 +64,36 @@ def test_archived_only_view_includes_hidden_archived_sessions(db): assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["plain"] # ...and the archived-only view must surface the archived+hidden row. assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["both"] + + +def _stale_lineage(db: SessionDB, prefix: str) -> tuple[str, str]: + """root(compression, 40 days old) -> tip; the tip's state is the caller's.""" + root, tip = f"{prefix}-root", f"{prefix}-tip" + db.create_session(root, source="feishu") + db.create_session(tip, source="feishu", parent_session_id=root) + base = time.time() - 40 * 86400 + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', last_activity_at = ? WHERE id = ?", + (base, base + 10, base + 10, root)) + db._conn.commit() + return root, tip + + +def test_bulk_archive_matches_a_lineage_through_its_tip_only(db): + """#115489: `hermes sessions archive --older-than` must never hide an OPEN, active continuation + because its compression ancestor is old — the lineage is archived through its tip, and an idle + ended tip still takes its whole chain with it.""" + live_root, live_tip = _stale_lineage(db, "live") + db.append_message(live_tip, "user", "still chatting") + stale_root, stale_tip = _stale_lineage(db, "stale") + stale = time.time() - 35 * 86400 + db._conn.execute("UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'cli_close', last_activity_at = ? " + "WHERE id = ?", (stale, stale + 10, stale + 10, stale_tip)) + db._conn.commit() + + assert [r["id"] for r in db.list_prune_candidates(older_than_days=30, archived=False, lineage_tips_only=True)] == [stale_tip] + assert db.archive_sessions(older_than_days=30) == 1 + + assert {s: db.get_session(s)["archived"] for s in (live_root, live_tip)} == {live_root: 0, live_tip: 0} + assert {s: db.get_session(s)["archived"] for s in (stale_root, stale_tip)} == {stale_root: 1, stale_tip: 1} + assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == [live_tip] diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index eff8e03494..af3b2b9cd8 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -585,7 +585,10 @@ hermes sessions archive --title "dry run" --yes ``` At least one filter is required — a bare `hermes sessions archive` refuses to -archive your entire history. Archived sessions are hidden from +archive your entire history. A compacted conversation is archived as a unit +through its live tip: an old compression segment never matches on its own age, +so a chat that is still active is never hidden because its history is long. +Archived sessions are hidden from `hermes sessions list` and `/resume` but remain in the database and can be unarchived from the Desktop/Dashboard session list.