Skip to content

fix(gateway): stamp profile before busy/approval checks in handle_message - #69156

Closed
Shunkleburger wants to merge 1 commit into
NousResearch:mainfrom
Shunkleburger:fix/multiplex-busy-session-adapter-misroute
Closed

fix(gateway): stamp profile before busy/approval checks in handle_message#69156
Shunkleburger wants to merge 1 commit into
NousResearch:mainfrom
Shunkleburger:fix/multiplex-busy-session-adapter-misroute

Conversation

@Shunkleburger

Copy link
Copy Markdown

What does this PR do?

Fixes cross-profile Telegram (and general platform-adapter) reply misrouting on a multiplexed gateway. When a secondary profile's session is busy and a follow-up message arrives, BasePlatformAdapter.handle_message() resolves its busy/approval/draining state and builds the session key before the profile is known, so that state silently collided with the default profile's agent:main namespace and any resulting reply went out through the default bot instead of the secondary profile's own bot.

Related Issue

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Root Cause

_make_profile_message_handler() (gateway/run.py) stamps event.source.profile for a secondary profile, but only inside self._message_handler. BasePlatformAdapter.handle_message() runs its active-session busy check, approval-mode gate, draining check, and build_session_key() call before self._message_handler is ever invoked — all of that reads event.source.profile while it is still unset, so _adapter_for_source() and the busy-session bucket both fall back to the default profile.

Net effect on a running multiplexed gateway: every secondary profile's busy-session state lived under the default profile's agent:main key instead of its own agent:<profile> key, and a busy-session follow-up reply for a secondary profile's chat was dispatched/sent through the default profile's adapter (wrong bot token, wrong chat in the worst case).

Changes Made

  • gateway/platforms/base.py: added self.profile_name to BasePlatformAdapter.__init__ (defaults to None); at the top of handle_message(), stamp event.source.profile from self.profile_name before any busy/approval/draining check reads it, and pass profile= explicitly into the build_session_key() call in that method.
  • gateway/run.py: _configure_profile_adapter() now sets adapter.profile_name = profile_name synchronously, covering both the startup path and the reconnect path (previously only the deferred message-handler wrapper set the equivalent state, too late for the checks above).
  • plugins/platforms/telegram/adapter.py: _text_batch_key() and _photo_batch_key() now key off self.profile_name instead of the not-yet-stamped event.source.profile.
  • tests/gateway/test_multiplex_busy_session_profile_routing.py (new): two regression tests — one asserts event.source.profile is stamped synchronously before the busy check runs, the other asserts a secondary profile's busy-session state lands under its own agent:<profile> key and never under the default agent:main key or an unscoped bucket.

All profile_name reads use getattr(self, "profile_name", None) rather than a direct attribute access, because a number of existing tests construct adapters via object.__new__() and bypass __init__ entirely.

How to Test

  1. Run the focused matrix: pytest tests/gateway/test_multiplex_busy_session_profile_routing.py tests/gateway/test_active_session_text_merge.py -q — both new tests fail against the pre-patch code with the exact real-world symptom (the follow-up lands in the unscoped {} bucket instead of the profile-scoped one) and pass with the patch applied.
  2. Run the full tests/gateway/ suite: pytest tests/gateway/ -q — 9841 passed, 14 failed, 11 skipped, and all 14 failures are pre-existing on main (verified by running the same 14 tests against the pre-patch parent commit; identical failures, unrelated to profile/session routing).
  3. Manual repro (what surfaced this originally): run a multiplexed gateway with a secondary profile configured, message that profile's bot, and while it's still generating a reply, send a second message to the same profile's bot. Before the fix, the busy-session follow-up (and in some cases the reply itself) is delivered via the default profile's bot/session instead of the secondary profile's own.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — ran pytest tests/gateway/ -q (the affected subsystem) locally; did not run the full repo-wide tests/ suite
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Linux (local targeted + full tests/gateway/ run)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A, internal routing fix with no external-facing behavior/config change; explanatory comments added inline at each of the three fix sites
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A, no config keys changed
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — N/A, no platform-specific code touched
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A, no tool schemas touched

…sage

BasePlatformAdapter.handle_message() runs its active-session busy check,
approval-mode gate, and draining check -- and builds the session key used
by all of them -- before self._message_handler is ever called. But
_make_profile_message_handler() (gateway/run.py) only stamps
event.source.profile *inside* that handler, so on a multiplexed gateway
every one of those upstream checks resolved source.profile as unset and
_adapter_for_source() silently fell back to the default profile's
adapter. In practice: a secondary profile's busy-session state collided
under the default profile's agent:main namespace, and any busy-session
reply for that profile went out through the default bot instead of its
own.

Give each adapter a profile_name known synchronously at construction
time (set in GatewayRunner._configure_profile_adapter, covering both
startup and reconnect), and stamp event.source.profile from it at the
top of handle_message, before any routing decision reads it. Also route
TelegramAdapter's text/photo batch-key builders off self.profile_name
instead of the not-yet-stamped event.source.profile, for the same
reason.

profile_name reads use getattr(..., None) because ~40 existing tests
construct adapters via object.__new__(), bypassing __init__.
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter area/profiles Multi-profile isolation, HERMES_HOME scoping P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 22, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the pre-handler busy-key path; current main still builds that key before _message_handler runs (gateway/platforms/base.py:5413-5428), so the secondary-adapter premise is valid.

Problems

  • The added profile=_profile_name would discard a profile already stamped by build_source() for gateway.profile_routes (gateway/platforms/base.py:6439-6485) when ingress uses the default/shared adapter. That adapter has no profile_name, although routed delivery through it is intentional (tests/gateway/test_profile_resolution.py:410-431), so its busy key remains agent:main.

Suggested changes

  • Prefer event.source.profile and use the adapter-owned name only as fallback when constructing the key. Add a shared-adapter/profile-route busy-session regression test alongside the secondary-adapter case.

Automated hermes-sweeper review.

Comment thread gateway/platforms/base.py
@@ -4834,6 +4856,7 @@ async def handle_message(self, event: MessageEvent) -> None:
event.source,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_profile_name is None for the default/shared adapter, but build_source() can already have stamped event.source.profile from gateway.profile_routes. Prefer the source profile here and use _profile_name only as fallback; otherwise routed ingress still keys its busy state under agent:main.

@fmercurio

Copy link
Copy Markdown

I independently reproduced one remaining part of this PR's Telegram path against current main: BasePlatformAdapter.build_source() already resolves event.source.profile before Telegram media batching, but _photo_batch_key() still omits that profile when it calls build_session_key(). A routed photo burst can therefore enter the legacy agent:main:...:photo-burst lane instead of its profile-scoped lane.

The batching hunk here is useful. The rest of this branch overlaps newer routing work and the PR is currently blocked/stale, so I am preparing a narrow, behavior-tested follow-up stacked on #82980 rather than reviving the wider older design. It will preserve the legacy no-profile key and reference this prior work. If maintainers prefer this PR itself to be rebased instead, I am happy to defer to that direction.

@teknium1

Copy link
Copy Markdown
Contributor

Closing as superseded: commit 21260c3 (PR #89860, salvage of #88437 by @69k4xmdfm2-blip) fixed #88404 at the adapter-ownership seam — the owner profile is now installed in _configure_profile_adapter before any inbound event, the ingress session key resolves via _session_key_profile(), and the busy path is profile-stamped. Every site this PR patched is covered on main. You were one of the earliest to identify this bug class — thanks for pushing on it.

@teknium1 teknium1 closed this Aug 19, 2026
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 platform/telegram Telegram bot adapter sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users 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.

4 participants