{"repo":"NousResearch/hermes-agent","public_id":"2130","display_label":"Thread safety fixes for session and message counts","summary":"Multiple issues and pull requests addressing missing thread locks in session_count() and message_count() methods, with related concurrent subagent thread safety fixes. The group centers on a specific thread-safety bug and its proposed fixes.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2466},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-03-22T11:40:21+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":2128,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2130,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2137,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2428,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2130,"title":"Bug: session_count() and message_count() missing thread lock","url":null,"state":"closed"},{"kind":"pr","number":2131,"title":"fix(state): add missing thread lock to session_count() and message_count()","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":2133,"title":"fix(state): add thread lock to session_count() and message_count()","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":2251,"title":"fix(state): add missing thread lock to session_count() and message_count()","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":2288,"title":"fix(state): add missing thread lock to session_count() and message_count()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2466","number":2466}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2466,"title":"fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/email.py","hermes_state.py","run_agent.py","tools/voice_mode.py"],"fix_hunks":[{"file":"gateway/platforms/email.py","hunks":["@@ -230,7 +230,7 @@ async def connect(self) -> bool:\n             # Mark all existing messages as seen so we only process new ones\n             imap.select(\"INBOX\")\n             status, data = imap.uid(\"search\", None, \"ALL\")\n-            if status == \"OK\" and data[0]:\n+            if status == \"OK\" and data and data[0]:\n                 for uid in data[0].split():\n                     self._seen_uids.add(uid)\n             imap.logout()","@@ -295,7 +295,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:\n             imap.select(\"INBOX\")\n \n             status, data = imap.uid(\"search\", None, \"UNSEEN\")\n-            if status != \"OK\" or not data[0]:\n+            if status != \"OK\" or not data or not data[0]:\n                 imap.logout()\n                 return results\n "]},{"file":"hermes_state.py","hunks":["@@ -855,23 +855,25 @@ def search_sessions(\n \n     def session_count(self, source: str = None) -> int:\n         \"\"\"Count sessions, optionally filtered by source.\"\"\"\n-        if source:\n-            cursor = self._conn.execute(\n-                \"SELECT COUNT(*) FROM sessions WHERE source = ?\", (source,)\n-            )\n-        else:\n-            cursor = self._conn.execute(\"SELECT COUNT(*) FROM sessions\")\n-        return cursor.fetchone()[0]\n+        with self._lock:\n+            if source:\n+                cursor = self._conn.execute(\n+                    \"SELECT COUNT(*) FROM sessions WHERE source = ?\", (source,)\n+                )\n+            else:\n+                cursor = self._conn.execute(\"SELECT COUNT(*) FROM sessions\")\n+            return cursor.fetchone()[0]\n \n     def message_count(self, session_id: str = None) -> int:\n         \"\"\"Count messages, optionally for a specific session.\"\"\"\n-        if session_id:\n-            cursor = self._conn.execute(\n-                \"SELECT COUNT(*) FROM messages WHERE session_id = ?\", (session_id,)\n-            )\n-        else:\n-            cursor = self._conn.execute(\"SELECT COUNT(*) FROM messages\")\n-        return cursor.fetchone()[0]\n+        with self._lock:\n+            if session_id:\n+                cursor = self._conn.execute(\n+                    \"SELECT COUNT(*) FROM messages WHERE session_id = ?\", (session_id,)\n+                )\n+            else:\n+                cursor = self._conn.execute(\"SELECT COUNT(*) FROM messages\")\n+            return cursor.fetchone()[0]\n \n     # =========================================================================\n     # Export and cleanup"]},{"file":"run_agent.py","hunks":["@@ -108,7 +108,7 @@\n \n \n class _SafeWriter:\n-    \"\"\"Transparent stdio wrapper that catches OSError from broken pipes.\n+    \"\"\"Transparent stdio wrapper that catches OSError/ValueError from broken pipes.\n \n     When hermes-agent runs as a systemd service, Docker container, or headless\n     daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer","@@ -117,8 +117,13 @@ class _SafeWriter:\n     run_conversation() — especially via double-fault when an except handler\n     also tries to print.\n \n+    Additionally, when subagents run in ThreadPoolExecutor threads, the shared\n+    stdout handle can close between thread teardown and cleanup, raising\n+    ``ValueError: I/O operation on closed file`` instead of OSError.\n+\n     This wrapper delegates all writes to the underlying stream and silently\n-    catches OSError. It is transparent when the wrapped stream is healthy.\n+    catches both OSError and ValueError. It is transparent when the wrapped\n+    stream is healthy.\n     \"\"\"\n \n     __slots__ = (\"_inner\",)","@@ -129,13 +134,13 @@ def __init__(self, inner):\n     def write(self, data):\n         try:\n             return self._inner.write(data)\n-        except OSError:\n+        except (OSError, ValueError):\n             return len(data) if isinstance(data, str) else 0\n \n     def flush(self):\n         try:\n             self._inner.flush()\n-        except OSError:\n+        except (OSError, ValueError):\n             pass\n \n     def fileno(self):","@@ -144,7 +149,7 @@ def fileno(self):\n     def isatty(self):\n         try:\n             return self._inner.isatty()\n-        except OSError:\n+        except (OSError, ValueError):\n             return False\n \n     def __getattr__(self, name):","@@ -2438,7 +2443,18 @@ def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any\n                 \"Pre-call sanitizer: added %d stub tool result(s)\",\n                 len(missing_results),\n             )\n-\n+        # 3. Strip trailing empty assistant messages to prevent prefill rejection.\n+        # These can leak from Responses API reasoning-only turns (Codex/MiniMax)\n+        # where an empty assistant message is required by the Responses API but\n+        # must NOT be sent to Chat Completions or Anthropic Messages API providers.\n+        while (\n+            messages\n+            and messages[-1].get(\"role\") == \"assistant\"\n+            and not (messages[-1].get(\"content\") or \"\").strip()\n+            and not messages[-1].get(\"tool_calls\")\n+        ):\n+            logger.debug(\"Pre-call sanitizer: removed trailing empty assistant message\")\n+            messages = messages[:-1]\n         return messages\n \n     @staticmethod"]},{"file":"tools/voice_mode.py","hunks":["@@ -81,8 +81,15 @@ def detect_audio_environment() -> dict:\n                 warnings.append(\"No audio input/output devices detected\")\n         except Exception:\n             warnings.append(\"Audio subsystem error (PortAudio cannot query devices)\")\n-    except (ImportError, OSError):\n+    except ImportError:\n         warnings.append(\"Audio libraries not installed (pip install sounddevice numpy)\")\n+    except OSError:\n+        warnings.append(\n+            \"PortAudio system library not found -- install it first:\\n\"\n+            \"  Linux:  sudo apt-get install libportaudio2\\n\"\n+            \"  macOS:  brew install portaudio\\n\"\n+            \"Then retry /voice on.\"\n+        )\n \n     return {\n         \"available\": len(warnings) == 0,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}