diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 76fe1e8cb2..f42381067b 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -156,9 +156,9 @@ 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 +from hermes_state_errors import SessionActiveWriteGuardError logger = logging.getLogger(__name__) diff --git a/hermes_cli/sessions_cmd_browse.py b/hermes_cli/sessions_cmd_browse.py index 1508abdac4..2e095a0a63 100644 --- a/hermes_cli/sessions_cmd_browse.py +++ b/hermes_cli/sessions_cmd_browse.py @@ -4,6 +4,7 @@ delete-with-confirmation; numbered-list fallback when curses is unavailable (Win from typing import Optional from hermes_cli.timefmt import relative_time as _relative_time +from hermes_state_errors import SessionActiveWriteGuardError def _session_status_tag(status: Optional[str]) -> str: @@ -152,7 +153,12 @@ class _CursesBrowser: target, self.confirm_delete = self.confirm_delete, None if key not in {ord("y"), ord("Y")}: return False - if not self.delete_fn(target["id"]): + try: + ok = self.delete_fn(target["id"]) + except SessionActiveWriteGuardError: + self.flash = "Session is active (a live turn owns it); try again when it finishes." + return False + if not ok: self.flash = "Delete failed." return False self.sessions[:] = [s for s in self.sessions if s["id"] != target["id"]] @@ -255,6 +261,8 @@ def _session_browse_picker(sessions: list, session_db=None) -> Optional[str]: sessions_dir = None try: return bool(session_db.delete_session(session_id, sessions_dir=sessions_dir, exclude_active_write_guards=True)) + except SessionActiveWriteGuardError: + raise # the browser tells the user the session is busy instead of a generic failure except Exception: return False try: # curses first; any failure (no curses module, odd terminal) falls back diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 6e70c305b0..31517e8627 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -193,6 +193,8 @@ export const en: Translations = { deleteSelectedConfirmMessage: "This permanently removes {count} selected sessions and all their messages. This cannot be undone.", selectedSessionsDeleted: "{count} sessions deleted", + selectedSessionsSkippedActive: + "{count} sessions kept: a live turn is still running in them", failedToDeleteSelected: "Failed to delete selected sessions", resumeInChat: "Resume in Chat", newChat: "New chat", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index bddad31aca..f7fd2bd983 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -207,6 +207,7 @@ export interface Translations { deleteSelectedConfirmTitle: string; deleteSelectedConfirmMessage: string; selectedSessionsDeleted: string; + selectedSessionsSkippedActive: string; failedToDeleteSelected: string; resumeInChat: string; newChat: string; diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 3bdec148fd..93834ca7d1 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -1403,6 +1403,16 @@ export default function SessionsPage() { ), "success", ); + if (resp.skipped_active?.length) { + // Rows a live turn owns were refused server-side; say so instead of implying all went. + showToast( + t.sessions.selectedSessionsSkippedActive.replace( + "{count}", + String(resp.skipped_active.length), + ), + "error", + ); + } setDeleteSelectedOpen(false); // Drop deleted rows out of the visible list immediately rather // than waiting for the reload. The reload still runs so total /