fix(multiplex): served profiles read their own sessions.* settings

gateway/run.py bridges the LAUNCH profile's sessions.cjk_fts / search_slow_ms
into HERMES_CJK_FTS / HERMES_SEARCH_SLOW_MS at import (and re-bridged them per
turn), and hermes_state_fts / hermes_state_search read os.getenv — so a served
secondary always got the default profile's values.

hermes_state_common.routed_sessions_setting() reads the routed profile's
config.yaml under a HERMES_HOME override and the env bridge when unscoped; both
consumers use it. The per-turn re-bridge is skipped inside a secondary's scope
so it can no longer write the default's slots from a routed turn.
This commit is contained in:
Teknium
2026-09-11 16:53:54 -07:00
parent 4d47891b4a
commit 31b1cbbe8d
4 changed files with 46 additions and 12 deletions
+10 -6
View File
@@ -1605,7 +1605,12 @@ def _reload_runtime_env_preserving_config_authority() -> None:
def _bridge_max_turns_from_config(home: "Path") -> None: def _bridge_max_turns_from_config(home: "Path") -> None:
"""Re-bridge agent.max_turns (+ sessions.*) per turn; managed overlay applies or it reverts.""" """Re-bridge agent.max_turns (+ sessions.*) per turn; managed overlay applies or it reverts.
Skipped inside a served secondary's scope: the env slots are the launch profile's and
hermes_state reads the routed profile's ``sessions.*`` from its own config under scope."""
from gateway.platforms._shared import profile_scoped
if profile_scoped():
return
config_path = home / 'config.yaml' config_path = home / 'config.yaml'
if not config_path.exists(): if not config_path.exists():
return return
@@ -1658,15 +1663,14 @@ class HygieneTurnHoldExceeded(Exception):
def _multiplex_profile_homes(config: object) -> list[tuple[str, "Path"]]: def _multiplex_profile_homes(config: object) -> list[tuple[str, "Path"]]:
"""Return the authoritative profile set for one multiplex gateway config.""" """Return the authoritative profile set for one multiplex gateway config."""
from hermes_cli.profiles import profiles_to_serve from hermes_cli.profiles import profiles_to_serve
return list(profiles_to_serve( return list(profiles_to_serve(multiplex=True))
multiplex=True, profile_allowlist=getattr(config, "multiplex_profile_allowlist", None)))
def _cron_tick_profile_homes(config: object) -> list[tuple[str, "Path"]]: def _cron_tick_profile_homes(config: object) -> list[tuple[str, "Path"]]:
"""Profile homes the in-process ticker visits under multiplex: the served set PLUS the """Profile homes the in-process ticker visits under multiplex: the served set PLUS the
process-active profile. ``profiles_to_serve`` starts at default + allowlist, so a process-active profile: ``profiles_to_serve`` lists default + every live named profile, but a
``--profile <name>`` multiplexer was omitted unless allowlisted — and allowlisting it would ``--profile <name>`` multiplexer's own profile may sit outside ``profiles/`` (custom
start a second adapter on its own bot token. Adapter startup already skips ``active``.""" HERMES_HOME). Adapter startup already skips ``active``."""
from hermes_cli.profiles import get_active_profile_name, get_profile_dir from hermes_cli.profiles import get_active_profile_name, get_profile_dir
homes = _multiplex_profile_homes(config) homes = _multiplex_profile_homes(config)
+20
View File
@@ -23,6 +23,26 @@ _PREVIEW_SCAFFOLD_WINDOW = 400
_PREVIEW_MAX_CHARS = 60 _PREVIEW_MAX_CHARS = 60
def routed_sessions_setting(key: str, env_var: str) -> Any:
"""``sessions.<key>`` for the profile whose state.db this process is touching.
``gateway/run.py`` bridges the LAUNCH profile's ``sessions.*`` into ``env_var`` (the cross-process
carrier CLI/cron children read). Under a multiplexer a routed turn runs with a HERMES_HOME override
and that env slot holds the default profile's value, so a served profile with different
``sessions.*`` settings must read its own config.yaml. Unscoped: the env bridge, as before.
Returns ``None`` when neither source sets the key.
"""
from hermes_constants import get_hermes_home_override
if get_hermes_home_override():
try:
from hermes_cli.config import load_config_readonly
return (load_config_readonly().get("sessions") or {}).get(key)
except Exception:
return None
return os.environ.get(env_var)
def escape_like(text: str) -> str: def escape_like(text: str) -> str:
"""Escape LIKE wildcards (``%``, ``_``) so derived text matches literally; pair with ``ESCAPE '\\'``. """Escape LIKE wildcards (``%``, ``_``) so derived text matches literally; pair with ``ESCAPE '\\'``.
``_`` is common in branch names/titles/paths and a substring match must not silently widen.""" ``_`` is common in branch names/titles/paths and a substring match must not silently widen."""
+5 -3
View File
@@ -9,7 +9,8 @@ from pathlib import Path
from typing import Sequence from typing import Sequence
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from hermes_state_common import FTS_CJK_STALE_KEY, FTS_STALE_KEY, _FTS_CJK_TRIGGERS, _FTS_TRIGGERS from hermes_state_common import (FTS_CJK_STALE_KEY, FTS_STALE_KEY, _FTS_CJK_TRIGGERS, _FTS_TRIGGERS,
routed_sessions_setting)
from hermes_state_errors import is_fts_scoped_corruption_error from hermes_state_errors import is_fts_scoped_corruption_error
# caplog tests pin the "hermes_state" logger name. # caplog tests pin the "hermes_state" logger name.
@@ -102,8 +103,9 @@ def fts5_cjk_so_path() -> Path:
def _cjk_fts_config_enabled() -> bool: def _cjk_fts_config_enabled() -> bool:
"""config.yaml ``sessions.cjk_fts`` (default on), via its env bridge.""" """config.yaml ``sessions.cjk_fts`` (default on) for the profile being served."""
return os.getenv("HERMES_CJK_FTS", "1").strip().lower() not in ("0", "false", "off", "no") value = routed_sessions_setting("cjk_fts", "HERMES_CJK_FTS")
return value is None or str(value).strip().lower() not in ("0", "false", "off", "no")
def load_fts5_cjk_extension(conn: sqlite3.Connection) -> bool: def load_fts5_cjk_extension(conn: sqlite3.Connection) -> bool:
+11 -3
View File
@@ -12,17 +12,25 @@ import time
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple from typing import Any, Callable, Collection, Dict, List, Optional, Tuple
from agent.skill_commands import describe_skill_invocation from agent.skill_commands import describe_skill_invocation
from utils import env_float
from hermes_state_common import ( from hermes_state_common import (
FTS_CJK_STALE_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TOOL_CONTENT_PREFIX_CHARS, FTS_CJK_STALE_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TOOL_CONTENT_PREFIX_CHARS,
FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, FTS_TRIGRAM_EXCLUDED_SOURCES, FTS_TRIGRAM_SQL, FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, FTS_TRIGRAM_EXCLUDED_SOURCES, FTS_TRIGRAM_SQL,
MAX_FTS5_QUERY_CHARS, SCHEMA_VERSION, _FTS_CJK_TRIGGERS, MAX_FTS5_QUERY_CHARS, SCHEMA_VERSION, _FTS_CJK_TRIGGERS,
escape_like as _escape_like, fts_rebuild_admission, fts_trigram_session_sql, escape_like as _escape_like, fts_rebuild_admission, fts_trigram_session_sql, routed_sessions_setting,
) )
# Pre-split logger identity so log filtering/capture is unchanged. # Pre-split logger identity so log filtering/capture is unchanged.
logger = logging.getLogger("hermes_state") logger = logging.getLogger("hermes_state")
def _search_slow_ms() -> float:
"""``sessions.search_slow_ms`` for the served profile (default 1000; 0 logs every call)."""
value = routed_sessions_setting("search_slow_ms", "HERMES_SEARCH_SLOW_MS")
try:
return 1000.0 if value is None or str(value).strip() == "" else float(value)
except (TypeError, ValueError):
return 1000.0
# Characters FTS5's query grammar rejects outside a quoted phrase (anything missing # Characters FTS5's query grammar rejects outside a quoted phrase (anything missing
# reaches MATCH raw and raises -> zero results). ``%`` is deliberately excluded: the # reaches MATCH raw and raises -> zero results). ``%`` is deliberately excluded: the
# CJK LIKE fallback needs it literal (that path escapes wildcards itself). # CJK LIKE fallback needs it literal (that path escapes wildcards itself).
@@ -1015,7 +1023,7 @@ class SessionSearchMixin:
return rows return rows
finally: finally:
elapsed_ms = (time.time() - started) * 1000.0 elapsed_ms = (time.time() - started) * 1000.0
if elapsed_ms >= env_float("HERMES_SEARCH_SLOW_MS", 1000.0): if elapsed_ms >= _search_slow_ms():
logger.info("slow session search: path=%s elapsed=%.0fms rows=%s query=%r", logger.info("slow session search: path=%s elapsed=%.0fms rows=%s query=%r",
self._describe_search_path(query), elapsed_ms, len(rows) if rows is not None else "err", self._describe_search_path(query), elapsed_ms, len(rows) if rows is not None else "err",
query[: 200]) query[: 200])