feat(slack): multi-workspace Socket Mode (semantic port of #20587) - #66398
feat(slack): multi-workspace Socket Mode (semantic port of #20587)#66398jordanhubbard wants to merge 3 commits into
Conversation
…ch#20587) Salvages PR NousResearch#20587 onto current main. The original PR modified the pre-relocation gateway/platforms/slack.py; commit 5600105 moved the Slack adapter to the bundled plugin, so this is a semantic re-implementation against plugins/platforms/slack/adapter.py that also fixes the hermes-sweeper review findings. What this adds - ~/.hermes/slack_accounts.json: one AsyncApp + Socket Mode websocket per account. Slack distributes a token's events across competing sockets, so each workspace needs its own app-token connection to receive reliably. - The primary account keeps the legacy self._app/_handler/_socket_mode_task fields and lock, so existing single-workspace behavior and tests are unchanged; extra accounts live in self._extra_connections with their own scoped slack-app-token locks. - _register_app_handlers(app) shares handler registration across every account's app. - Channel→team routing is persisted to ~/.hermes/slack_channel_teams.json and reloaded on connect so outbound sends after a restart reach the right workspace client. Addresses the review feedback on NousResearch#20587 - Ported into the bundled plugin, not the relocated module. - Retains per-socket recovery: the existing Socket Mode watchdog now monitors every extra connection and restarts dead/disconnected ones per-account (_restart_extra_connection). - Session/routing key changes stay Slack-scoped (scope_id); Discord and other platforms keep their existing key format (from the session-scoping work this is stacked on). - Preserves file_shared -> _handle_slack_file_shared (the original PR regressed it to a no-op) and all newer handlers. Tests (tests/gateway/test_slack.py::TestSlackMultiAccountSocketMode) - one connection per account; each holds its own app-token lock - watchdog restarts a dead extra connection; restart rebuilds the socket - file_shared dispatches to the real handler; action handlers registered per app; colliding workspace IDs still open both sockets; duplicate app token in config is skipped; channel→team routing survives a restart Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-workspace-socket-mode-v3 # Conflicts: # gateway/session.py # plugins/platforms/slack/adapter.py
|
Merged latest
|
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the semantic port and for retaining the per-socket recovery design. The multi-account receive premise remains real on current main: website/docs/user-guide/messaging/slack.md:564-605 configures multiple bot tokens but one Socket Mode app token.
Problems
gateway/session.py:1527-1533catches everyfind_latest_gateway_session_for_peerfailure in_find_gateway_session_row()and returnsNone. This bypasses_recover_session_from_db(..., raise_on_lookup_error=True)atgateway/session.py:1541-1569. Current main's startup prune deliberately catches that propagated failure and retains the stale route (gateway/session.py:1203-1220); with this change, an unavailable/failed DB lookup is indistinguishable from no recoverable session and can remove the route.
Suggested changes
- Preserve the existing fail-closed propagation path, then add a regression test for a raised recovery lookup during stale-route pruning.
Automated hermes-sweeper review.
| chat_type=source.chat_type if allow_peer_fallback else None, | ||
| thread_id=source.thread_id, | ||
| ) | ||
| except Exception as exc: |
There was a problem hiding this comment.
This catch makes raise_on_lookup_error=True ineffective: _recover_session_from_db() receives None rather than the DB exception. Current startup pruning relies on that exception to retain a stale route rather than remove it. Please propagate errors for the fail-closed caller (or parameterize this helper) and add coverage.
Conflict-resolution follow-up composing #68925 (Bob) with the already- applied #20583/#66398 (jordanhubbard) recovery design: - #68925's caller-level second _query_recoverable_session pass (via lookup_session_key=) referenced a variable that no longer exists — the legacy exact-key fallback now lives INSIDE _query_recoverable_session, which also claims the legacy key once per process and rewrites the peer row to the scoped key. Drop the dead caller-level pass. - Keep #68925's _recovered_row_matches_source_scope origin guard wired into both recovery paths: a scoped channel lookup refuses rows whose recorded origin names another workspace (or no workspace at all). - Routing-index migration adoption policy documented at the site: origin names a workspace -> exact match only; scope-less DM -> first workspace claims once; scope-less channel -> refuse.
Conflict-resolution follow-up composing #68925 (Bob) with the already- applied #20583/#66398 (jordanhubbard) recovery design: - #68925's caller-level second _query_recoverable_session pass (via lookup_session_key=) referenced a variable that no longer exists — the legacy exact-key fallback now lives INSIDE _query_recoverable_session, which also claims the legacy key once per process and rewrites the peer row to the scoped key. Drop the dead caller-level pass. - Keep #68925's _recovered_row_matches_source_scope origin guard wired into both recovery paths: a scoped channel lookup refuses rows whose recorded origin names another workspace (or no workspace at all). - Routing-index migration adoption policy documented at the site: origin names a workspace -> exact match only; scope-less DM -> first workspace claims once; scope-less channel -> refuse.
Conflict-resolution follow-up composing #68925 (Bob) with the already- applied #20583/#66398 (jordanhubbard) recovery design: - #68925's caller-level second _query_recoverable_session pass (via lookup_session_key=) referenced a variable that no longer exists — the legacy exact-key fallback now lives INSIDE _query_recoverable_session, which also claims the legacy key once per process and rewrites the peer row to the scoped key. Drop the dead caller-level pass. - Keep #68925's _recovered_row_matches_source_scope origin guard wired into both recovery paths: a scoped channel lookup refuses rows whose recorded origin names another workspace (or no workspace at all). - Routing-index migration adoption policy documented at the site: origin names a workspace -> exact match only; scope-less DM -> first workspace claims once; scope-less channel -> refuse.
Conflict-resolution follow-up composing NousResearch#68925 (Bob) with the already- applied NousResearch#20583/NousResearch#66398 (jordanhubbard) recovery design: - NousResearch#68925's caller-level second _query_recoverable_session pass (via lookup_session_key=) referenced a variable that no longer exists — the legacy exact-key fallback now lives INSIDE _query_recoverable_session, which also claims the legacy key once per process and rewrites the peer row to the scoped key. Drop the dead caller-level pass. - Keep NousResearch#68925's _recovered_row_matches_source_scope origin guard wired into both recovery paths: a scoped channel lookup refuses rows whose recorded origin names another workspace (or no workspace at all). - Routing-index migration adoption policy documented at the site: origin names a workspace -> exact match only; scope-less DM -> first workspace claims once; scope-less channel -> refuse.
Supersedes #20587. That PR modified the pre-relocation
gateway/platforms/slack.py; commit5600105moved the Slack adapter to the bundled plugin, so this is a semantic re-implementation againstplugins/platforms/slack/adapter.pythat also resolves the hermes-sweeper review findings.Stack
Stacked on #20583 (scopes Slack sessions/routing by workspace). Review/merge #20583 first; the meaningful diff here is the single top commit on top of that branch.
mainhas advanced since the stack was cut, so a rebase onto latest main is needed before merge (same situation as #20587). Advances #10099.What this adds
~/.hermes/slack_accounts.json: oneAsyncApp+ Socket Mode websocket per account. Slack distributes a single app token's events across competing sockets, so each workspace needs its own app-token connection to receive reliably.self._app/_handler/_socket_mode_taskfields and lock, so existing single-workspace behavior (and its tests) are unchanged; extra accounts live inself._extra_connectionswith their own scopedslack-app-tokenlocks._register_app_handlers(app)shares handler registration across every account's app.~/.hermes/slack_channel_teams.jsonand reloaded on connect, so outbound sends after a restart reach the right workspace client.Addresses the hermes-sweeper review on #20587
gateway/platforms/slack.py._restart_extra_connection) — the original PR's rawcreate_task()path had no monitor.scope_id); Discord and other platforms keep their existing key format (via the fix(slack): scope sessions and routing by workspace #20583 base this is stacked on) — no cross-platform session-key regression.file_shared→_handle_slack_file_shared(the original PR regressed it to a no-oppass) and all newer handlers.Tests
tests/gateway/test_slack.py::TestSlackMultiAccountSocketMode(9 tests):file_shareddispatches to the real handler; action handlers registered per appFull
tests/gateway/test_slack.py+test_session.py+test_slack_approval_buttons.py: 403 passed (394 pre-existing + 9 new).🤖 Generated with Claude Code