fix(web): lock the remaining off-loop config RMWs; keep model probes outside the lock

local_models._set_runtime_enabled (quickstart/activate/stop job threads) and
profiles._disable_unselected_skills ran load_config -> mutate -> save_config
without _CONFIG_MUTATION_LOCK, so the dashboard's debounced PUT /api/config
autosave could erase their writes exactly like the routers this PR already
fixed. Both spans now hold the lock.

POST /api/model/set held the global lock across switch_model's catalog
fetches / endpoint probes, stalling every other config writer for the
duration of a network round-trip. The main-slot validation is split into
_prepare_main_assignment (runs under the profile scope only) and the
load -> apply -> save half runs under the lock. The Nous entitlement refresh
(force_fresh) stays inside the write half: it must read the on-disk config
it mutates, and it is bounded by the portal timeout.
This commit is contained in:
teknium1
2026-09-14 21:26:52 -07:00
committed by Teknium
parent 131ea24026
commit f9da9e8385
4 changed files with 43 additions and 18 deletions
+22 -8
View File
@@ -653,17 +653,30 @@ def _cron_model_impact(cfg: dict, provider: str, model: str) -> Any:
return build_cron_model_impact(config=cfg, jobs={})
def _apply_main_assignment_sync(cfg: dict, provider: str, model: str, base_url: str, api_key: str) -> dict:
from hermes_cli.config import save_config
def _provider_entry(cfg: dict, provider: str) -> Any:
providers_cfg = cfg.get("providers")
return providers_cfg.get(provider) if isinstance(providers_cfg, dict) else None
def _prepare_main_assignment(cfg: dict, provider: str, model: str, base_url: str, api_key: str) -> "tuple[str, ModelSwitchResult]":
"""Validation half of a main-slot assignment: ``(effective base_url, switch result)``.
``switch_model`` fetches catalogs / probes endpoints, so callers run this BEFORE taking
``_CONFIG_MUTATION_LOCK``; it writes nothing."""
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
provider, model = _normalize_main_model_assignment(provider, model)
providers_cfg = cfg.get("providers")
provider_entry = providers_cfg.get(provider) if isinstance(providers_cfg, dict) else None
provider_entry = _provider_entry(cfg, provider)
if not base_url and isinstance(provider_entry, dict) and provider_entry.get("base_url"):
base_url = str(provider_entry.get("base_url") or "").strip()
result = _validated_main_model_selection(cfg, provider, model, base_url, api_key)
return base_url, _validated_main_model_selection(cfg, provider, model, base_url, api_key)
def _apply_main_assignment_sync(cfg: dict, provider: str, model: str, base_url: str, api_key: str,
prepared: "Optional[tuple[str, ModelSwitchResult]]" = None) -> dict:
from hermes_cli.config import save_config
base_url, result = prepared or _prepare_main_assignment(cfg, provider, model, base_url, api_key)
provider, model = result.target_provider, result.new_model
provider_entry = _provider_entry(cfg, provider)
model_cfg = _apply_main_model_assignment(cfg.get("model", {}), result, api_key)
_resolve_assignment_credentials(model_cfg, provider, provider_entry)
cfg["model"] = model_cfg
@@ -773,17 +786,18 @@ def _apply_aux_assignment_sync(cfg: dict, provider: str, model: str, task: str,
def _apply_model_assignment_sync(
scope: str, provider: str, model: str, task: str, base_url: str, api_key: str = "",
reasoning_effort: Optional[str] = _UNSET,
reasoning_effort: Optional[str] = _UNSET, prepared: "Optional[tuple[str, ModelSwitchResult]]" = None,
):
"""Synchronous body of POST /api/model/set.
Runs inside ``_profile_scope`` (worker thread) so every load_config/save_config lands in
the requested profile. Raises HTTPException for validation errors.
the requested profile. Raises HTTPException for validation errors. ``prepared`` is a
``_prepare_main_assignment`` result computed outside the config lock.
"""
from hermes_cli.config import load_config
cfg = load_config()
if scope == "main":
return _apply_main_assignment_sync(cfg, provider, model, base_url, api_key)
return _apply_main_assignment_sync(cfg, provider, model, base_url, api_key, prepared)
return _apply_aux_assignment_sync(cfg, provider, model, task, base_url, api_key, reasoning_effort)