From 019a4814b73257fc1d56669f1f31f78250b6a442 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 23:40:10 +0000 Subject: [PATCH 1/2] fix(#5567): pin worker profile via hermes-agent v0.18.0 context-local HERMES_HOME override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile_env_for_background_worker mirrored the profile home into process-global os.environ["HERMES_HOME"] and yielded the worker body OUTSIDE the setup lock, so a concurrent cross-profile worker (e.g. background title generation on another profile) could clobber os.environ mid-body — the agent config reader then resolved the WRONG profile's config (intermittent turn-init failures citing an unused provider; b3nw's v0.51.849 repro). Now installs hermes-agent v0.18.0's context-local home override (set_hermes_home_override, a ContextVar that get_hermes_home() consults before os.environ) for the resolved non-default profile home — set before yield, reset in finally — so a config read in the worker body resolves THIS profile's home from task-local state, immune to a concurrent os.environ clobber. No worker serialization, no os.environ mutation, no detection heuristic (both earlier WebUI-only mitigations were rejected as regression-unsafe). Graceful degradation: the override module is resolved lazily+optionally (_resolve_hermes_home_override, mirroring _resolve_secret_scope_module). On agents < v0.18.0 the symbol is absent -> returns None -> pre-existing os.environ-mirror behavior unchanged (no regression, no new failure mode). Self-built (nesquena-hermes); nesquena independent review APPROVED. --- api/profiles.py | 59 +++++++++ tests/test_issue5567_profile_home_override.py | 125 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tests/test_issue5567_profile_home_override.py diff --git a/api/profiles.py b/api/profiles.py index 973aa4fc..6402a08d 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1060,6 +1060,41 @@ def _resolve_secret_scope_module(): return None +# #5567: hermes-agent v0.18.0+ exposes a CONTEXT-LOCAL Hermes-home override +# (`hermes_constants.set_hermes_home_override`) that `get_hermes_home()` — and +# therefore `hermes_cli.config.get_config_path()` / `load_config()` — consults +# BEFORE the process-global `os.environ["HERMES_HOME"]`. Installing it inside the +# profile worker scope eliminates the cross-profile HERMES_HOME race at the +# reader (a config read resolves the task-local profile home even if another +# thread clobbers os.environ mid-body) WITHOUT serializing workers or mutating +# shared state. Resolved lazily + optionally so OLDER agents (no override symbol) +# degrade gracefully to the pre-existing os.environ-mirror behavior — unchanged. +_hermes_home_override_available = None + + +def _resolve_hermes_home_override(): + """Return the hermes_constants module iff it exposes the v0.18.0+ context-local + home override (set/reset), else None. Cached; import-safe on older agents.""" + global _hermes_home_override_available + import sys as _sys + if _hermes_home_override_available is False: + return None + mod = _sys.modules.get('hermes_constants') + if mod is None and _hermes_home_override_available is None: + try: + import hermes_constants as mod # noqa: F811 + except Exception: + _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' + ): + _hermes_home_override_available = True + return mod + _hermes_home_override_available = False + return None + + @contextmanager def profile_env_for_background_worker( session, @@ -1117,6 +1152,10 @@ def profile_env_for_background_worker( ) _scope_token = None _has_scope = False + # #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 try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True @@ -1129,6 +1168,20 @@ def profile_env_for_background_worker( _has_scope = True except Exception: pass + # #5567: install the context-local Hermes-home override so the agent + # config reader (get_hermes_home -> get_config_path/load_config) resolves + # THIS profile's home from task-local state, immune to a concurrent + # cross-profile os.environ["HERMES_HOME"] clobber during the worker body. + # No-op on agents < v0.18.0 (resolver returns None) → os.environ mirror + # below remains the behavior, exactly as today. + _home_override_mod = _resolve_hermes_home_override() + if _home_override_mod is not None: + try: + _home_override_token = _home_override_mod.set_hermes_home_override( + str(profile_home_path) + ) + except Exception: + _home_override_token = None with _ENV_LOCK: old_runtime_env = _apply_profile_env_to_process( os.environ, @@ -1151,6 +1204,12 @@ def profile_env_for_background_worker( ) 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) diff --git a/tests/test_issue5567_profile_home_override.py b/tests/test_issue5567_profile_home_override.py new file mode 100644 index 00000000..69b70477 --- /dev/null +++ b/tests/test_issue5567_profile_home_override.py @@ -0,0 +1,125 @@ +"""Regression test for #5567 — cross-profile HERMES_HOME race at the config reader. + +Root cause: `profile_env_for_background_worker` mirrors the profile's HERMES_HOME +into the process-global `os.environ`, and the worker body runs outside the setup +lock. A concurrent cross-profile worker can clobber `os.environ["HERMES_HOME"]` +mid-body, so the agent config reader (`hermes_cli.config.get_config_path` / +`load_config`, which read `get_hermes_home()`) resolves the WRONG profile's +config — intermittent turn-init failures referencing another profile's provider. + +Fix (#5567): when hermes-agent >= v0.18.0 exposes the context-local home +override (`hermes_constants.set_hermes_home_override`), the worker scope installs +it so `get_hermes_home()` resolves THIS task's profile home from task-local state, +immune to the process-global clobber — without serializing workers. + +Per #2321's acceptance criteria, this exercises the REAL +`hermes_cli.config.load_config()` against a non-default profile with an +intentional mid-body `os.environ` clobber and NO mocking of the production reader. + +Degrades gracefully on agents without the override (skips with a clear reason). +""" +import os +import textwrap +from pathlib import Path + +import pytest + +# The production reader — imported unmocked, exactly as #2321 requires. Skip the +# whole module if the agent isn't importable in this environment. +config_mod = pytest.importorskip("hermes_cli.config") +hermes_constants = pytest.importorskip("hermes_constants") + +HAS_OVERRIDE = hasattr(hermes_constants, "set_hermes_home_override") and hasattr( + hermes_constants, "get_hermes_home" +) + +from api import profiles as profiles_api # noqa: E402 + + +def _seed_profile_home(base: Path, name: str, provider: str, model: str) -> Path: + home = base / name + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + textwrap.dedent( + f"""\ + model: + default: {model} + provider: {provider} + """ + ), + encoding="utf-8", + ) + return home + + +@pytest.mark.skipif( + not HAS_OVERRIDE, + reason="hermes-agent < v0.18.0: no set_hermes_home_override; WebUI degrades to the os.environ mirror", +) +def test_load_config_resolves_worker_profile_despite_env_clobber(tmp_path, monkeypatch): + """The crux (#2321 criterion): inside profile_env_for_background_worker(A), + a concurrent clobber of os.environ['HERMES_HOME']=B must NOT make the real + load_config() read B — the context-local override pins A.""" + home_a = _seed_profile_home(tmp_path, "alpha", provider="anthropic", model="claude-x") + home_b = _seed_profile_home(tmp_path, "beta", provider="ollama", model="llama-y") + + # The CM's INPUT (which profile home to scope to) — this is not the reader + # under test; the reader is the real hermes_cli.config below. + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: home_a) + + # Establish a benign starting env, then simulate the race: while the worker + # body for profile A runs, a sibling profile-B worker clobbers the global. + monkeypatch.setenv("HERMES_HOME", str(home_a)) + + # Clear any cached config so load_config actually hits the resolver. + for fn in ("reload_config", "_reset_config_cache", "clear_config_cache"): + if hasattr(config_mod, fn): + try: + getattr(config_mod, fn)() + except Exception: + pass + + with profiles_api.profile_env_for_background_worker("alpha", "test worker"): + # The clobber: another profile's worker overwrites the process global. + os.environ["HERMES_HOME"] = str(home_b) + # get_config_path must resolve profile A via the context-local override, + # NOT profile B from the clobbered os.environ. + resolved = config_mod.get_config_path() + assert resolved == home_a / "config.yaml", ( + f"config path must resolve profile A ({home_a}) via the context-local " + f"override despite os.environ clobbered to B ({home_b}); got {resolved}" + ) + # And the real load_config() must read A's model, not B's. + cfg = config_mod.load_config() + model_default = (cfg.get("model") or {}).get("default") + assert model_default == "claude-x", ( + f"load_config must read profile A's model 'claude-x' despite the " + f"HERMES_HOME clobber to B; got {model_default!r} (B is 'llama-y')" + ) + + +@pytest.mark.skipif( + not HAS_OVERRIDE, + reason="requires the v0.18.0 override to assert the override is cleared on exit", +) +def test_override_is_cleared_after_worker_exits(tmp_path, monkeypatch): + """The context-local override must not leak past the worker scope.""" + home_a = _seed_profile_home(tmp_path, "alpha", provider="anthropic", model="claude-x") + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: home_a) + + assert hermes_constants.get_hermes_home_override() is None + with profiles_api.profile_env_for_background_worker("alpha", "test worker"): + assert hermes_constants.get_hermes_home_override() == str(home_a) + # Cleared on exit — no leak into subsequent tasks on this context. + assert hermes_constants.get_hermes_home_override() is None + + +def test_graceful_degradation_resolver_is_optional(): + """On an agent WITHOUT the override, the resolver returns None and the CM + falls back to the pre-existing os.environ mirror — never raises. We assert + the resolver is import-safe and boolean-clean regardless of agent version.""" + mod = profiles_api._resolve_hermes_home_override() + if HAS_OVERRIDE: + 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 From 3d11a6ad544c39548682b4789b6a3f6e10acb765 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 23:50:54 +0000 Subject: [PATCH 2/2] release #5567: context-local HERMES_HOME override for background workers + CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b2447b..faf04c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed +- **Concurrent chats on different profiles no longer intermittently fail turn-init citing the wrong profile's provider.** A background worker (e.g. automatic title generation) mirrored its profile's home into the process-global `HERMES_HOME` env var and ran the worker body outside the setup lock, so a concurrent worker on another profile could clobber that env var mid-run — making the agent's config reader resolve the wrong profile's `config.yaml` and fail the turn citing a provider the session never used. The worker now pins its profile via the agent's context-local home override (a task-local `ContextVar` the config reader consults before the env var), so a concurrent env-var clobber can't affect it — no worker serialization, no env mutation. Degrades cleanly on older agents (the override is resolved optionally; without it, behavior is exactly as before). Thanks @b3nw. (#5567) - **Forking or truncating a large/compacted conversation no longer misaligns the model context (occasional "dangling tool-call" API rejection).** WebUI keeps two arrays per session — the visible transcript and the trimmed model context — and forking a big session translated a display cut-point into the matching context cut-point using a per-message identity. But the model-context rows carried no stable id (a 989-session scan found zero), so on large sessions full of look-alike rows the aligner went ambiguous and could cut mid-turn — leaving the fork's context ending on a tool-call whose result was dropped, which the provider then rejected. Each message now gets a stable id shared between the transcript and model-context copies (the key the aligner already prefers, shipped in the previous release), so large-session forks/truncations align exactly. The id is additive, stripped before it ever reaches the model provider, and legacy id-less sessions keep working (they just fall back to the previous matching). Thanks @b3nw. (#5564) - **The sidebar now re-sorts a reactivated conversation to the top even if the tab was in the background.** When a conversation became active again from another device (or another tab) while your current tab was unfocused or backgrounded, the sidebar didn't bump it up to "Today" / the top until you manually refreshed. The session-list refresh coalescing only preserved the refresh *reason* and dropped its options, so a `force` refresh queued behind an in-flight one silently lost its force flag and got skipped on a hidden tab (`refreshSessionList` early-returns while `document.hidden`). Refresh options are now preserved through coalescing (force / refresh-active are OR-merged), and returning focus to the sidebar forces a catch-up — so cross-device recency re-sorts land on their own. The extra refresh coalesces into the existing render-skip no-op path, so there's no flicker and no background rebuild storm. Thanks @rodboev. (#5562, #5551) - **Transparent Stream: the final reply stays a visible answer after the turn settles.** In Transparent Stream mode, the moment a turn settled its final reply folded into the collapsed worklog/activity disclosure group — the answer vanished from view until you refreshed the page (the stored transcript was always correct, so a reload fixed it). The live-anchor scene renderer was hardcoding the compact-worklog layout mode when it projected the settling turn, regardless of your active display mode, so the final answer got compact-worklog display hints and was grouped away. It now reads your actual active mode and applies per-mode display hints, so in Transparent Stream the answer settles as a visible chronological reply. Compact Worklog is unchanged (bit-identical projection). Thanks @rodboev. (#5558, #5550)