fix(profile): propagate profile context across thread/executor boundaries

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.
This commit is contained in:
Erosika 2026-06-30 19:27:53 +00:00 committed by Teknium
parent 10e60060d9
commit 09af0a8c1d
3 changed files with 30 additions and 4 deletions

View file

@ -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:

View file

@ -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(

View file

@ -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)