diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc4dc1e..eeb817fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Hermes Web UI -- Changelog +## [v0.51.10] — 2026-05-06 — 2-PR full-sweep batch + +### Fixed + +- **PR #1741** by @Michaelyklam — Isolate in-process cron scheduler profiles (closes #1575). The existing manual `/api/crons/run` flow already enters `cron_profile_context_for_home(...)` before calling `cron.scheduler.run_job()`, but a future in-process scheduler tick path (no request TLS) would call `run_job()` directly with whatever process-global profile happened to be active. New `install_cron_scheduler_profile_isolation()` in `api/profiles.py` (called once at WebUI profile-state init) wraps `cron.scheduler.run_job()` so it resolves the job's persisted `profile` to the matching `HERMES_HOME` and enters the same `cron_profile_context_for_home(...)` before execution. Thread-local cron-context depth tracking prevents re-entry when the manual path already pinned the profile (otherwise the non-reentrant `_cron_env_lock` would deadlock). Idempotent install via `_webui_profile_isolated` sentinel. Defensive: closes a future architectural gap; no behavior change to existing manual cron path. 4 new regression tests for the wrapper and the manual-run no-reentry guard. +- **PR #1742** by @Michaelyklam — Allow profile switching during active streams (closes #1700). The previous `switch_profile()` blocked ALL profile switches whenever any stream was active, but the WebUI route uses cookie/thread-local switching (`process_wide=False`) which doesn't actually mutate `HERMES_HOME`, module-level path caches, process `.env`, or global config. Split the guard: process-wide global mutations remain blocked during active streams (still correct), per-client cookie switches now proceed unblocked. Frontend `static/panels.js` removes the `S.busy`-based early return and treats `active_stream_id`/`pending_user_message` as in-progress, so switching away creates a fresh session for the target profile rather than retagging the running one (matches the convention used in `static/boot.js`, `static/messages.js`, `static/commands.js`). 4 new regression tests + browser QA screenshot. + +### In-stage absorbed fix + +**Opus follow-up (absorbed in-release):** + +- **i18n cleanup — remove orphaned `profiles_busy_switch` keys.** PR #1742 removed the only consumer of this toast (the frontend `S.busy`-based early return). 9 locale entries were left orphaned. Opus stage-304 advisor flagged this as a low-priority SHOULD-FIX; absorbed per the absorb-default policy. Locale parity tests still pass (key removed from English first). + +### Tests + +4590 → **4596 passing** (+6 regression tests across the 2 PRs). 0 regressions. Full suite ~129s. + +### Pre-release verification + +- Stage-304: 2 PRs merged with sibling-rebase against stage HEAD on `api/profiles.py` (different regions: #1741 lines 248-345, #1742 around line 596 + #1741's offset). No conflicts. +- All JS files syntax-clean (`node -c static/{panels,i18n}.js`). +- All Python files syntax-clean. +- pytest: 4596 passed, 0 failed (single clean run). +- `scripts/run-browser-tests.sh`: all 11 endpoints PASS on isolated port 8789 with stage-304 binary. +- Pre-stamp re-fetch: both PR heads still match local rebases — no late contributor commits. +- Opus advisor: SHIP both, 5/5 verification questions clean, 0 MUST-FIX, 1 SHOULD-FIX absorbed (orphaned i18n keys). + +Closes #1575, #1700. + ## [v0.51.9] — 2026-05-06 — 2-PR full-sweep batch ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 969c1cc1..c5982e0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.9 (May 6, 2026) — 4590 tests collected — 2-PR full-sweep batch (#1735, #1738) +> Last updated: v0.51.10 (May 6, 2026) — 4596 tests collected — 2-PR full-sweep batch (#1741, #1742) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index 0546e658..bf5f624b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.9, May 6, 2026* -*Total automated tests collected: 4590* +*Last updated: v0.51.10, May 6, 2026* +*Total automated tests collected: 4596* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/api/profiles.py b/api/profiles.py index 52c552b2..f9744c27 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -248,6 +248,82 @@ def get_active_hermes_home() -> Path: _cron_env_lock = threading.Lock() +def _cron_profile_context_depth() -> int: + return int(getattr(_tls, 'cron_profile_depth', 0) or 0) + + +def _push_cron_profile_context_depth() -> None: + _tls.cron_profile_depth = _cron_profile_context_depth() + 1 + + +def _pop_cron_profile_context_depth() -> None: + depth = _cron_profile_context_depth() + _tls.cron_profile_depth = max(0, depth - 1) + + +def _home_for_scheduled_cron_job(job: dict) -> Path: + """Resolve the profile home an auto-fired scheduler job should execute in. + + Legacy jobs with no profile keep the scheduler's server-default profile. + Jobs pinned to a named profile execute under that profile's HERMES_HOME, so + an in-process WebUI scheduler thread does not leak process-global config or + .env into the agent run. If a profile was deleted after the job was saved, + fall back to the server default rather than crashing every scheduler tick. + """ + raw = str((job or {}).get('profile') or '').strip() + if not raw: + return get_active_hermes_home() + if _is_root_profile(raw): + return _DEFAULT_HERMES_HOME + if not _PROFILE_ID_RE.fullmatch(raw): + logger.warning( + "Cron job %s has invalid profile %r; falling back to server default", + (job or {}).get('id', '?'), raw, + ) + return get_active_hermes_home() + home = _resolve_named_profile_home(raw) + if not home.is_dir(): + logger.warning( + "Cron job %s references missing profile %r; falling back to server default", + (job or {}).get('id', '?'), raw, + ) + return get_active_hermes_home() + return home + + +def install_cron_scheduler_profile_isolation() -> None: + """Patch cron.scheduler.run_job for WebUI in-process scheduler safety. + + Standard WebUI deployments do not start the scheduler thread in-process, but + if a future/single-process deployment calls cron.scheduler.tick() from the + WebUI worker, tick's background job path has no request TLS context. Wrap + run_job so each auto-fired job's persisted ``profile`` field gets the same + HERMES_HOME isolation as the manual /api/crons/run path. + """ + try: + import cron.scheduler as _cs + except ImportError: + logger.debug("install_cron_scheduler_profile_isolation: cron.scheduler unavailable") + return + + original = getattr(_cs, 'run_job', None) + if original is None or getattr(original, '_webui_profile_isolated', False): + return + + def _webui_profile_isolated_run_job(job, *args, **kwargs): + # Manual WebUI runs already enter cron_profile_context_for_home before + # calling run_job. Avoid nesting the non-reentrant env lock or changing + # the explicitly selected manual execution profile. + if _cron_profile_context_depth() > 0: + return original(job, *args, **kwargs) + with cron_profile_context_for_home(_home_for_scheduled_cron_job(job)): + return original(job, *args, **kwargs) + + _webui_profile_isolated_run_job._webui_profile_isolated = True + _webui_profile_isolated_run_job._webui_original_run_job = original + _cs.run_job = _webui_profile_isolated_run_job + + class cron_profile_context_for_home: """Context manager that pins HERMES_HOME to an explicit profile home path. @@ -261,6 +337,7 @@ class cron_profile_context_for_home: def __enter__(self): _cron_env_lock.acquire() + _push_cron_profile_context_depth() try: self._prev_env = os.environ.get('HERMES_HOME') os.environ['HERMES_HOME'] = str(self._home) @@ -296,6 +373,7 @@ class cron_profile_context_for_home: except (ImportError, AttributeError): logger.debug("cron_profile_context_for_home: cron.scheduler unavailable") except Exception: + _pop_cron_profile_context_depth() _cron_env_lock.release() raise return self @@ -319,6 +397,7 @@ class cron_profile_context_for_home: except (ImportError, AttributeError): pass finally: + _pop_cron_profile_context_depth() _cron_env_lock.release() return False @@ -337,6 +416,7 @@ class cron_profile_context: def __enter__(self): _cron_env_lock.acquire() + _push_cron_profile_context_depth() try: self._prev_env = os.environ.get('HERMES_HOME') home = get_active_hermes_home() @@ -371,6 +451,7 @@ class cron_profile_context: except (ImportError, AttributeError): logger.debug("cron_profile_context: cron.scheduler unavailable; env-var only") except Exception: + _pop_cron_profile_context_depth() _cron_env_lock.release() raise return self @@ -397,6 +478,7 @@ class cron_profile_context: except (ImportError, AttributeError): pass finally: + _pop_cron_profile_context_depth() _cron_env_lock.release() return False @@ -573,6 +655,7 @@ def init_profile_state() -> None: _active_profile = _read_active_profile_file() home = get_active_hermes_home() _set_hermes_home(home) + install_cron_scheduler_profile_isolation() _reload_dotenv(home) @@ -596,13 +679,18 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: # Import here to avoid circular import at module load from api.config import STREAMS, STREAMS_LOCK, reload_config - # Block if agent is running - with STREAMS_LOCK: - if len(STREAMS) > 0: - raise RuntimeError( - 'Cannot switch profiles while an agent is running. ' - 'Cancel or wait for it to finish.' - ) + # Process-wide profile switches mutate HERMES_HOME, module-level path caches, + # os.environ-backed .env keys, and the global config cache. Keep those blocked + # while any agent stream is active. Per-client WebUI switches are cookie/TLS + # scoped (process_wide=False) and do not mutate those globals, so users can + # leave a running session in one profile and start work in another (#1700). + if process_wide: + with STREAMS_LOCK: + if len(STREAMS) > 0: + raise RuntimeError( + 'Cannot switch profiles while an agent is running. ' + 'Cancel or wait for it to finish.' + ) # Resolve profile directory if _is_root_profile(name): diff --git a/docs/pr-media/1700/profile-switch-away-from-running-session.png b/docs/pr-media/1700/profile-switch-away-from-running-session.png new file mode 100644 index 00000000..a6c1f178 Binary files /dev/null and b/docs/pr-media/1700/profile-switch-away-from-running-session.png differ diff --git a/static/i18n.js b/static/i18n.js index 6a9bf433..68ca08bf 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -905,7 +905,6 @@ const LOCALES = { profile_api_key_placeholder: 'API key (optional)', manage_profiles: 'Manage profiles', profiles_load_failed: 'Failed to load profiles', - profiles_busy_switch: 'Cannot switch profiles while agent is running', profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, profile_switched: (name) => `Switched to profile: ${name}`, profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', @@ -1914,7 +1913,6 @@ const LOCALES = { profile_api_key_placeholder: 'APIキー (任意)', manage_profiles: 'プロファイルを管理', profiles_load_failed: 'プロファイルの読み込みに失敗しました', - profiles_busy_switch: 'エージェント実行中はプロファイルを切り替えできません', profile_switched_new_conversation: (name) => `プロファイルを切替: ${name} — 新しい会話を開始しました`, profile_switched: (name) => `プロファイルを切替: ${name}`, profile_name_rule: '小文字、数字、ハイフン、アンダースコアのみ', @@ -2713,7 +2711,6 @@ const LOCALES = { profile_api_key_placeholder: 'API-ключ (необязательно)', manage_profiles: 'Управление профилями', profiles_load_failed: 'Не удалось загрузить профили', - profiles_busy_switch: 'Нельзя переключать профили, пока агент работает', profile_switched_new_conversation: (name) => `Переключено на профиль: ${name} — начата новая беседа`, profile_switched: (name) => `Переключено на профиль: ${name}`, profile_name_rule: 'Только строчные буквы, цифры, дефисы и подчёркивания', @@ -3665,7 +3662,6 @@ const LOCALES = { profile_api_key_placeholder: 'Clave API (opcional)', manage_profiles: 'Manage profiles', profiles_load_failed: 'Failed to load profiles', - profiles_busy_switch: 'Cannot switch profiles while agent is running', profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, profile_switched: (name) => `Switched to profile: ${name}`, profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', @@ -4742,7 +4738,6 @@ const LOCALES = { profile_api_key_placeholder: 'sk-…', manage_profiles: 'Profile verwalten', profiles_load_failed: 'Profile konnten nicht geladen werden.', - profiles_busy_switch: 'Profil kann nicht gewechselt werden.', profile_switched_new_conversation: 'Profil gewechselt. Neue Konversation.', profile_switched: 'Profil gewechselt.', profile_name_rule: 'Nur alphanumerische Zeichen.', @@ -5534,7 +5529,6 @@ const LOCALES = { profile_api_key_placeholder: 'API 密钥(可选)', manage_profiles: '管理配置档', profiles_load_failed: '加载配置档失败', - profiles_busy_switch: 'Agent 运行中,无法切换配置档', profile_switched_new_conversation: (name) => `已切换到配置档:${name},并新建对话`, profile_switched: (name) => `已切换到配置档:${name}`, profile_name_rule: '仅允许小写字母、数字、连字符和下划线', @@ -6415,7 +6409,6 @@ const LOCALES = { profile_switched: (name) => `已切換到 ${name}`, profile_switched_new_conversation: (name) => `已切換到 ${name}(新會話)`, profile_use: '\u4f7f\u7528', - profiles_busy_switch: 'Agent \u57f7\u884c\u4e2d\u7121\u6cd5\u5207\u63db\u8a2d\u5b9a\u6a94', profiles_load_failed: '\u8f09\u5165\u8a2d\u5b9a\u6a94\u5931\u6557', profiles_no_profiles: '\u627e\u4e0d\u5230\u8a2d\u5b9a\u6a94\u3002', remove: '\u79fb\u9664', @@ -7516,7 +7509,6 @@ const LOCALES = { profile_api_key_placeholder: 'Opcional', manage_profiles: 'Gerenciar perfis', profiles_load_failed: 'Falha ao carregar perfis', - profiles_busy_switch: 'Não pode trocar perfis com agente rodando', profile_switched_new_conversation: (name) => `Trocado para perfil: ${name} — nova conversa iniciada`, profile_switched: (name) => `Trocado para perfil: ${name}`, profile_delete_confirm: (name) => `Excluir perfil "${name}"?`, @@ -8427,7 +8419,6 @@ const LOCALES = { profile_api_key_placeholder: 'API key (optional)', manage_profiles: 'Manage profiles', profiles_load_failed: 'Failed to load profiles', - profiles_busy_switch: 'Cannot switch profiles while agent is running', profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, profile_switched: (name) => `Switched to profile: ${name}`, profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', diff --git a/static/panels.js b/static/panels.js index c07e6229..12531130 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3766,7 +3766,9 @@ window.addEventListener('resize',()=>{ }); async function switchToProfile(name) { - if (S.busy) { showToast(t('profiles_busy_switch')); return; } + // Profile switches are per-client cookie/TLS scoped, so a running stream in + // the current session can safely continue while this tab moves to another + // profile. The in-flight session stays attached to its original profile. // ── Loading indicator ─────────────────────────────────────────────────── // Show spinner on the profile chip immediately so the user gets visual @@ -3781,7 +3783,11 @@ async function switchToProfile(name) { // Determine whether the current session has any messages. // A session with messages is "in progress" and belongs to the current profile — // we must not retag it. We'll start a fresh session for the new profile instead. - const sessionInProgress = S.session && S.messages && S.messages.length > 0; + const sessionInProgress = S.session && ( + (S.messages && S.messages.length > 0) || + S.session.active_stream_id || + S.session.pending_user_message + ); try { const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) }); diff --git a/tests/test_issue1700_parallel_profile_switch.py b/tests/test_issue1700_parallel_profile_switch.py new file mode 100644 index 00000000..7928b9a7 --- /dev/null +++ b/tests/test_issue1700_parallel_profile_switch.py @@ -0,0 +1,95 @@ +"""Regression coverage for issue #1700 parallel profile switching. + +A WebUI profile switch uses cookie/thread-local profile state, so it should be +allowed while another session is streaming. Only process-wide profile switches +must remain blocked because they mutate global Hermes runtime state. +""" +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") + + +def _extract_switch_to_profile() -> str: + marker = "async function switchToProfile(name) {" + idx = PANELS_JS.find(marker) + assert idx != -1, "switchToProfile() not found in static/panels.js" + depth = 0 + for i, ch in enumerate(PANELS_JS[idx:], idx): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return PANELS_JS[idx : i + 1] + raise AssertionError("Could not extract switchToProfile() body") + + +def _prepare_profile_tree(tmp_path, monkeypatch): + import api.profiles as profiles + + default_home = tmp_path / ".hermes" + target_home = default_home / "profiles" / "writer" + target_workspace = tmp_path / "writer-workspace" + target_workspace.mkdir(parents=True) + target_home.mkdir(parents=True) + (target_home / "config.yaml").write_text( + f"model:\n provider: openai-codex\n default: gpt-5.5\n" + f"terminal:\n cwd: {target_workspace}\n", + encoding="utf-8", + ) + + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home) + monkeypatch.setattr(profiles, "_active_profile", "default") + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "default"}, {"name": "writer"}]) + profiles._tls.profile = None + return profiles + + +def test_process_wide_switch_still_blocks_when_stream_is_active(tmp_path, monkeypatch): + profiles = _prepare_profile_tree(tmp_path, monkeypatch) + from api.config import STREAMS + + STREAMS.clear() + STREAMS["stream-default"] = object() + try: + with pytest.raises(RuntimeError, match="Cannot switch profiles while an agent is running"): + profiles.switch_profile("writer", process_wide=True) + finally: + STREAMS.clear() + profiles._tls.profile = None + + +def test_per_client_switch_allowed_when_stream_is_active(tmp_path, monkeypatch): + profiles = _prepare_profile_tree(tmp_path, monkeypatch) + from api.config import STREAMS + + STREAMS.clear() + STREAMS["stream-default"] = object() + try: + result = profiles.switch_profile("writer", process_wide=False) + finally: + STREAMS.clear() + profiles._tls.profile = None + + assert result["active"] == "writer" + assert result["default_model"] == "gpt-5.5" + + +def test_frontend_profile_switch_no_longer_blocks_on_busy_state(): + fn = _extract_switch_to_profile() + + assert "profiles_busy_switch" not in fn + assert "if (S.busy)" not in fn + assert "Profile switches are per-client cookie/TLS scoped" in fn + + +def test_frontend_treats_active_or_pending_session_as_in_progress(): + fn = _extract_switch_to_profile() + session_block = fn[fn.find("const sessionInProgress") : fn.find("try {", fn.find("const sessionInProgress"))] + + assert "S.session.active_stream_id" in session_block + assert "S.session.pending_user_message" in session_block + assert "S.messages.length > 0" in session_block diff --git a/tests/test_scheduled_jobs_profile_isolation.py b/tests/test_scheduled_jobs_profile_isolation.py index 0d05cc4d..04f69224 100644 --- a/tests/test_scheduled_jobs_profile_isolation.py +++ b/tests/test_scheduled_jobs_profile_isolation.py @@ -199,6 +199,94 @@ def test_cron_run_does_not_silently_swallow_profile_resolution_errors(): ) +def test_webui_installs_profile_context_on_in_process_scheduler_run_job(tmp_path, monkeypatch): + """If WebUI ever runs cron.scheduler.tick in-process, scheduled run_job calls + must execute under the job's selected profile home, not the process-global + HERMES_HOME that happened to be active when the scheduler thread fired. + """ + import types + + from api import profiles as p + + default_home = tmp_path / "home" + research_home = default_home / "profiles" / "research" + research_home.mkdir(parents=True) + events = [] + + class Ctx: + def __init__(self, home): + self.home = str(home) + + def __enter__(self): + events.append(("enter", self.home)) + return self + + def __exit__(self, exc_type, exc, tb): + events.append(("exit", self.home)) + return False + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_scheduler = types.ModuleType("cron.scheduler") + cron_scheduler.run_job = lambda job: events.append(("run", job["id"])) or "ok" + + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler) + monkeypatch.setattr(p, "_DEFAULT_HERMES_HOME", default_home) + monkeypatch.setattr(p, "cron_profile_context_for_home", Ctx) + + p.install_cron_scheduler_profile_isolation() + + assert cron_scheduler.run_job({"id": "job1575", "profile": "research"}) == "ok" + assert events == [ + ("enter", str(research_home)), + ("run", "job1575"), + ("exit", str(research_home)), + ] + + +def test_scheduler_run_job_wrapper_does_not_reenter_manual_cron_context(tmp_path, monkeypatch): + """Manual /api/crons/run already pins run_job before calling it. + + The scheduler safety wrapper must detect that existing context and delegate + directly, otherwise the non-reentrant env lock would deadlock or override the + manual execution profile. + """ + import types + + from api import profiles as p + + events = [] + + class Ctx: + def __init__(self, home): + self.home = str(home) + + def __enter__(self): + events.append(("unexpected-enter", self.home)) + return self + + def __exit__(self, exc_type, exc, tb): + events.append(("unexpected-exit", self.home)) + return False + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_scheduler = types.ModuleType("cron.scheduler") + cron_scheduler.run_job = lambda job: events.append(("run", job["id"])) or "ok" + + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler) + monkeypatch.setattr(p, "_DEFAULT_HERMES_HOME", tmp_path / "home") + monkeypatch.setattr(p, "cron_profile_context_for_home", Ctx) + monkeypatch.setattr(p._tls, "cron_profile_depth", 1, raising=False) + + p.install_cron_scheduler_profile_isolation() + + assert cron_scheduler.run_job({"id": "manual1575", "profile": "research"}) == "ok" + assert events == [("run", "manual1575")] + + def test_cron_worker_does_not_silently_fall_back_on_profile_context_failure(): """_run_cron_tracked must NOT silently set ctx=None when cron_profile_context_for_home(...).__enter__() raises.