{"repo":"NousResearch/hermes-agent","public_id":"60747","display_label":"Offload blocking SessionDB calls off event loop","summary":"These issues and PRs address synchronous SQLite/FTS session database operations blocking the event loop, with fixes proposed to offload that work in both the API server and dashboard components.","subsystem":"apps/desktop","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":117041,"merged_at":"2026-09-20T09:45:37+00:00"},"resolution":{"state":"resolved","by":"pr-117041"},"freshness":{"state":"verified","last_verified_at":"2026-09-20T09:45:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":60747,"pr_number":65893,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-16T19:23:33+00:00"},{"issue_number":60747,"pr_number":117041,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-09-20T09:45:37+00:00"}],"members":[{"kind":"issue","number":60747,"title":"Desktop sessions freeze: synchronous SQLite/FTS session loading blocks event loop with large state.db","url":null,"state":"closed"},{"kind":"pr","number":60875,"title":"Offload dashboard session DB work","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65893","number":65893},{"kind":"pr","value":"#63082","number":63082}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":65893,"title":"fix(dashboard): offload blocking SessionDB handlers (supersedes #63082)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","tests/test_web_server_sessiondb_eventloop.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -9936,12 +9936,15 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):\n             status_code=400,\n             detail=\"ids must contain at most 500 entries\",\n         )\n-    db = _open_session_db_for_profile(body.profile)\n-    try:\n-        deleted = db.delete_sessions(body.ids)\n-        return {\"ok\": True, \"deleted\": deleted}\n-    finally:\n-        db.close()\n+    def _delete() -> int:\n+        db = _open_session_db_for_profile(body.profile)\n+        try:\n+            return db.delete_sessions(body.ids)\n+        finally:\n+            db.close()\n+\n+    deleted = await asyncio.to_thread(_delete)\n+    return {\"ok\": True, \"deleted\": deleted}\n \n \n @app.post(\"/api/sessions/import\")","@@ -9978,11 +9981,14 @@ async def count_empty_sessions_endpoint(profile: Optional[str] = None):\n     UI hides the affordance so users aren't presented with a button\n     that does nothing. Cheap, single-COUNT query.\n     \"\"\"\n-    db = _open_session_db_for_profile(profile)\n-    try:\n-        return {\"count\": db.count_empty_sessions()}\n-    finally:\n-        db.close()\n+    def _count() -> int:\n+        db = _open_session_db_for_profile(profile)\n+        try:\n+            return db.count_empty_sessions()\n+        finally:\n+            db.close()\n+\n+    return {\"count\": await asyncio.to_thread(_count)}\n \n \n @app.delete(\"/api/sessions/empty\")","@@ -10005,12 +10011,15 @@ async def delete_empty_sessions_endpoint(profile: Optional[str] = None):\n     prune-on-startup pass. Matching that pre-existing trade-off keeps\n     the two delete endpoints' DB-vs-disk behaviour consistent.\n     \"\"\"\n-    db = _open_session_db_for_profile(profile)\n-    try:\n-        deleted = db.delete_empty_sessions()\n-        return {\"ok\": True, \"deleted\": deleted}\n-    finally:\n-        db.close()\n+    def _delete() -> int:\n+        db = _open_session_db_for_profile(profile)\n+        try:\n+            return db.delete_empty_sessions()\n+        finally:\n+            db.close()\n+\n+    deleted = await asyncio.to_thread(_delete)\n+    return {\"ok\": True, \"deleted\": deleted}\n \n \n @app.get(\"/api/sessions/stats\")","@@ -10080,19 +10089,22 @@ async def get_session_latest_descendant(\n     session_id: str,\n     profile: Optional[str] = None,\n ):\n-    db = _open_session_db_for_profile(profile)\n-    try:\n-        latest, path = _session_latest_descendant(session_id, db)\n-        if not latest:\n-            raise HTTPException(status_code=404, detail=\"Session not found\")\n-        return {\n-            \"requested_session_id\": path[0] if path else session_id,\n-            \"session_id\": latest,\n-            \"path\": path,\n-            \"changed\": bool(path and latest != path[0]),\n-        }\n-    finally:\n-        db.close()\n+    def _lookup():\n+        db = _open_session_db_for_profile(profile)\n+        try:\n+            return _session_latest_descendant(session_id, db)\n+        finally:\n+            db.close()\n+\n+    latest, path = await asyncio.to_thread(_lookup)\n+    if not latest:\n+        raise HTTPException(status_code=404, detail=\"Session not found\")\n+    return {\n+        \"requested_session_id\": path[0] if path else session_id,\n+        \"session_id\": latest,\n+        \"path\": path,\n+        \"changed\": bool(path and latest != path[0]),\n+    }\n \n @app.get(\"/api/sessions/{session_id}/messages\")\n async def get_session_messages(","@@ -10101,26 +10113,32 @@ async def get_session_messages(\n     limit: Optional[int] = None,\n     offset: int = 0,\n ):\n-    db = _open_session_db_for_profile(profile)\n-    try:\n-        sid = db.resolve_session_id(session_id)\n-        if not sid:\n-            raise HTTPException(status_code=404, detail=\"Session not found\")\n-        sid = db.resolve_resume_session_id(sid)\n-        # Clamp limit to prevent abuse (max 500 per page)\n-        _limit = min(limit, 500) if limit is not None else None\n-        messages = db.get_messages(sid, limit=_limit, offset=offset)\n-        return {\n-            \"session_id\": sid,\n-            \"messages\": messages,\n-            \"pagination\": {\n-                \"limit\": _limit,\n-                \"offset\": offset,\n-                \"returned\": len(messages),\n-            },\n-        }\n-    finally:\n-        db.close()\n+    def _read():\n+        db = _open_session_db_for_profile(profile)\n+        try:\n+            sid = db.resolve_session_id(session_id)\n+            if not sid:\n+                return None\n+            sid = db.resolve_resume_session_id(sid)\n+            # Clamp limit to prevent abuse (max 500 per page)\n+            _limit = min(limit, 500) if limit is not None else None\n+            return sid, _limit, db.get_messages(sid, limit=_limit, offset=offset)\n+        finally:\n+            db.close()\n+\n+    result = await asyncio.to_thread(_read)\n+    if result is None:\n+        raise HTTPException(status_code=404, detail=\"Session not found\")\n+    sid, _limit, messages = result\n+    return {\n+        \"session_id\": sid,\n+        \"messages\": messages,\n+        \"pagination\": {\n+            \"limit\": _limit,\n+            \"offset\": offset,\n+            \"returned\": len(messages),\n+        },\n+    }\n \n \n @app.delete(\"/api/sessions/{session_id}\")","@@ -10128,24 +10146,27 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None\n     # ``profile`` deletes a session belonging to another (local) profile by\n     # opening its state.db directly. Remote profiles never reach here — the\n     # desktop routes their DELETE to the remote backend. Omit for current/default.\n-    db = _open_session_db_for_profile(profile)\n-    try:\n-        # Resolve exact ids / unique prefixes like every other session endpoint\n-        # (detail, messages, rename, export all do). A session that no longer\n-        # exists is an idempotent success: DELETE's contract is \"ensure it's\n-        # gone\", and the desktop optimistically removes the row then RESTORES it\n-        # on any error — so a 404 on an already-absent row resurrected a ghost\n-        # row and surfaced \"session not found\". /goal + auto-compression churn\n-        # leaves transient empty rows (reaped by empty-session hygiene) that\n-        # race the sidebar snapshot, which is exactly when this fired. Mirrors\n-        # the bulk-delete endpoint, which already treats ghost ids as success.\n-        sid = db.resolve_session_id(session_id)\n-        if not sid:\n-            return {\"ok\": True, \"already_absent\": True}\n-        db.delete_session(sid)\n-        return {\"ok\": True}\n-    finally:\n-        db.close()\n+    def _delete():\n+        db = _open_session_db_for_profile(profile)\n+        try:\n+            # Resolve exact ids / unique prefixes like every other session endpoint\n+            # (detail, messages, rename, export all do). A session that no longer\n+            # exists is an idempotent success: DELETE's contract is \"ensure it's\n+            # gone\", and the desktop optimistically removes the row then RESTORES it\n+            # on any error — so a 404 on an already-absent row resurrected a ghost\n+            # row and surfaced \"session not found\". /goal + auto-compression churn\n+            # leaves transient empty rows (reaped by empty-session hygiene) that\n+            # race the sidebar snapshot, which is exactly when this fired. Mirrors\n+            # the bulk-delete endpoint, which already treats ghost ids as success.\n+            sid = db.resolve_session_id(session_id)\n+            if not sid:\n+                return {\"ok\": True, \"already_absent\": True}\n+            db.delete_session(sid)\n+            return {\"ok\": True}\n+        finally:\n+            db.close()\n+\n+    return await asyncio.to_thread(_delete)\n \n \n class SessionRename(BaseModel):","@@ -10193,17 +10214,18 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):\n @app.get(\"/api/sessions/{session_id}/export\")\n async def export_session_endpoint(session_id: str, profile: Optional[str] = None):\n     \"\"\"Export a single session (metadata + messages) as JSON.\"\"\"\n-    db = _open_session_db_for_profile(profile)\n-    try:\n-        sid = db.resolve_session_id(session_id)\n-        if not sid:\n-            raise HTTPException(status_code=404, detail=\"Session not found\")\n-        data = db.export_session(sid)\n-        if data is None:\n-            raise HTTPException(status_code=404, detail=\"Session not found\")\n-        return data\n-    finally:\n-        db.close()\n+    def _export():\n+        db = _open_session_db_for_profile(profile)\n+        try:\n+            sid = db.resolve_session_id(session_id)\n+            return db.export_session(sid) if sid else None\n+        finally:\n+            db.close()\n+\n+    data = await asyncio.to_thread(_export)\n+    if data is None:\n+        raise HTTPException(status_code=404, detail=\"Session not found\")\n+    return data\n \n \n class SessionPrune(BaseModel):","@@ -10234,8 +10256,7 @@ class SessionPrune(BaseModel):\n     dry_run: bool = False\n \n \n[redacted:email](\"/api/sessions/prune\")\n-async def prune_sessions_endpoint(body: SessionPrune):\n+def _prune_sessions(body: SessionPrune):\n     \"\"\"Delete ended sessions matching filters (mirrors `hermes sessions prune`).\"\"\"\n     has_window = (\n         body.started_before is not None or body.started_after is not None","@@ -10317,6 +10338,12 @@ async def prune_sessions_endpoint(body: SessionPrune):\n         db.close()\n \n \n[redacted:email](\"/api/sessions/prune\")\n+async def prune_sessions_endpoint(body: SessionPrune):\n+    \"\"\"Delete ended sessions matching filters without blocking the event loop.\"\"\"\n+    return await asyncio.to_thread(_prune_sessions, body)\n+\n+\n # ---------------------------------------------------------------------------\n # Log viewer endpoint\n # ---------------------------------------------------------------------------","@@ -14481,8 +14508,7 @@ def _aux_task_summary(aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n     return result\n \n \n[redacted:email](\"/api/analytics/usage\")\n-async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):\n+def _get_usage_analytics(days: int = 30, profile: Optional[str] = None):\n     from agent.insights import InsightsEngine\n \n     db = _open_session_db_for_profile(profile)","@@ -14563,8 +14589,12 @@ async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):\n         db.close()\n \n \n[redacted:email](\"/api/analytics/models\")\n-async def get_models_analytics(days: int = 30, profile: Optional[str] = None):\n[redacted:email](\"/api/analytics/usage\")\n+async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):\n+    return await asyncio.to_thread(_get_usage_analytics, days, profile)\n+\n+\n+def _get_models_analytics(days: int = 30, profile: Optional[str] = None):\n     \"\"\"Rich per-model analytics for the Models dashboard page.\n \n     Returns token/cost/session breakdown per model plus capability metadata","@@ -14740,6 +14770,12 @@ async def get_models_analytics(days: int = 30, profile: Optional[str] = None):\n         db.close()\n \n \n[redacted:email](\"/api/analytics/models\")\n+async def get_models_analytics(days: int = 30, profile: Optional[str] = None):\n+    \"\"\"Return model analytics without blocking the serving event loop.\"\"\"\n+    return await asyncio.to_thread(_get_models_analytics, days, profile)\n+\n+\n # ---------------------------------------------------------------------------\n # /api/pty — PTY-over-WebSocket bridge for the dashboard \"Chat\" tab.\n #"]},{"file":"tests/test_web_server_sessiondb_eventloop.py","hunks":["@@ -0,0 +1,96 @@\n+import ast\n+import asyncio\n+import threading\n+from pathlib import Path\n+\n+from hermes_cli import web_server\n+\n+\n+TARGET_HANDLERS = {\n+    \"bulk_delete_sessions_endpoint\",\n+    \"count_empty_sessions_endpoint\",\n+    \"delete_empty_sessions_endpoint\",\n+    \"get_session_latest_descendant\",\n+    \"get_session_messages\",\n+    \"delete_session_endpoint\",\n+    \"export_session_endpoint\",\n+    \"prune_sessions_endpoint\",\n+    \"get_usage_analytics\",\n+    \"get_models_analytics\",\n+}\n+\n+\n+def _call_name(call: ast.Call) -> str | None:\n+    if isinstance(call.func, ast.Name):\n+        return call.func.id\n+    if isinstance(call.func, ast.Attribute):\n+        return call.func.attr\n+    return None\n+\n+\n+def test_sessiondb_handlers_open_connections_inside_executor_helpers():\n+    tree = ast.parse(Path(web_server.__file__).read_text(encoding=\"utf-8\"))\n+    handlers = {\n+        node.name: node\n+        for node in tree.body\n+        if isinstance(node, ast.AsyncFunctionDef) and node.name in TARGET_HANDLERS\n+    }\n+    top_level_helpers = {\n+        node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)\n+    }\n+    assert handlers.keys() == TARGET_HANDLERS\n+\n+    for name, handler in handlers.items():\n+        helpers = {\n+            **top_level_helpers,\n+            **{\n+                node.name: node\n+                for node in handler.body\n+                if isinstance(node, ast.FunctionDef)\n+            },\n+        }\n+        offloaded = {\n+            arg.id\n+            for node in ast.walk(handler)\n+            if isinstance(node, ast.Call)\n+            and _call_name(node) == \"to_thread\"\n+            for arg in node.args[:1]\n+            if isinstance(arg, ast.Name)\n+        }\n+        db_open_owners = {\n+            helper_name\n+            for helper_name, helper in helpers.items()\n+            if helper_name in offloaded\n+            and any(\n+                isinstance(node, ast.Call)\n+                and _call_name(node) == \"_open_session_db_for_profile\"\n+                for node in ast.walk(helper)\n+            )\n+        }\n+        assert db_open_owners, f\"{name} does not offload SessionDB open + work\"\n+\n+\n+def test_bulk_delete_sessiondb_work_runs_off_event_loop(monkeypatch):\n+    loop_thread = threading.get_ident()\n+    db_threads: list[int] = []\n+\n+    class _DB:\n+        def delete_sessions(self, ids):\n+            db_threads.append(threading.get_ident())\n+            assert ids == [\"one\", \"two\"]\n+            return 2\n+\n+        def close(self):\n+            db_threads.append(threading.get_ident())\n+\n+    monkeypatch.setattr(web_server, \"_open_session_db_for_profile\", lambda profile=None: _DB())\n+\n+    result = asyncio.run(\n+        web_server.bulk_delete_sessions_endpoint(\n+            web_server.BulkDeleteSessions(ids=[\"one\", \"two\"])\n+        )\n+    )\n+\n+    assert result == {\"ok\": True, \"deleted\": 2}\n+    assert db_threads\n+    assert all(thread_id != loop_thread for thread_id in db_threads)\n"]}]},{"kind":"pr","number":117041,"title":"Dashboard session search/stats/detail no longer block the event loop on a large state.db (#60747)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_routers/sessions.py","tests/hermes_cli/test_web_server_sessiondb_eventloop.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}