Skip to content

fix(gateway): isolate multiplex profile sessions and voice state - #75198

Closed
davidxyuan wants to merge 4 commits into
NousResearch:mainfrom
davidxyuan:fix/multiplex-profile-session-voice-isolation
Closed

davidxyuan wants to merge 4 commits into
NousResearch:mainfrom
davidxyuan:fix/multiplex-profile-session-voice-isolation

Conversation

@davidxyuan

Copy link
Copy Markdown

Summary

Isolate gateway session recovery and persisted voice mode by multiplex profile.

This is not the Auto-TTS OGG/Opus issue from #73486. Current main already contains the platform-aware Auto-TTS output-path fix from 1753369f7c3c4797e26ec446d5892619149c9728; this PR does not modify build_auto_tts_output_path, Telegram .ogg handling, or OPUS_VOICE_PLATFORMS.

Problem

In multiplex mode, the default profile and a named profile such as catgirl can serve the same Telegram user/chat tuple.

Two current-main behaviors are not profile-scoped:

  1. find_latest_gateway_session_for_peer() falls back by platform/user/chat/thread without constraining profile_name.
  2. _voice_key() persists state as only platform:chat_id.

If the routing index is temporarily missing, a named profile can recover the default profile's session. Voice state and adapter synchronization can also cross profiles that share the same platform/chat tuple.

Changes

  • Add profile_name to find_latest_gateway_session_for_peer().
  • Constrain peer fallback with:
COALESCE(NULLIF(profile_name, ''), 'default') = ?
  • Parse the profile from the gateway session key and pass it into fallback recovery.
  • Preserve the historical default key telegram:chat_id; use profile:telegram:chat_id for named profiles.
  • Route /voice, adapter synchronization, startup/reconnect, voice join/leave/timeout, and voice replies through the source profile's adapter/state.
  • Add behavioral regression tests for profile-scoped session fallback and voice persistence/synchronization.

Reproduction

  1. Configure multiplex profiles default and catgirl using the same Telegram user/chat tuple.
  2. Create history and voice state under default.
  3. Remove or miss the exact routing-index entry for the named profile.
  4. Recover the peer session or synchronize /voice for catgirl.

Before this change, the peer fallback can select the default session and the persisted voice key is shared. After this change, the named profile sees only its own session and voice state.

Files

  • gateway/run.py
  • gateway/session.py
  • gateway/slash_commands.py
  • hermes_state.py
  • tests/gateway/test_voice_mode_platform_isolation.py
  • tests/test_hermes_state.py

Validation

The original combined change set previously completed 295 tests with 0 failures. This PR was split to the six profile-isolation files and three-way merged onto current upstream main (b1858f33a1cc6083ee34aea41417ea5e18564d2e) with no conflicts. Targeted current-main CI is pending on this PR.

Intended focused command:

scripts/run_tests.sh tests/gateway/test_voice_mode_platform_isolation.py tests/test_hermes_state.py

Preserve current upstream changes while applying only the six profile/session/voice isolation files.
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for the focused multiplex isolation work. The session fallback premise is real on current main: hermes_state.py:3060-3072 falls back on the peer tuple without a profile predicate, and this PR correctly adds one.

Problems

  • The Discord voice-input path is still not profile-scoped. The PR leaves _voice_input_callback bound to _handle_voice_channel_input; that handler selects self.adapters[Platform.DISCORD] at gateway/run.py:18133, which is the default adapter. A transcript from a named profile's Discord adapter can therefore be sent through the default adapter or dropped.

Suggested changes

  • Bind a profile-aware input callback and select the captured secondary adapter/profile in _handle_voice_channel_input; retain SessionSource.profile in the synthetic event.
  • Add a regression test invoking a named adapter's voice callback and asserting the named adapter receives the event.

Automated hermes-sweeper review.

