fix(multiplex): background threads and teardown paths carry the turn's profile scope
The profile scope (HERMES_HOME override, secret scope, terminal policy) is a contextvar bundle bound per turn. A bare threading.Thread / Timer / gRPC callback starts with an empty context and resolves the LAUNCH profile: - agent/title_generator.py: the auto-title thread read auxiliary.title_generation (model, language, provider key) from the default profile's config and billed the default's key for a secondary's session. Spawn via agent.memory_provider.spawn_context_thread (copy_context). - tui_gateway/session_lifecycle.py: every teardown caller is a bare Timer (ws-orphan reap), the idle-reaper thread, atexit _shutdown_sessions, the session.close pool RPC, superseded_by_resume or compute_host flush - none carries a scope, yet on_session_end / commit_memory_session / agent.close -> shutdown_memory_provider read the provider's config + credentials at call time. Under multiplex they failed closed (tail never committed, #110622 class); on the Desktop backend a secondary's transcript went to the launch profile's memory tenant. _finalize_session and _teardown_session now bind _session_profile_runtime_scope(session) around those blocks, which covers every spawn site through the single chokepoint. - plugins/platforms/google_chat/adapter.py: Pub/Sub callbacks run on the gRPC SubscriberClient's threads and run_coroutine_threadsafe copies THAT empty context onto the loop task, so _dispatch_message and everything under it (attachment cache, per-user OAuth token store via _acquire_user_chat_api -> _load_per_user_chat_api, TTS keys, delivery ledger, bot-id cache) resolved the launch profile. connect() captures its scope; _on_pubsub_message and _submit_on_loop run under a per-callback copy of it. spawn_context_thread gains a kwargs passthrough for the title thread's callbacks.
This commit is contained in:
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import contextvars
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
@@ -384,6 +385,12 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
||||
self._project_id = self._subscription_path = self._bot_user_id = None # bot id is users/{id}
|
||||
self._supervisor_task: Optional[asyncio.Task] = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
# The profile scope this adapter was connected under (multiplex: HERMES_HOME override + secret
|
||||
# scope). Pub/Sub callbacks arrive on the gRPC SubscriberClient's own threads with an EMPTY
|
||||
# context, and ``run_coroutine_threadsafe`` copies THAT context onto the loop task — so
|
||||
# ``_dispatch_message`` and everything it reaches (attachment cache, per-user OAuth token
|
||||
# store, delivery ledger, TTS keys) would resolve the launch profile. Captured in ``connect()``.
|
||||
self._scope_ctx: Optional[contextvars.Context] = None
|
||||
# User-authed Chat clients for native ``media.upload`` (bot identity is rejected
|
||||
# there) keyed by sender email; ``_user_credentials``/``_user_chat_api`` = LEGACY fallback.
|
||||
self._user_chat_api = self._user_credentials = None
|
||||
@@ -496,9 +503,13 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
||||
return
|
||||
try:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
future = safe_schedule_threadsafe(
|
||||
coro, loop, logger=logger, log_message="[GoogleChat] Failed to schedule background callback",
|
||||
log_level=logging.WARNING,
|
||||
# run_coroutine_threadsafe copies the CALLING thread's context onto the loop task; from the
|
||||
# gRPC callback thread that is empty. Run the scheduling inside the adapter's connect-time
|
||||
# scope so the task (and every to_thread/create_task under it) carries the profile.
|
||||
ctx = self._scope_ctx.copy() if self._scope_ctx is not None else contextvars.copy_context()
|
||||
future = ctx.run(
|
||||
safe_schedule_threadsafe, coro, loop, logger=logger,
|
||||
log_message="[GoogleChat] Failed to schedule background callback", log_level=logging.WARNING,
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.warning("[GoogleChat] Loop closed between check and submit")
|
||||
@@ -608,6 +619,7 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
||||
message="google-cloud-pubsub / google-api-python-client not installed")
|
||||
return False
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._scope_ctx = contextvars.copy_context() # the profile scope connect() runs under (see __init__)
|
||||
try:
|
||||
project_id, subscription_path = self._validate_config()
|
||||
credentials = self._load_sa_credentials()
|
||||
@@ -782,7 +794,13 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
||||
def _on_pubsub_message(self, message: Any) -> None:
|
||||
"""Pub/Sub callback — parse envelope and dispatch to the asyncio loop.
|
||||
Runs in a SubscriberClient worker thread: never block, never raise (that
|
||||
triggers nack + infinite redelivery). Event type comes from ``ce-type``."""
|
||||
triggers nack + infinite redelivery). Event type comes from ``ce-type``. The body runs under
|
||||
the adapter's profile scope (a per-callback copy: a Context cannot be entered concurrently) so
|
||||
``_save_cached_bot_id`` and the loop hand-off resolve the served profile, not the launch one."""
|
||||
ctx = self._scope_ctx.copy() if self._scope_ctx is not None else contextvars.copy_context()
|
||||
ctx.run(self._handle_pubsub_message, message)
|
||||
|
||||
def _handle_pubsub_message(self, message: Any) -> None:
|
||||
if self._shutting_down:
|
||||
message.nack()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user