fix(sessions): refuse to delete a session row a live turn still owns (#123583)

Refactor entry-side deletion refusal to execute in-transaction via
`_write_guards_reject(conn, sid)` (#123583), per maintainer review:

- Underlying `delete_session` and `delete_sessions` now accept an opt-in
  kwarg `exclude_active_write_guards=True` running inside `_do` write
  transaction, eliminating the race condition where a turn acquires the lease
  between check and delete.
- Raises `SessionActiveWriteGuardError` when refusing single delete, leaving
  the row untouched; `delete_sessions` atomically skips active rows.
- Checks both active turn leases and compression locks via the existing
  reclaim-aware `_write_guards_reject` helper.
- Covers all user-facing delete sinks:
  * Web `DELETE /api/sessions/{id}` -> 409 Conflict
  * Web `POST /api/sessions/bulk-delete` -> skips active rows
  * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage
    parents of active conversations are not pruned
  * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn
  * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1
  * CLI browse picker -> refuses active delete
  * TUI Gateway `session.delete` -> 4023 error
- Conforms to rubric with 2 targeted invariant tests in
  `tests/hermes_state/test_delete_session_write_guards.py`.
- Updates user guide and web dashboard docs for 409 / exit 1.

(cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
This commit is contained in:
shali10
2026-09-27 11:13:42 +08:00
committed by kshitij
parent a7f146fd15
commit 40523600b0
12 changed files with 132 additions and 22 deletions

View File

@@ -1563,15 +1563,23 @@ class SessionSessionsMixin:
self, session_id: str, sessions_dir: Optional[Path] = None,
expected_delete_ids: Optional[List[str]] = None,
expected_display_messages: Optional[Dict[str, List[Dict[str, Any]]]] = None,
exclude_active_write_guards: bool = False,
) -> bool:
"""Delete a session and its messages; delegate children cascade, branch/compression children
are orphaned. Optional expected ids fence delegate drift; expected display snapshots fence
transcript drift. Both checks run inside the same write transaction as deletion."""
transcript drift. Both checks run inside the same write transaction as deletion.
With ``exclude_active_write_guards``, raises :class:`SessionActiveWriteGuardError` if the row
is protected by an active turn lease or compression lock."""
from hermes_state_errors import SessionActiveWriteGuardError
removed_ids: List[str] = []
expected_ids = set(expected_delete_ids) if expected_delete_ids is not None else None
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._write_guards_reject(conn, session_id):
raise SessionActiveWriteGuardError(
f"session '{session_id}' 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])
}:
@@ -1622,9 +1630,13 @@ class SessionSessionsMixin:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_sessions(self, session_ids: List[str], sessions_dir: Optional[Path] = None) -> int:
def delete_sessions(
self, session_ids: List[str], sessions_dir: Optional[Path] = None,
exclude_active_write_guards: bool = False,
) -> int:
"""Bulk delete with :meth:`delete_session` semantics per row, in ONE transaction. Unknown ids
are skipped (UI selection can race another tab's delete). Returns the number deleted."""
are skipped (UI selection can race another tab's delete). With ``exclude_active_write_guards``,
rows protected by an active turn lease or compression lock are skipped. Returns the number deleted."""
unique_ids = list({sid for sid in session_ids or () if isinstance(sid, str) and sid})
if not unique_ids:
return 0
@@ -1635,6 +1647,11 @@ class SessionSessionsMixin:
).fetchall()]
if not existing:
return 0
if exclude_active_write_guards:
active_ids = {sid for sid in existing if self._write_guards_reject(conn, sid)}
existing = [sid for sid in existing if sid not in active_ids]
if not existing:
return 0
removed_ids.extend(_delete_delegate_children(conn, existing))
for chunk in _id_chunks(existing):
ph = _session_ids_placeholders(chunk)