From 09af0a8c1d2ae55eaf78b9976619023fac41d041 Mon Sep 17 00:00:00 2001 From: Erosika Date: Tue, 30 Jun 2026 19:27:53 +0000 Subject: [PATCH] fix(profile): propagate profile context across thread/executor boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare threading.Thread / ThreadPoolExecutor worker starts with an empty contextvars.Context, so the context-local profile override (_HERMES_HOME_OVERRIDE) does not cross the spawn boundary. In single-process multi-profile runtimes (desktop tui_gateway) the worker then resolves get_hermes_home() to the launch/default profile, leaking one profile's reads/writes into another. The fix primitive (tools.thread_context. propagate_context_to_thread, which copies the parent context) already exists; the leaking spawns simply did not use it. - model_tools.py _run_async: wrap the worker-thread loop runner. This is the generic sync->async bridge for every async tool, so wrapping it here fixes the leak for all async tools at once (verified: an async tool reading get_hermes_home() under an override now resolves the active profile). - run_agent.py bg-review thread: wrap so MEMORY.md / skill review writes land in the spawning turn's profile (#54937 path). - tools/async_delegation.py: wrap both single + batch executor.submit calls so detached children resolve the dispatching profile's paths. Scope: the vision CPU executor is intentionally left unwrapped — it runs pure in-memory encode/resize and never resolves profile-scoped paths. --- model_tools.py | 10 +++++++++- run_agent.py | 11 ++++++++++- tools/async_delegation.py | 13 +++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/model_tools.py b/model_tools.py index fb16bb745ac..a69f9f84efe 100644 --- a/model_tools.py +++ b/model_tools.py @@ -144,7 +144,15 @@ def _run_async(coro): worker_loop.close() pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) - future = pool.submit(_run_in_worker) + # Propagate the parent thread's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) and approval/sudo callbacks into + # the worker thread. Without this, any async tool that resolves + # get_hermes_home() inside its coroutine falls back to the launch/default + # profile in single-process multi-profile runtimes (desktop tui_gateway), + # leaking one profile's reads/writes into another. + from tools.thread_context import propagate_context_to_thread + + future = pool.submit(propagate_context_to_thread(_run_in_worker)) try: return future.result(timeout=300) except concurrent.futures.TimeoutError: diff --git a/run_agent.py b/run_agent.py index 0116edf876d..37e9a09b73b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1525,13 +1525,22 @@ class AIAgent: keep working. """ from agent.background_review import spawn_background_review_thread + from tools.thread_context import propagate_context_to_thread target, _prompt = spawn_background_review_thread( self, messages_snapshot, review_memory=review_memory, review_skills=review_skills, ) - t = threading.Thread(target=target, daemon=True, name="bg-review") + # Propagate the spawning turn's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) into the review thread. A bare + # threading.Thread starts with an empty context, so the review would + # resolve get_hermes_home() to the launch/default profile and write + # MEMORY.md / skill review into the wrong profile in single-process + # multi-profile runtimes (desktop tui_gateway) — see #54937. + t = threading.Thread( + target=propagate_context_to_thread(target), daemon=True, name="bg-review" + ) t.start() def _build_memory_write_metadata( diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 92f58c83afb..987935df479 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -45,6 +45,8 @@ from concurrent.futures import ThreadPoolExecutor from concurrent.futures.thread import _worker from typing import Any, Callable, Dict, List, Optional +from tools.thread_context import propagate_context_to_thread + logger = logging.getLogger(__name__) @@ -247,7 +249,11 @@ def dispatch_async_delegation( _finalize(delegation_id, result, status) try: - executor.submit(_worker) + # Capture the dispatching turn's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) so the detached child resolves + # get_hermes_home() under the right profile in single-process + # multi-profile runtimes (desktop tui_gateway). + executor.submit(propagate_context_to_thread(_worker)) except Exception as exc: # pragma: no cover — pool submit failure is rare with _records_lock: _records.pop(delegation_id, None) @@ -432,7 +438,10 @@ def dispatch_async_delegation_batch( _finalize_batch(delegation_id, combined, status) try: - executor.submit(_worker) + # Capture the dispatching turn's ContextVars (notably the + # _HERMES_HOME_OVERRIDE profile scope) so the detached batch children + # resolve get_hermes_home() under the right profile. + executor.submit(propagate_context_to_thread(_worker)) except Exception as exc: # pragma: no cover with _records_lock: _records.pop(delegation_id, None)