Merge pull request #1745 from nesquena/stage-304

v0.51.10 — 2-PR batch (cron profile isolation + profile switch during streams)
This commit is contained in:
nesquena-hermes
2026-05-06 09:28:59 -07:00
committed by GitHub
9 changed files with 318 additions and 21 deletions
+29
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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: <repo>/*
+95 -7
View File
@@ -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):
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

-9
View File
@@ -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',
+8 -2
View File
@@ -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 }) });
@@ -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
@@ -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.