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:
teknium1
2026-09-14 23:31:08 -07:00
committed by Teknium
parent d1794d5539
commit 8b181940b4
5 changed files with 150 additions and 24 deletions
+22 -4
View File
@@ -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