Skip to content

fix(gateway): multiplexed bots stop sharing one session lane (#88404, salvage #88437) - #89860

Merged
teknium1 merged 1 commit into
mainfrom
salvage/88437-multiplex-adapter-keys
Aug 19, 2026
Merged

fix(gateway): multiplexed bots stop sharing one session lane (#88404, salvage #88437)#89860
teknium1 merged 1 commit into
mainfrom
salvage/88437-multiplex-adapter-keys

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Under a multiplexed gateway, every bot no longer shares one agent:main session lane: adapters now know their owning profile before any inbound event is keyed, so text/media batching, _active_sessions, and the busy guard are all scoped per bot.

Salvage of #88437 by @69k4xmdfm2-blip (fixes #88404). The original PR was correct and approved for merge, but its fork-side CI was unrecoverable (wedged queue → flaky e2e → repeated zero-job startup failures surviving the parse-cache cure), so the contributor's commit is cherry-picked onto current main on an origin branch with authorship preserved.

Changes

  • gateway/platforms/base.py: _owner_profile + set_owner_profile() on BasePlatformAdapter; _session_key_profile() resolver (source.profile → owner profile → session-store resolver) used by handle_message
  • GatewayRunner._configure_profile_adapter installs the owner profile before any inbound event
  • Batch-key sites swept across all 8 adapters: discord, feishu, matrix, raft, slack, telegram, wecom, whatsapp
  • tests/gateway/test_multiplex_adapter_session_key_namespace.py: 195-line regression suite; test_multiplex_busy_input_mode.py updated
  • contributors/emails/lucasxavier926@gmail.com: attribution mapping

Validation

Result
Multiplex key/busy suites 35/35 pass
e2e platform commands 86 pass; the 1 failure (test_unauthorized_user_gets_pairing_response) reproduces identically on plain origin/main — pre-existing, unrelated

Fixes #88404.

Infographic

infographic

Adapter ingress derives a session key BEFORE the runner stamps
source.profile in _make_profile_message_handler, so the namespace fell
back to the active profile and every bot in a multiplexed gateway
produced agent:main:<platform>:<chat>. A Telegram private chat reports
the user's own id as chat.id, identical for every bot, so two profiles
sharing one human collapsed onto a single lane: _pending_text_batches,
_active_sessions, the busy-session guard and _post_delivery_callbacks are
all keyed on that string. A day of production logs across two bots shows
60 flushes, none carrying the secondary profile's namespace.

set_owner_profile records credential ownership on the adapter and
_session_key_profile resolves the namespace as source.profile ->
_owner_profile -> the session store's resolver, so a secondary adapter
keys into its own namespace even before the source is stamped. Stamped
sources keep priority, so relay/connector ingress, which routes per event
rather than per credential, is unchanged. _configure_profile_adapter
installs the owner alongside the other handlers, covering startup and
reconnect.

Every candidate is type-checked as a non-blank str, and every attribute
read goes through getattr: adapters are routinely built without
BasePlatformAdapter.__init__, and a duck-typed session store returns a
truthy non-string that would otherwise be interpolated into the key as
agent:<MagicMock ...>:.

Also routes the four call sites that passed no profile at all (feishu
media batches, raft, slack _session_key_for_source, telegram photo
batches) through the same resolver.

test_multiplex_busy_input_mode's secondary-adapter busy case seeded
_active_sessions with the unstamped agent:main: key, asserting the
pre-fix collapse. It now seeds the lane the profile-owned adapter
actually derives.

A primary adapter has no owner and an unstamped source, so it resolves
exactly as before; with multiplex_profiles off the resolver returns None
and every key is byte-identical to today's.
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery area/profiles Multi-profile isolation, HERMES_HOME scoping P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 19, 2026
@teknium1
teknium1 merged commit 21260c3 into main Aug 19, 2026
7 checks passed
@teknium1
teknium1 deleted the salvage/88437-multiplex-adapter-keys branch August 19, 2026 09:00
100yenadmin added a commit to 100yenadmin/hermes-agent-for-upstream-PR-only that referenced this pull request Aug 19, 2026
Both durable-recovery entrypoints ask SessionDB for a row with
`allow_peer_fallback=legacy_key is None`. `legacy_key` is Slack-only, so on
Telegram (and every other platform) the fallback is unconditionally on. That
fallback query matches on `(source, user_id, chat_id, chat_type, thread_id)`
alone — it carries no profile predicate — so a multiplexed gateway whose
routed profile has no resolvable key of its own adopts whichever sibling
profile last spoke in that chat: one conversation's transcript, cache and
turn lease served under another bot's identity.

Neither existing guard catches it. `_recovered_row_allowed_for_active_profile`
returns True as its first statement once `multiplex_profiles` is on, and
`_recovered_row_matches_source_scope` returns True for anything that is not a
scoped Slack channel. Both are inert exactly where the fallback is live.

Two prior fixes narrowed the blast radius without closing the path:

  * NousResearch#88734 resolved the SessionDB handle per access, so each profile writes
    into its own `profiles/<name>/state.db` FILE. It does not scope the
    recovery QUERY, and it is forward-only: rows already in an un-migrated
    root store still share one file, as do namespaces that resolve the root
    scope (a default-profile handler pinned there while profile-stamped keys
    land in the same DB).
  * NousResearch#89860 minted session KEYS per bot, so the two namespaces no longer
    collide on one key. Key separation is what makes the exact lookup miss
    for the routed profile — it does not stop the keyless peer fallback that
    runs next from returning the other profile's row.

Turning the fallback off under `multiplex_profiles` leaves the exact-key
lookup, the legacy-Slack migration path and every unmultiplexed gateway
byte-identical; the flag-off peer fallback keeps its existing coverage.

The regression test builds the shared-DB precondition explicitly rather than
inferring it from path layout (that is NousResearch#88734's suite): one real state.db
holding both `agent:main:telegram:dm:99` and `agent:jarvis:telegram:dm:99`
against an identical peer tuple, with the requesting profile's own row closed
on a reset boundary — what a plain `/new` leaves behind. Recovery for either
key must return None rather than the sibling's session id, and an exact key
must still recover its own.

Receipts: 4 fail-before -> 0; targeted files 20 -> 27 passed; 12 session
recovery suites 392 passed, 1 failed
(tests/test_hermes_state.py::TestFTS5Search::
test_search_projection_skips_context_enrichment_queries — reproduced on the
clean base commit, unrelated to this change).
andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 19, 2026
…er seam

Semantic restack per review; current head was green but carried three stale
constructs that duplicated or over-reached main.

1. Drop the duplicate adapter-owner field. main (NousResearch#89860) already gives
   BasePlatformAdapter the canonical set_owner_profile/_owner_profile seam and
   _configure_profile_adapter calls it; the branch's ad-hoc
   adapter._multiplex_profile_name = profile_name was a second owner source.
   Removed the write in run.py and made _credential_owner_for_adapter read
   adapter._owner_profile, keeping the adapter-registry identity check as
   corroboration, not a second owner source. The contract test now stamps via
   set_owner_profile.

2. Remove the dead credential_claimants fail-closed parameter. canonicalize_routing_identity
   rejected >1 claimant but production never computed or passed it (only the
   leaf unit test exercised it). Duplicate-credential enforcement is a startup
   invariant already enforced by _configure_profile_adapter's claimed map via
   _adapter_credential_fingerprint. Removed the unwired parameter and its leaf
   test; documented the startup guarantee in the docstring.

3. Drop the unrelated image-generation upscale policy change from this branch
   so the routing change can be reviewed and reverted independently.

Focused identity/adapter/session/repair suites: 123 passed.
100yenadmin added a commit to electricsheephq/evaOS-hermes-desktop-app-adapter that referenced this pull request Aug 20, 2026
Both durable-recovery entrypoints ask SessionDB for a row with
`allow_peer_fallback=legacy_key is None`. `legacy_key` is Slack-only, so on
Telegram (and every other platform) the fallback is unconditionally on. That
fallback query matches on `(source, user_id, chat_id, chat_type, thread_id)`
alone — it carries no profile predicate — so a multiplexed gateway whose
routed profile has no resolvable key of its own adopts whichever sibling
profile last spoke in that chat: one conversation's transcript, cache and
turn lease served under another bot's identity.

Neither existing guard catches it. `_recovered_row_allowed_for_active_profile`
returns True as its first statement once `multiplex_profiles` is on, and
`_recovered_row_matches_source_scope` returns True for anything that is not a
scoped Slack channel. Both are inert exactly where the fallback is live.

Two prior fixes narrowed the blast radius without closing the path:

  * NousResearch#88734 resolved the SessionDB handle per access, so each profile writes
    into its own `profiles/<name>/state.db` FILE. It does not scope the
    recovery QUERY, and it is forward-only: rows already in an un-migrated
    root store still share one file, as do namespaces that resolve the root
    scope (a default-profile handler pinned there while profile-stamped keys
    land in the same DB).
  * NousResearch#89860 minted session KEYS per bot, so the two namespaces no longer
    collide on one key. Key separation is what makes the exact lookup miss
    for the routed profile — it does not stop the keyless peer fallback that
    runs next from returning the other profile's row.

Turning the fallback off under `multiplex_profiles` leaves the exact-key
lookup, the legacy-Slack migration path and every unmultiplexed gateway
byte-identical; the flag-off peer fallback keeps its existing coverage.

The regression test builds the shared-DB precondition explicitly rather than
inferring it from path layout (that is NousResearch#88734's suite): one real state.db
holding both `agent:main:telegram:dm:99` and `agent:jarvis:telegram:dm:99`
against an identical peer tuple, with the requesting profile's own row closed
on a reset boundary — what a plain `/new` leaves behind. Recovery for either
key must return None rather than the sibling's session id, and an exact key
must still recover its own.

Receipts: 4 fail-before -> 0; targeted files 20 -> 27 passed; 12 session
recovery suites 392 passed, 1 failed
(tests/test_hermes_state.py::TestFTS5Search::
test_search_projection_skips_context_enrichment_queries — reproduced on the
clean base commit, unrelated to this change).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiplex: adapter-level session keys omit the profile, so all bots share one agent:main lane

3 participants