Skip to content

fix(gateway): identity survives restore, relay, callbacks and thread hops (#88715 phase 5) - #115847

Merged
teknium1 merged 6 commits into
mainfrom
fix/identity-survives-boundaries
Sep 19, 2026
Merged

teknium1 merged 6 commits into
mainfrom
fix/identity-survives-boundaries

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

A multiplexed gateway now remembers which bot received each conversation across a restart, so a revived lane delivers through that bot (or fails closed) instead of falling back to the default bot by heuristic — and the relay echoes the routed profile so the connector can keep a routed chat's next interaction in the same profile.

Phase 5 of #88715 (stacked on PR-1 ad651b8250 / PR-2 c07708671d, both on main).

What changed

  • SessionEntry.transport_profile (routing JSON) + nullable sessions.transport_profile (SCHEMA_SQL, added by the existing column-reconcile path — no version bump; agent:main keys and standalone gateways untouched: they write nothing). Stamped from the pinned RoutingIdentity at create, reset/switch, DB recovery and every peer refresh; compression forks inherit it with the other routing columns.
  • session_identity.restore_identity() re-pins a RoutingIdentity(transport=None) from the persisted bot (the restored/synthetic row of PR-4's transport matrix). authz_mixin._restored_source(entry) is the one seam every revive path reads entry.origin through: auto-resume (run_startup), heartbeat restore, plugin injection (run_inbound), background-process events (run_notifications).
  • _adapter_for_source / _adapter_profile_for_source honour a restored identity — the persisted bot's adapter or None, never the default bot. Entries written before the column exist (transport_profile absent) keep the old _is_shared_bot_satellite chain.
  • Relay: _capture_scope remembers the routed profile per chat; _with_scope echoes metadata.profile on every chat-addressed frame and send_follow_up derives it from the agent:<profile>: key namespace. Single-profile gateways emit no key (frames byte-identical). Contract doc §4 documents the round-trip.
  • Ambient reads: run.py::_session_key_for_source reads the identity first; the remaining get_active_profile_name() reads in gateway/ are boot-only and marked # launch profile, pre-identity (adapter boot, cron ticker homes, startup log, advertised model name). Callback capture (/model picker → profile_home at command time) and _run_in_executor_with_context already carry the scope; verified, not rewritten.
  • Docs: gateway/AGENTS.md § Profile scope, multiplexing-gateway.md § Restore, relay, callbacks and thread hops, session-storage.md, relay-connector-contract.md §4.

Root cause

The routing index persisted the runtime profile (key namespace) but not the receiving bot, so after a restart the only way to pick an adapter was a heuristic on the runtime profile — wrong whenever the runtime profile is a satellite of a different bot than the one that received the lane.

Validation

Check Result
tests/gateway/test_session_identity_restore.py (2 invariants: team_b-received lane routed to ops → after restart delivers via team_b, fails closed when team_b is offline, satellite lane keeps default egress, pre-column entry unchanged; standalone control: nothing persisted, agent:main byte-stable) red on base (AttributeError: transport_profile), green on head
tests/gateway/relay/test_relay_passthrough.py::test_routed_profile_round_trips_on_every_egress_frame red on base (metadata has no profile), green on head
A→B→A E2E (real GatewayRunner handlers _stamp_event_profile/_admit_primary_source, real SessionStore over two homes' state.db, fresh runner reload from gateway_routing, executor hop keeps HERMES_HOME scope) team_b lane → TEAM_B; satellite → PRIMARY; team_b offline → None; legacy entry → heuristic unchanged
scripts/run_tests.sh tests/gateway tests/hermes_state tests/plugins/platforms 10974 passed / 6 failed → all 6 fixed or re-run green (3 bare-__new__ relay adapters: lazy _profile_by_chat; 1 test lambda signature; test_session_hygiene 2.4s>2.0s timing under 40 workers — passes alone) ; re-run of tests/hermes_state + session files: 1475 passed
check_profile_scope_patterns.py --base origin/main 2 advisory hits, both docstring mentions of agent:main
ruff, check-windows-footguns --all, check_compat_pointers, git diff --check clean

Not covered (lane NOT_COVERED.md): connector-side stamping of the echoed profile (connector repo); backfill of transport_profile on pre-existing rows (PR-6 repair-profiles); live two-token Telegram rig.

Infographic

identity-survives-restart

@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 7b31433 — fix(gateway): a restored lane whose receiving bot is offline

debug info

CI timings

CI timings · View report · View job

Wall time 19m17s vs 24m35s (-21.6%). 7 job(s) slower, 4 faster, 2 unchanged.

  • Python lints / Windows footguns (blocking): +12.0s
  • Python lints / ruff enforcement (blocking): +7.0s
  • Python tests / e2e: -6.0s
  • OS-specific tests / macOS-only tests: +5.0s
  • OS-specific tests / Windows-only tests: +5.0s

@teknium1
teknium1 force-pushed the fix/identity-survives-boundaries branch 2 times, most recently from 84a2aee to 8f2e99f Compare September 19, 2026 07:27
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery area/sessions Session lifecycle, resume, persistence, history 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 Sep 19, 2026
…es deliver through it or fail closed

After a restart the routing index rebuilt every lane from `SessionEntry.origin`,
which carries the runtime profile (key namespace) but not the bot that received
the conversation. Delivery then fell to `_is_shared_bot_satellite`: a lane owned
by a secondary bot whose runtime profile is ALSO a satellite of the default bot
was handed to the default bot, and authorization read the wrong allowlist.

- `SessionEntry.transport_profile` (routing JSON) + nullable
  `sessions.transport_profile` (SCHEMA_SQL, reconciled by the existing column
  path; `agent:main` keys untouched, standalone gateways write nothing). Stamped
  from the pinned `RoutingIdentity` at create, reset/switch, DB recovery and
  every peer refresh; compression forks inherit it like the other routing columns.
- `session_identity.restore_identity()` re-pins a `RoutingIdentity(transport=None)`
  from the persisted transport profile; `authz_mixin._restored_source(entry)` is
  the one seam every revive path uses (auto-resume, heartbeat restore, plugin
  injection, background-process events).
- `_adapter_for_source` / `_adapter_profile_for_source` honour a restored identity:
  the persisted bot's adapter or None — never the default bot by heuristic.
  Entries written before the column exist keep the old chain.

Phase 5 of #88715.
…w_up

The connector stamps `profile` on inbound and passthrough_forward frames but the
gateway never sent it back, so the connector had nothing to stamp on the NEXT
interaction of a routed chat. `_capture_scope` now remembers the routed profile
per chat, `_with_scope` echoes it as `metadata.profile` on chat-addressed frames,
and `send_follow_up` derives it from the `agent:<profile>:` key namespace. A
single-profile gateway emits no key — frames stay byte-identical. Contract §4
documents the round-trip.
…t profile reads

`_session_key_for_source` reads the pinned identity before falling back to
`source.profile` / the active profile. The remaining `get_active_profile_name()`
reads in gateway/ run before any event exists (adapter boot, cron ticker homes,
startup log, advertised model name) and are marked `# launch profile, pre-identity`.
…story

The page-level salvage lane infers a salvaged store's physical column order from
SCHEMA_HISTORY; a column added to SCHEMA_SQL without an event here makes the newest rows of
every upgraded store map to nothing and take the positional fallback.
… nowhere

Composing PR-4 (intake/delivery split) with PR-5 (persisted transport_profile): the delivery
fallback to the runtime profile's unique adapter is only for sources with NO identity. A pinned
identity names the receiving bot; if that bot has no adapter it is offline and the lane fails
closed, never answering from the runtime profile's bot. Also: tests and docs reference the split
helper, not the removed _adapter_for_source.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history 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.

2 participants