Skip to content

fix(gateway): route SessionDB calls off the event loop via AsyncSessionDB - #55159

Merged
teknium1 merged 6 commits into
mainfrom
hermes/hermes-d8cc2c46
Jun 29, 2026
Merged

teknium1 merged 6 commits into
mainfrom
hermes/hermes-d8cc2c46

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Summary

Salvage of #54876 by @yoniebans — cherry-picked onto current main with authorship preserved.

The gateway runs one asyncio loop serving every session, watcher, and heartbeat. SessionDB is synchronous and its calls ran directly on the loop, so any blocking call (held write lock, slow checkpoint, degraded filesystem) froze the loop thread for the call's full duration — starving every other conversation and every platform heartbeat until it returned.

This adds AsyncSessionDB, a generic facade that offloads each SessionDB call via asyncio.to_thread, and routes the gateway's loop-side DB calls through it. The call takes just as long, but it no longer freezes everything else. #51890 already offloaded the one observed-freeze site (the handoff watcher); this closes the whole class and adds a guard so a new raw call on the loop fails CI rather than waiting to be found in production.

SessionDB internals, serialization, and the single-writer model are unchanged. This is the seam a future async-native or writer-thread DB sits behind without touching call sites.

Changes

  • hermes_state.py: AsyncSessionDB — generic __getattr__ forwarder, offloads every callable via asyncio.to_thread. Return-type audit confirms no method returns a live cursor/generator.
  • gateway/run.py, gateway/slash_commands.py: await-ify ~43 loop-reachable calls; off-loop sync helpers (Telegram topic recovery, the run_sync executor closure, construction-time prune) keep the sync handle via ._db.
  • gateway/platforms/base.py: offload _apply_topic_recovery at its single async caller.
  • tests/gateway/test_async_session_db.py: AST guard (fails if any non-awaited SessionDB call appears on the loop, direct or aliased), offload-contract guard, sync-escape confinement guard, and concurrent-interleaving safety tests.

Validation

CI green on the original PR (all 8 test slices + e2e + lints + docker). Re-verified after rebase onto current main.

Local (canonical runner, rebased branch): 12 affected gateway suites — all pass (async_session_db, handoff_watcher_async_db, agent_cache, resume_command, status/title/usage_command, session_race_guard, session_boundary_security_state, matrix_project_context_isolation, telegram_topic_mode, 35809_auto_reset).

Live stress tests (real SessionDB, real SQLite, real event loop):

Test Result
Offload runs off the loop thread DB call ran on worker tid ≠ loop tid
Loop liveness — facade, 1053ms held-write-lock block 105 / 105 ticks fired during the block (loop alive)
Loop liveness — raw sync control, same block 0 ticks fired (loop frozen) — confirms the bug
Concurrency — 1000 concurrent claim_handoff across 5 sessions exactly 1 winner per session
Concurrency — 300 concurrent create_session same id exactly 1 row (idempotent)
Concurrency — 300 interleaved read/write storm 0 exceptions, row intact
Guard mutation check — plant one raw call guard fails and names gateway/run.py:<line>

The side-by-side held-write-lock test is the direct demonstration: same 1-second block, the facade keeps the loop ticking 105/105 while a raw on-loop call freezes it to 0.

Infographic

asyncsessiondb-gateway-off-loop

The migration's call-site sweep keyed on the literal self._session_db.
spelling and missed calls bound to a local first
(db = getattr(self, '_session_db', None); db.method(...)). Convert the
three in async contexts: get_telegram_topic_binding in the topic-rename
coroutine, and the two update_session_model sites on the model-switch path.
… loop

The topic-mode helpers (_telegram_topic_mode_enabled,
_recover_telegram_topic_thread_id, _record/_sync_telegram_topic_binding,
_is_telegram_topic_lane/_root_lobby, _normalize_source_for_session_key,
_telegram_topic_new_header, _schedule_telegram_topic_title_rename, and the
base.py _apply_topic_recovery hook) each run a synchronous SessionDB read or
write. They reach the event loop through async handlers, so a contended
state.db froze the loop the same way the handoff watcher did.

