diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 2c96dd9f33..76fe1e8cb2 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -156,6 +156,7 @@ from gateway.browser_control_broker import ( browser_control_protocol_supported, filter_browser_control_capabilities, get_browser_control_broker) from gateway.platforms._shared import coerce_port as _coerce_port +from hermes_state_errors import SessionActiveWriteGuardError from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret from gateway.platforms.tcp_site import start_tcp_site @@ -3216,7 +3217,6 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter): sessions_dir = Path(get_hermes_home()) / "sessions" except Exception: logger.debug("sessions dir unavailable for delete of %s", session_id, exc_info=True) - from hermes_state_errors import SessionActiveWriteGuardError try: deleted = await asyncio.to_thread( db.delete_session, session_id, sessions_dir=sessions_dir, exclude_active_write_guards=True) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index 6348b69ee3..a4b836444f 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -16,6 +16,7 @@ from pathlib import Path from hermes_cli.cli_output import print_truncated from hermes_cli.sessions_cmd_browse import _relative_time, _session_browse_picker +from hermes_state_errors import SessionActiveWriteGuardError def get_hermes_home(): @@ -562,7 +563,6 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical print(f"Export verification failed; not deleting session '{data.get('id')}': {reason}") return expected_messages.update(snapshots) - from hermes_state_errors import SessionActiveWriteGuardError try: if not db.delete_session( resolved_session_id, sessions_dir=_sessions_dir(), expected_delete_ids=delete_target_ids, @@ -593,7 +593,6 @@ def _cmd_delete(db, args): return elif _pinned_note: print(f"Warning: deleting a pinned session '{resolved_session_id}'.") - from hermes_state_errors import SessionActiveWriteGuardError try: if not db.delete_session(resolved_session_id, sessions_dir=_sessions_dir(), exclude_active_write_guards=True): return _not_found(args.session_id) diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py index f5e69b4176..7be68ec2a7 100644 --- a/hermes_cli/web_routers/sessions.py +++ b/hermes_cli/web_routers/sessions.py @@ -440,9 +440,10 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): if len(body.ids) > 500: raise HTTPException(status_code=400, detail="ids must contain at most 500 entries") profile = destructive_profile(body.profile, "POST /api/sessions/bulk-delete") - deleted = await asyncio.to_thread( - _with_db, profile, lambda db: db.delete_sessions(body.ids, exclude_active_write_guards=True), read_only=False) - return {"ok": True, "deleted": deleted} + skipped: list[str] = [] # rows a live turn/compression still owns; the UI must keep them listed + deleted = await asyncio.to_thread(_with_db, profile, lambda db: db.delete_sessions( + body.ids, exclude_active_write_guards=True, skipped_ids=skipped), read_only=False) + return {"ok": True, "deleted": deleted, "skipped_active": skipped} @manage_router.post("/api/sessions/import") diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 3714579185..0117d6768a 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -15,6 +15,7 @@ from agent.session_activity import ( ActivityProvenance, bound_activity_description, normalize_activity_provenance, ) from hermes_startup_watchdog import report_startup_progress +from hermes_state_errors import SessionActiveWriteGuardError from hermes_state_common import ( _LISTABLE_CHILD_SQL, _PREVIEW_ELIGIBLE_SQL, _PREVIEW_RAW_SELECT, _RECOVERABLE_END_REASONS, _RECOVERABLE_END_REASONS_SQL, _RESET_CHILD_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _non_continuation_child_sql, @@ -1570,7 +1571,6 @@ class SessionSessionsMixin: 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): @@ -1632,11 +1632,12 @@ class SessionSessionsMixin: def delete_sessions( self, session_ids: List[str], sessions_dir: Optional[Path] = None, - exclude_active_write_guards: bool = False, + exclude_active_write_guards: bool = False, skipped_ids: Optional[List[str]] = None, ) -> 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). With ``exclude_active_write_guards``, - rows protected by an active turn lease or compression lock are skipped. Returns the number deleted.""" + rows protected by an active turn lease or compression lock are skipped and, when given, appended + to ``skipped_ids`` so callers can tell the user. 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 @@ -1650,6 +1651,8 @@ class SessionSessionsMixin: 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 skipped_ids is not None: + skipped_ids.extend(sorted(active_ids)) if not existing: return 0 removed_ids.extend(_delete_delegate_children(conn, existing)) diff --git a/tests/hermes_state/test_delete_session_write_guards.py b/tests/hermes_state/test_delete_session_write_guards.py index 5ab9b65972..70c286c4e8 100644 --- a/tests/hermes_state/test_delete_session_write_guards.py +++ b/tests/hermes_state/test_delete_session_write_guards.py @@ -53,13 +53,15 @@ def test_delete_sessions_bulk_skips_active_write_guards(tmp_path): 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) + skipped: list[str] = [] + deleted_count = db.delete_sessions( + ["bulk-active", "bulk-idle"], exclude_active_write_guards=True, skipped_ids=skipped) assert deleted_count == 1 + assert skipped == ["bulk-active"] # reported to the caller, not silently dropped # 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() diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 9e449a57c7..7a52d152c8 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -7,6 +7,7 @@ server.py the same way (tests monkeypatching ``server.X`` still intercept).""" import contextlib from .method_ctx import HandlerRegistry, bind_module +from hermes_state_errors import SessionActiveWriteGuardError _registry = HandlerRegistry() method = _registry.method @@ -1086,7 +1087,6 @@ def _(rid, params: dict) -> dict: with _profile_db(params, writer=True) as db: if db is None: return _db_unavailable_error(rid, code=5036) - from hermes_state_errors import SessionActiveWriteGuardError try: home = Path(profile_home) if profile_home is not None else get_hermes_home() deleted = db.delete_session(target, sessions_dir=home / "sessions", exclude_active_write_guards=True) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 05dbc8b4c4..96bf20e003 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -502,7 +502,7 @@ export const api = { }, ), bulkDeleteSessions: (ids: string[], profile = getManagementProfile()) => - fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", { + fetchJSON<{ ok: boolean; deleted: number; skipped_active?: string[] }>("/api/sessions/bulk-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, profile: profile || undefined }), diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 618bbe45d9..3bdec148fd 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -1408,7 +1408,9 @@ export default function SessionsPage() { // than waiting for the reload. The reload still runs so total / // pagination stays correct, and so any rows the reload pulls in // from later pages render in place. - const deletedSet = new Set(ids); + // Rows a live turn still owns were refused server-side; keep them listed. + const skipped = new Set(resp.skipped_active ?? []); + const deletedSet = new Set(ids.filter((id) => !skipped.has(id))); setSessions((prev) => prev.filter((s) => !deletedSet.has(s.id))); setTotal((prev) => Math.max(0, prev - resp.deleted)); if (expandedId && deletedSet.has(expandedId)) setExpandedId(null);