From 3a358cb56b47fe22b1eb21a45f91e5c9259f3a76 Mon Sep 17 00:00:00 2001 From: doncazper Date: Tue, 28 Jul 2026 17:11:12 +0500 Subject: [PATCH] fix(cron): warn before model config changes trip cron drift guard When an operator changes the global model/provider config, warn that unpinned cron jobs with stored snapshots will fail-closed on their next run. Adds a cron.model_drift_guard config opt-out (default true) for fleets that should deliberately track changing global defaults. Addresses #59031. Original PR #59177 by @doncazper. --- cli.py | 9 + cron/scheduler.py | 75 ++++---- hermes_cli/config.py | 109 +++++++++++ tests/cli/test_cli_save_config_value.py | 12 ++ tests/cron/test_cron_provider_pin.py | 38 +++- tests/hermes_cli/test_set_config_value.py | 218 +++++++++++++++++++++- website/docs/user-guide/features/cron.md | 29 ++- 7 files changed, 449 insertions(+), 41 deletions(-) diff --git a/cli.py b/cli.py index b15a821eb63..caf31036c87 100644 --- a/cli.py +++ b/cli.py @@ -4032,6 +4032,15 @@ def save_config_value(key_path: str, value: any) -> bool: os.chmod(config_path, 0o600) except (OSError, NotImplementedError): pass + + # Model/provider changes made through /model and the TUI use this + # persistence path rather than ``hermes config set``. Surface the same + # fail-closed cron drift warning for every operator-facing model switch. + from hermes_cli.config import ( + warn_unpinned_cron_jobs_after_model_config_change, + ) + + warn_unpinned_cron_jobs_after_model_config_change(key_path, value) return True except Exception as e: diff --git a/cron/scheduler.py b/cron/scheduler.py index 91c3168268c..39d70ab5090 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -41,7 +41,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from hermes_constants import get_hermes_home from hermes_cli._subprocess_compat import windows_hide_flags -from hermes_cli.config import load_config, _expand_env_vars +from hermes_cli.config import ( + _expand_env_vars, + cron_model_drift_guard_enabled, + load_config, +) from hermes_cli.fallback_config import get_fallback_chain from hermes_time import now as _hermes_now @@ -3359,42 +3363,43 @@ def run_job( # Back-compat: an axis with no snapshot (pre-existing jobs, no_agent, or # any axis whose creation-time resolution failed) behaves exactly as # before — the guard never engages for it. Pinned axes are unaffected. - _drift: list[str] = [] - _provider_snapshot = (job.get("provider_snapshot") or "").strip().lower() - if _provider_snapshot and not (job.get("provider") or "").strip(): - _current_provider = str( - primary_provider_for_drift or runtime.get("provider") or "" - ).strip().lower() - if _current_provider and _current_provider != _provider_snapshot: - _drift.append( - f"provider '{_provider_snapshot}' -> '{_current_provider}'" + if cron_model_drift_guard_enabled(_cfg): + _drift: list[str] = [] + _provider_snapshot = (job.get("provider_snapshot") or "").strip().lower() + if _provider_snapshot and not (job.get("provider") or "").strip(): + _current_provider = str( + primary_provider_for_drift or runtime.get("provider") or "" + ).strip().lower() + if _current_provider and _current_provider != _provider_snapshot: + _drift.append( + f"provider '{_provider_snapshot}' -> '{_current_provider}'" + ) + _model_snapshot = (job.get("model_snapshot") or "").strip().lower() + if _model_snapshot and not (job.get("model") or "").strip(): + _current_model = str(primary_model_for_drift or "").strip().lower() + if _current_model and _current_model != _model_snapshot: + _drift.append( + f"model '{_model_snapshot}' -> '{_current_model}'" + ) + if _drift: + _changes = "; ".join(_drift) + logger.warning( + "Job '%s': SKIPPED — global inference config drifted since " + "creation (%s) and this job is unpinned. Skipped to prevent " + "unintended spend. Pin explicitly to proceed: " + "`cronjob action=update job_id=%s provider=

