From 786df3ca6cf3fa063391491caa63d7ffad17e286 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:55:30 -0700 Subject: [PATCH] fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile Two follow-ups to the per-job model pin surface (#67472 / #49948 review): - cron/scheduler.py: pass target_model= to resolve_runtime_provider() on the primary path, so providers with model-specific api_mode routing derive the mode from the model the job actually runs (per-job pin > env > config default) instead of the stale persisted default. The auth-fallback path already did this for its fb_model. - hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no longer hardcodes profile="default" when the request carries no profile param. A pool backend scoped to a named profile now resolves its own profile via get_active_profile_name(), so pre-profileScoped desktop clients can't write a named profile's job into ~/.hermes. Unscoped / custom HERMES_HOME keeps the legacy default fallback. Tests: target_model capture test on run_job; two profile-default tests on the create endpoint. --- cron/scheduler.py | 5 ++ hermes_cli/web_server.py | 26 ++++++++-- tests/cron/test_cron_provider_pin.py | 39 +++++++++++++++ .../test_web_server_cron_profiles.py | 50 +++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 26b7eb517125..c5957070021e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -3178,6 +3178,11 @@ def run_job( # example DeepSeek) for cron jobs that do not pin provider/model. runtime_kwargs = { "requested": job.get("provider"), + # Derive provider-specific api_mode from the model this job + # will actually run (per-job pin > env > config default), not + # the stale persisted default — mirrors the fallback path + # below, which already passes its fb_model. + "target_model": model, } if job.get("base_url"): runtime_kwargs["explicit_base_url"] = job.get("base_url") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1d4abc7f896e..4ddb16487fda 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -11096,11 +11096,31 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]: return _fallback_profile_dicts(profiles_mod) +def _cron_default_profile() -> str: + """Profile to target when a cron request carries no explicit ``profile``. + + A desktop pool backend runs one process per profile (HERMES_HOME already + scoped), but these cron endpoints deliberately route storage through the + profiles tree via ``_cron_profile_home`` — so a hardcoded ``"default"`` + fallback would write a non-default profile's job into ``~/.hermes``. + Resolve the process's own profile instead. ``custom`` (an unrecognized + HERMES_HOME outside the profiles tree) has no profile-dir equivalent, so + it keeps the legacy ``default`` fallback. + """ + try: + from hermes_cli.profiles import get_active_profile_name + + name = get_active_profile_name() + except Exception: + return "default" + return "default" if name in ("default", "custom") else name + + def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]: """Resolve a profile query value to (profile_name, HERMES_HOME).""" from hermes_cli import profiles as profiles_mod - raw = (profile or "default").strip() or "default" + raw = (profile or _cron_default_profile()).strip() or "default" try: canon = profiles_mod.normalize_profile_name(raw) profiles_mod.validate_profile_name(canon) @@ -11256,7 +11276,7 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit) -def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"): +def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): try: profile_name, profile_home = _cron_profile_home(profile) script = _normalize_dashboard_cron_script(body.script, profile_home) @@ -11295,7 +11315,7 @@ def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"): @app.post("/api/cron/jobs") -async def create_cron_job(body: CronJobCreate, profile: str = "default"): +async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None): return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile) diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py index 23fa89ddde17..23a42ce99b14 100644 --- a/tests/cron/test_cron_provider_pin.py +++ b/tests/cron/test_cron_provider_pin.py @@ -334,3 +334,42 @@ class TestModelDriftGuard: ) assert agent_constructed is True assert success is True + + +class TestRuntimeResolutionTargetModel: + """run_job must resolve the primary provider against the model the job + will actually run (per-job pin > env > config default), so providers with + model-specific api_mode routing (e.g. OpenCode Zen/Go) pick the mode for + the pinned model instead of the stale persisted default.""" + + def test_primary_resolution_passes_effective_model(self, tmp_path): + job = _base_job(model="my-pinned-model", provider="openrouter") + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return { + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + } + + fake_db = MagicMock() + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_capture, + ), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + run_job(job) + + assert captured.get("target_model") == "my-pinned-model" + assert captured.get("requested") == "openrouter" diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index abcdbb681fa4..2c5d8bfc71ec 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -735,3 +735,53 @@ async def test_cron_profile_validation_errors(isolated_profiles): with pytest.raises(HTTPException) as missing: await web_server.list_cron_jobs(profile="missing_profile") assert missing.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_cron_job_without_profile_uses_backend_own_profile( + isolated_profiles, monkeypatch +): + """A pool backend scoped to a named profile must not default creates to + ``~/.hermes`` when the request carries no explicit ``profile`` (the + Desktop app's pre-profileScoped clients sent none).""" + from hermes_cli import web_server + + monkeypatch.setenv( + "HERMES_HOME", str(isolated_profiles["worker_alpha"]) + ) + + job = await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="runs in my own profile", + schedule="every 1h", + name="own-profile-job", + ), + profile=None, + ) + + assert job["profile"] == "worker_alpha" + assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists() + assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists() + + +@pytest.mark.asyncio +async def test_create_cron_job_without_profile_defaults_when_unscoped( + isolated_profiles, monkeypatch +): + """HERMES_HOME at the default home (or unrecognized) keeps the legacy + ``default`` fallback.""" + from hermes_cli import web_server + + monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"])) + + job = await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="runs in default", + schedule="every 1h", + name="default-job", + ), + profile=None, + ) + + assert job["profile"] == "default" + assert (isolated_profiles["default"] / "cron" / "jobs.json").exists()