Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
548aac90a4 |
+150
-38
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user