Files
hermes-agent/tests/hermes_state/test_delete_session_write_guards.py
shali10 40523600b0 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)
2026-09-27 20:49:04 +05:30

66 lines
2.8 KiB
Python

"""Invariant tests for entry-side deletion refusal on active write guards (#123583)."""
import os
import pytest
from hermes_state import SessionDB
from hermes_state_errors import SessionActiveWriteGuardError
def test_delete_session_refuses_when_write_guard_active(tmp_path):
"""Invariant 1: delete_session(..., exclude_active_write_guards=True) refuses inside
the transaction while an active turn lease or compression lock protects the row."""
path = tmp_path / "state.db"
db = SessionDB(path)
db.create_session("sess-lease", source="test")
db.create_session("sess-cmp", source="test")
turn_holder = f"pid={os.getpid()}:turn=1"
cmp_holder = f"pid={os.getpid()}:cmp=1"
assert db.try_acquire_session_turn_lease("sess-lease", turn_holder, ttl_seconds=300.0) is True
assert db.try_acquire_compression_lock("sess-cmp", cmp_holder, ttl_seconds=300.0) is True
# 1. Active turn lease -> raises SessionActiveWriteGuardError and row survives
with pytest.raises(SessionActiveWriteGuardError):
db.delete_session("sess-lease", exclude_active_write_guards=True)
assert db.get_session("sess-lease") is not None
# 2. Active compression lock -> raises SessionActiveWriteGuardError and row survives
with pytest.raises(SessionActiveWriteGuardError):
db.delete_session("sess-cmp", exclude_active_write_guards=True)
assert db.get_session("sess-cmp") is not None
# 3. Released guards -> deletion succeeds
db.release_session_turn_lease("sess-lease", turn_holder)
db.release_compression_lock("sess-cmp", cmp_holder)
assert db.delete_session("sess-lease", exclude_active_write_guards=True) is True
assert db.get_session("sess-lease") is None
assert db.delete_session("sess-cmp", exclude_active_write_guards=True) is True
assert db.get_session("sess-cmp") is None
db.close()
def test_delete_sessions_bulk_skips_active_write_guards(tmp_path):
"""Invariant 2: delete_sessions(..., exclude_active_write_guards=True) atomically
skips rows with active guards and removes only the idle ones."""
path = tmp_path / "state.db"
db = SessionDB(path)
db.create_session("bulk-active", source="test")
db.create_session("bulk-idle", source="test")
turn_holder = f"pid={os.getpid()}:turn=bulk"
assert db.try_acquire_session_turn_lease("bulk-active", turn_holder, ttl_seconds=300.0) is True
deleted_count = db.delete_sessions(["bulk-active", "bulk-idle"], exclude_active_write_guards=True)
assert deleted_count == 1
# Protected row survived; idle row was deleted
assert db.get_session("bulk-active") is not None
assert db.get_session("bulk-idle") is None
# Lineage protection: ended compression parent of an active conversation is spared by prune
db.release_session_turn_lease("bulk-active", turn_holder)
db.close()