From 48b40d930a7a3924b41504cb14e9a71dbb5aa7bf Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 20:48:00 -0700 Subject: [PATCH] Release: isolate Hermes home per streaming turn (#5877, @starship-s) (#6379) * fix(profiles): isolate Hermes home per streaming turn Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): gate skill isolation by capability Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): harden fallback lock boundaries Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * test(profiles): adapt streaming isolation harness Assisted-by: Codex:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * Release: isolate Hermes home per streaming turn (#5877, @starship-s) --------- Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com> Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/profiles.py | 188 ++- api/streaming.py | 148 ++- ...est_issue2023_hermes_home_skill_modules.py | 115 ++ tests/test_issue2848_checkpoint_profile.py | 78 +- tests/test_issue5567_profile_home_override.py | 1097 +++++++++++++++++ tests/test_sprint46.py | 5 +- tests/test_title_aux_routing.py | 9 +- tests/test_update_banner_fixes.py | 4 +- 9 files changed, 1587 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2908a634..51cf8af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- **Concurrent chats under different profiles can no longer cross the profile boundary mid-turn.** In a single WebUI process, two chats streaming at once could resolve different Hermes profiles, but agent construction and later prompt/config/skill reads still depended on process-global `HERMES_HOME` and globally-patched skill-module paths after the env lock was released — so a second profile's turn could overwrite those globals mid-first-turn and bleed one profile's `SOUL.md`/skills/config into another's chat. The resolved profile home is now bound through the agent's per-thread context-local override for the entire streaming turn (and detached worker scope) and reset in the run's terminal `finally`, so concurrent profiles stay isolated even if the process globals are clobbered mid-run. Checkpoint saves skip the skill lock (no deadlock) and the capability check rejects globally-patched module state (no leak), with a serialized snapshot/patch/restore fallback that keeps older agents safe. Thanks @starship-s. (#5877, #5567, #2023) + - **The consolidated Kanban board now fills the panel's vertical space.** In consolidated view (a single board, not split into per-profile lanes), the columns were previously clamped to a short fixed height, leaving a large empty gap below the board. The consolidated board now grows to the full panel height and scrolls as one tall surface; the per-profile lane view keeps its existing height clamp and contained scrolling. CSS-only. Thanks @jpalazz2. (#6308) - **Long-running installs use less memory: the default resident-session cache cap is lowered from 300 to 100.** The in-memory `SESSIONS` LRU (compact session objects) is a dominant contributor to WebUI RSS climbing over hours of continuous use; the shipped no-override default is now tuned for the common single-user install. Larger deployments can still raise it via `webui.sessions_cache_max` in `config.yaml` (preferred) or the `HERMES_WEBUI_SESSIONS_MAX` env fallback. Eviction safety is unchanged — only clean, persisted, non-active sessions are ever dropped, and an evicted session lazily reloads from its JSON sidecar on next access (no data loss). Thanks @rodboev. (#6362, #6351) diff --git a/api/profiles.py b/api/profiles.py index 7f047673..1bc80047 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -56,6 +56,7 @@ _loaded_profile_env_keys: set[str] = set() _tls = threading.local() _SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool") +_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock() def snapshot_skill_home_modules() -> dict[str, dict[str, object]]: @@ -76,6 +77,71 @@ def snapshot_skill_home_modules() -> dict[str, dict[str, object]]: return snapshot +def _skill_modules_support_profile_home(profile_home: Path) -> bool: + """Return ``True`` when both skill modules resolve to this profile home.""" + expected_dir = (Path(profile_home) / 'skills').expanduser() + + for module_name in _SKILL_HOME_MODULES: + module = sys.modules.get(module_name) + if module is None: + logger.debug("Skill capability check: %s not pre-imported in sys.modules", module_name) + return False + + if not hasattr(module, 'SKILLS_DIR'): + logger.debug("Skill capability check: %s missing SKILLS_DIR", module_name) + return False + + if not hasattr(module, '_SKILLS_DIR_AT_IMPORT'): + logger.debug("Skill capability check: %s missing _SKILLS_DIR_AT_IMPORT", module_name) + return False + + try: + current_skills_dir = Path(module.SKILLS_DIR).expanduser() + import_skills_dir = Path(module._SKILLS_DIR_AT_IMPORT).expanduser() + except Exception: + logger.debug( + "Skill capability check: %s has invalid SKILLS_DIR or _SKILLS_DIR_AT_IMPORT", + module_name, + exc_info=True, + ) + return False + + if current_skills_dir != import_skills_dir: + logger.debug( + "Skill capability check: %s.SKILLS_DIR %r does not match imported baseline %r", + module_name, + current_skills_dir, + import_skills_dir, + ) + return False + + skills_dir = getattr(module, '_skills_dir', None) + if not callable(skills_dir): + logger.debug("Skill capability check: %s._skills_dir is not callable", module_name) + return False + + try: + resolved = Path(skills_dir()).expanduser() + except Exception: + logger.debug( + "Skill capability check: %s._skills_dir() failed", + module_name, + exc_info=True, + ) + return False + + if resolved != expected_dir: + logger.debug( + "Skill capability check: %s resolves %r instead of %r", + module_name, + str(resolved), + str(expected_dir), + ) + return False + + return True + + def patch_skill_home_modules(home: Path) -> None: """Patch imported skill modules that cache HERMES_HOME at import time.""" for module_name in _SKILL_HOME_MODULES: @@ -1100,6 +1166,8 @@ def profile_env_for_background_worker( session, purpose: str = "background worker", logger_override: Optional[logging.Logger] = None, + *, + scope_skill_modules: bool = True, ): """Temporarily route detached worker config reads through a profile. @@ -1152,10 +1220,15 @@ def profile_env_for_background_worker( ) _scope_token = None _has_scope = False + _secret_scope_mod = None # #5567: context-local Hermes-home override (hermes-agent v0.18.0+). None on # older agents → graceful no-op (falls back to the os.environ mirror below). _home_override_mod = None _home_override_token = None + _home_override_installed = False + has_profile_skill_home = False + should_restore_skill_modules = False + _acquired_skill_home_patch_lock = False try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True @@ -1180,9 +1253,43 @@ def profile_env_for_background_worker( _home_override_token = _home_override_mod.set_hermes_home_override( str(profile_home_path) ) + _home_override_installed = True except Exception: _home_override_token = None + _home_override_installed = False + + if scope_skill_modules: + if _home_override_mod is not None and _home_override_installed: + try: + has_profile_skill_home = _skill_modules_support_profile_home( + profile_home_path + ) + except Exception: + logger.debug( + "Failed to evaluate profile-home skill module capability for %s in %s", + profile, + purpose, + exc_info=True, + ) + has_profile_skill_home = False + + # #5567-fallback: if override is unavailable, or module-side + # profile resolution is missing/failed, serialize the full worker + # lifespan under the shared legacy patch lock. + should_restore_skill_modules = not ( + _home_override_installed and has_profile_skill_home + ) + if should_restore_skill_modules: + _SKILL_HOME_MODULE_PATCH_LOCK.acquire() + _acquired_skill_home_patch_lock = True + with _ENV_LOCK: + if scope_skill_modules and should_restore_skill_modules: + # Snapshot and patch before mutating process env so setup + # failures can unwind without leaking either state. + skill_home_snapshot = snapshot_skill_home_modules() + patch_skill_home_modules(profile_home_path) + old_runtime_env = _apply_profile_env_to_process( os.environ, safe_runtime_env, @@ -1190,48 +1297,43 @@ def profile_env_for_background_worker( ) had_hermes_home = "HERMES_HOME" in os.environ old_hermes_home = os.environ.get("HERMES_HOME") - skill_home_snapshot = snapshot_skill_home_modules() os.environ.update(safe_runtime_env) os.environ["HERMES_HOME"] = str(profile_home_path) - try: - patch_skill_home_modules(profile_home_path) - except Exception: - log.debug( - "Failed to patch skill modules for %s profile %s", - purpose, - profile, - exc_info=True, - ) yield finally: - # #5567: pop the context-local home override first (reverse of setup order). - if _home_override_mod is not None and _home_override_token is not None: - try: - _home_override_mod.reset_hermes_home_override(_home_override_token) - except Exception: - pass - if _has_scope and _secret_scope_mod is not None: - try: - _secret_scope_mod.reset_secret_scope(_scope_token) - except Exception: - pass - _thread_ctx.block_process_env_fallback = previous_block_process_env - if previous_thread_env: - _set_thread_env(**previous_thread_env) - else: - _clear_thread_env() - with _ENV_LOCK: - for key, old_value in old_runtime_env.items(): - if old_value is None: - os.environ.pop(key, None) + try: + with _ENV_LOCK: + for key, old_value in old_runtime_env.items(): + if old_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = old_value + if had_hermes_home: + os.environ["HERMES_HOME"] = old_hermes_home or "" else: - os.environ[key] = old_value - if had_hermes_home: - os.environ["HERMES_HOME"] = old_hermes_home or "" + os.environ.pop("HERMES_HOME", None) + if should_restore_skill_modules and skill_home_snapshot is not None: + restore_skill_home_modules(skill_home_snapshot) + finally: + if _acquired_skill_home_patch_lock: + _SKILL_HOME_MODULE_PATCH_LOCK.release() + _acquired_skill_home_patch_lock = False + # Reset context-local state after the fallback globals are restored. + if _home_override_mod is not None and _home_override_installed: + try: + _home_override_mod.reset_hermes_home_override(_home_override_token) + except Exception: + pass + if _has_scope and _secret_scope_mod is not None: + try: + _secret_scope_mod.reset_secret_scope(_scope_token) + except Exception: + pass + _thread_ctx.block_process_env_fallback = previous_block_process_env + if previous_thread_env: + _set_thread_env(**previous_thread_env) else: - os.environ.pop("HERMES_HOME", None) - if skill_home_snapshot is not None: - restore_skill_home_modules(skill_home_snapshot) + _clear_thread_env() @contextmanager @@ -1292,6 +1394,7 @@ def profile_env_for_active_request_readonly( getattr(_thread_ctx, "block_process_env_fallback", False) ) home_override_token = None + home_override_installed = False _scope_token = None _has_scope = False try: @@ -1307,7 +1410,16 @@ def profile_env_for_active_request_readonly( except Exception: pass if set_hermes_home_override is not None: - home_override_token = set_hermes_home_override(profile_home_path) + try: + home_override_token = set_hermes_home_override(profile_home_path) + home_override_installed = True + except Exception: + logger.debug( + "Failed to install Hermes-home override for active request profile %s in %s", + profile, + purpose, + exc_info=True, + ) yield finally: if _has_scope and _secret_scope_mod is not None: @@ -1315,7 +1427,7 @@ def profile_env_for_active_request_readonly( _secret_scope_mod.reset_secret_scope(_scope_token) except Exception: pass - if home_override_token is not None and reset_hermes_home_override is not None: + if reset_hermes_home_override is not None and home_override_installed: try: reset_hermes_home_override(home_override_token) except Exception: diff --git a/api/streaming.py b/api/streaming.py index a05997c1..115b6fe5 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1831,6 +1831,77 @@ def _build_agent_thread_env(profile_runtime_env: dict | None, workspace: str, se return env +_streaming_hermes_home_override_available = None + + +def _resolve_streaming_hermes_home_override(): + """Return hermes_constants module if context-local home override APIs exist. + + Cached import-safe resolver mirrors the optional pattern used by + `api.profiles` so older agent versions safely degrade to the process-global + env mirror fallback. + """ + global _streaming_hermes_home_override_available + import sys as _sys + + if _streaming_hermes_home_override_available is False: + return None + + mod = _sys.modules.get('hermes_constants') + if mod is None and _streaming_hermes_home_override_available is None: + try: + import hermes_constants # noqa: F401 + mod = _sys.modules.get('hermes_constants') + except Exception: + _streaming_hermes_home_override_available = False + return None + + if ( + mod is not None + and hasattr(mod, 'set_hermes_home_override') + and hasattr(mod, 'reset_hermes_home_override') + ): + _streaming_hermes_home_override_available = True + return mod + + _streaming_hermes_home_override_available = False + return None + + +def _set_streaming_hermes_home_override(profile_home: str): + """Install the context-local home override if available. + + Returns ``(module, token, installed)`` so callers can restore it with the + matching module in the inverse order. + """ + if not profile_home: + return None, None, False + + _home_override_mod = _resolve_streaming_hermes_home_override() + if _home_override_mod is None: + return None, None, False + + try: + _token = _home_override_mod.set_hermes_home_override(profile_home) + return _home_override_mod, _token, True + except Exception: + logger.debug( + "Failed to set streaming Hermes home override; continuing with os.environ mirror", + exc_info=True, + ) + return None, None, False + + +def _reset_streaming_hermes_home_override(override_mod, override_token, override_installed: bool) -> None: + """Reset the context-local home override if it was installed.""" + if override_mod is None or not override_installed: + return + try: + override_mod.reset_hermes_home_override(override_token) + except Exception: + logger.debug("Failed to reset streaming Hermes home override", exc_info=True) + + # ── Per-turn session identity (xsession wakeup misroute root fix — Option 1) ─ # WebUI bound per-turn session identity ONLY to the process-global # os.environ['HERMES_SESSION_KEY'] (turn-start, line ~3263) and released the @@ -5405,6 +5476,7 @@ def _save_streaming_checkpoint(session): session, "streaming checkpoint", logger_override=logger, + scope_skill_modules=False, ): session.save(skip_index=True) @@ -7465,6 +7537,10 @@ def _run_agent_streaming( _turn_session_identity_tokens = None _streaming_cron_profile_home_token = None _turn_pending_source = 'webui' + _streaming_hermes_home_override_ctx = (None, None, False) + _streaming_skill_home_snapshot = None + _restore_streaming_skill_home_modules = False + _acquired_streaming_skill_home_patch_lock = False # Initialised here (before any code that may raise) so the outer `finally` # block can safely check `if _checkpoint_stop is not None` even when an # exception fires before the checkpoint thread is created (Issue #765). @@ -7532,8 +7608,12 @@ def _run_agent_streaming( from api.profiles import ( filter_runtime_env_for_gateway_parity, patch_skill_home_modules, + restore_skill_home_modules, + snapshot_skill_home_modules, get_hermes_home_for_profile, get_profile_runtime_env, + _skill_modules_support_profile_home, + _SKILL_HOME_MODULE_PATCH_LOCK, ) _profile_home_path = get_hermes_home_for_profile(getattr(s, 'profile', None)) _profile_home = str(_profile_home_path) @@ -7545,6 +7625,10 @@ def _run_agent_streaming( _profile_runtime_env = {} _safe_profile_runtime_env = {} patch_skill_home_modules = None + snapshot_skill_home_modules = None + restore_skill_home_modules = None + _skill_modules_support_profile_home = None + _SKILL_HOME_MODULE_PATCH_LOCK = None # Profile-aware provider/model enrichment: when the session belongs # to a profile that specifies model.provider and model.default, use @@ -7594,6 +7678,7 @@ def _run_agent_streaming( session_id, _profile_home, ) + _streaming_hermes_home_override_ctx = _set_streaming_hermes_home_override(_profile_home) _set_thread_env(**_thread_env) # process_complete agent-wakeup wiring (ours-original, Option B): bind # this session's HERMES_SESSION_KEY to its WebUI session_id so the @@ -7609,11 +7694,41 @@ def _run_agent_streaming( ensure_agent_runtime_current() _prewarm_skill_tool_modules() _install_streaming_cronjob_profile_wrapper() + + # Full-turn serialization is only needed for static/legacy skill-module + # resolution, where process-global skill-module globals are still used. + # Dynamic-capable modules continue concurrent execution. + _streaming_override_installed = bool(_streaming_hermes_home_override_ctx[2]) + _streaming_modules_are_dynamic = False + if patch_skill_home_modules is not None and snapshot_skill_home_modules is not None: + if _streaming_override_installed and _skill_modules_support_profile_home is not None: + try: + _streaming_modules_are_dynamic = bool( + _skill_modules_support_profile_home(_profile_home_path) + ) + except Exception: + logger.debug( + "Failed to evaluate streaming skill-module home capability for profile %r", + _profile_home, + exc_info=True, + ) + _streaming_modules_are_dynamic = False + + if not (_streaming_override_installed and _streaming_modules_are_dynamic): + _restore_streaming_skill_home_modules = True + _SKILL_HOME_MODULE_PATCH_LOCK.acquire() + _acquired_streaming_skill_home_patch_lock = True + # Still set process-level env as fallback for tools that bypass thread-local # Acquire lock only for the env mutation, then release before the agent runs. # The finally block re-acquires to restore — keeping critical sections short # and preventing a deadlock where the restore would re-enter the same lock. with _ENV_LOCK: + if _restore_streaming_skill_home_modules: + # Snapshot and patch before mutating process env so setup + # failures can unwind without leaking either state. + _streaming_skill_home_snapshot = snapshot_skill_home_modules() + patch_skill_home_modules(Path(_profile_home)) old_profile_env = {key: os.environ.get(key) for key in _safe_profile_runtime_env} old_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') @@ -7633,18 +7748,14 @@ def _run_agent_streaming( os.environ['HERMES_SESSION_CHAT_ID'] = str(session_id) if _profile_home: os.environ['HERMES_HOME'] = _profile_home - # Patch skill module caches to match the active profile. - # _set_hermes_home() does this for process-wide switches - # but per-request switches skip it (#1700). The in-chat - # cronjob tool is wrapped separately at its tool-call boundary - # with cron_profile_context_for_home (#4580) so cron.jobs path - # caches are not mutated for the entire agent turn. - # Modules were prewarmed by _prewarm_skill_tool_modules() - # above, so we only do lightweight sys.modules lookups and - # attribute assignments here — no first-time import under - # the lock (#2024). - if patch_skill_home_modules is not None: - patch_skill_home_modules(Path(_profile_home)) + # Prefer context-local Hermes-home overrides when available. + # In that mode, tools.skills_tool._skills_dir() and + # tools.skill_manager_tool._skills_dir() can resolve the active + # profile from get_hermes_home() and keep per-thread isolation + # without mutating module globals. If override installation + # succeeds for both modules, skip process-cache patching. + # If either module is static/missing/raises, the legacy path + # above has already snapshotted and patched under this lock. # Lock released — agent runs without holding it # ── MCP Server Discovery (lazy import, idempotent) ── # MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()` @@ -10856,6 +10967,19 @@ def _run_agent_streaming( _clear_thread_env() # TD1: always clear thread-local context if _streaming_cron_profile_home_token is not None: _STREAMING_CRON_PROFILE_HOME.reset(_streaming_cron_profile_home_token) + if _restore_streaming_skill_home_modules and _streaming_skill_home_snapshot is not None: + with _ENV_LOCK: + if restore_skill_home_modules is not None: + try: + restore_skill_home_modules(_streaming_skill_home_snapshot) + except Exception: + logger.debug("Failed to restore skill module state for streaming profile", exc_info=True) + _streaming_skill_home_snapshot = None + _restore_streaming_skill_home_modules = False + if _acquired_streaming_skill_home_patch_lock: + _SKILL_HOME_MODULE_PATCH_LOCK.release() + _acquired_streaming_skill_home_patch_lock = False + _reset_streaming_hermes_home_override(*_streaming_hermes_home_override_ctx) # xsession wakeup misroute root fix (Option 1): restore the per-turn # session-identity context-locals (reset-token semantics). MUST run on # every exit path so a reused thread-pool worker leaks no identity and diff --git a/tests/test_issue2023_hermes_home_skill_modules.py b/tests/test_issue2023_hermes_home_skill_modules.py index cec4b5b9..40b20803 100644 --- a/tests/test_issue2023_hermes_home_skill_modules.py +++ b/tests/test_issue2023_hermes_home_skill_modules.py @@ -33,3 +33,118 @@ def test_set_hermes_home_patches_both_skill_tool_module_caches(monkeypatch, tmp_ assert skills_tool.SKILLS_DIR == new_home / "skills" assert skill_manager_tool.HERMES_HOME == new_home assert skill_manager_tool.SKILLS_DIR == new_home / "skills" + + +def test_skill_modules_support_profile_home_returns_true_for_dynamic_modules(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.HERMES_HOME = profile_home.parent + skills_tool.SKILLS_DIR = baseline + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = lambda: expected + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.HERMES_HOME = profile_home.parent + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is True + + +def test_skill_modules_support_profile_home_returns_false_when_already_globally_patched(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + patched = tmp_path / "alpha" / "skills" + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.HERMES_HOME = profile_home.parent + skills_tool.SKILLS_DIR = patched + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = lambda: expected + + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.HERMES_HOME = profile_home.parent + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False + + +def test_skill_modules_support_profile_home_returns_false_when_module_is_static(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.SKILLS_DIR = baseline + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = expected + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False + + +def test_skill_modules_support_profile_home_returns_false_when_resolver_raises(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + def _raise(): + raise RuntimeError("not callable") + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.SKILLS_DIR = baseline + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = _raise + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False + + +def test_skill_modules_support_profile_home_returns_false_when_module_missing(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.delitem(sys.modules, "tools.skills_tool", raising=False) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False diff --git a/tests/test_issue2848_checkpoint_profile.py b/tests/test_issue2848_checkpoint_profile.py index f4792f78..c5029f69 100644 --- a/tests/test_issue2848_checkpoint_profile.py +++ b/tests/test_issue2848_checkpoint_profile.py @@ -1,7 +1,9 @@ """Regression coverage for #2848 checkpoint saves in background threads.""" from __future__ import annotations +import os from pathlib import Path +import threading def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path): @@ -20,7 +22,11 @@ def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path): captured = {} monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home) - monkeypatch.setattr(profiles, "get_profile_runtime_env", lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")}) + monkeypatch.setattr( + profiles, + "get_profile_runtime_env", + lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")}, + ) def fake_save(self, *args, **kwargs): captured["kwargs"] = kwargs @@ -35,3 +41,73 @@ def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path): assert captured["kwargs"] == {"skip_index": True} assert captured["thread_env"]["HERMES_HOME"] == str(profile_home) assert captured["thread_env"]["HERMES_CONFIG_PATH"] == str(profile_home / "config.yaml") + + +def test_checkpoint_save_completes_without_skill_lock(monkeypatch, tmp_path): + """Checkpoint saves must not block on the legacy skill module lock.""" + + from api.models import Session + from api.streaming import _save_streaming_checkpoint + import api.config as config + import api.profiles as profiles + + profile_home = tmp_path / "profiles" / "maiko" + profile_home.mkdir(parents=True) + captured = {} + + monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr( + profiles, + "get_profile_runtime_env", + lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")}, + ) + monkeypatch.setattr(profiles, "_resolve_hermes_home_override", lambda: None) + + patch_calls: list[dict] = [] + + def fake_save(self, *args, **kwargs): + captured["kwargs"] = kwargs + captured["thread_env"] = dict(getattr(config._thread_ctx, "env", {}) or {}) + captured["env_hermes_home"] = os.environ.get("HERMES_HOME") + + def patch_skill_home_modules(*_): + patch_calls.append({"patched": True}) + + monkeypatch.setattr(Session, "save", fake_save) + monkeypatch.setattr(profiles, "patch_skill_home_modules", patch_skill_home_modules) + + completion = threading.Event() + + session = Session(session_id="issue2848-lock", profile="maiko") + + acquired_lock = profiles._SKILL_HOME_MODULE_PATCH_LOCK.acquire(timeout=1) + assert acquired_lock, "lock was unexpectedly unavailable before checkpoint test" + + try: + def _worker() -> None: + try: + _save_streaming_checkpoint(session) + completion.set() + except Exception as exc: # pragma: no cover - defensive + captured["error"] = exc + completion.set() + + worker = threading.Thread(target=_worker, daemon=True) + worker.start() + + assert completion.wait(0.5), ( + "checkpoint worker should not block on skill module lock" + ) + finally: + profiles._SKILL_HOME_MODULE_PATCH_LOCK.release() + worker.join(timeout=1) + + assert captured.get("kwargs") == {"skip_index": True} + assert captured.get("thread_env", {}).get("HERMES_HOME") == str(profile_home) + assert ( + captured.get("thread_env", {}).get("HERMES_CONFIG_PATH") + == str(profile_home / "config.yaml") + ) + assert captured.get("env_hermes_home") == str(profile_home) + assert not patch_calls + assert "error" not in captured diff --git a/tests/test_issue5567_profile_home_override.py b/tests/test_issue5567_profile_home_override.py index 69b70477..bf373317 100644 --- a/tests/test_issue5567_profile_home_override.py +++ b/tests/test_issue5567_profile_home_override.py @@ -19,7 +19,14 @@ intentional mid-body `os.environ` clobber and NO mocking of the production reade Degrades gracefully on agents without the override (skips with a clear reason). """ import os +import json +import inspect import textwrap +import contextlib +import queue +import threading +import sys +import types from pathlib import Path import pytest @@ -123,3 +130,1093 @@ def test_graceful_degradation_resolver_is_optional(): assert mod is not None and hasattr(mod, "set_hermes_home_override") else: assert mod is None # older agent: graceful no-op, os.environ mirror stays + + +def test_profile_env_for_background_worker_uses_legacy_skill_module_patching(monkeypatch, tmp_path): + """When override support is unavailable for this run, fallback still patches skill modules.""" + profile_home = tmp_path / "legacy-profile-home" + profile_home.mkdir(parents=True, exist_ok=True) + + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + monkeypatch.setenv("HERMES_HOME", "default-home") + monkeypatch.delenv("HERMES_TEST_PROFILE_ENV", raising=False) + + monkeypatch.setattr(profiles_api, "_hermes_home_override_available", False) + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr( + profiles_api, + "get_profile_runtime_env", + lambda home: {"HERMES_TEST_PROFILE_ENV": "legacy-runtime"}, + ) + monkeypatch.setattr( + profiles_api, + "filter_runtime_env_for_gateway_parity", + lambda env: env, + ) + + with profiles_api.profile_env_for_background_worker("legacy", "legacy worker"): + assert os.environ.get("HERMES_HOME") == str(profile_home) + assert os.environ.get("HERMES_TEST_PROFILE_ENV") == "legacy-runtime" + assert fake_skill_module.HERMES_HOME == profile_home + assert fake_skill_module.SKILLS_DIR == profile_home / "skills" + + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert os.environ.get("HERMES_HOME") == "default-home" + assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None + + +def test_run_agent_streaming_installs_and_resets_profile_home_override(tmp_path, monkeypatch): + """Streaming must install the worker home override and clear it in teardown.""" + + import api.streaming as _streaming + + _session_id = "streaming-override-session" + _stream_id = "streaming-override-stream" + _workspace = tmp_path / "workspace" + _workspace.mkdir() + _home = tmp_path / "alpha" + _home.mkdir() + + class _Session: + def __init__(self): + self.session_id = _session_id + self.workspace = str(_workspace) + self.profile = "alpha" + self.model = "gpt-4" + self.model_provider = "hermes" + self.messages = [] + self.context_messages = [] + self.path = str(_workspace / "session.json") + self.active_stream_id = _stream_id + self.pending_user_message = None + self.pending_started_at = None + self.pending_user_source = None + self.pending_attachments = [] + + def save(self, *args, **kwargs): + return None + + _events = {} + + class _FakeMeter: + def begin_session(self, *args, **kwargs): + _events["begin_session"] = (_events.get("begin_session", 0) + 1) + + def end_session(self, *args, **kwargs): + _events["end_session"] = (_events.get("end_session", 0) + 1) + + def get_interval(self): + return 11.0 + + def set_pending_started_at(self, stream_id, pending_started_at): + _events.setdefault("set_pending_started_at", []).append( + (stream_id, pending_started_at) + ) + + def get_ttft_ms(self, stream_id): + _events.setdefault("get_ttft_ms", []).append(stream_id) + return None + + def get_stats(self): + return {} + + def record_token(self, *args, **kwargs): + pass + + def record_reasoning(self, *args, **kwargs): + pass + + q = queue.Queue() + _streaming.STREAMS[_stream_id] = q + + def _set_thread_env(**kwargs): + _events["set_thread_env"] = True + + def _set_override(profile_home: str): + _events["set_override_home"] = profile_home + return ("sentinel-module", None, True) + + def _reset_override(mod, reset_token, override_installed): + _events["reset_override"] = (mod, reset_token, override_installed) + + def _patch_skill_home_modules(*_): + _events["patch_skill_home_modules"] = _events.get("patch_skill_home_modules", 0) + 1 + raise AssertionError("patch_skill_home_modules must be skipped when context-local override installs") + + class _SentinelAgent: + def __init__(self, *args, **kwargs): + pass + + def run_conversation(self, *args, **kwargs): + _events["run_conversation"] = True + raise RuntimeError("streaming test sentinel") + + def _get_ai_agent(): + return _SentinelAgent + + def _discover_mcp_tools(): + _events["discover_mcp_tools"] = _events.get("discover_mcp_tools", 0) + 1 + + monkeypatch.setattr(_streaming, "register_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "update_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "get_session", lambda sid: _Session()) + monkeypatch.setattr(_streaming, "_get_session_agent_lock", lambda sid: contextlib.nullcontext()) + monkeypatch.setattr(_streaming, "_set_streaming_hermes_home_override", _set_override) + monkeypatch.setattr(_streaming, "_reset_streaming_hermes_home_override", _reset_override) + monkeypatch.setattr(_streaming, "_set_thread_env", _set_thread_env) + monkeypatch.setattr(_streaming, "_prewarm_skill_tool_modules", lambda: None) + monkeypatch.setattr(_streaming, "_install_streaming_cronjob_profile_wrapper", lambda: None) + monkeypatch.setattr(_streaming, "_clear_thread_env", lambda: None) + monkeypatch.setattr(_streaming, "_get_ai_agent", _get_ai_agent) + monkeypatch.setattr(_streaming, "_materialize_pending_user_turn_before_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_snapshot_and_append_partial_on_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "append_turn_journal_event_for_stream", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "meter", lambda: _FakeMeter()) + monkeypatch.setattr(_streaming, "RunJournalWriter", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_build_agent_thread_env", lambda *a, **k: {}) + monkeypatch.setattr( + _streaming, + "resolve_model_provider", + lambda model_with_provider_context, explicitly_picked=None: (model_with_provider_context, None, None), + ) + monkeypatch.setattr(_streaming, "_runtime_preferred_base_url", lambda rt, provider, configured_base_url: configured_base_url) + import api.profiles as profiles_api + + monkeypatch.setattr(profiles_api, "_skill_modules_support_profile_home", lambda profile_home: True) + import api.config as _config_mod + monkeypatch.setattr(_config_mod, "_resolve_cli_toolsets", lambda cfg: []) + monkeypatch.setattr(_config_mod, "get_config_for_profile_home", lambda profile_home: {}) + _fake_mcp_module = types.ModuleType("tools.mcp_tool") + _fake_mcp_module.discover_mcp_tools = _discover_mcp_tools + monkeypatch.setitem(sys.modules, "tools.mcp_tool", _fake_mcp_module) + + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: _home) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: {}) + monkeypatch.setattr(profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(_streaming, "_apply_profile_home_context_to_streaming_model", + lambda model, provider_context, profile_home, has_profile: (model, provider_context, False)) + + _streaming._run_agent_streaming( + session_id=_session_id, + msg_text="hi", + model="gpt-4", + workspace=str(_workspace), + stream_id=_stream_id, + ) + + assert _events.get("set_override_home") == str(_home) + assert _events.get("reset_override") == ("sentinel-module", None, True) + assert _events.get("run_conversation") is True + assert _events.get("discover_mcp_tools", 0) == 1 + assert _events.get("set_thread_env") is True + assert _events.get("patch_skill_home_modules", 0) == 0 + assert _stream_id not in _streaming.STREAMS + + +def test_run_agent_streaming_falls_back_to_skill_module_patch_for_static_modules( + tmp_path, + monkeypatch, +): + """Streaming should use snapshot/patch/restore when skill modules are static.""" + + import api.streaming as _streaming + + _session_id = "streaming-override-static-session" + _stream_id = "streaming-override-static-stream" + _workspace = tmp_path / "workspace" + _workspace.mkdir() + _home = tmp_path / "alpha" + _home.mkdir() + + class _Session: + def __init__(self): + self.session_id = _session_id + self.workspace = str(_workspace) + self.profile = "alpha" + self.model = "gpt-4" + self.model_provider = "hermes" + self.messages = [] + self.context_messages = [] + self.path = str(_workspace / "session.json") + self.active_stream_id = _stream_id + self.pending_user_message = None + self.pending_started_at = None + self.pending_user_source = None + self.pending_attachments = [] + + def save(self, *args, **kwargs): + return None + + _events = {} + + class _FakeMeter: + def begin_session(self, *args, **kwargs): + _events["begin_session"] = (_events.get("begin_session", 0) + 1) + + def end_session(self, *args, **kwargs): + _events["end_session"] = (_events.get("end_session", 0) + 1) + + def get_interval(self): + return 11.0 + + def set_pending_started_at(self, stream_id, pending_started_at): + _events.setdefault("set_pending_started_at", []).append( + (stream_id, pending_started_at) + ) + + def get_ttft_ms(self, stream_id): + _events.setdefault("get_ttft_ms", []).append(stream_id) + return None + + def get_stats(self): + return {} + + q = queue.Queue() + _streaming.STREAMS[_stream_id] = q + + def _set_thread_env(**kwargs): + _events["set_thread_env"] = True + + def _set_override(profile_home: str): + _events["set_override_home"] = profile_home + return ("sentinel-module", None, True) + + def _reset_override(mod, reset_token, override_installed): + _events["reset_override"] = (mod, reset_token, override_installed) + + def _caller_info(): + _frame = inspect.currentframe() + _wrapper = _frame.f_back if _frame is not None else None + _caller = _wrapper.f_back if _wrapper is not None else None + try: + if _caller is None: + return "", "", -1 + return ( + Path(_caller.f_code.co_filename).name, + _caller.f_code.co_name, + _caller.f_lineno, + ) + finally: + del _wrapper + del _caller + del _frame + + def _snapshot_skill_home_modules(): + _events.setdefault("snapshot_skill_home_modules", []).append( + {"source": _caller_info(), "args": ()} + ) + payload = {"snapshot": True} + return payload + + def _patch_skill_home_modules(*args): + _events.setdefault("patch_skill_home_modules", []).append( + {"source": _caller_info(), "args": tuple(args)} + ) + + def _restore_skill_home_modules(snapshot): + _events.setdefault("restore_skill_home_modules", []).append( + {"source": _caller_info(), "snapshot": snapshot, "args": (snapshot,)} + ) + + class _SentinelAgent: + def __init__(self, *args, **kwargs): + pass + + def run_conversation(self, *args, **kwargs): + _events["run_conversation"] = True + raise RuntimeError("streaming test sentinel") + + def _get_ai_agent(): + return _SentinelAgent + + def _discover_mcp_tools(): + _events["discover_mcp_tools"] = _events.get("discover_mcp_tools", 0) + 1 + + monkeypatch.setattr(_streaming, "register_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "update_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "get_session", lambda sid: _Session()) + monkeypatch.setattr(_streaming, "_get_session_agent_lock", lambda sid: contextlib.nullcontext()) + monkeypatch.setattr(_streaming, "_set_streaming_hermes_home_override", _set_override) + monkeypatch.setattr(_streaming, "_reset_streaming_hermes_home_override", _reset_override) + monkeypatch.setattr(_streaming, "_set_thread_env", _set_thread_env) + monkeypatch.setattr(_streaming, "_prewarm_skill_tool_modules", lambda: None) + monkeypatch.setattr(_streaming, "_install_streaming_cronjob_profile_wrapper", lambda: None) + monkeypatch.setattr(_streaming, "_clear_thread_env", lambda: None) + monkeypatch.setattr(_streaming, "_get_ai_agent", _get_ai_agent) + monkeypatch.setattr(_streaming, "_materialize_pending_user_turn_before_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_snapshot_and_append_partial_on_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "append_turn_journal_event_for_stream", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "meter", lambda: _FakeMeter()) + monkeypatch.setattr(_streaming, "RunJournalWriter", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_build_agent_thread_env", lambda *a, **k: {}) + monkeypatch.setattr( + _streaming, + "resolve_model_provider", + lambda model_with_provider_context, explicitly_picked=None: (model_with_provider_context, None, None), + ) + monkeypatch.setattr(_streaming, "_runtime_preferred_base_url", lambda rt, provider, configured_base_url: configured_base_url) + import api.profiles as profiles_api + + monkeypatch.setattr(profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules) + monkeypatch.setattr(profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(profiles_api, "restore_skill_home_modules", _restore_skill_home_modules) + + fake_skills_tool = types.ModuleType('tools.skills_tool') + fake_skills_tool.HERMES_HOME = 'default-home' + fake_skills_tool.SKILLS_DIR = 'default-home/skills' + fake_skill_manager_tool = types.ModuleType('tools.skill_manager_tool') + fake_skill_manager_tool.HERMES_HOME = 'default-home' + fake_skill_manager_tool.SKILLS_DIR = 'default-home/skills' + monkeypatch.setitem(sys.modules, 'tools.skills_tool', fake_skills_tool) + monkeypatch.setitem(sys.modules, 'tools.skill_manager_tool', fake_skill_manager_tool) + + _fake_mcp_module = types.ModuleType("tools.mcp_tool") + _fake_mcp_module.discover_mcp_tools = _discover_mcp_tools + monkeypatch.setitem(sys.modules, "tools.mcp_tool", _fake_mcp_module) + + import api.config as _config_mod + monkeypatch.setattr(_config_mod, "_resolve_cli_toolsets", lambda cfg: []) + monkeypatch.setattr(_config_mod, "get_config_for_profile_home", lambda profile_home: {}) + + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: _home) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: {}) + monkeypatch.setattr( + _streaming, + "_apply_profile_home_context_to_streaming_model", + lambda model, provider_context, profile_home, has_profile: (model, provider_context, False), + ) + + _streaming._run_agent_streaming( + session_id=_session_id, + msg_text="hi", + model="gpt-4", + workspace=str(_workspace), + stream_id=_stream_id, + ) + + assert _events.get("set_override_home") == str(_home) + assert _events.get("reset_override") == ("sentinel-module", None, True) + assert _events.get("run_conversation") is True + # Two call paths can reach the legacy skill-module helpers now: + # 1) upstream runtime-refresh model-resolution scope, and + # 2) the PR-owned streaming fallback branch at _run_agent_streaming. + # Assert that the streaming-owned fallback still executes once and fully + # restores the snapshot it captured. + _streaming_patch_calls = [ + entry["source"] + for entry in _events.get("patch_skill_home_modules", []) + if entry["source"][0] == "streaming.py" + ] + _streaming_snapshot_calls = [ + entry["source"] + for entry in _events.get("snapshot_skill_home_modules", []) + if entry["source"][0] == "streaming.py" + ] + _streaming_restore_calls = [ + entry["snapshot"] + for entry in _events.get("restore_skill_home_modules", []) + if entry["source"][0] == "streaming.py" + ] + assert len(_streaming_patch_calls) == 1 + assert len(_streaming_snapshot_calls) == 1 + assert _streaming_restore_calls == [{"snapshot": True}] + assert _stream_id not in _streaming.STREAMS + + +def test_profile_env_for_background_worker_uses_static_modules_fallback_when_dynamic_checks_fail( + tmp_path, + monkeypatch, +): + """Background worker falls back to module patching when dynamic checks fail.""" + + import api.profiles as _profiles_api + + profile_home = tmp_path / "legacy-home" + profile_home.mkdir(parents=True, exist_ok=True) + + events = {} + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_module._SKILLS_DIR_AT_IMPORT = "default-home/skills" + + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module._SKILLS_DIR_AT_IMPORT = "default-home/skills" + + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + fake_constants = types.SimpleNamespace() + + def _set_override(profile_home: str): + events["set_override_home"] = str(profile_home) + return None + + def _reset_override(token): + events["reset_token"] = token + + fake_constants.set_hermes_home_override = _set_override + fake_constants.reset_hermes_home_override = _reset_override + monkeypatch.setattr(_profiles_api, "_resolve_hermes_home_override", lambda: fake_constants) + monkeypatch.setattr(_profiles_api, "_hermes_home_override_available", True) + + def _snapshot_skill_home_modules(): + events["snapshot"] = True + return {"snapshot": True} + + def _patch_skill_home_modules(*_): + events["patch"] = events.get("patch", 0) + 1 + fake_skill_module.HERMES_HOME = profile_home + fake_skill_module.SKILLS_DIR = profile_home / "skills" + fake_skill_manager_module.HERMES_HOME = profile_home + fake_skill_manager_module.SKILLS_DIR = profile_home / "skills" + + def _restore_skill_home_modules(snapshot): + events["restore"] = snapshot + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + + monkeypatch.setattr(_profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules) + monkeypatch.setattr(_profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(_profiles_api, "restore_skill_home_modules", _restore_skill_home_modules) + monkeypatch.setattr(_profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr(_profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(_profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + with _profiles_api.profile_env_for_background_worker("legacy", "legacy worker"): + assert fake_skill_module.HERMES_HOME == profile_home + assert fake_skill_module.SKILLS_DIR == profile_home / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_home + assert fake_skill_manager_module.SKILLS_DIR == profile_home / "skills" + + assert events.get("snapshot") is True + assert events.get("patch") == 1 + assert events.get("restore") == {"snapshot": True} + assert events.get("reset_token") is None + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + +def test_profile_env_for_background_worker_serializes_static_module_scope_with_lock(tmp_path, monkeypatch): + """`_SKILL_HOME_MODULE_PATCH_LOCK` serializes concurrent static module scopes.""" + + profile_alpha = tmp_path / "alpha" + profile_beta = tmp_path / "beta" + profile_alpha.mkdir() + profile_beta.mkdir() + + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + events = { + "set": [], + "reset": [], + } + + fake_constants = types.SimpleNamespace() + + def _set_override(profile_home: str): + events["set"].append(str(profile_home)) + return None + + def _reset_override(reset_token): + events["reset"].append(reset_token) + + fake_constants.set_hermes_home_override = _set_override + fake_constants.reset_hermes_home_override = _reset_override + monkeypatch.setattr(profiles_api, "_resolve_hermes_home_override", lambda: fake_constants) + monkeypatch.setattr(profiles_api, "_hermes_home_override_available", True) + monkeypatch.setattr( + profiles_api, + "get_hermes_home_for_profile", + lambda profile: profile_alpha if profile == "alpha" else profile_beta, + ) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + alpha_entered = threading.Event() + beta_entered = threading.Event() + alpha_release = threading.Event() + worker_errors: list[tuple[str, BaseException]] = [] + + def _worker_alpha() -> None: + try: + with profiles_api.profile_env_for_background_worker("alpha", "lock holder"): + assert fake_skill_module.HERMES_HOME == profile_alpha + assert fake_skill_module.SKILLS_DIR == profile_alpha / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_alpha + assert fake_skill_manager_module.SKILLS_DIR == profile_alpha / "skills" + alpha_entered.set() + assert alpha_release.wait(timeout=5) + raise RuntimeError("alpha worker sentinel") + except BaseException as exc: + worker_errors.append(("alpha", exc)) + + def _worker_beta() -> None: + try: + with profiles_api.profile_env_for_background_worker("beta", "lock waiter"): + beta_entered.set() + assert fake_skill_module.HERMES_HOME == profile_beta + assert fake_skill_module.SKILLS_DIR == profile_beta / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_beta + assert fake_skill_manager_module.SKILLS_DIR == profile_beta / "skills" + except BaseException as exc: + worker_errors.append(("beta", exc)) + + _thread_alpha = threading.Thread(target=_worker_alpha) + _thread_beta = threading.Thread(target=_worker_beta) + + _thread_alpha.start() + assert alpha_entered.wait(timeout=5) + + _thread_beta.start() + assert not beta_entered.wait(timeout=0.2) + alpha_release.set() + assert beta_entered.wait(timeout=5) + + _thread_alpha.join(timeout=5) + _thread_beta.join(timeout=5) + + assert not _thread_alpha.is_alive() + assert not _thread_beta.is_alive() + + alpha_error = next((exc for name, exc in worker_errors if name == "alpha"), None) + beta_error = next((exc for name, exc in worker_errors if name == "beta"), None) + assert beta_error is None + assert isinstance(alpha_error, RuntimeError) + assert str(alpha_error) == "alpha worker sentinel" + + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert fake_skill_manager_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + assert sorted(events["set"]) == [str(profile_alpha), str(profile_beta)] + assert len(events["reset"]) == 2 + assert all(token is None for token in events["reset"]) + + with profiles_api.profile_env_for_background_worker("alpha", "same-thread follow up"): + assert fake_skill_module.HERMES_HOME == profile_alpha + assert fake_skill_module.SKILLS_DIR == profile_alpha / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_alpha + assert fake_skill_manager_module.SKILLS_DIR == profile_alpha / "skills" + + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert fake_skill_manager_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + +@pytest.mark.skipif( + not HAS_OVERRIDE, + reason="requires v0.18.0 context-local home override support", +) +def test_profile_env_for_background_worker_uses_real_modules_and_serializes_overlap(tmp_path, monkeypatch): + """Real skill modules should serialize static fallback scopes across profiles.""" + + try: + import tools.skills_tool as skills_tool + import tools.skill_manager_tool as skill_manager_tool + except Exception as exc: + pytest.skip(f"hermes-agent skill modules unavailable for this environment: {exc}") + + required_attrs = ("_SKILLS_DIR_AT_IMPORT", "SKILLS_DIR", "HERMES_HOME") + for _name, _mod in ( + ("tools.skills_tool", skills_tool), + ("tools.skill_manager_tool", skill_manager_tool), + ): + for _attr in required_attrs: + if not hasattr(_mod, _attr): + pytest.skip(f"{_name} missing {_attr}") + + if not hasattr(skills_tool, "skills_list"): + pytest.skip("tools.skills_tool missing skills_list API") + + alpha_home = tmp_path / "alpha" + beta_home = tmp_path / "beta" + alpha_home.mkdir() + beta_home.mkdir() + + alpha_skill = f"alpha-only-{tmp_path.name}" + beta_skill = f"beta-only-{tmp_path.name}" + + def _write_skill(home: Path, name: str) -> None: + skill_dir = home / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + textwrap.dedent( + f"""\ + --- + name: {name} + description: skill for overlap test + --- + skill body + """ + ), + encoding="utf-8", + ) + + def _parse_skill_names(raw: object) -> set[str]: + payload = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw + data = json.loads(payload) if isinstance(payload, str) else payload + if isinstance(data, dict): + data = data.get("skills", []) + return { + item.get("name") + for item in data or [] + if isinstance(item, dict) and item.get("name") is not None + } + + _write_skill(alpha_home, alpha_skill) + _write_skill(beta_home, beta_skill) + + baseline_override = hermes_constants.get_hermes_home_override() + baseline_env_home = os.environ.get("HERMES_HOME") + baseline_env_has = "HERMES_HOME" in os.environ + + baseline_skill_dir = skills_tool.SKILLS_DIR + baseline_skill_home = getattr(skills_tool, "HERMES_HOME", None) + baseline_skill_manager_dir = skill_manager_tool.SKILLS_DIR + baseline_skill_manager_home = getattr(skill_manager_tool, "HERMES_HOME", None) + + alpha_skills_dir = str(alpha_home / "skills") + + # Start with both modules deliberately patched to alpha. + skills_tool.HERMES_HOME = str(alpha_home) + skills_tool.SKILLS_DIR = alpha_skills_dir + skill_manager_tool.HERMES_HOME = str(alpha_home) + skill_manager_tool.SKILLS_DIR = alpha_skills_dir + + monkeypatch.setenv("HERMES_HOME", str(alpha_home)) + monkeypatch.setattr( + profiles_api, + "get_hermes_home_for_profile", + lambda profile: alpha_home if profile == "alpha" else beta_home, + ) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + alpha_ready = threading.Event() + alpha_listed = threading.Event() + beta_started = threading.Event() + beta_entered = threading.Event() + release_alpha = threading.Event() + + seen: dict[str, set[str]] = {} + observed: dict[str, dict[str, object]] = {} + worker_errors: list[tuple[str, BaseException]] = [] + + def _worker_alpha() -> None: + try: + with profiles_api.profile_env_for_background_worker("alpha", "real alpha worker"): + alpha_ready.set() + names = _parse_skill_names(skills_tool.skills_list()) + seen["alpha"] = names + assert alpha_skill in names + assert beta_skill not in names + alpha_listed.set() + assert release_alpha.wait(timeout=5) + except BaseException as exc: # pragma: no cover - defensive + worker_errors.append(("alpha", exc)) + finally: + observed["alpha"] = { + "override": hermes_constants.get_hermes_home_override(), + } + + def _worker_beta() -> None: + try: + beta_started.set() + with profiles_api.profile_env_for_background_worker("beta", "real beta worker"): + beta_entered.set() + names = _parse_skill_names(skills_tool.skills_list()) + seen["beta"] = names + assert beta_skill in names + assert alpha_skill not in names + except BaseException as exc: # pragma: no cover - defensive + worker_errors.append(("beta", exc)) + finally: + observed["beta"] = { + "override": hermes_constants.get_hermes_home_override(), + } + + thread_alpha = threading.Thread(target=_worker_alpha) + thread_beta = threading.Thread(target=_worker_beta) + + try: + thread_alpha.start() + assert alpha_ready.wait(timeout=5), "alpha worker should enter first" + + thread_beta.start() + assert beta_started.wait(timeout=5), "beta worker should attempt to start" + assert not beta_entered.wait(0.25), "beta must block on fallback scope lock while alpha holds it" + + assert alpha_listed.wait(timeout=5), "alpha must list skills while beta is waiting" + + release_alpha.set() + assert beta_entered.wait(timeout=5), "beta should enter after alpha exits" + + thread_alpha.join(timeout=5) + thread_beta.join(timeout=5) + + assert not thread_alpha.is_alive() + assert not thread_beta.is_alive() + assert not worker_errors + + assert seen.get("alpha", set()) == {alpha_skill} + assert seen.get("beta", set()) == {beta_skill} + + assert observed["alpha"]["override"] == baseline_override + assert observed["beta"]["override"] == baseline_override + + assert hermes_constants.get_hermes_home_override() == baseline_override + assert skills_tool.HERMES_HOME == str(alpha_home) + assert skills_tool.SKILLS_DIR == alpha_skills_dir + assert skill_manager_tool.HERMES_HOME == str(alpha_home) + assert skill_manager_tool.SKILLS_DIR == alpha_skills_dir + assert os.environ.get("HERMES_HOME") == str(alpha_home) + finally: + thread_alpha.join(timeout=1) + thread_beta.join(timeout=1) + release_alpha.set() + + if baseline_env_has: + if baseline_env_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = baseline_env_home + else: + os.environ.pop("HERMES_HOME", None) + + skills_tool.SKILLS_DIR = baseline_skill_dir + if baseline_skill_home is not None: + skills_tool.HERMES_HOME = baseline_skill_home + else: + skills_tool.__dict__.pop("HERMES_HOME", None) + + skill_manager_tool.SKILLS_DIR = baseline_skill_manager_dir + if baseline_skill_manager_home is not None: + skill_manager_tool.HERMES_HOME = baseline_skill_manager_home + else: + skill_manager_tool.__dict__.pop("HERMES_HOME", None) + + +def test_profile_env_for_background_worker_resets_override_when_dynamic_check_raises( + tmp_path, + monkeypatch, +): + """A dynamic capability check exception must still clear the override state.""" + import api.profiles as _profiles_api + + profile_home = tmp_path / "legacy-home" + profile_home.mkdir(parents=True, exist_ok=True) + + events = {} + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + fake_constants = types.SimpleNamespace() + + def _set_override(profile_home: str): + events["set"] = str(profile_home) + return "override-token" + + def _reset_override(reset_token): + events["reset"] = reset_token + + fake_constants.set_hermes_home_override = _set_override + fake_constants.reset_hermes_home_override = _reset_override + monkeypatch.setattr(_profiles_api, "_resolve_hermes_home_override", lambda: fake_constants) + monkeypatch.setattr(_profiles_api, "_hermes_home_override_available", True) + + def _snapshot_skill_home_modules(): + events["snapshot"] = True + return {"snapshot": True} + + def _patch_skill_home_modules(*_): + events["patch"] = events.get("patch", 0) + 1 + fake_skill_module.HERMES_HOME = profile_home + fake_skill_module.SKILLS_DIR = profile_home / "skills" + fake_skill_manager_module.HERMES_HOME = profile_home + fake_skill_manager_module.SKILLS_DIR = profile_home / "skills" + + def _restore_skill_home_modules(snapshot): + events["restore"] = snapshot + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + + def _raise(*_): + raise RuntimeError("dynamic capability probe failed") + + monkeypatch.setattr(_profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules) + monkeypatch.setattr(_profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(_profiles_api, "restore_skill_home_modules", _restore_skill_home_modules) + monkeypatch.setattr(_profiles_api, "_skill_modules_support_profile_home", _raise) + monkeypatch.setattr(_profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr(_profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(_profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + with _profiles_api.profile_env_for_background_worker("legacy", "legacy worker"): + assert fake_skill_module.HERMES_HOME == profile_home + assert fake_skill_module.SKILLS_DIR == profile_home / "skills" + + assert events.get("set") == str(profile_home) + assert events.get("reset") == "override-token" + assert events.get("snapshot") is True + assert events.get("patch") == 1 + assert events.get("restore") == {"snapshot": True} + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert fake_skill_manager_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + +@pytest.mark.skipif( + not HAS_OVERRIDE, + reason="requires v0.18.0 context-local home override support", +) +def test_run_agent_streaming_override_helpers_with_concurrent_skills_list_workers(tmp_path, monkeypatch): + """Foreground streaming override and background profile scope resolve skills correctly. + + Foreground: call the streaming override helper for alpha and then clobber + process env to beta. + Background: run `profile_env_for_background_worker('beta', ...)` and then + also clobber env to alpha. + Both threads call real `tools.skills_tool.skills_list()` and should see their + own profile's skill directory. + """ + + import api.streaming as _streaming + try: + import tools.skills_tool as skills_tool + import tools.skill_manager_tool as skill_manager_tool + except Exception as exc: # pragma: no cover - hermes-agent dependency probe + pytest.skip(f"hermes-agent skill modules unavailable for this environment: {exc}") + + _home_alpha = tmp_path / "alpha" + _home_beta = tmp_path / "beta" + _home_alpha.mkdir() + _home_beta.mkdir() + + alpha_name = f"alpha-skill-{tmp_path.name}" + beta_name = f"beta-skill-{tmp_path.name}" + + def _write_skill_dir(root: Path, name: str) -> None: + skill_dir = root / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + textwrap.dedent( + f"""\ + --- + name: {name} + description: {name} description + --- + skill body + """ + ), + encoding="utf-8", + ) + + _write_skill_dir(_home_alpha, alpha_name) + _write_skill_dir(_home_beta, beta_name) + + _baseline_override = hermes_constants.get_hermes_home_override() + _baseline_has_env = "HERMES_HOME" in os.environ + _baseline_env = os.environ.get("HERMES_HOME") + _baseline_skill_dir = skills_tool.SKILLS_DIR + _baseline_skill_home = getattr(skills_tool, "HERMES_HOME", None) + _baseline_manager_skill_dir = skill_manager_tool.SKILLS_DIR + _baseline_manager_skill_home = getattr(skill_manager_tool, "HERMES_HOME", None) + + # Force deterministic resolution path independent of previous suite side effects. + skills_tool.SKILLS_DIR = skills_tool._SKILLS_DIR_AT_IMPORT + if _baseline_skill_home is not None: + skills_tool.HERMES_HOME = _baseline_skill_home + skill_manager_tool.SKILLS_DIR = getattr( + skill_manager_tool, + "_SKILLS_DIR_AT_IMPORT", + _baseline_manager_skill_dir, + ) + if _baseline_manager_skill_home is not None: + skill_manager_tool.HERMES_HOME = _baseline_manager_skill_home + os.environ["HERMES_HOME"] = str(_baseline_skill_home) if _baseline_skill_home else "" + + def _get_profile_home(name: str) -> Path: + if name == "beta": + return _home_beta + if name == "alpha": + return _home_alpha + return _home_alpha + + # Keep the helper and module state deterministic for this threaded race test. + assert _baseline_override is None + _baseline_skills_tuple = (skills_tool._SKILLS_DIR_AT_IMPORT, _baseline_skill_home) + _baseline_manager_tuple = ( + getattr(skill_manager_tool, "_SKILLS_DIR_AT_IMPORT", _baseline_manager_skill_dir), + _baseline_manager_skill_home, + ) + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", _get_profile_home) + + start_barrier = threading.Barrier(2) + clobber_barrier = threading.Barrier(2) + _results: dict[str, set[str]] = {} + _worker_errors: list[tuple[str, BaseException]] = [] + _lock = threading.Lock() + _post_reset_overrides: dict[str, object | None] = {} + _post_reset_skill_dirs: dict[str, Path] = {} + _post_reset_skill_homes: dict[str, object | None] = {} + _post_reset_manager_skill_dirs: dict[str, Path] = {} + _post_reset_manager_skill_homes: dict[str, object | None] = {} + _beta_scope_snapshots: dict[str, dict[str, tuple[object | None, object | None]]] = {} + + def _parse_skills(raw: str) -> set[str]: + payload = json.loads(raw) + return { + item.get("name") + for item in payload.get("skills", []) + if isinstance(item, dict) and "name" in item + } + + def _worker_alpha() -> None: + mod_ctx, reset_token, override_installed = _streaming._set_streaming_hermes_home_override( + str(_home_alpha) + ) + try: + start_barrier.wait(timeout=5) + os.environ["HERMES_HOME"] = str(_home_beta) + clobber_barrier.wait(timeout=5) + + names = _parse_skills(skills_tool.skills_list()) + with _lock: + _results["alpha"] = names + except BaseException as exc: + with _lock: + _worker_errors.append(("alpha", exc)) + finally: + _streaming._reset_streaming_hermes_home_override( + mod_ctx, + reset_token, + override_installed, + ) + with _lock: + _post_reset_overrides["alpha"] = hermes_constants.get_hermes_home_override() + _post_reset_skill_dirs["alpha"] = skills_tool.SKILLS_DIR + _post_reset_skill_homes["alpha"] = getattr(skills_tool, "HERMES_HOME", None) + _post_reset_manager_skill_dirs["alpha"] = skill_manager_tool.SKILLS_DIR + _post_reset_manager_skill_homes["alpha"] = getattr(skill_manager_tool, "HERMES_HOME", None) + + def _worker_beta() -> None: + pre_scope = ( + skills_tool.SKILLS_DIR, + getattr(skills_tool, "HERMES_HOME", None), + ) + try: + with profiles_api.profile_env_for_background_worker( + "beta", + "test-streaming-skill-list", + ): + start_barrier.wait(timeout=5) + os.environ["HERMES_HOME"] = str(_home_alpha) + clobber_barrier.wait(timeout=5) + + names = _parse_skills(skills_tool.skills_list()) + with _lock: + _results["beta"] = names + _beta_scope_snapshots["beta"] = { + "pre": pre_scope, + "during": ( + skills_tool.SKILLS_DIR, + getattr(skills_tool, "HERMES_HOME", None), + ), + } + except BaseException as exc: + with _lock: + _worker_errors.append(("beta", exc)) + finally: + with _lock: + _post_reset_overrides["beta"] = hermes_constants.get_hermes_home_override() + _post_reset_skill_dirs["beta"] = skills_tool.SKILLS_DIR + _post_reset_skill_homes["beta"] = getattr(skills_tool, "HERMES_HOME", None) + _post_reset_manager_skill_dirs["beta"] = skill_manager_tool.SKILLS_DIR + _post_reset_manager_skill_homes["beta"] = getattr(skill_manager_tool, "HERMES_HOME", None) + + _thread_alpha = threading.Thread(target=_worker_alpha) + _thread_beta = threading.Thread(target=_worker_beta) + + _thread_alpha.start() + _thread_beta.start() + _thread_alpha.join(timeout=10) + _thread_beta.join(timeout=10) + + try: + assert not _thread_alpha.is_alive() + assert not _thread_beta.is_alive() + assert _results.get("alpha") is not None + assert _results.get("beta") is not None + if _worker_errors: + raise _worker_errors[0][1] + + alpha_names = _results.get("alpha", set()) + beta_names = _results.get("beta", set()) + + assert alpha_name in alpha_names + assert beta_name not in alpha_names + assert beta_name in beta_names + assert alpha_name not in beta_names + + assert _post_reset_overrides["alpha"] == _baseline_override + assert _post_reset_overrides["beta"] == _baseline_override + + assert _post_reset_skill_dirs["alpha"] == skills_tool._SKILLS_DIR_AT_IMPORT + assert _post_reset_skill_dirs["beta"] == skills_tool._SKILLS_DIR_AT_IMPORT + assert _post_reset_manager_skill_dirs["alpha"] == _baseline_manager_tuple[0] + assert _post_reset_manager_skill_dirs["beta"] == _baseline_manager_tuple[0] + assert _post_reset_skill_homes["alpha"] == _baseline_skill_home + assert _post_reset_skill_homes["beta"] == _baseline_skill_home + assert _post_reset_manager_skill_homes["alpha"] == _baseline_manager_skill_home + assert _post_reset_manager_skill_homes["beta"] == _baseline_manager_skill_home + + beta_snapshot = _beta_scope_snapshots.get("beta") + assert beta_snapshot is not None + assert beta_snapshot["pre"] == _baseline_skills_tuple + assert beta_snapshot["during"] == _baseline_skills_tuple + finally: + if _baseline_has_env: + os.environ["HERMES_HOME"] = _baseline_env or "" + else: + os.environ.pop("HERMES_HOME", None) + skills_tool.SKILLS_DIR = _baseline_skill_dir + if _baseline_skill_home is not None: + skills_tool.HERMES_HOME = _baseline_skill_home + skill_manager_tool.SKILLS_DIR = _baseline_manager_skill_dir + if _baseline_manager_skill_home is not None: + skill_manager_tool.HERMES_HOME = _baseline_manager_skill_home diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py index 9ca5bed6..9426f9d8 100644 --- a/tests/test_sprint46.py +++ b/tests/test_sprint46.py @@ -688,7 +688,6 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, } routes._run_manual_compression_job(sid, {"session_id": sid}) - assert EnvAssertingAgent.seen_env == { "HERMES_HOME": str(profile_home), "HERMES_TEST_PROFILE_ENV": "work-runtime", @@ -697,8 +696,8 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, "SKILL_MODULE_HOME": profile_home, "SKILL_MODULE_DIR": profile_home / "skills", } - assert str(getattr(fake_skill_module, "HERMES_HOME")) == "default-home" - assert str(getattr(fake_skill_module, "SKILLS_DIR")) == "default-home/skills" + assert str(fake_skill_module.HERMES_HOME) == "default-home" + assert str(fake_skill_module.SKILLS_DIR) == "default-home/skills" assert os.environ.get("HERMES_HOME") == "default-home" assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None with routes._MANUAL_COMPRESSION_JOBS_LOCK: diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index 39f55f96..24c927ca 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -786,10 +786,13 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase): self.assertEqual(captured.get('hermes_home'), 'profile-home') self.assertEqual(str(captured.get('skill_module_home')), 'profile-home') - self.assertEqual(Path(str(captured.get('skill_module_dir'))), Path('profile-home') / 'skills') + self.assertEqual( + Path(str(captured.get('skill_module_dir'))), + Path('profile-home') / 'skills', + ) self.assertEqual(captured.get('restored_hermes_home'), 'default-home') - self.assertEqual(getattr(fake_skill_module, 'HERMES_HOME'), 'default-home') - self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills') + self.assertEqual(fake_skill_module.HERMES_HOME, 'default-home') + self.assertEqual(fake_skill_module.SKILLS_DIR, 'default-home/skills') self.assertEqual(mock_session.title, 'Profile Routed Title') def test_background_profile_env_routes_load_config_and_provider_credentials(self): diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 0da5ad41..5634834d 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -1785,8 +1785,8 @@ class TestUpdateSummaryRouteModelSelection: 'SKILL_MODULE_DIR': profile_home / 'skills', } assert captured['aux_create']['model'] == 'profile-compression-model' - assert getattr(fake_skill_module, 'HERMES_HOME') == 'default-home' - assert getattr(fake_skill_module, 'SKILLS_DIR') == 'default-home/skills' + assert fake_skill_module.HERMES_HOME == 'default-home' + assert fake_skill_module.SKILLS_DIR == 'default-home/skills' assert os.environ.get('HERMES_HOME') == 'default-home' assert os.environ.get('HERMES_TEST_PROFILE_ENV') == 'default-runtime'