[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_providersiterates ALL OAuth providers regardless of authentication state and unconditionally live-fetches the catalog, whileapi/config.py:_build_available_models_uncachedonly iterates providers indetected_providers, gated onhermes_cli.models.list_available_providers().authenticated. That flag can disagree withhermes_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 explicitget_auth_status("nous").logged_incheck after the existinglist_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 atapi/config.py:965runs the same algorithm in both/api/modelsand/api/models/liveso background enrichment doesn't undo the trim. Selection rules (deterministic): sticky-selection always pinned, every curated flagship preserved, vendor round-robin via_NOUS_VENDOR_PRIORITYfor top-up to 15. Disclosure pattern: optgroup label"Nous Portal (15 of 397)", newextra_modelsfield on the API surface, slash command +_dynamicModelLabelsmap hydrated from both halves so a model selected outside the featured slice still renders with its proper label, providers card usesmodels_totalfor the header count + small+N moredisclosure 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 whenhermes_cliis unavailable or raises (test envs, package mismatches). 20 new tests intests/test_issue1567_nous_picker_capacity_and_symmetry.pycovering 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 intocron.jobs(fromhermes-agent), whose path resolver readsHERMES_HOMEfromos.environat call time. The WebUI's per-request profile isolation (#798) is thread-local — set per-request from thehermes_profilecookie inserver.py, cleared after the request — so those two mechanisms didn't talk to each other andcron.jobsalways saw the process-defaultHERMES_HOMEno matter which profile the request belonged to. CRUD operations silently wrote to the wrongjobs.json. Fix: two new context managers inapi/profiles.py:139-260, both holding a module-level_cron_env_lock.cron_profile_context()is the HTTP-side variant (resolves home viaget_active_hermes_home()which honors the TLS cookie, swapsos.environ['HERMES_HOME'], re-patches the cachedcron.jobs.HERMES_DIR/CRON_DIR/JOBS_FILE/OUTPUT_DIRmodule 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_runadditionally 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-agentrun the suite cleanly. Post-review tightening: removed an over-broadexcept Exceptionaroundget_active_hermes_home()in_handle_cron_run(silent fallback to_profile_home=Nonewould 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 onos.environmutation explaining why_cron_env_lockis sufficient given CPython GIL semantics +subprocess.Popenenv inheritance at fork time. 4 regression tests intests/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 inapi/config.py:_build_available_models_uncached). (1) Detection-path id leakage —cfg["providers"]keys are read verbatim, so a config withproviders.opencode_go.api_key(underscore variant) AND another path adding the canonicalopencode-go(e.g. viaactive_provider) end up with both indetected_providers, creating two distinct provider groups with the second labelled viapid.title()fallback as"Opencode_Go". (2) Injection-block rogue model — the default-model injection block puts ANYmodel.defaultstring into the picker as a fake option, so a straymodel.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 intodetected_providersbut 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 preventsx-aifrom round-tripping through the alias table toxai). Detection-path canonicalises before adding todetected_providers; same treatment in theonly_show_configuredintersection. Post-collection dedup pass re-canonicalises every entry (belt-and-braces against future regressions in any of the ~25detected_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 alogger.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, withcustom:exemption since users may want an empty card visible as a reminder). 17 new tests intests/test_issue1568_duplicate_provider_groups.pycovering the helper unit, dedup E2E, model.default guard, empty-group filter. Plus one structural test fix intests/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_MODELSpast 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) passnode -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_lockand_available_models_cache_lock, subprocess env inheritance under lock verified,_canonicalise_provider_iddedup-pass idempotent, stale-fallback handling correct under partial network failure). One non-blocking symmetry nit on_run_cron_trackedworker-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 onpull/1571/headwhile reviewer'sd83e1d8(test-skip-on-missing-agent) was onorigin/fix/scheduled-jobs-profile-isolation. Cherry-picked the test-skip commit onto the contributor branch to combine both fixes before merging into stage.