These helpers already run off-loop in the run_sync thread-pool closure, so
they are proven thread-safe there. Rather than colour them async, loop-side
callers now invoke them via asyncio.to_thread(...); the executor callers are
unchanged. Inside the helpers the SessionDB handle is unwrapped to the sync
door (getattr(db, '_db', db)) since they always run on a worker thread, and
AIAgent construction + query_session_listing are handed the sync SessionDB
directly. base.py wraps its single _apply_topic_recovery call in to_thread.

The guard is now alias-aware (catches db = getattr(self, '_session_db', None);
db.method(...)) and enforces the offload contract: the offloaded sync helpers
may never be called bare on the loop. Sibling test fixtures wrap their injected
SessionDB in AsyncSessionDB to match how the gateway holds it.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-d8cc2c46 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11723 on HEAD, 11705 on base (🆕 +18)

🆕 New issues (24):

Rule Count
unresolved-attribute 18
invalid-argument-type 3
unresolved-import 2
invalid-assignment 1
First entries
tests/gateway/test_matrix_project_context_isolation.py:356: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.get_session(session_id: str) -> dict[str, Any] | None`
tests/gateway/test_async_session_db.py:114: [invalid-argument-type] invalid-argument-type: Argument to `AsyncSessionDB.__init__` is incorrect: Expected `SessionDB`, found `_SpyDB`
gateway/run.py:12620: [unresolved-attribute] unresolved-attribute: Attribute `get_session_title` is not defined on `None` in union `None | AsyncSessionDB`
tests/gateway/test_matrix_project_context_isolation.py:352: [unresolved-attribute] unresolved-attribute: Unresolved attribute `side_effect` on type `bound method SessionDB.resolve_resume_session_id(session_id: str) -> str`
gateway/run.py:12584: [unresolved-attribute] unresolved-attribute: Attribute `resolve_session_id` is not defined on `None` in union `None | AsyncSessionDB`
tests/gateway/test_status_command.py:61: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.get_session(session_id: str) -> dict[str, Any] | None`
gateway/run.py:12588: [unresolved-attribute] unresolved-attribute: Attribute `get_session` is not defined on `None` in union `None | AsyncSessionDB`
gateway/run.py:12623: [unresolved-attribute] unresolved-attribute: Attribute `get_messages` is not defined on `None` in union `None | AsyncSessionDB`
tests/gateway/test_async_session_db.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_session_boundary_security_state.py:121: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.get_session_title(session_id: str) -> str | None`
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
gateway/run.py:12596: [unresolved-attribute] unresolved-attribute: Attribute `is_telegram_session_linked_to_topic` is not defined on `None` in union `None | AsyncSessionDB`
tests/gateway/test_matrix_project_context_isolation.py:348: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.list_sessions_rich(source: str = None, exclude_sources: list[str] = None, cwd_prefix: str = None, limit: int = 20, offset: int = 0, include_children: bool = False, min_message_count: int = 0, project_compression_tips: bool = True, order_by_last_active: bool = False, include_archived: bool = False, archived_only: bool = False, id_query: str = None) -> list[dict[str, Any]]`
tests/gateway/test_session_boundary_security_state.py:122: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.get_next_title_in_lineage(base_title: str) -> str`
tests/gateway/test_matrix_project_context_isolation.py:353: [unresolved-attribute] unresolved-attribute: Unresolved attribute `side_effect` on type `bound method SessionDB.get_session_title(session_id: str) -> str | None`
tests/gateway/test_session_boundary_security_state.py:91: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.resolve_session_by_title(title: str) -> str | None`
tests/gateway/test_agent_cache.py:1711: [invalid-argument-type] invalid-argument-type: Argument to `AsyncSessionDB.__init__` is incorrect: Expected `SessionDB`, found `_BoomDB`
gateway/run.py:12607: [unresolved-attribute] unresolved-attribute: Attribute `bind_telegram_topic` is not defined on `None` in union `None | AsyncSessionDB`
tests/gateway/test_status_command.py:58: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.get_session_title(session_id: str) -> str | None`
tests/gateway/test_usage_command.py:202: [unresolved-attribute] unresolved-attribute: Unresolved attribute `return_value` on type `bound method SessionDB.get_session(session_id: str) -> dict[str, Any] | None`
gateway/run.py:12545: [unresolved-attribute] unresolved-attribute: Attribute `list_unlinked_telegram_sessions_for_user` is not defined on `None` in union `None | AsyncSessionDB`
gateway/run.py:12597: [unresolved-attribute] unresolved-attribute: Attribute `get_telegram_topic_binding` is not defined on `None` in union `None | AsyncSessionDB`
gateway/slash_commands.py:3307: [invalid-argument-type] invalid-argument-type: Argument to function `to_thread` is incorrect: Expected `str | None`, found `Literal["local", "telegram", "discord", "whatsapp", "whatsapp_cloud", ... omitted 19 literals] | set[Unknown]`
tests/gateway/test_agent_cache.py:15: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues (11):

Rule Count
unresolved-attribute 10
invalid-argument-type 1
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
gateway/run.py:12531: [unresolved-attribute] unresolved-attribute: Attribute `list_unlinked_telegram_sessions_for_user` is not defined on `None` in union `None | SessionDB`
gateway/slash_commands.py:3304: [invalid-argument-type] invalid-argument-type: Argument to function `query_session_listing` is incorrect: Expected `str | None`, found `Literal["local", "telegram", "discord", "whatsapp", "whatsapp_cloud", ... omitted 19 literals] | set[Unknown]`
gateway/run.py:12574: [unresolved-attribute] unresolved-attribute: Attribute `get_session` is not defined on `None` in union `None | SessionDB`
gateway/run.py:12570: [unresolved-attribute] unresolved-attribute: Attribute `resolve_session_id` is not defined on `None` in union `None | SessionDB`
gateway/run.py:12593: [unresolved-attribute] unresolved-attribute: Attribute `bind_telegram_topic` is not defined on `None` in union `None | SessionDB`
run_agent.py:3069: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
gateway/run.py:12606: [unresolved-attribute] unresolved-attribute: Attribute `get_session_title` is not defined on `None` in union `None | SessionDB`
gateway/run.py:12582: [unresolved-attribute] unresolved-attribute: Attribute `is_telegram_session_linked_to_topic` is not defined on `None` in union `None | SessionDB`
gateway/run.py:12609: [unresolved-attribute] unresolved-attribute: Attribute `get_messages` is not defined on `None` in union `None | SessionDB`
gateway/run.py:12583: [unresolved-attribute] unresolved-attribute: Attribute `get_telegram_topic_binding` is not defined on `None` in union `None | SessionDB`

Unchanged: 6150 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/perf Performance improvement or optimization comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state P2 Medium — degraded but workaround exists labels Jun 29, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Salvage of #54876 by @yoniebans — cherry-picked onto current main with authorship preserved. Generalizes the narrow merged handoff-watcher offload (#51890) into a whole-gateway AsyncSessionDB boundary with an AST guard test. Related: #54876 (predecessor), #51890 (merged narrow fix). Maintainer to close the predecessor in favor of this salvage.

@teknium1
teknium1 merged commit d2ce2c8 into main Jun 29, 2026
30 checks passed
@teknium1
teknium1 deleted the hermes/hermes-d8cc2c46 branch June 29, 2026 22:51
kenyonxu added a commit to kenyonxu/hermes-agent that referenced this pull request Jul 5, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.
kenyonxu added a commit to kenyonxu/hermes-agent that referenced this pull request Jul 7, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.
kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Jul 10, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
kshitijk4poor pushed a commit that referenced this pull request Jul 10, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream #55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as #55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
caozuohua pushed a commit to caozuohua/hermes-agent that referenced this pull request Jul 16, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.
justemu pushed a commit to justemu/hermes-agent that referenced this pull request Jul 18, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…cio.to_thread

Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream NousResearch#55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as NousResearch#55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants