From 230f89b47b894a618d16c5b87b5036bf101f8238 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:34:22 +0530 Subject: [PATCH] fix(sessions): share the write-guard filter and trim repeated walks prune_sessions hand-rolled the same "guarded by a live lease/lock" comprehension as the new _guarded_ids helper, so the two could drift on the next guard change. Move _guarded_ids next to _write_guards_reject in the maintenance mixin and have prune call it. delete_session walked the delegate tree up to three times in one write transaction; compute the target ids once for both the guard check and the expected-ids fence. delete_sessions ran a per-root guard walk for every selected id; do one batched check over all roots and their children first and only attribute per root when something is actually guarded. The CLI export --delete message repeated "session 'X'" because the exception text already names the session. --- hermes_cli/sessions_cmd.py | 2 +- hermes_state_maintenance.py | 10 +++++++--- hermes_state_sessions.py | 28 +++++++++++++--------------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index a4b836444f..f275bc33b0 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -572,7 +572,7 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical "changed after export.") return except SessionActiveWriteGuardError as exc: - print(f"Exported, but session '{resolved_session_id}' was not deleted because it is active: {exc}") + print(f"Exported, but not deleted: {exc}") return delegates = len(delete_target_ids) - 1 delegate_suffix = f" and {delegates} delegate session{'' if delegates == 1 else 's'}" if delegates else "" diff --git a/hermes_state_maintenance.py b/hermes_state_maintenance.py index 1bec2dda49..77fde86158 100644 --- a/hermes_state_maintenance.py +++ b/hermes_state_maintenance.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import time from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple from hermes_state_common import ( AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _non_continuation_child_sql, _placeholders, _sql_session_last_active, @@ -118,6 +118,11 @@ class SessionMaintenanceMixin: self._remove_session_files(sessions_dir, sid) return len(removed_ids) + def _guarded_ids(self, conn, ids: Iterable[str]) -> set: + """Ids in *ids* protected by a live turn lease / compression lock. Idle compression-ended + parents are closed, not live, so they are not guarded (prune and delete share this).""" + return {sid for sid in ids if self._write_guards_reject(conn, sid, allow_closed_compression_parent=True)} + def _write_guards_reject(self, conn, sid: str, **kwargs) -> bool: """True when a live turn lease / compression lock protects ``sid``; expired or dead-holder guards are reclaimed and fenced as a side effect.""" @@ -308,8 +313,7 @@ class SessionMaintenanceMixin: cursor = conn.execute(f"SELECT s.id FROM sessions s WHERE {where}", where_params) session_ids = {row["id"] for row in cursor.fetchall()} if exclude_active_write_guards: - session_ids -= {sid for sid in session_ids - if self._write_guards_reject(conn, sid, allow_closed_compression_parent=True)} + session_ids -= self._guarded_ids(conn, session_ids) if not session_ids: return 0 # Batched: a cron-heavy store prunes tens of thousands of ids in one call. diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 18e8eb8744..10f684cc25 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -1576,16 +1576,16 @@ class SessionSessionsMixin: def _do(conn): if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None: return False - if exclude_active_write_guards and self._guarded_ids( - conn, [session_id, *_collect_delegate_child_ids(conn, [session_id])], - ): + target_ids = ( + [session_id, *_collect_delegate_child_ids(conn, [session_id])] + if exclude_active_write_guards or expected_ids is not None else None + ) + if exclude_active_write_guards and self._guarded_ids(conn, target_ids): # Delegate children cascade with the root, so a guard on any of them refuses too. raise SessionActiveWriteGuardError( f"session '{session_id}' (or a delegate child) has an active turn lease or compression lock" ) - if expected_ids is not None and expected_ids != { - session_id, *_collect_delegate_child_ids(conn, [session_id]) - }: + if expected_ids is not None and expected_ids != set(target_ids): return False if expected_display_messages is not None and any( self._display_messages_from_conn(conn, covered_id) != expected @@ -1633,11 +1633,6 @@ class SessionSessionsMixin: self._remove_session_files(sessions_dir, session_id) return deleted - def _guarded_ids(self, conn, ids: List[str]) -> set: - """Ids in *ids* protected by a live turn lease / compression lock. Idle compression-ended - parents are closed, not live, so they are not guarded (matches prune).""" - return {sid for sid in ids if self._write_guards_reject(conn, sid, allow_closed_compression_parent=True)} - def delete_sessions( self, session_ids: List[str], sessions_dir: Optional[Path] = None, exclude_active_write_guards: bool = False, skipped_ids: Optional[List[str]] = None, @@ -1659,10 +1654,13 @@ class SessionSessionsMixin: if exclude_active_write_guards: # A root is skipped when it or any delegate child it would cascade is guarded, so the # cascade below never deletes a guarded row reported back as kept. - active_ids = { - sid for sid in existing - if self._guarded_ids(conn, [sid, *_collect_delegate_child_ids(conn, [sid])]) - } + # One batched check first; per-root attribution only when something is guarded. + active_ids: set = set() + if self._guarded_ids(conn, [*existing, *_collect_delegate_child_ids(conn, existing)]): + active_ids = { + sid for sid in existing + if self._guarded_ids(conn, [sid, *_collect_delegate_child_ids(conn, [sid])]) + } existing = [sid for sid in existing if sid not in active_ids] if skipped_ids is not None: skipped_ids.extend(sorted(active_ids))