model=`.", + job_id, + _changes, + job_id, ) - _model_snapshot = (job.get("model_snapshot") or "").strip().lower() - if _model_snapshot and not (job.get("model") or "").strip(): - _current_model = str(primary_model_for_drift or "").strip().lower() - if _current_model and _current_model != _model_snapshot: - _drift.append( - f"model '{_model_snapshot}' -> '{_current_model}'" + raise RuntimeError( + f"Skipped to prevent unintended spend: global inference config " + f"drifted since this job was created ({_changes}), and this job " + f"is unpinned. No inference call was made. To run on the new " + f"config, pin it explicitly: `cronjob action=update " + f"job_id={job_id} provider= model=` " + f"(or pin the original values to keep them). See #44585." ) - if _drift: - _changes = "; ".join(_drift) - logger.warning( - "Job '%s': SKIPPED — global inference config drifted since " - "creation (%s) and this job is unpinned. Skipped to prevent " - "unintended spend. Pin explicitly to proceed: " - "`cronjob action=update job_id=%s provider=

model=`.", - job_id, - _changes, - job_id, - ) - raise RuntimeError( - f"Skipped to prevent unintended spend: global inference config " - f"drifted since this job was created ({_changes}), and this job " - f"is unpinned. No inference call was made. To run on the new " - f"config, pin it explicitly: `cronjob action=update " - f"job_id={job_id} provider= model=` " - f"(or pin the original values to keep them). See #44585." - ) fallback_model = get_fallback_chain(_cfg) or None credential_pool = None diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 60886675143..94eb49d6de9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2906,6 +2906,11 @@ DEFAULT_CONFIG = { }, "cron": { + # Fail closed when an unpinned job's current global model/provider + # differs from its creation-time snapshot. This prevents unattended + # jobs from silently inheriting a paid default. Set to false only when + # jobs should deliberately track changing global inference defaults. + "model_drift_guard": True, # Active cron SCHEDULER provider (Axis B — the trigger that decides # WHEN a due job fires). Empty string = the built-in in-process 60s # ticker (default). Name an installed provider (plugins/cron_providers// or @@ -8848,6 +8853,109 @@ def edit_config(): subprocess.run([editor, str(config_path)]) +def _cron_model_drift_axis_for_config_key(key: str) -> Optional[str]: + """Return the cron drift guard axis affected by a config key, if any.""" + normalized = str(key or "").strip().lower() + if normalized in {"model", "model.default", "model.model"}: + return "model" + if normalized in {"model.provider", "provider"}: + return "provider" + return None + + +def cron_model_drift_guard_enabled( + config: Optional[Dict[str, Any]] = None, +) -> bool: + """Return whether cron must fail closed on unpinned inference drift. + + Only the literal YAML boolean ``false`` disables this spend-safety guard. + Missing, malformed, or non-boolean values stay fail-closed. When *config* + is omitted, load the active merged configuration so CLI warnings honor the + same user/managed setting as the scheduler. + """ + if config is None: + try: + config = load_config() + except Exception: + return True + if not isinstance(config, dict): + return True + cron_config = config.get("cron") + if not isinstance(cron_config, dict): + return True + return cron_config.get("model_drift_guard", True) is not False + + +def _load_cron_jobs_for_config_warning() -> List[Dict[str, Any]]: + """Best-effort direct read of the active profile's cron jobs database.""" + jobs_path = get_hermes_home() / "cron" / "jobs.json" + try: + if not jobs_path.exists(): + return [] + data = json.loads(jobs_path.read_text(encoding="utf-8")) + except Exception: + return [] + + if isinstance(data, dict): + raw_jobs = data.get("jobs", []) + elif isinstance(data, list): + raw_jobs = data + else: + return [] + if not isinstance(raw_jobs, list): + return [] + return [job for job in raw_jobs if isinstance(job, dict)] + + +def warn_unpinned_cron_jobs_after_model_config_change( + key: str, + value: Any, +) -> None: + """Warn when a global model/provider change will trip cron's drift guard. + + Cron intentionally fails closed when an unpinned agent job's current global + model/provider differs from its creation-time snapshot. Surface that outcome + when the operator changes the global axis instead of letting the next tick + be the first visible signal. + """ + axis = _cron_model_drift_axis_for_config_key(key) + if axis is None: + return + if not cron_model_drift_guard_enabled(): + return + + new_value = str(value or "").strip().lower() + if not new_value: + return + + pinned_field = axis + snapshot_field = f"{axis}_snapshot" + affected = 0 + for job in _load_cron_jobs_for_config_warning(): + if not job.get("enabled", True): + continue + if job.get("no_agent"): + continue + if str(job.get(pinned_field) or "").strip(): + continue + snapshot = str(job.get(snapshot_field) or "").strip().lower() + if snapshot and snapshot != new_value: + affected += 1 + + if affected <= 0: + return + + noun = "job" if affected == 1 else "jobs" + print( + f"⚠️ {affected} enabled unpinned cron {noun} have stored " + 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=`." + ) + + def _default_value_for_key(dotted_key: str): """Return the leaf value declared for *dotted_key* in ``DEFAULT_CONFIG``. @@ -9162,6 +9270,7 @@ def set_config_value(key: str, value: str, force: bool = False): else: _display_value = value print(f"✓ Set {key} = {_display_value} in {config_path}") + warn_unpinned_cron_jobs_after_model_config_change(key, value) # Post-write unknown-key notice (#34067): value IS saved, but tell the # user the runtime may never read it and suggest the likely-intended path. diff --git a/tests/cli/test_cli_save_config_value.py b/tests/cli/test_cli_save_config_value.py index 49cdd623564..a966217065b 100644 --- a/tests/cli/test_cli_save_config_value.py +++ b/tests/cli/test_cli_save_config_value.py @@ -77,6 +77,18 @@ class TestSaveConfigValueAtomic: assert result["model"]["default"] == "doubao-pro" assert result["custom_providers"][0]["api_key"] == "${TU_ZI_API_KEY}" + def test_model_write_runs_shared_cron_drift_warning(self, config_env, monkeypatch): + warning = MagicMock() + monkeypatch.setattr( + "hermes_cli.config.warn_unpinned_cron_jobs_after_model_config_change", + warning, + ) + + from cli import save_config_value + + assert save_config_value("model.default", "new-model") is True + warning.assert_called_once_with("model.default", "new-model") + def test_preserves_comments_after_config_mutation(self, config_env): """CLI config writes should not strip existing user comments.""" config_env.write_text( diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py index 23a42ce99b1..4e36c174508 100644 --- a/tests/cron/test_cron_provider_pin.py +++ b/tests/cron/test_cron_provider_pin.py @@ -243,12 +243,22 @@ class TestCreateJobSnapshot: assert job["model_snapshot"] is None -def _run_with_current_provider_and_model(job, current_provider, current_model, tmp_path): +def _run_with_current_provider_and_model( + job, + current_provider, + current_model, + tmp_path, + *, + model_drift_guard=None, +): """Drive run_job with resolved provider pinned and config.yaml model.default set to ``current_model`` (the unpinned-model fire-time source).""" - (tmp_path / "config.yaml").write_text( - f"model:\n default: {current_model}\n" - ) + config_yaml = f"model:\n default: {current_model}\n" + if model_drift_guard is not None: + config_yaml += ( + f"cron:\n model_drift_guard: {str(model_drift_guard).lower()}\n" + ) + (tmp_path / "config.yaml").write_text(config_yaml) fake_db = MagicMock() with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._get_hermes_home", return_value=tmp_path), \ @@ -335,6 +345,26 @@ class TestModelDriftGuard: assert agent_constructed is True assert success is True + def test_explicit_opt_out_allows_provider_and_model_drift(self, tmp_path): + """The opt-out lets large unpinned fleets track changing defaults.""" + job = _base_job( + provider_snapshot="old-provider", + model_snapshot="old-model", + ) + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider_and_model( + job, + "new-provider", + "new-model", + tmp_path, + model_drift_guard=False, + ) + + assert agent_constructed is True + assert success is True + assert final_response == "ok" + assert error is None + class TestRuntimeResolutionTargetModel: """run_job must resolve the primary provider against the model the job diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 0e2cb6fd57c..ece13dc65a2 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -1,12 +1,17 @@ """Tests for set_config_value — verifying secrets route to .env and config to config.yaml.""" import argparse +import json import os from unittest.mock import patch import pytest -from hermes_cli.config import set_config_value, config_command +from hermes_cli.config import ( + config_command, + cron_model_drift_guard_enabled, + set_config_value, +) @pytest.fixture(autouse=True) @@ -370,6 +375,217 @@ class TestListNavigation: assert allowlist[1] == {"name": "bob", "role": "admin"} +# --------------------------------------------------------------------------- +# Cron drift guard warning — regression tests for #59031 +# --------------------------------------------------------------------------- + +def _write_cron_jobs(tmp_path, jobs): + cron_dir = tmp_path / "cron" + cron_dir.mkdir(parents=True, exist_ok=True) + (cron_dir / "jobs.json").write_text( + json.dumps({"jobs": jobs}), + encoding="utf-8", + ) + + +class TestCronModelDriftConfigWarning: + """Warn operators before unpinned snapshot-bearing cron jobs fail closed.""" + + def test_model_default_change_warns_for_unpinned_snapshot_jobs( + self, + _isolated_hermes_home, + capsys, + ): + _write_cron_jobs( + _isolated_hermes_home, + [ + { + "id": "model-drift-job", + "enabled": True, + "no_agent": False, + "model": None, + "provider": None, + "model_snapshot": "old-model", + "provider_snapshot": "openrouter", + "prompt": "do not print this prompt", + }, + ], + ) + + set_config_value("model.default", "new-model") + + captured = capsys.readouterr() + assert "1 enabled unpinned cron job" in captured.out + assert "model_snapshot" in captured.out + assert "fail closed" in captured.out + assert "cronjob action=update job_id= provider= model=" in captured.out + assert "do not print this prompt" not in captured.out + + def test_provider_change_warns_for_unpinned_snapshot_jobs( + self, + _isolated_hermes_home, + capsys, + ): + _write_cron_jobs( + _isolated_hermes_home, + [ + { + "id": "provider-drift-job", + "enabled": True, + "no_agent": False, + "model": None, + "provider": None, + "model_snapshot": "same-model", + "provider_snapshot": "openrouter", + }, + ], + ) + + set_config_value("model.provider", "new-provider") + + captured = capsys.readouterr() + assert "1 enabled unpinned cron job" in captured.out + assert "provider_snapshot" in captured.out + assert "new global provider" in captured.out + assert "cronjob action=update job_id= provider= model=" in captured.out + + def test_pinned_jobs_and_missing_snapshots_do_not_warn( + self, + _isolated_hermes_home, + capsys, + ): + _write_cron_jobs( + _isolated_hermes_home, + [ + { + "id": "model-pinned", + "enabled": True, + "model": "old-model", + "model_snapshot": "old-model", + }, + { + "id": "missing-model-snapshot", + "enabled": True, + "model": None, + }, + { + "id": "provider-pinned", + "enabled": True, + "provider": "openrouter", + "provider_snapshot": "openrouter", + }, + { + "id": "missing-provider-snapshot", + "enabled": True, + "provider": None, + }, + { + "id": "disabled-unpinned", + "enabled": False, + "model": None, + "model_snapshot": "old-model", + "provider": None, + "provider_snapshot": "openrouter", + }, + { + "id": "script-only", + "enabled": True, + "no_agent": True, + "model": None, + "model_snapshot": "old-model", + "provider": None, + "provider_snapshot": "openrouter", + }, + ], + ) + + set_config_value("model.default", "new-model") + set_config_value("model.provider", "new-provider") + + captured = capsys.readouterr() + assert "fail closed" not in captured.out + assert "cronjob action=update" not in captured.out + + def test_unreadable_cron_database_does_not_break_config_set( + self, + _isolated_hermes_home, + capsys, + ): + cron_dir = _isolated_hermes_home / "cron" + cron_dir.mkdir(parents=True, exist_ok=True) + (cron_dir / "jobs.json").write_text("{not-json", encoding="utf-8") + + set_config_value("model.default", "new-model") + + captured = capsys.readouterr() + assert "Set model.default = new-model" in captured.out + assert "fail closed" not in captured.out + + def test_model_name_does_not_warn_for_unread_cron_axis( + self, + _isolated_hermes_home, + capsys, + ): + _write_cron_jobs( + _isolated_hermes_home, + [ + { + "id": "model-drift-job", + "enabled": True, + "model_snapshot": "old-model", + } + ], + ) + + set_config_value("model.name", "display-only-name") + + captured = capsys.readouterr() + assert "Set model.name = display-only-name" in captured.out + assert "fail closed" not in captured.out + + def test_explicit_opt_out_suppresses_warning( + 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("cron.model_drift_guard", "false") + capsys.readouterr() + set_config_value("model.default", "new-model") + + import yaml + reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) + captured = capsys.readouterr() + assert reloaded["cron"]["model_drift_guard"] is False + assert "Set model.default = new-model" in captured.out + assert "fail closed" not in captured.out + + @pytest.mark.parametrize( + ("configured_value", "expected"), + [ + (False, False), + (True, True), + ("false", True), + (0, True), + (None, True), + ], + ) + def test_only_literal_false_disables_guard(self, configured_value, expected): + config = {"cron": {"model_drift_guard": configured_value}} + assert cron_model_drift_guard_enabled(config) is expected + + # --------------------------------------------------------------------------- # String-typed config values — regression tests for #47515 # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 44ad20cf808..c42b1a1e137 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -22,7 +22,7 @@ Cron jobs can: All of this is available to Hermes itself through the `cronjob` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required. :::tip -At creation, an unpinned job (one you don't give an explicit `provider`/`model`) follows the global default selected by `hermes model` — and Hermes **snapshots** that provider and model on the job. If the global default later changes, the job **fails closed**: it skips the run, makes no inference call, and sends an alert telling you to pin the provider/model explicitly (`cronjob action=update job_id=… provider=… model=…`) to proceed. This prevents an unattended job from silently inheriting a switch to a paid provider/model and spending money you didn't intend (#44585). To make a job deliberately track your global default, pin it to the new values after changing them. `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal). +At creation, an unpinned job (one you don't give an explicit `provider`/`model`) follows the global default selected by `hermes model` — and Hermes **snapshots** that provider and model on the job. If the global default later changes, the job **fails closed**: it skips the run, makes no inference call, and sends an alert telling you to pin the provider/model explicitly (`cronjob action=update job_id=… provider=… model=…`) to proceed. This prevents an unattended job from silently inheriting a switch to a paid provider/model and spending money you didn't intend (#44585). To make a job deliberately track your global default, pin it to the new values after changing them. Operators who intentionally maintain large fleets of unpinned jobs 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). ::: :::warning @@ -61,6 +61,33 @@ Every morning at 9am, check Hacker News for AI news and send me a summary on Tel Hermes will use the unified `cronjob` tool internally. +## Letting unpinned jobs track global defaults + +The model/provider drift guard is enabled by default. If your unpinned cron +jobs should deliberately follow every global model or provider change, disable +it in `config.yaml`: + +```yaml +cron: + model_drift_guard: false +``` + +Or use the config command: + +```bash +hermes config set cron.model_drift_guard false +``` + +This disables both the runtime block and the warning shown when global +inference settings change. Existing snapshots remain stored, so setting the +option back to `true` re-enables protection without recreating jobs. + +:::warning +With the guard disabled, unattended unpinned jobs immediately inherit changed +global defaults. A switch to a paid provider or model can therefore spend money +on every scheduled run. +::: + ## Skill-backed cron jobs A cron job can load one or more skills before it runs the prompt.