diff --git a/gateway/run.py b/gateway/run.py index fcaffe205a..e14e6958aa 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2089,7 +2089,8 @@ if not _configured_cwd or _configured_cwd in CWD_PLACEHOLDERS: from gateway.config import ( ChannelOverride, Platform, GatewayConfig, PlatformConfig, _getenv, load_gateway_config) from gateway.session import ( - AsyncSessionStore, SessionStore, SessionSource, SessionContext, build_session_key) + AsyncSessionStore, SessionStore, SessionSource, SessionContext, build_session_key, + profile_from_session_key_namespace) # Telegram topic routing (#22773, regression fixed #52060): a # ``telegram::`` cron target is ambiguous — a forum-style topic in a # private chat and a genuine Bot API channel Direct-Messages topic share the same shape and need OPPOSITE @@ -2869,7 +2870,8 @@ _PROFILE_ID_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") def _parse_session_key(session_key: str) -> "dict | None": """Parse a session key (``agent:{ns}:{platform}:{chat_type}:{chat_id}[:{extra}...]``). - ``{ns}`` is ``main`` for the default profile or a named-profile id (profile ids match + ``{ns}`` is ``main`` for the default profile, ``main~`` for a profile literally named ``main`` + (``gateway.session._session_key_namespace``), or a named-profile id (profile ids match ``[a-z0-9][a-z0-9_-]{0,63}`` — never contain ``:`` — so a plain split stays unambiguous). For group/channel sessions the suffix may be a user_id, not a thread_id, so ``thread_id`` is omitted. Named profiles are reported as ``profile``; ``main`` keys keep their historical @@ -2879,11 +2881,11 @@ def _parse_session_key(session_key: str) -> "dict | None": if ( len(parts) >= 5 and parts[0] == "agent" - and (parts[1] == "main" or _PROFILE_ID_KEY_RE.match(parts[1])) + and (parts[1] in ("main", "main~") or _PROFILE_ID_KEY_RE.match(parts[1])) ): result = {"platform": parts[2], "chat_type": parts[3], "chat_id": parts[4]} if parts[1] != "main": - result["profile"] = parts[1] + result["profile"] = profile_from_session_key_namespace(parts[1]) if len(parts) > 5 and parts[3] in {"dm", "thread"}: result["thread_id"] = parts[5] return result diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 440db0d8eb..1fa8c80267 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -438,9 +438,10 @@ class GatewayNotificationsMixin: profile = str(data.get("profile") or "").strip() if profile: return profile + from gateway.session import profile_from_session_key_namespace parts = str(data.get("session_key") or "").split(":") if len(parts) >= 5 and parts[0] == "agent" and parts[1] not in ("main", ""): - return parts[1] + return profile_from_session_key_namespace(parts[1]) return None def _resolve_update_target(self, paths: "_UpdatePaths") -> Optional["_UpdateTarget"]: diff --git a/gateway/run_profile_reconcile.py b/gateway/run_profile_reconcile.py index 4f69115f45..e1ec0c9511 100644 --- a/gateway/run_profile_reconcile.py +++ b/gateway/run_profile_reconcile.py @@ -201,7 +201,8 @@ class GatewayProfileReconcileMixin: self._served_profile_homes.pop(name, None) if isinstance(self._served_profile_signatures, dict): self._served_profile_signatures.pop(name, None) - prefix = f"agent:{name}:" + from gateway.session import _session_key_namespace + prefix = _session_key_namespace(name) + ":" cache = getattr(self, "_agent_cache", None) for key in [k for k in list(cache or {}) if str(k).startswith(prefix)]: with _log_suppressed(logging.DEBUG, "agent eviction failed for %s", key, exc_info=True): diff --git a/gateway/session.py b/gateway/session.py index 001d27f3b4..fb074d0cc6 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -625,8 +625,21 @@ def is_shared_multi_user_session( def _session_key_namespace(profile: Optional[str]) -> str: """``agent:`` prefix for a session key: default/None profile → ``agent:main`` (BYTE-IDENTICAL to every historical key); named profile → ``agent:`` so two - profiles serving the same chat never collide.""" - return "agent:main" if not profile or profile == "default" else f"agent:{profile}" + profiles serving the same chat never collide. A profile literally named ``main`` would + otherwise produce the default's namespace and share every session (routing index, agent + cache, store) with it, so it is marked ``main~``: ``~`` is outside the profile-id alphabet, + so the marked form can never be another profile's id.""" + if not profile or profile == "default": + return "agent:main" + return "agent:main~" if profile == "main" else f"agent:{profile}" + + +def profile_from_session_key_namespace(namespace: str) -> str: + """Inverse of :func:`_session_key_namespace` for the ```` slot of a key: ``"default"`` for + ``main``, ``"main"`` for the marked ``main~``, else the slot is the profile id.""" + if namespace == "main": + return "default" + return "main" if namespace == "main~" else namespace def _canonical_participant(source: SessionSource) -> Optional[str]: diff --git a/gateway/session_recovery.py b/gateway/session_recovery.py index 6fdf48a13b..cfe0a99cb4 100644 --- a/gateway/session_recovery.py +++ b/gateway/session_recovery.py @@ -53,8 +53,8 @@ class SessionRecoveryMixin: parts = str(session_key).split(":") if len(parts) < 2 or parts[0] != "agent": return None - namespace = parts[1] or "main" - return "default" if namespace == "main" else namespace + from gateway.session import profile_from_session_key_namespace + return profile_from_session_key_namespace(parts[1] or "main") @staticmethod def _active_profile_name() -> str: diff --git a/tests/gateway/test_multiplex_session_db_profile_scope.py b/tests/gateway/test_multiplex_session_db_profile_scope.py index fe4b747297..34a2a18975 100644 --- a/tests/gateway/test_multiplex_session_db_profile_scope.py +++ b/tests/gateway/test_multiplex_session_db_profile_scope.py @@ -790,3 +790,33 @@ def test_default_namespace_rows_stay_in_launch_store_under_secondary_scope(multi reset_hermes_home_override(scope) assert Path(db.db_path) == root / "state.db" + + +def test_profile_named_main_keeps_its_own_namespace_and_store(multiplex_homes): + """``main`` is a valid profile name, but ``agent:main`` is the default profile's namespace: a + profile literally named ``main`` produced byte-identical keys to the default and, once default + keys were pinned to the launch store, its scoped sessions were written into the ROOT + ``state.db``. Its namespace must differ from the default's and resolve back to ``profiles/main``.""" + from gateway.run import _parse_session_key + from gateway.session import build_session_key + + root, _profile = multiplex_homes + main_home = root / "profiles" / "main" + main_home.mkdir(parents=True) + store = _multiplex_store(root) + source = SessionSource(platform=Platform.TELEGRAM, chat_id="555", user_id="u1", profile="main") + + main_key = build_session_key(source, profile="main") + default_key = build_session_key(source, profile=None) + assert main_key != default_key + assert store._profile_from_session_key(main_key) == "main" + assert store._profile_from_session_key(default_key) == "default" + assert _parse_session_key(main_key)["profile"] == "main" + assert "profile" not in _parse_session_key(default_key) + + scope = set_hermes_home_override(str(main_home)) + try: + assert Path(store._db_for_key(main_key).db_path) == main_home / "state.db" + assert Path(store._db_for_key(default_key).db_path) == root / "state.db" + finally: + reset_hermes_home_override(scope) diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 3e9f3d381d..cee56c8538 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -294,7 +294,9 @@ migration, no orphaned history. Every gateway path that reads a key back — delegation completions after a restart, shutdown notices, a per-user-thread `/stop` of a sibling's run, `/undo`, QQ approval buttons — accepts the `agent::…` shape too, so secondary profiles get the same behaviour -as the default one. +as the default one. The one profile name that would collide with the default's +namespace, a profile literally called `main`, is keyed `agent:main~:…` so it +keeps its own sessions and its own `profiles/main/state.db`. Each profile's rows land in **its own** `state.db`: a named profile's under `profiles//state.db`, the default profile's under the launch home — even