diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4613fc..506b119e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Hermes Web UI -- Changelog +## [v0.51.15] — 2026-05-07 — 4-PR contributor batch + 1 self-built (cron spawn migration, context menu, codex quota, model prefix) + +### Fixed + +- **PR #1767** by @Michaelyklam — Use `spawn` for manual cron subprocesses (closes #1754, the architectural follow-up filed in v0.51.12). One-line context change `multiprocessing.get_context("fork")` → `"spawn"` at `api/routes.py:367` plus +207 LOC of regression coverage in `tests/test_issue1574_cron_profile_lock.py`. Validates: (a) source-level pin that the helper uses spawn, (b) end-to-end harness showing `fork` deadlocks on a parent-thread-held lock while `spawn` succeeds, (c) drain-large-result regression preserved, (d) executes-under-selected-profile-home regression preserved. **Auto-fix applied at stage**: 2 of the 5 tests fail on dev machines with an editable `hermes_agent` install (the spawn child resolves the real `cron.scheduler` first instead of the fake one written under `HERMES_WEBUI_AGENT_DIR`). Added `_real_hermes_agent_editable_install_present()` detector using `importlib.util.find_spec` origin check + `pytest.skip` guard. Tests skip on dev (where they cannot work as designed) and run cleanly on CI (where no editable install exists). Closes the fork-from-multi-threaded-WebUI hazard class noted in #1754: import-lock and logging-lock inheritance no longer apply, since spawn starts a fresh interpreter. +- **PR #1770** by @Michaelyklam — Surface Codex usage exhaustion errors (closes #1765). New `quota_exhausted` SSE event for Codex 429/quota responses replaces the previous behavior (empty turn with no inline error) with a clear inline error card. `_classify_provider_error()` distinguishes quota-exhaustion (requires re-auth) from transient rate-limit (just needs to wait) — Opus stage-309 verified the classifier order (quota check first, rate-limit is `not _is_quota AND ...`) preserves the distinction. Detection covers Codex OAuth shapes: "plan limit reached", "usage_limit_exceeded", "reached the limit of messages", "used up your usage", plus the multi-token fallback. Both error paths properly clean up runtime state (INFLIGHT, approval/clarify pollers via `finally` block) and run `_materialize_pending_user_turn_before_error()` before `pending_user_message = None` clearing — preserving the user-turn data-loss fix from PR #1760 (v0.51.14). 62 LOC test coverage in `tests/test_issue1765_codex_quota.py`. Includes 2 PNG screenshots. +- **PR #1762** by @bergeouss — Add missing `openrouter/` prefix for `tencent/hy3-preview:free` in `_FALLBACK_MODELS` (closes #1744). Pure data fix; resolves the model to the right provider. Includes rsplit-fallback path so OpenRouter-shaped IDs with `:free`/`:beta`/`:thinking` suffixes resolve correctly. **One edge case filed as follow-up #1776** (Opus stage-309 noted: `@custom:::free` mis-resolves because the rsplit-fallback skips on `custom:` provider hint — uncommon combination, non-blocking). + +### Added + +- **PR #1769** by @nesquena-hermes — Three high-leverage context-menu essentials from #1764 (self-built, **independently APPROVED by @nesquena** at exact head SHA `102157bc`). Adds Reveal-in-finder, Copy-path, and Open-with-system context menu entries on attachment chips. Two new endpoints `_handle_file_reveal` + `_handle_file_path` in `api/routes.py` (gated by `safe_resolve()` path-validation against the session workspace root; all shell-outs use list-form `subprocess.Popen([...])` with no `shell=True` — Opus stage-309 verified XSS/CSRF/shell-injection clean), `static/ui.js` right-click handler + `_showFileContextMenu` (isolated absolute-positioned menu, no global delegate that could interfere with #1770's quota error card), `static/sessions.js` integration, locale strings × 6 in `static/i18n.js`. 343 LOC test coverage in `tests/test_1764_context_menu_essentials.py`. + +### Tests + +4662 → **4694 collected** (+32 across 4 new test files plus regression coverage tightening). 4687 passed, 4 skipped (2 from #1767 dev-only spawn tests + 2 from prong-2 noise), 3 xpassed, 0 failed in 134.82s. + +### Pre-release verification + +- All 4 PRs CI-green individually. +- Auto-fix on #1767 verified (3 passed, 2 skipped on dev — would be 5 passed on CI). +- `node -c` clean on all 4 changed JS files (`static/ui.js`, `static/messages.js`, `static/i18n.js`, `static/sessions.js`). +- pytest: 4687 passed, 0 failed (single clean run, ~135s). +- `scripts/run-browser-tests.sh`: all 11 endpoints PASS on isolated port 8789. +- Pre-stamp re-fetch: all 4 PR heads still match local rebases — no late commits. +- Opus advisor: SHIP all 4, all 5 verification questions clean, 0 MUST-FIX, 2 SHOULD-FIX (one absorbed in-release: editable-install detector tightened to use `importlib.util.find_spec`-origin check; one filed as follow-up #1776). + +Closes #1744, #1754, #1764, #1765. + ## [v0.51.14] — 2026-05-06 — 4-PR contributor batch ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index c0517281..1121ff29 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.14 (May 6, 2026) — 4662 tests collected — 4-PR contributor batch (#1756, #1757, #1760, #1761) +> Last updated: v0.51.15 (May 7, 2026) — 4694 tests collected — 4-PR contributor batch + 1 self-built (#1762, #1767, #1769, #1770) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index 1fa48fe5..48d0d3ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.14, May 6, 2026* -*Total automated tests collected: 4662* +*Last updated: v0.51.15, May 7, 2026* +*Total automated tests collected: 4694* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/tests/test_issue1574_cron_profile_lock.py b/tests/test_issue1574_cron_profile_lock.py index 964b7524..738e2693 100644 --- a/tests/test_issue1574_cron_profile_lock.py +++ b/tests/test_issue1574_cron_profile_lock.py @@ -84,6 +84,42 @@ def _activate_spawn_fake_agent(fake_agent_root: Path): sys.modules.pop(module_name, None) +def _real_hermes_agent_editable_install_present() -> bool: + """Detect a developer-machine editable install of hermes-agent. + + The two tests that spawn a real subprocess + import the fake `cron.scheduler` + from ``HERMES_WEBUI_AGENT_DIR`` only work when the spawn child does NOT have + a competing real `cron.scheduler` reachable via the venv's editable finder. + On CI runners (and most production installs) there's no editable install, + so the fake at ``fake_agent_root`` is the only `cron.scheduler` Python can + resolve; on a maintainer's dev machine an editable install of hermes-agent + is registered through a `.pth` file in site-packages, and the spawn child + will resolve the real `cron.scheduler` first — which then fails because the + real `run_job` requires a configured inference provider. + + Detection strategy: ask Python's import machinery directly via + ``importlib.util.find_spec`` whether `cron.scheduler` is currently + resolvable. If yes AND the resolved origin is outside any tmp dir + (i.e., not a fake we just wrote), assume a competing real install is + present. This is more robust than name-pattern matching against + site-packages entries, which misses PEP 660 schemes (hatchling/poetry) + and legacy egg-links. + """ + try: + import importlib.util + spec = importlib.util.find_spec("cron.scheduler") + except Exception: + return False + if spec is None or not spec.origin: + return False + origin = str(spec.origin) + # Tests write fake cron.scheduler under tmp_path; tmp paths shouldn't + # count as a "real" competing install. Treat anything outside common tmp + # roots as a real install that will out-resolve the fake. + tmp_prefixes = ("/tmp/", "/var/folders/", os.path.expandvars("$TMPDIR/") if os.environ.get("TMPDIR") else "") + return not any(p and origin.startswith(p) for p in tmp_prefixes) + + def _large_cron_payload_runner(profile_home, result_queue): try: fake_agent_root = Path(profile_home).parent / "fake-agent" @@ -210,6 +246,13 @@ def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path): def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path): """A >100 KB result must not deadlock the parent before it can persist output.""" + if _real_hermes_agent_editable_install_present(): + import pytest as _pytest + _pytest.skip( + "skipped on dev machines with an editable hermes-agent install — " + "the spawn child resolves the real cron.scheduler first instead of " + "the fake one written under HERMES_WEBUI_AGENT_DIR. Runs cleanly on CI." + ) # Use fork only for the outer test harness so this pytest module does not # need to be importable as a package. The product helper under test owns its # own multiprocessing context. @@ -307,6 +350,13 @@ def test_manual_cron_run_does_not_hold_profile_lock_for_job_duration(tmp_path, m def test_cron_job_subprocess_executes_under_selected_profile_home(tmp_path, monkeypatch): + if _real_hermes_agent_editable_install_present(): + import pytest as _pytest + _pytest.skip( + "skipped on dev machines with an editable hermes-agent install — " + "the spawn child resolves the real cron.scheduler first instead of " + "the fake one written under HERMES_WEBUI_AGENT_DIR. Runs cleanly on CI." + ) exec_home = tmp_path / "exec-profile" ctx = multiprocessing.get_context("fork") result_queue = ctx.Queue()