@teknium1 teknium1 added 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 sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history area/profiles Multi-profile isolation, HERMES_HOME scoping labels Jul 31, 2026
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P2 Medium — degraded but workaround exists labels Jul 31, 2026
DavidX added 2 commits July 31, 2026 15:11
@davidxyuan

Copy link
Copy Markdown
Author

Addressed the sweeper feedback and updated the branch onto current upstream/main (f3cda0ceb1) without force-pushing.

  • Voice input callbacks now capture the receiving Discord adapter and profile.
  • Synthetic voice events preserve SessionSource.profile and dispatch through the named profile adapter.
  • Added a regression test that invokes a named adapter callback and verifies the default adapter is not used.
  • The PR diff remains limited to the original six gateway/session/state test files.

Local CI-parity tests:

  • scripts/run_tests.sh tests/gateway/test_voice_mode_platform_isolation.py tests/test_hermes_state.py — 145 passed, 0 failed.
  • scripts/run_tests.sh tests/gateway/test_session.py tests/gateway/test_session_override_thread_recovery.py tests/gateway/test_multiplex_adapter_registry.py tests/gateway/test_profile_routing.py tests/gateway/test_voice_command.py tests/gateway/test_platform_reconnect.py — 167 passed, 0 failed.

The latest GitHub Actions run is still action_required with no jobs started, so it needs maintainer approval rather than a test fix.

@teknium1

teknium1 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this PR. Merged via #101248 (ad8b9e0) on current main — per-profile /voice state, off-loop startup hydration, session-recovery owner fence, int route ids.

#101248 won as the consolidated fix because it covers the whole multiplex-profile bug class in one change (with tests) rather than the single symptom addressed here; this PR is superseded by it.
You are credited via Co-authored-by / in the PR body of #101248 as noted there.

If anything from your original change is still missing on main >= ad8b9e0, please open a fresh PR/issue against main and tag it. Thanks again.

@teknium1 teknium1 closed this Sep 2, 2026
teknium1 added a commit that referenced this pull request Sep 19, 2026
…file durable state

The per-profile store model (#88734), the parent-inheritance fence (#88381),
profile-stamped topic rows (#76423) and profile-prefixed voice keys (#75198)
are all forward-only: they put NEW state under the right profile and refuse to
widen existing damage, but nothing walks the stores and settles what earlier
releases left crossed. #113884 found 246 sessions stranded that way and could
only warn.

`hermes sessions repair-profiles` scans every profile's state.db plus the
gateway's voice-mode and sessions.json files and names six kinds of crossing:

1. `profile_name` disagreeing with the row's own session key -> relabel;
2. rows physically in another profile's store -> move (all message
   generations, usage rows, system prompt) to the owning store, parents before
   children so lineage survives, copy-then-delete so a crash leaves a duplicate
   the next run settles;
3. `parent_session_id` crossing namespaces -> sever (own identity kept);
4. routing rows outside the default store under multiplexing -> move (an
   existing row wins); routing rows for a profile that no longer exists -> drop;
5. Telegram topic bindings and voice-mode entries missing their bot's profile
   -> relabel from the sessions that hold the chat (ambiguous chats reported);
6. sessions.json mirror entries for an unclaimed namespace -> drop (the legacy
   import re-injects them into routing every boot).

Report-only by default. `--apply` refuses while a gateway owns any store, takes
a quick snapshot of every store first, and is idempotent. Two cases are
reported but never guessed: rows keyed to a profile that does not exist, and
`agent:main` rows inside a named profile's store (`--legacy-main rekey|move`
says which of the two histories they are).

Storage side lives in `hermes_state_profile_repair.py` (SessionDB mixin);
orchestration across stores in `hermes_cli/sessions_repair_profiles.py`; the
CLI face in `hermes_cli/sessions_cmd_repair_profiles.py` (pre-DB handler: it
opens every store itself).

Part of #88715 (PR-6). Closes the remediation gap #113884 only warns about.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/profiles Multi-profile isolation, HERMES_HOME scoping area/sessions Session lifecycle, resume, persistence, history comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data 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.

3 participants