Compare commits

...
8 changed files with 1585 additions and 59 deletions
+150 -38
View File
@@ -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:
+136 -12
View File
@@ -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
@@ -5335,6 +5406,7 @@ def _save_streaming_checkpoint(session):
session,
"streaming checkpoint",
logger_override=logger,
scope_skill_modules=False,
):
session.save(skip_index=True)
@@ -7395,6 +7467,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).
@@ -7462,8 +7538,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)
@@ -7475,6 +7555,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
@@ -7524,6 +7608,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
@@ -7539,11 +7624,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')
@@ -7563,18 +7678,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()`
@@ -10784,6 +10895,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
@@ -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
+77 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -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:
+6 -3
View File
@@ -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):
+2 -2
View File
@@ -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'