# NousResearch/hermes-agent · Thread safety fixes for session and message counts

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #2466. Verify it addresses your case.
- **Recommended:** read PR #2466 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.33`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2130: Bug: session_count() and message_count() missing thread lock

## Prior & competing PRs

- **#2251: fix(state): add missing thread lock to session_count() and message_count()**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#2466: fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/email.py`, `hermes_state.py`, `run_agent.py`, `tools/voice_mode.py`
  - fix hunks:
  - `gateway/platforms/email.py`

    ```diff
    @@ -230,7 +230,7 @@ async def connect(self) -> bool:
                 # Mark all existing messages as seen so we only process new ones
                 imap.select("INBOX")
                 status, data = imap.uid("search", None, "ALL")
    -            if status == "OK" and data[0]:
    +            if status == "OK" and data and data[0]:
                     for uid in data[0].split():
                         self._seen_uids.add(uid)
                 imap.logout()
    ```

    ```diff
    @@ -295,7 +295,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
                 imap.select("INBOX")
     
                 status, data = imap.uid("search", None, "UNSEEN")
    -            if status != "OK" or not data[0]:
    +            if status != "OK" or not data or not data[0]:
                     imap.logout()
                     return results
     
    ```
  - `hermes_state.py`

    ```diff
    @@ -855,23 +855,25 @@ def search_sessions(
     
         def session_count(self, source: str = None) -> int:
             """Count sessions, optionally filtered by source."""
    -        if source:
    -            cursor = self._conn.execute(
    -                "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
    -            )
    -        else:
    -            cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
    -        return cursor.fetchone()[0]
    +        with self._lock:
    +            if source:
    +                cursor = self._conn.execute(
    +                    "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
    +                )
    +            else:
    +                cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
    +            return cursor.fetchone()[0]
     
         def message_count(self, session_id: str = None) -> int:
             """Count messages, optionally for a specific session."""
    -        if session_id:
    -            cursor = self._conn.execute(
    -                "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
    -            )
    -        else:
    -            cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
    -        return cursor.fetchone()[0]
    +        with self._lock:
    +            if session_id:
    +                cursor = self._conn.execute(
    +                    "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
    +                )
    +            else:
    +                cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
    +            return cursor.fetchone()[0]
     
         # =========================================================================
         # Export and cleanup
    ```
  - `run_agent.py`

    ```diff
    @@ -108,7 +108,7 @@
     
     
     class _SafeWriter:
    -    """Transparent stdio wrapper that catches OSError from broken pipes.
    +    """Transparent stdio wrapper that catches OSError/ValueError from broken pipes.
     
         When hermes-agent runs as a systemd service, Docker container, or headless
         daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer
    ```

    ```diff
    @@ -117,8 +117,13 @@ class _SafeWriter:
         run_conversation() — especially via double-fault when an except handler
         also tries to print.
     
    +    Additionally, when subagents run in ThreadPoolExecutor threads, the shared
    +    stdout handle can close between thread teardown and cleanup, raising
    +    ``ValueError: I/O operation on closed file`` instead of OSError.
    +
         This wrapper delegates all writes to the underlying stream and silently
    -    catches OSError. It is transparent when the wrapped stream is healthy.
    +    catches both OSError and ValueError. It is transparent when the wrapped
    +    stream is healthy.
         """
     
         __slots__ = ("_inner",)
    ```

    ```diff
    @@ -129,13 +134,13 @@ def __init__(self, inner):
         def write(self, data):
             try:
                 return self._inner.write(data)
    -        except OSError:
    +        except (OSError, ValueError):
                 return len(data) if isinstance(data, str) else 0
     
         def flush(self):
             try:
                 self._inner.flush()
    -        except OSError:
    +        except (OSError, ValueError):
                 pass
     
         def fileno(self):
    ```

    ```diff
    @@ -144,7 +149,7 @@ def fileno(self):
         def isatty(self):
             try:
                 return self._inner.isatty()
    -        except OSError:
    +        except (OSError, ValueError):
                 return False
     
         def __getattr__(self, name):
    ```

    ```diff
    @@ -2438,7 +2443,18 @@ def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any
                     "Pre-call sanitizer: added %d stub tool result(s)",
                     len(missing_results),
                 )
    -
    +        # 3. Strip trailing empty assistant messages to prevent prefill rejection.
    +        # These can leak from Responses API reasoning-only turns (Codex/MiniMax)
    +        # where an empty assistant message is required by the Responses API but
    +        # must NOT be sent to Chat Completions or Anthropic Messages API providers.
    +        while (
    +            messages
    +            and messages[-1].get("role") == "assistant"
    +            and not (messages[-1].get("content") or "").strip()
    +            and not messages[-1].get("tool_calls")
    +        ):
    +            logger.debug("Pre-call sanitizer: removed trailing empty assistant message")
    +            messages = messages[:-1]
             return messages
     
         @staticmethod
    ```
  - `tools/voice_mode.py`

    ```diff
    @@ -81,8 +81,15 @@ def detect_audio_environment() -> dict:
                     warnings.append("No audio input/output devices detected")
             except Exception:
                 warnings.append("Audio subsystem error (PortAudio cannot query devices)")
    -    except (ImportError, OSError):
    +    except ImportError:
             warnings.append("Audio libraries not installed (pip install sounddevice numpy)")
    +    except OSError:
    +        warnings.append(
    +            "PortAudio system library not found -- install it first:\n"
    +            "  Linux:  sudo apt-get install libportaudio2\n"
    +            "  macOS:  brew install portaudio\n"
    +            "Then retry /voice on."
    +        )
     
         return {
             "available": len(warnings) == 0,
    ```
- **#2131: fix(state): add missing thread lock to session_count() and message_count()**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#2133: fix(state): add thread lock to session_count() and message_count()**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#2288: fix(state): add missing thread lock to session_count() and message_count()**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#2466`

## Fix status

- Merged to main in PR #2466 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-03-22`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `agent`
