Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,19 +958,22 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat

export function getCronJobs(): Promise<CronJob[]> {
return window.hermesDesktop.api<CronJob[]>({
...profileScoped(),
path: '/api/cron/jobs',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}

export function getCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
})
}

export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
})

Expand All @@ -979,6 +982,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise<Session

export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: '/api/cron/jobs',
method: 'POST',
body
Expand All @@ -987,6 +991,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {

export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'PUT',
body: { updates }
Expand All @@ -995,27 +1000,31 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<C

export function pauseCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
method: 'POST'
})
}

export function resumeCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
method: 'POST'
})
}

export function triggerCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
method: 'POST'
})
}

export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'DELETE'
})
Expand Down
5 changes: 5 additions & 0 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
26 changes: 23 additions & 3 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion tests/cron/test_codex_execution_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions tests/cron/test_cron_provider_pin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
50 changes: 50 additions & 0 deletions tests/hermes_cli/test_web_server_cron_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading