{"repo":"NousResearch/hermes-agent","public_id":"19615","display_label":"Cron job model configuration, drift, and lifecycle handling","summary":"Multiple related themes around cron job model sourcing (inherit vs pinned), drift guard policies and configuration gates, suppression of environment leaks into scheduled jobs, and interruption of in-flight jobs on pause or shutdown.","subsystem":"cron","counts":{"members":17,"open_issues":5,"open_issues_distinct":5,"open_prs":4,"closed_issues":2,"merged_prs":2,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51051},"resolution":{"state":"partially_resolved","by":"pr-51051,pr-73323"},"freshness":{"state":"verified","last_verified_at":"2026-07-28T12:22:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09470588235294118},"fix_edges":[{"issue_number":44585,"pr_number":51051,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":59031,"pr_number":73323,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":19615,"title":"Cron jobs should distinguish global vs pinned model configuration","url":null,"state":"open"},{"kind":"issue","number":20125,"title":"Cron jobs fail silently when llama.cpp model changes — should query /v1/models at runtime","url":null,"state":"open"},{"kind":"issue","number":39196,"title":"[Feature]: Cron jobs should support \"inherit main model\" mode","url":null,"state":"open"},{"kind":"issue","number":44585,"title":"[Bug]: Cron can inherit temporary paid provider state and continue billing during pause/stop containment","url":null,"state":"closed"},{"kind":"issue","number":59031,"title":"UX: model drift guardrail silently breaks all unpinned cron jobs on model change","url":null,"state":"closed"},{"kind":"issue","number":61404,"title":"feat: auto-sync cron jobs when global inference config changes","url":null,"state":"open"},{"kind":"issue","number":67490,"title":"[Bug]: hermes refusing to run cron jobs if base model changes breaks local model workflows","url":null,"state":"open"},{"kind":"pr","number":19617,"title":"fix(cron): make job model source explicit","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"a4e61ddf043864b88a5a40df0e23efcf3c6334f5","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":20150,"title":"fix: auto-discover model from local inference server in cron scheduler","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":44617,"title":"Interrupt in-flight cron jobs on pause and gateway shutdown","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"24e9ed73c","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44650,"title":"fix(cron): suppress env var provider/model leaks into scheduled jobs (#44585)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":51051,"title":"fix(cron): fail closed when an unpinned job provider drifts from creation snapshot (#44585)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","cron/scheduler.py","tests/cron/test_cron_provider_pin.py","website/docs/user-guide/features/cron.md"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -755,6 +755,45 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:\n     return str(resolved)\n \n \n+def _resolve_default_model_snapshot() -> Optional[str]:\n+    \"\"\"Resolve the global default model the same way the cron ticker does.\n+\n+    Mirrors the unpinned-model resolution in ``cron/scheduler.py`` ``run_job``:\n+    read ``config.yaml`` ``model.default`` (or the ``model`` alias / bare string\n+    form), applying the managed-scope overlay and env expansion. Used by\n+    ``create_job`` to snapshot the default model for unpinned jobs so a later\n+    swap of the global default is detected at fire time (#44585).\n+\n+    Returns the resolved model string, or ``None`` if config is missing/empty\n+    or resolution fails (fail-open — caller treats ``None`` as \"no snapshot\").\n+    \"\"\"\n+    try:\n+        import yaml\n+        from hermes_cli.config import _expand_env_vars\n+\n+        cfg_path = get_hermes_home() / \"config.yaml\"\n+        if not cfg_path.exists():\n+            return None\n+        with cfg_path.open(encoding=\"utf-8\") as f:\n+            cfg = yaml.safe_load(f) or {}\n+        try:\n+            from hermes_cli import managed_scope\n+            cfg = managed_scope.apply_managed_overlay(cfg)\n+        except Exception:\n+            pass\n+        cfg = _expand_env_vars(cfg)\n+        model_cfg = cfg.get(\"model\") or {}\n+        if isinstance(model_cfg, str):\n+            return model_cfg.strip() or None\n+        if isinstance(model_cfg, dict):\n+            default = model_cfg.get(\"default\") or model_cfg.get(\"model\")\n+            if isinstance(default, str):\n+                return default.strip() or None\n+        return None\n+    except Exception:\n+        return None\n+\n+\n def create_job(\n     prompt: Optional[str],\n     schedule: str,","@@ -870,6 +909,47 @@ def create_job(\n \n     prompt_text = _coerce_job_text(prompt)\n     label_source = (prompt_text or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or \"cron job\"\n+\n+    # Provider/model-drift guard (#44585). When the caller does NOT pin a\n+    # provider and/or model, the job follows the global default — model.default\n+    # in config.yaml and whatever resolve_runtime_provider() picks at fire time.\n+    # That global state can change (e.g. a temporary switch to a paid provider\n+    # OR a paid model like claude-fable-5 on the SAME provider), and an unpinned\n+    # job would then silently inherit it and spend real money. To detect that,\n+    # snapshot what resolution WOULD pick *right now*, at creation, for each\n+    # axis the job leaves unpinned. The fire-time guard (run_job) fails closed\n+    # when an unpinned job's currently-resolved provider OR model differs from\n+    # its snapshot.\n+    #\n+    # Only captured for agent-backed jobs (no_agent script jobs make no paid\n+    # inference). Each axis is snapshotted only when that axis is unpinned —\n+    # a pinned provider/model doesn't drift with global state. Fail-open to None\n+    # on any resolution error so job creation never breaks; a missing snapshot\n+    # preserves the legacy no-guard behaviour for that axis.\n+    provider_snapshot: Optional[str] = None\n+    model_snapshot: Optional[str] = None\n+    if not normalized_no_agent:\n+        if normalized_provider is None:\n+            try:\n+                from hermes_cli.runtime_provider import resolve_runtime_provider\n+                _runtime_kwargs = {\"requested\": None}\n+                if normalized_base_url:\n+                    _runtime_kwargs[\"explicit_base_url\"] = normalized_base_url\n+                _snap = resolve_runtime_provider(**_runtime_kwargs)\n+                _snap_provider = str(_snap.get(\"provider\") or \"\").strip().lower()\n+                provider_snapshot = _snap_provider or None\n+            except Exception:\n+                provider_snapshot = None\n+        if normalized_model is None:\n+            # Mirror the fire-time unpinned-model resolution (run_job reads\n+            # config.yaml model.default / model). Capture that value so a later\n+            # swap of the global default model is detected even when the\n+            # provider is unchanged (e.g. a premium model on the same endpoint).\n+            try:\n+                model_snapshot = _resolve_default_model_snapshot() or None\n+            except Exception:\n+                model_snapshot = None\n+\n     job = {\n         \"id\": job_id,\n         \"name\": name or label_source[:50].strip(),","@@ -878,6 +958,11 @@ def create_job(\n         \"skill\": normalized_skills[0] if normalized_skills else None,\n         \"model\": normalized_model,\n         \"provider\": normalized_provider,\n+        # Provider/model resolution captured at creation for unpinned jobs\n+        # (#44585). None for pinned axes, no_agent jobs, resolution failures, and\n+        # any pre-existing job written before these fields existed (back-compat).\n+        \"provider_snapshot\": provider_snapshot,\n+        \"model_snapshot\": model_snapshot,\n         \"base_url\": normalized_base_url,\n         \"script\": normalized_script,\n         \"no_agent\": normalized_no_agent,"]},{"file":"cron/scheduler.py","hunks":["@@ -2016,6 +2016,60 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n             message = format_runtime_provider_error(exc)\n             raise RuntimeError(message) from exc\n \n+        # Provider/model-drift fail-closed guard (#44585).\n+        #\n+        # An UNPINNED job (no explicit job[\"provider\"]/[\"model\"]) follows the\n+        # global default, which can change after the job was created — a switch\n+        # to a paid PROVIDER (e.g. nous) OR a paid MODEL on the same provider\n+        # (e.g. claude-fable-5 on openrouter). Without a guard the job would\n+        # silently inherit that change and spend real money on every tick — the\n+        # $7.73 incident named BOTH a provider and a model.\n+        #\n+        # create_job() snapshots whatever resolution would have picked at\n+        # creation for each unpinned axis (job[\"provider_snapshot\"] /\n+        # job[\"model_snapshot\"]). Here, for each axis that (a) has a snapshot and\n+        # (b) is unpinned and (c) currently resolves to a DIFFERENT value, we\n+        # fail closed: skip this run, make NO paid call, and deliver a loud,\n+        # actionable alert telling the user to pin the axis explicitly.\n+        #\n+        # Back-compat: an axis with no snapshot (pre-existing jobs, no_agent, or\n+        # any axis whose creation-time resolution failed) behaves exactly as\n+        # before — the guard never engages for it. Pinned axes are unaffected.\n+        _drift: list[str] = []\n+        _provider_snapshot = (job.get(\"provider_snapshot\") or \"\").strip().lower()\n+        if _provider_snapshot and not (job.get(\"provider\") or \"\").strip():\n+            _current_provider = str(runtime.get(\"provider\") or \"\").strip().lower()\n+            if _current_provider and _current_provider != _provider_snapshot:\n+                _drift.append(\n+                    f\"provider '{_provider_snapshot}' -> '{_current_provider}'\"\n+                )\n+        _model_snapshot = (job.get(\"model_snapshot\") or \"\").strip().lower()\n+        if _model_snapshot and not (job.get(\"model\") or \"\").strip():\n+            _current_model = str(model or \"\").strip().lower()\n+            if _current_model and _current_model != _model_snapshot:\n+                _drift.append(\n+                    f\"model '{_model_snapshot}' -> '{_current_model}'\"\n+                )\n+        if _drift:\n+            _changes = \"; \".join(_drift)\n+            logger.warning(\n+                \"Job '%s': SKIPPED — global inference config drifted since \"\n+                \"creation (%s) and this job is unpinned. Skipped to prevent \"\n+                \"unintended spend. Pin explicitly to proceed: \"\n+                \"`cronjob action=update job_id=%s provider=<p> model=<m>`.\",\n+                job_id,\n+                _changes,\n+                job_id,\n+            )\n+            raise RuntimeError(\n+                f\"Skipped to prevent unintended spend: global inference config \"\n+                f\"drifted since this job was created ({_changes}), and this job \"\n+                f\"is unpinned. No inference call was made. To run on the new \"\n+                f\"config, pin it explicitly: `cronjob action=update \"\n+                f\"job_id={job_id} provider=<provider> model=<model>` \"\n+                f\"(or pin the original values to keep them). See #44585.\"\n+            )\n+\n         fallback_model = _cfg.get(\"fallback_providers\") or _cfg.get(\"fallback_model\") or None\n         credential_pool = None\n         runtime_provider = str(runtime.get(\"provider\") or \"\").strip().lower()"]},{"file":"tests/cron/test_cron_provider_pin.py","hunks":["@@ -0,0 +1,334 @@\n+\"\"\"Provider-drift fail-closed guard for cron jobs (#44585).\n+\n+Background: an UNPINNED cron job follows the global default provider. If that\n+global state is changed (e.g. a temporary switch to a paid provider like\n+nous/claude-fable-5), the job would silently inherit it on its next tick and\n+spend real money — the $7.73 incident.\n+\n+The fix has two halves:\n+  - create_job() snapshots the provider resolution WOULD pick at creation into\n+    job[\"provider_snapshot\"] (only for unpinned, agent-backed jobs).\n+  - run_job() fails closed when an unpinned job's CURRENTLY-resolved provider\n+    differs from that snapshot: it skips the run, makes no paid call, and\n+    delivers a loud actionable error.\n+\n+These tests exercise the full run_job path (real imports, mocked AIAgent +\n+resolve_runtime_provider against a temp HERMES_HOME) and the create_job\n+snapshot capture. They are load-bearing: without the guard, cases (b) call the\n+agent and \"succeed\" instead of failing closed.\n+\"\"\"\n+\n+import sys\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+# Ensure project root is importable.\n+sys.path.insert(0, str(Path(__file__).parent.parent.parent))\n+\n+from cron.scheduler import run_job\n+\n+\n+def _base_job(**overrides):\n+    job = {\n+        \"id\": \"pin-test\",\n+        \"name\": \"pin test\",\n+        \"prompt\": \"hello\",\n+        \"model\": None,\n+        \"provider\": None,\n+        \"provider_snapshot\": None,\n+        \"base_url\": None,\n+    }\n+    job.update(overrides)\n+    return job\n+\n+\n+def _run_with_current_provider(job, current_provider, tmp_path):\n+    \"\"\"Drive run_job with resolve_runtime_provider pinned to ``current_provider``.\n+\n+    Returns (success, output, final_response, error, agent_constructed).\n+    \"\"\"\n+    fake_db = MagicMock()\n+    with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+         patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+         patch(\"dotenv.load_dotenv\"), \\\n+         patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+         patch(\n+             \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+             return_value={\n+                 \"api_key\": \"test-key\",\n+                 \"base_url\": \"https://example.invalid/v1\",\n+                 \"provider\": current_provider,\n+                 \"api_mode\": \"chat_completions\",\n+             },\n+         ), \\\n+         patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+        mock_agent = MagicMock()\n+        mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+        mock_agent_cls.return_value = mock_agent\n+\n+        success, output, final_response, error = run_job(job)\n+        agent_constructed = mock_agent_cls.called\n+\n+    return success, output, final_response, error, agent_constructed\n+\n+\n+class TestProviderDriftGuard:\n+    def test_a_unpinned_snapshot_matches_runs_normally(self, tmp_path):\n+        \"\"\"(a) Unpinned job whose snapshot == current provider → runs normally.\"\"\"\n+        job = _base_job(provider_snapshot=\"openrouter\")\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider(job, \"openrouter\", tmp_path)\n+\n+        assert success is True\n+        assert error is None\n+        assert final_response == \"ok\"\n+        assert agent_constructed is True\n+\n+    def test_b_unpinned_snapshot_differs_fails_closed(self, tmp_path):\n+        \"\"\"(b) Unpinned job whose snapshot != current provider → fail closed.\n+\n+        The paid call must NOT be made (AIAgent never constructed) and the\n+        delivered error must name both providers and tell the user to pin.\n+        \"\"\"\n+        job = _base_job(provider_snapshot=\"openrouter\")\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider(job, \"nous\", tmp_path)\n+\n+        # Fail closed: no agent constructed, no inference call.\n+        assert agent_constructed is False\n+        assert success is False\n+        assert error is not None\n+\n+        # Loud + actionable: names both providers, mentions spend + pinning.\n+        blob = f\"{error}\\n{output}\".lower()\n+        assert \"openrouter\" in blob\n+        assert \"nous\" in blob\n+        assert \"spend\" in blob\n+        assert \"cronjob action=update\" in blob\n+        assert \"44585\" in blob\n+\n+    def test_c_no_snapshot_runs_backcompat(self, tmp_path):\n+        \"\"\"(c) Pre-existing job with NO provider_snapshot → runs (back-compat).\n+\n+        Even though the current provider differs from anything, a job without a\n+        snapshot must behave exactly as before this fix: the guard never engages.\n+        \"\"\"\n+        # A job dict that predates the field entirely (key absent, not None).\n+        job = _base_job()\n+        job.pop(\"provider_snapshot\", None)\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider(job, \"nous\", tmp_path)\n+\n+        assert success is True\n+        assert error is None\n+        assert agent_constructed is True\n+\n+    def test_c2_snapshot_none_runs_backcompat(self, tmp_path):\n+        \"\"\"(c') Job with provider_snapshot explicitly None → runs (back-compat).\"\"\"\n+        job = _base_job(provider_snapshot=None)\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider(job, \"nous\", tmp_path)\n+\n+        assert success is True\n+        assert error is None\n+        assert agent_constructed is True\n+\n+    def test_d_explicitly_pinned_runs_regardless_of_drift(self, tmp_path):\n+        \"\"\"(d) Explicitly-pinned job (job[\"provider\"] set) → runs regardless.\n+\n+        A pinned job does not follow global state, so even a snapshot/current\n+        mismatch must not skip it. (Snapshot would normally be None for pinned\n+        jobs, but we set a mismatching one to prove the pin wins.)\n+        \"\"\"\n+        job = _base_job(provider=\"openrouter\", provider_snapshot=\"anthropic\")\n+        # Current resolution differs from the (stale) snapshot, but the job is\n+        # pinned, so the guard must not engage.\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider(job, \"nous\", tmp_path)\n+\n+        assert success is True\n+        assert error is None\n+        assert agent_constructed is True\n+\n+\n+class TestCreateJobSnapshot:\n+    \"\"\"create_job captures provider_snapshot for unpinned agent jobs only.\"\"\"\n+\n+    @staticmethod\n+    def _isolate_storage(monkeypatch):\n+        \"\"\"Patch cron.jobs storage so create_job never touches the real store.\"\"\"\n+        import contextlib\n+        import cron.jobs as jobs\n+\n+        @contextlib.contextmanager\n+        def _noop_lock():\n+            yield\n+\n+        monkeypatch.setattr(jobs, \"_jobs_lock\", _noop_lock, raising=True)\n+        monkeypatch.setattr(jobs, \"load_jobs\", lambda: [], raising=True)\n+        monkeypatch.setattr(jobs, \"save_jobs\", lambda j: None, raising=True)\n+        return jobs\n+\n+    def test_unpinned_job_captures_snapshot(self, monkeypatch):\n+        jobs = self._isolate_storage(monkeypatch)\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            return_value={\"provider\": \"openrouter\"},\n+        ):\n+            job = jobs.create_job(prompt=\"do a thing\", schedule=\"every 1 hour\")\n+\n+        assert job[\"provider\"] is None\n+        assert job[\"provider_snapshot\"] == \"openrouter\"\n+\n+    def test_pinned_job_skips_snapshot(self, monkeypatch):\n+        jobs = self._isolate_storage(monkeypatch)\n+\n+        resolver = MagicMock(return_value={\"provider\": \"openrouter\"})\n+        with patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\", resolver):\n+            job = jobs.create_job(\n+                prompt=\"do a thing\", schedule=\"every 1 hour\", provider=\"nous\"\n+            )\n+\n+        # Explicit provider → pinned → no snapshot needed, and resolution skipped.\n+        assert job[\"provider\"] == \"nous\"\n+        assert job[\"provider_snapshot\"] is None\n+        resolver.assert_not_called()\n+\n+    def test_snapshot_resolution_error_fails_open_to_none(self, monkeypatch):\n+        \"\"\"If resolution raises at creation, snapshot is None — creation never breaks.\"\"\"\n+        jobs = self._isolate_storage(monkeypatch)\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=RuntimeError(\"no creds\"),\n+        ):\n+            job = jobs.create_job(prompt=\"do a thing\", schedule=\"every 1 hour\")\n+\n+        assert job[\"provider_snapshot\"] is None\n+\n+    def test_unpinned_model_captures_model_snapshot(self, monkeypatch, tmp_path):\n+        \"\"\"An unpinned model captures config.yaml model.default into model_snapshot.\"\"\"\n+        jobs = self._isolate_storage(monkeypatch)\n+        (tmp_path / \"config.yaml\").write_text(\"model:\\n  default: llama-3.3-70b:free\\n\")\n+        monkeypatch.setattr(\n+            \"cron.jobs.get_hermes_home\", lambda: tmp_path, raising=True\n+        )\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            return_value={\"provider\": \"openrouter\"},\n+        ):\n+            job = jobs.create_job(prompt=\"do a thing\", schedule=\"every 1 hour\")\n+        assert job[\"model\"] is None\n+        assert job[\"model_snapshot\"] == \"llama-3.3-70b:free\"\n+\n+    def test_pinned_model_skips_model_snapshot(self, monkeypatch, tmp_path):\n+        \"\"\"An explicit model → pinned → no model_snapshot captured.\"\"\"\n+        jobs = self._isolate_storage(monkeypatch)\n+        (tmp_path / \"config.yaml\").write_text(\"model:\\n  default: llama-3.3-70b:free\\n\")\n+        monkeypatch.setattr(\n+            \"cron.jobs.get_hermes_home\", lambda: tmp_path, raising=True\n+        )\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            return_value={\"provider\": \"openrouter\"},\n+        ):\n+            job = jobs.create_job(\n+                prompt=\"do a thing\", schedule=\"every 1 hour\", model=\"my-model\"\n+            )\n+        assert job[\"model\"] == \"my-model\"\n+        assert job[\"model_snapshot\"] is None\n+\n+\n+def _run_with_current_provider_and_model(job, current_provider, current_model, tmp_path):\n+    \"\"\"Drive run_job with resolved provider pinned and config.yaml model.default\n+    set to ``current_model`` (the unpinned-model fire-time source).\"\"\"\n+    (tmp_path / \"config.yaml\").write_text(\n+        f\"model:\\n  default: {current_model}\\n\"\n+    )\n+    fake_db = MagicMock()\n+    with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+         patch(\"cron.scheduler._get_hermes_home\", return_value=tmp_path), \\\n+         patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+         patch(\"dotenv.load_dotenv\"), \\\n+         patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+         patch(\n+             \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+             return_value={\n+                 \"api_key\": \"test-key\",\n+                 \"base_url\": \"https://example.invalid/v1\",\n+                 \"provider\": current_provider,\n+                 \"api_mode\": \"chat_completions\",\n+             },\n+         ), \\\n+         patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+        mock_agent = MagicMock()\n+        mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+        mock_agent_cls.return_value = mock_agent\n+        success, output, final_response, error = run_job(job)\n+        agent_constructed = mock_agent_cls.called\n+    return success, output, final_response, error, agent_constructed\n+\n+\n+class TestModelDriftGuard:\n+    \"\"\"#44585 C1: model drift on the SAME provider must also fail closed —\n+    the incident named a model (claude-fable-5), and an unpinned job reads\n+    config.yaml model.default fresh every tick independently of provider.\"\"\"\n+\n+    def test_model_drift_same_provider_fails_closed(self, tmp_path):\n+        # Provider unchanged (openrouter==openrouter), but the global default\n+        # MODEL swapped to a premium model since creation → must fail closed.\n+        job = _base_job(\n+            provider_snapshot=\"openrouter\",\n+            model_snapshot=\"llama-3.3-70b-instruct:free\",\n+        )\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider_and_model(\n+                job, \"openrouter\", \"claude-fable-5\", tmp_path\n+            )\n+        assert agent_constructed is False, \"paid call must not be made on model drift\"\n+        assert success is False\n+        blob = f\"{error}\\n{output}\".lower()\n+        assert \"claude-fable-5\" in blob\n+        assert \"llama-3.3-70b-instruct:free\" in blob\n+        assert \"44585\" in blob\n+\n+    def test_model_snapshot_matches_runs(self, tmp_path):\n+        # Default model unchanged → runs normally.\n+        job = _base_job(\n+            provider_snapshot=\"openrouter\",\n+            model_snapshot=\"llama-3.3-70b-instruct:free\",\n+        )\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider_and_model(\n+                job, \"openrouter\", \"llama-3.3-70b-instruct:free\", tmp_path\n+            )\n+        assert agent_constructed is True\n+        assert success is True\n+\n+    def test_pinned_model_bypasses_guard(self, tmp_path):\n+        # Explicit job[\"model\"] → not unpinned → no model-drift skip even if the\n+        # global default differs from any snapshot.\n+        job = _base_job(\n+            provider_snapshot=\"openrouter\",\n+            model_snapshot=\"old-model\",\n+            model=\"my-pinned-model\",\n+        )\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider_and_model(\n+                job, \"openrouter\", \"claude-fable-5\", tmp_path\n+            )\n+        assert agent_constructed is True\n+        assert success is True\n+\n+    def test_no_model_snapshot_backcompat(self, tmp_path):\n+        # Pre-existing job without model_snapshot → no model-drift skip.\n+        job = _base_job(provider_snapshot=\"openrouter\")  # no model_snapshot key set to a value\n+        success, output, final_response, error, agent_constructed = \\\n+            _run_with_current_provider_and_model(\n+                job, \"openrouter\", \"claude-fable-5\", tmp_path\n+            )\n+        assert agent_constructed is True\n+        assert success is True"]},{"file":"website/docs/user-guide/features/cron.md","hunks":["@@ -22,7 +22,7 @@ Cron jobs can:\n 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.\n \n :::tip\n-Cron jobs use whatever provider `hermes model` selected. `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal).\n+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).\n :::\n \n :::warning\n"]}]},{"kind":"pr","number":54980,"title":"feat(cron): add cron.drift_guard_enabled config gate for the #44585 spend guard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#73323","number":73323},{"kind":"pr","value":"#59177","number":59177},{"kind":"pr","value":"#59031","number":59031},{"kind":"pr","value":"#73377","number":73377}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":67506,"title":"fix(cron): add cron.drift_guard config to bypass provider/model drift guard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54980","number":54980}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":68691,"title":"feat(cron): add config-driven drift_policy (adapt|fail) for unpinned cron jobs","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":73323,"title":"fix(cron): warn before model config changes trip cron drift guard","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","cron/scheduler.py","hermes_cli/config.py","tests/cli/test_cli_save_config_value.py","tests/cron/test_cron_provider_pin.py","tests/hermes_cli/test_set_config_value.py","website/docs/user-guide/features/cron.md"]},{"kind":"pr","number":135291,"title":"feat(cron): discover the loaded model on a loopback server when an unpinned job has none","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}