fix(gateway): a profile named 'main' gets its own session namespace
`main` is a valid profile name (only hermes/default/test/tmp/root/sudo are
reserved), but _session_key_namespace mapped it to `agent:main` — the default
profile's namespace. Both profiles then built byte-identical keys: one routing
entry, one cached agent, and, since 75ae2859b9 pinned default-namespace
keys to the launch store, profiles/main's scoped sessions were written into
the ROOT state.db instead of profiles/main/state.db.
Key the `main` profile as `agent:main~` (`~` is outside the profile-id
alphabet, so the marked form cannot be any other profile's id) and give the
namespace slot one inverse, profile_from_session_key_namespace, used by the
store's key parser, _parse_session_key, the update-marker profile reader and
the profile-delete eviction prefix. Default keys stay byte-identical.
This commit is contained in:
+6
-4
@@ -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:<positive_chat_id>:<numeric_thread_id>`` 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
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
+15
-2
@@ -625,8 +625,21 @@ def is_shared_multi_user_session(
|
||||
def _session_key_namespace(profile: Optional[str]) -> str:
|
||||
"""``agent:<ns>`` prefix for a session key: default/None profile → ``agent:main``
|
||||
(BYTE-IDENTICAL to every historical key); named profile → ``agent:<name>`` 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 ``<ns>`` 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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<profile>:…` 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/<name>/state.db`, the default profile's under the launch home — even
|
||||
|
||||
Reference in New Issue
Block a user