github nesquena/hermes-webui v0.50.288
v0.50.288 — picker symmetry + cron profile isolation (3 PRs)

latest release: v0.50.289
3 hours ago

[v0.50.288] — 2026-05-03

Fixed (3 PRs — picker symmetry + cron profile isolation — closes #1567, #1568, #1573)

  • Nous Portal endpoint disagreement + featured-set cap (#1569; closes #1567) — reporter (Deor, Discord, relayed by @AvidFuturist) saw Settings → Providers card showing "Nous Portal — 396 models · OAuth" while the in-conversation model picker dropdown listed only the 4 hardcoded curated entries (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini 3.1 Pro Preview). Two related root-shape bugs bundled. (1) Asymmetric auth detection — api/providers.py:get_providers iterates ALL OAuth providers regardless of authentication state and unconditionally live-fetches the catalog, while api/config.py:_build_available_models_uncached only iterates providers in detected_providers, gated on hermes_cli.models.list_available_providers().authenticated. That flag can disagree with hermes_cli.auth.get_auth_status(<id>).logged_in, so when the disagreement happens for Nous, the picker silently falls through to the curated 4-entry static list while the providers card keeps showing the live catalog. Fix: added explicit get_auth_status("nous").logged_in check after the existing list_available_providers() loop — picker now includes Nous whenever the providers card would. (2) UX cap — even with the disagreement fixed, dumping a 397-model catalog into a flat dropdown is unusable. New _build_nous_featured_set() helper at api/config.py:965 runs the same algorithm in both /api/models and /api/models/live so background enrichment doesn't undo the trim. Selection rules (deterministic): sticky-selection always pinned, every curated flagship preserved, vendor round-robin via _NOUS_VENDOR_PRIORITY for top-up to 15. Disclosure pattern: optgroup label "Nous Portal (15 of 397)", new extra_models field on the API surface, slash command + _dynamicModelLabels map hydrated from both halves so a model selected outside the featured slice still renders with its proper label, providers card uses models_total for the header count + small +N more disclosure pill at the end of the rendered pill list. (3) Stale-fallback poisoning — when authenticated AND live-fetch returns [] (transient hermes_cli failure, OAuth refresh in flight, cache miss), omit the Nous group entirely rather than falling back to stale-4 (which actively contradicts the providers card instead of self-healing). Static fallback only when hermes_cli is unavailable or raises (test envs, package mismatches). 20 new tests in tests/test_issue1567_nous_picker_capacity_and_symmetry.py covering selection helper invariants, large-catalog cap behavior, detection symmetry, live-fetch-empty handling, providers/picker symmetry, frontend extras contract.

  • Cron Scheduled Jobs panel respects per-request active profile (#1571 by @kowenhaoai; closes #1573) — /api/crons* endpoints called into cron.jobs (from hermes-agent), whose path resolver reads HERMES_HOME from os.environ at call time. The WebUI's per-request profile isolation (#798) is thread-local — set per-request from the hermes_profile cookie in server.py, cleared after the request — so those two mechanisms didn't talk to each other and cron.jobs always saw the process-default HERMES_HOME no matter which profile the request belonged to. CRUD operations silently wrote to the wrong jobs.json. Fix: two new context managers in api/profiles.py:139-260, both holding a module-level _cron_env_lock. cron_profile_context() is the HTTP-side variant (resolves home via get_active_hermes_home() which honors the TLS cookie, swaps os.environ['HERMES_HOME'], re-patches the cached cron.jobs.HERMES_DIR/CRON_DIR/JOBS_FILE/OUTPUT_DIR module constants, restores everything on exit). cron_profile_context_for_home(home) is the thread-side variant (worker threads have no TLS context, so the HTTP handler captures the active home at dispatch time and passes it explicitly). All 12 cron endpoints wrapped (6 GET + 6 POST). _handle_cron_run additionally captures the TLS-active home at dispatch and forwards it into _run_cron_tracked(job, profile_home) so cron output files land in the correct profile directory. Pre-release reviewer pushed test-skip-on-missing-agent fix so machines without ~/hermes-agent run the suite cleanly. Post-review tightening: removed an over-broad except Exception around get_active_hermes_home() in _handle_cron_run (silent fallback to _profile_home=None would have re-introduced the exact bug the PR fixes — let any unexpected exception 500 the request rather than risk silent cross-profile state corruption); added thread-safety note on os.environ mutation explaining why _cron_env_lock is sufficient given CPython GIL semantics + subprocess.Popen env inheritance at fork time. 4 regression tests in tests/test_scheduled_jobs_profile_isolation.py. Two follow-up issues filed for architectural concerns (#1574 lock granularity, #1575 in-process scheduler bypass) — both deferred as out of scope. Verified end-to-end via real browser test on isolated environment (12 sessions, 3 projects, 6 default crons + 1 work-only-cron, 2 profiles): UI profile switch → cron tab auto-refreshes to show only target profile's jobs, both directions; on-disk verification confirmed perfect isolation in ~/.hermes/cron/jobs.json (default profile) vs ~/.hermes/profiles/work/cron/jobs.json.

  • Collapse duplicate provider groups + guard provider-id-as-model.default (#1572; closes #1568) — reporter (Deor, Discord, relayed by @AvidFuturist) saw the Settings → Default Model dropdown rendering OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (canonical, with all 14 catalog models) and "Opencode_Go" (phantom group containing one self-referential entry). Three structural causes (all in api/config.py:_build_available_models_uncached). (1) Detection-path id leakage — cfg["providers"] keys are read verbatim, so a config with providers.opencode_go.api_key (underscore variant) AND another path adding the canonical opencode-go (e.g. via active_provider) end up with both in detected_providers, creating two distinct provider groups with the second labelled via pid.title() fallback as "Opencode_Go". (2) Injection-block rogue model — the default-model injection block puts ANY model.default string into the picker as a fake option, so a stray model.default: opencode_go (provider id mistakenly used as a model id) surfaces as a phantom model labelled "Opencode GO". (3) Empty-group bleed — when a non-canonical provider id makes it into detected_providers but has no entry in _PROVIDER_MODELS, the build loop creates an optgroup with zero models. Fix: new _canonicalise_provider_id() helper folds underscores to hyphens, lowercases, applies alias resolution only when the alias target is itself canonical in _PROVIDER_DISPLAY (the constraint that prevents x-ai from round-tripping through the alias table to xai). Detection-path canonicalises before adding to detected_providers; same treatment in the only_show_configured intersection. Post-collection dedup pass re-canonicalises every entry (belt-and-braces against future regressions in any of the ~25 detected_providers.add(...) callsites). Provider-id guard on the model.default injection block — when the injected value matches a known provider display name or alias (after underscore/case normalization), skip the injection and emit a logger.warning. Real unknown model IDs (newly released models, custom endpoints) still get injected — only provider-shaped values are rejected. Empty-group filter at end of build (drops optgroups with zero models, with custom: exemption since users may want an empty card visible as a reminder). 17 new tests in tests/test_issue1568_duplicate_provider_groups.py covering the helper unit, dedup E2E, model.default guard, empty-group filter. Plus one structural test fix in tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known — widened the regex window from 500 → 1500 chars so the new documentation comment block doesn't push _PROVIDER_MODELS past the substring slice (pre-existing brittle-window pattern, not a new issue).

Tests

4053 → 4094 passing (+41 net: +20 from #1569 Nous featured-set, +17 from #1572 dedup, +4 from #1571 cron isolation). 0 regressions. Full suite in 108s.

Pre-release verification

  • All 41 PR-related tests pass standalone.
  • All 4094 tests pass in the full suite (clean state, no pre-existing flakes triggered).
  • Browser sanity (HTTP API checks against port 8789): 11/11 endpoints verified.
  • All modified JS files (static/commands.js, static/panels.js, static/ui.js) pass node -c.
  • Real-world browser testing on isolated test environment (12 sessions, 3 projects, 6 default crons + 1 work cron, 4 skills, 2 profiles): profile switch via UI updates the chip, sidebar re-renders, cron tab auto-refreshes to show only target profile's jobs. On-disk verification confirms perfect isolation. Profile chip + cron tab UI confirmed by vision-model.
  • Pre-release Opus advisor: SHIP AS-IS — no MUST-FIX. All 5 verification questions check out (conflict-free merge, no deadlock between _cron_env_lock and _available_models_cache_lock, subprocess env inheritance under lock verified, _canonicalise_provider_id dedup-pass idempotent, stale-fallback handling correct under partial network failure). One non-blocking symmetry nit on _run_cron_tracked worker-side broad-except flagged as a follow-up issue.

Maintainer in-stage actions

  • PR rebase verified clean (REBASE-DEFAULT rule applied). All 3 PR branches were on or near current master; rebase was no-op.
  • #1571 post-review fix combination: contributor's df03055 (post-review tightening) was on pull/1571/head while reviewer's d83e1d8 (test-skip-on-missing-agent) was on origin/fix/scheduled-jobs-profile-isolation. Cherry-picked the test-skip commit onto the contributor branch to combine both fixes before merging into stage.

Don't miss a new hermes-webui release

NewReleases is sending notifications on new releases.