diff --git a/cron/scheduler.py b/cron/scheduler.py index 5985a88002..fe72208b22 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -210,9 +210,9 @@ def _summarize_cron_failure_for_delivery(job: dict, error: str | None) -> str: else: job_id = job.get("id") or "" remediation = ( - "Pin it explicitly: " - f"`cronjob action=update job_id={job_id} " - "provider= model=`." + "On the host running Hermes, pin it explicitly: " + f"`hermes cron edit {job_id} --provider " + "--model `." ) return ( f"⚠️ Cron '{job_name}' skipped before inference to prevent " @@ -4021,8 +4021,8 @@ def _preflight_check_provider_key(job: dict, cfg: dict) -> Optional[str]: return ( f"provider credential missing: {exc}. " "Set the provider API key in .env (or `hermes setup`), or pin a " - "working provider via `cronjob action=update job_id=" - f"{job.get('id')} provider=

`." + "working provider via `hermes cron edit " + f"{job.get('id')} --provider

`." ) except Exception: # Non-auth resolution errors (bad config shapes, network probes, @@ -4836,9 +4836,9 @@ def run_job( # Model resolution precedence: per-job override > cron.model (the # cron-fleet default) > HERMES_MODEL env > config.yaml ``model:`` # (string or ``{default: ...}``). The per-job value is intentionally - # re-read from storage every tick so a ``cronjob action=update - # model=...`` after a failed run takes effect on the next tick — there - # is no in-memory cache. + # re-read from storage every tick so a ``hermes cron edit --model`` + # after a failed run takes effect on the next tick — there is no + # in-memory cache. model = job.get("model") or os.getenv("HERMES_MODEL") or "" # cron.model / cron.model_provider: a deliberate cron-fleet default @@ -4900,7 +4900,7 @@ def run_job( f"HERMES_MODEL={os.getenv('HERMES_MODEL', '')!r}, " "config.yaml model.default missing or empty). " f"Set a per-job model via " - f"`cronjob action=update job_id={job_id} model=` or set a " + f"`hermes cron edit {job_id} --model ` or set a " "default with `hermes model `." ) @@ -5172,9 +5172,9 @@ def run_job( if _drift: _changes = "; ".join(_drift) # Lifecycle-aware remediation (#72056, @sashmatash): a finite - # one-shot is consumed by this attempted dispatch — telling the - # operator to `cronjob action=update` a spent job is a dead - # end. Recurring/repeatable jobs get the pin command instead. + # one-shot is consumed by this attempted dispatch — telling an + # operator to edit a spent job is a dead end. Recurring and + # repeatable jobs get the pin command instead. _repeat = job.get("repeat") if isinstance(job.get("repeat"), dict) else {} _finite_oneshot = ( isinstance(job.get("schedule"), dict) @@ -5189,10 +5189,11 @@ def run_job( ) else: _remediation = ( - "To run on the new config, pin it explicitly: " - f"`cronjob action=update job_id={job_id} " - "provider= model=` (or pin the original " - "values to keep them)." + "To run on the new config, on the host running Hermes " + "pin it explicitly: " + f"`hermes cron edit {job_id} --provider " + "--model ` (or pin the original values to keep " + "them)." ) logger.warning( "Job '%s': SKIPPED — global inference config drifted since " diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ae64562eb1..1137e100a6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5003,8 +5003,8 @@ def warn_unpinned_cron_jobs_after_model_config_change( f"{snapshot_field} values that differ from the new global {axis}. " "They will fail closed on their next run instead of silently using the " "changed model/provider. Inspect with `hermes cron list`, then pin the " - "intended values with `cronjob action=update job_id= " - "provider= model=`." + "intended values with `hermes cron edit --provider " + "--model `." ) diff --git a/tests/cron/test_cron_drift_alert_once.py b/tests/cron/test_cron_drift_alert_once.py index 89b6aa6965..5a68e1413b 100644 --- a/tests/cron/test_cron_drift_alert_once.py +++ b/tests/cron/test_cron_drift_alert_once.py @@ -92,9 +92,11 @@ class TestDriftAlertOnce: blob = deliveries[0].lower() assert "drift" in blob assert "pin" in blob - # The single alert must carry the complete remediation command — - # the generic summarizer's 180-char truncation must not eat it. - assert "cronjob action=update" in deliveries[0] + assert "host running hermes" in blob + # The single alert must carry the complete supported remediation + # command — the generic summarizer's 180-char truncation must not eat it. + assert "hermes cron edit drift-once-test" in deliveries[0] + assert "cronjob action=update" not in deliveries[0] assert "[drift_skip" not in deliveries[0] def test_healed_drift_clears_bit_and_redrift_realerts(self, tmp_path): diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py index b215418160..7c330f36f6 100644 --- a/tests/cron/test_cron_provider_pin.py +++ b/tests/cron/test_cron_provider_pin.py @@ -108,11 +108,14 @@ class TestProviderDriftGuard: assert "openrouter" in blob assert "nous" in blob assert "spend" in blob - assert "cronjob action=update" in blob + assert "hermes cron edit pin-test --provider --model " in blob + assert "cronjob action=update" not in blob assert "44585" in blob delivered = _summarize_cron_failure_for_delivery(job, error).lower() - assert "cronjob action=update" in delivered + assert "host running hermes" in delivered + assert "hermes cron edit pin-test --provider --model " in delivered + assert "cronjob action=update" not in delivered def test_c_no_snapshot_runs_backcompat(self, tmp_path): """(c) Pre-existing job with NO provider_snapshot → runs (back-compat). @@ -140,6 +143,18 @@ class TestProviderDriftGuard: assert error is None assert agent_constructed is True + def test_missing_model_guides_to_user_owned_cli(self, tmp_path, monkeypatch): + """A missing-model failure cannot advertise agent-owned pinning.""" + monkeypatch.delenv("HERMES_MODEL", raising=False) + success, _output, _final_response, error, agent_constructed = \ + _run_with_current_provider(_base_job(), "openrouter", tmp_path) + + assert success is False + assert agent_constructed is False + assert error is not None + assert "hermes cron edit pin-test --model " in error + assert "cronjob action=update" not in error + def test_d_explicitly_pinned_runs_regardless_of_drift(self, tmp_path): """(d) Explicitly-pinned job (job["provider"] set) → runs regardless. diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 5b14692d12..e90bc48e44 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -1,5 +1,6 @@ """Tests for hermes_cli.cron command handling.""" +import argparse from argparse import Namespace from types import SimpleNamespace @@ -8,6 +9,7 @@ import pytest from cron.jobs import create_job, get_job, list_jobs from hermes_cli import cron as cron_cli from hermes_cli.cron import cron_command +from hermes_cli.subcommands.cron import build_cron_parser @pytest.fixture() @@ -20,6 +22,30 @@ def tmp_cron_dir(tmp_path, monkeypatch): class TestCronCommandLifecycle: + def test_edit_persists_user_owned_inference_pins(self, tmp_cron_dir, capsys): + job = create_job(prompt="Daily report", schedule="every 1h") + parser = argparse.ArgumentParser(prog="hermes") + subparsers = parser.add_subparsers(dest="command") + build_cron_parser(subparsers, cmd_cron=cron_command) + + args = parser.parse_args( + [ + "cron", + "edit", + job["id"], + "--model", + "new-model", + "--provider", + "nous", + ] + ) + cron_command(args) + + updated = get_job(job["id"]) + assert updated["model"] == "new-model" + assert updated["provider"] == "nous" + assert "Updated job" in capsys.readouterr().out + def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys): job = create_job( prompt="Combine skill outputs", diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 8ff51b1de3..e764890d3c 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -302,6 +302,29 @@ def _write_cron_jobs(tmp_path, jobs): class TestCronModelDriftConfigWarning: """Warn operators before unpinned snapshot-bearing cron jobs fail closed.""" + def test_warning_names_the_user_owned_cli_pin_path( + self, + _isolated_hermes_home, + capsys, + ): + _write_cron_jobs( + _isolated_hermes_home, + [ + { + "id": "model-drift-job", + "enabled": True, + "model": None, + "model_snapshot": "old-model", + } + ], + ) + + set_config_value("model.default", "new-model") + + warning = capsys.readouterr().out + assert "hermes cron edit --provider --model " in warning + assert "cronjob action=update" not in warning + diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 77b67bcc60..529b051a8e 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -26,7 +26,7 @@ All of this is available to Hermes itself through the `cronjob` tool, so you can - **Per-job pin** — set by *you* via the dashboard, `hermes cron create/edit --model … --provider …`, or by editing `~/.hermes/cron/jobs.json`. Once set, it sticks until you change it. The agent's `cronjob` tool cannot set or change per-job models — inference pins are user-owned. - **`cron.model` / `cron.model_provider`** — a cron-fleet default: every unpinned job runs on this model, independent of your chat model. Set it once (`hermes config set cron.model `) and switching your chat model with `hermes model` or `/model` never touches your cron fleet. -- **Global default** — only when neither of the above is set does a job follow `hermes model`. In this case Hermes **snapshots** the provider and model at creation, and if the global default later changes the job **fails closed**: it skips the run, makes no inference call, and alerts you **once** — the job stays skipped (and silent) on subsequent ticks until you act or the config is restored (#44585). For recurring or otherwise repeatable jobs, pin the provider/model explicitly (`cronjob action=update job_id=… provider=… model=…`) to proceed. A consumed finite one-shot cannot be updated; create a new future one-shot with an explicit provider and model instead. This prevents an unattended job from silently inheriting a switch to a paid provider/model. Setting `cron.model` (or a per-job pin) is the deliberate way to route cron spend, and the drift guard does not engage for an axis covered by it. Operators who instead want unpinned jobs to track the changing global default can [disable the drift guard](#letting-unpinned-jobs-track-global-defaults). +- **Global default** — only when neither of the above is set does a job follow `hermes model`. In this case Hermes **snapshots** the provider and model at creation, and if the global default later changes the job **fails closed**: it skips the run, makes no inference call, and alerts you **once** — the job stays skipped (and silent) on subsequent ticks until you act or the config is restored (#44585). For recurring or otherwise repeatable jobs, pin the provider/model explicitly (`hermes cron edit --provider --model `) to proceed. A consumed finite one-shot cannot be updated; create a new future one-shot with an explicit provider and model instead. This prevents an unattended job from silently inheriting a switch to a paid provider/model. Setting `cron.model` (or a per-job pin) is the deliberate way to route cron spend, and the drift guard does not engage for an axis covered by it. Operators who instead want unpinned jobs to track the changing global default can [disable the drift guard](#letting-unpinned-jobs-track-global-defaults). `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal). :::