fix(sessions): pinned-skip note counts pinned rows, not spared ancestors

_note_pinned_skipped diffed count_prune_matches(include_pinned=True/False)
with whole_lineages on for prune. Excluding a pinned tip turns it into an
unmatched continuation, so its unpinned compression ancestors drop out of
the "without" count too and the note reported them as pinned (chain
a -> a-2 -> a-3 with only a-3 pinned said "3 pinned sessions").

Count plain filter matches for the note; the delete itself is unchanged.
count_prune_matches has no other caller that needs whole_lineages, so the
keyword goes back to base's signature.
This commit is contained in:
kshitijk4poor
2026-09-26 23:03:11 +05:30
committed by kshitij
parent 257f70db28
commit f1ad1934a4
3 changed files with 15 additions and 6 deletions

View File

@@ -639,8 +639,9 @@ def _note_pinned_skipped(db, filters, action):
"""Tell the user how many pinned rows bulk prune/archive spared (pin = durable keep; only
`prune --include-pinned` opts in, archive always spares them)."""
_base = {k: v for k, v in filters.items() if k != "include_pinned"}
with_pinned, without = (int(db.count_prune_matches(**_base, include_pinned=flag, whole_lineages=action == "prune"))
for flag in (True, False))
# Count matching pinned rows only: whole-lineage selection would also drop the unpinned
# ancestors a pinned tip spares, and report them as pinned.
with_pinned, without = (int(db.count_prune_matches(**_base, include_pinned=flag)) for flag in (True, False))
skipped = max(with_pinned - without, 0)
if not skipped:
return

View File

@@ -250,10 +250,10 @@ class SessionMaintenanceMixin:
FROM sessions s WHERE {where}
ORDER BY last_active ASC, s.started_at ASC""", params)]
def count_prune_matches(self, older_than_days: Optional[float] = None, source: str = None, *,
whole_lineages: bool = False, **filters) -> int:
def count_prune_matches(self, older_than_days: Optional[float] = None, source: str = None,
**filters) -> int:
"""Count-only :meth:`list_prune_candidates` (CLI reports spared pinned sessions)."""
where, params = self._prune_where(older_than_days, source, filters, whole_lineages=whole_lineages)
where, params = self._prune_where(older_than_days, source, filters)
return int(self._read_one(f"SELECT COUNT(*) FROM sessions s WHERE {where}", params)[0])
def count_open_prune_matches(self, older_than_days: Optional[float] = None, source: str = None, *,

View File

@@ -1,6 +1,7 @@
import time
from contextlib import closing
import pytest
from hermes_cli.sessions_cmd import _note_pinned_skipped
from hermes_state import SessionDB
@@ -47,7 +48,7 @@ def test_prune_sessions_respects_touch_session_activity(tmp_path):
assert db.get_session("active_by_heartbeat") is not None
def test_prune_keeps_the_compressed_segments_of_a_chat_still_in_use(tmp_path):
def test_prune_keeps_the_compressed_segments_of_a_chat_still_in_use(tmp_path, capsys):
"""A rotated conversation ages as one: its old compressed-away segments stay while a later segment
is still in use, and go together once the whole chat is idle. The CLI/dashboard preview lists what prune deletes."""
with closing(SessionDB(tmp_path / "state.db")) as db:
@@ -74,6 +75,13 @@ def test_prune_keeps_the_compressed_segments_of_a_chat_still_in_use(tmp_path):
db._conn.commit()
idle = {"idle", "idle-2", "idle-3"}
# The CLI pinned-skip note counts pinned rows, not the unpinned ancestors a pinned tip spares.
db._conn.execute("UPDATE sessions SET pinned = 1 WHERE id = 'idle-3'")
db._conn.commit()
_note_pinned_skipped(db, {"older_than_days": 90, "lineage_tips_only": False}, "prune")
assert "Note: 1 pinned session also match" in capsys.readouterr().out
db._conn.execute("UPDATE sessions SET pinned = 0 WHERE id = 'idle-3'")
db._conn.commit()
assert {c["id"] for c in db.list_prune_candidates(older_than_days=90, whole_lineages=True)} == idle
assert db.prune_sessions(older_than_days=90) == len(idle)
assert db.get_compression_lineage("live-3") == ["live", "live-2", "live-3"]