fbd9730e30
The multiplexed inbound handler wraps every message in _profile_runtime_scope,
which installs the routed profile's HERMES_HOME override and its secret scope
as contextvars. A bare loop.run_in_executor(None, fn) starts the worker with an
EMPTY context, so neither reaches the blocking work.
GatewaySlashCommandsMixin already knows this -- /compress goes through
_run_in_executor_with_context and the call site says why. Three siblings in the
same file still used the bare hop:
/insights SessionDB() with no explicit path resolves get_hermes_home() at
call time (_default_db_path), so the worker opened the DEFAULT
profile's state.db. Under multiplexing the command reported
another profile's conversations, session counts and sources to
this profile's user.
/debug collects that home's logs/config and uploads them to a public
paste, so it published the default profile's diagnostics from
another profile's chat.
/goal draft calls the auxiliary LLM, whose provider/credential resolution
reads the profile secret scope -- unscoped it falls back to
process-global os.environ, which under multiplexing may hold a
different profile's keys.
Route all three through _run_in_executor_with_context.
/reload-skills is deliberately left alone: tools.skills_tool binds SKILLS_DIR
at import time, so it does not follow the contextvar either way. Fixing that
needs the module-global retarget web_server._profile_scope performs under a
lock, which is a different change from context propagation.
Single-profile gateways never enter the scope, so their behaviour is unchanged.
95 lines
2.7 KiB
Python
95 lines
2.7 KiB
Python
"""Gateway slash commands must do their blocking work inside the routed profile.
|
|
|
|
The multiplexed inbound handler wraps the whole message in
|
|
``_profile_runtime_scope``, which installs the routed profile's ``HERMES_HOME``
|
|
override and its secret scope as **contextvars**. A bare
|
|
``loop.run_in_executor(None, ...)`` starts the worker with an EMPTY context, so
|
|
``SessionDB()`` / ``get_hermes_home()`` inside the worker resolve the LAUNCH
|
|
home — /insights reported the default profile's conversations from another
|
|
profile's chat. ``/compress`` already routes through
|
|
``_run_in_executor_with_context``; every other hop in the mixin must too.
|
|
|
|
Drives the real mixin methods and the real ``_profile_runtime_scope``: the
|
|
contextvar loss is a property of the hop, so mocking the hop away would test
|
|
nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def profile_home(tmp_path, monkeypatch):
|
|
root = tmp_path / ".hermes"
|
|
home = root / "profiles" / "coder"
|
|
home.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
return home
|
|
|
|
|
|
@pytest.fixture
|
|
def runner():
|
|
"""Minimal host exposing the mixin plus the runner's executor helpers."""
|
|
from gateway.run import GatewayRunner
|
|
from gateway.slash_commands import GatewaySlashCommandsMixin
|
|
|
|
class _Runner(GatewaySlashCommandsMixin):
|
|
_run_in_executor_with_context = GatewayRunner._run_in_executor_with_context
|
|
_get_executor = GatewayRunner._get_executor
|
|
|
|
r = _Runner()
|
|
r.adapters = {}
|
|
r._pending_skills_reload_notes = {}
|
|
return r
|
|
|
|
|
|
class _Event:
|
|
def __init__(self, args: str = ""):
|
|
self._args = args
|
|
self.source = None
|
|
|
|
def get_command_args(self) -> str:
|
|
return self._args
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insights_opens_session_db_under_the_routed_home(
|
|
runner, profile_home, monkeypatch
|
|
):
|
|
import agent.insights as insights_mod
|
|
import hermes_state
|
|
from gateway.run import _profile_runtime_scope
|
|
from hermes_constants import get_hermes_home
|
|
|
|
seen: dict = {}
|
|
|
|
class _RecordingDB:
|
|
def __init__(self, *a, **kw):
|
|
seen["home"] = str(get_hermes_home())
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
class _Engine:
|
|
def __init__(self, db):
|
|
pass
|
|
|
|
def generate(self, **kw):
|
|
return {}
|
|
|
|
def format_gateway(self, report):
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(hermes_state, "SessionDB", _RecordingDB)
|
|
monkeypatch.setattr(insights_mod, "InsightsEngine", _Engine)
|
|
|
|
with _profile_runtime_scope(profile_home):
|
|
result = await runner._handle_insights_command(_Event(""))
|
|
|
|
assert result == "ok"
|
|
assert seen["home"] == str(profile_home)
|