mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 19:50:15 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user