fix(gateway): multiplexed bots stop sharing one session lane (#88404, salvage #88437) - #89860
Merged
Merged
Conversation
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.
15 tasks
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).
4 tasks
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).
39 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Under a multiplexed gateway, every bot no longer shares one
agent:mainsession 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 byhandle_messageGatewayRunner._configure_profile_adapterinstalls the owner profile before any inbound eventtests/gateway/test_multiplex_adapter_session_key_namespace.py: 195-line regression suite;test_multiplex_busy_input_mode.pyupdatedcontributors/emails/lucasxavier926@gmail.com: attribution mappingValidation
test_unauthorized_user_gets_pairing_response) reproduces identically on plain origin/main — pre-existing, unrelatedFixes #88404.
Infographic