diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index f86843cd3b54..c91c015da5bf 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -958,6 +958,7 @@ export function testMessagingPlatform(platformId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/cron/jobs', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) @@ -965,12 +966,14 @@ export function getCronJobs(): Promise { export function getCronJob(jobId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}` }) } export async function getCronJobRuns(jobId: string, limit = 20): Promise { const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` }) @@ -979,6 +982,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/cron/jobs', method: 'POST', body @@ -987,6 +991,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise { export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'PUT', body: { updates } @@ -995,6 +1000,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, method: 'POST' }) @@ -1002,6 +1008,7 @@ export function pauseCronJob(jobId: string): Promise { export function resumeCronJob(jobId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, method: 'POST' }) @@ -1009,6 +1016,7 @@ export function resumeCronJob(jobId: string): Promise { export function triggerCronJob(jobId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, method: 'POST' }) @@ -1016,6 +1024,7 @@ export function triggerCronJob(jobId: string): Promise { export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'DELETE' }) 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_codex_execution_paths.py b/tests/cron/test_codex_execution_paths.py index 5c3e5cf060b9..8513ce695a14 100644 --- a/tests/cron/test_codex_execution_paths.py +++ b/tests/cron/test_codex_execution_paths.py @@ -98,7 +98,7 @@ def test_cron_run_job_codex_path_handles_internal_401_refresh(monkeypatch): monkeypatch.setattr(run_agent, "AIAgent", _Codex401ThenSuccessAgent) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", - lambda requested=None: { + lambda requested=None, **kwargs: { "provider": "openai-codex", "api_mode": "codex_responses", "base_url": "https://chatgpt.com/backend-api/codex", 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 @@ def test_no_model_snapshot_backcompat(self, tmp_path): ) 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()