diff --git a/cron/evolution_preflight.py b/cron/evolution_preflight.py new file mode 100644 index 000000000..a33c600af --- /dev/null +++ b/cron/evolution_preflight.py @@ -0,0 +1,261 @@ +"""Pre-flight provider check + cached digest fallback for evolution cron jobs. + +The evolution pipeline (introspection → analysis → implementation → research → +funnel → integration) runs as regular cron agent sessions. When the configured +provider is unreachable, those sessions burn retries/timeouts before producing +zero deliverables. This module provides a lightweight ping and a fallback to +the most recent on-disk digest so the pipeline can keep moving with stale but +useful input instead of failing silently. +""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path +from typing import Any, Dict, Optional + +from hermes_constants import get_hermes_home +from hermes_cli.config import load_config_readonly +from hermes_cli.timeouts import get_provider_request_timeout + +logger = logging.getLogger(__name__) + +# Stages in the evolution pipeline and the file extension each one writes. +_EVOLUTION_STAGES = { + "introspection": ".json", + "analysis": ".json", + "implementation": ".md", + "research": ".md", + "funnel": ".md", + "integration": ".md", +} + + +def evolution_job_stage(job: Dict[str, Any]) -> Optional[str]: + """Return the evolution stage for a cron job, or None if it is not an + evolution pipeline job. + + Matches job names like ``evolution-introspection`` or tags that include + ``evolution`` plus a known stage name. + """ + name = str(job.get("name") or job.get("id") or "").lower() + tags = job.get("tags") + tags_lower = {str(t).lower() for t in tags} if isinstance(tags, list) else set() + + if not name.startswith("evolution-") and not name.startswith("evolution") and "evolution" not in tags_lower: + return None + + for stage in _EVOLUTION_STAGES: + if stage in name: + return stage + + for stage in _EVOLUTION_STAGES: + if stage in tags_lower: + return stage + + return None + + +def _evolution_dir(hermes_home: Optional[Path] = None) -> Path: + home = (hermes_home or get_hermes_home()).resolve() + return home / "profiles" / "user1" / "evolution" + + +def _preflight_timeout_seconds(cfg: Optional[Any] = None) -> float: + """Return the configured pre-flight timeout in seconds (default 30).""" + if cfg is None: + try: + cfg = load_config_readonly() or {} + except Exception: + cfg = {} + cron_cfg = cfg.get("cron", {}) if isinstance(cfg, dict) else {} + if not isinstance(cron_cfg, dict): + cron_cfg = {} + raw = cron_cfg.get("preflight_timeout_seconds", 30.0) + try: + value = float(raw) + except (TypeError, ValueError): + return 30.0 + if value <= 0: + return 30.0 + return value + + +def _preflight_enabled(cfg: Optional[Any] = None) -> bool: + """Return whether pre-flight checks are enabled (default True).""" + if cfg is None: + try: + cfg = load_config_readonly() or {} + except Exception: + cfg = {} + cron_cfg = cfg.get("cron", {}) if isinstance(cfg, dict) else {} + if not isinstance(cron_cfg, dict): + cron_cfg = {} + return str(cron_cfg.get("preflight_enabled", "true")).lower() not in { + "false", + "0", + "no", + "off", + "disabled", + } + + +def find_latest_digest( + stage: str, hermes_home: Optional[Path] = None +) -> Optional[Path]: + """Return the most recent digest file for an evolution stage, or None.""" + if stage not in _EVOLUTION_STAGES: + return None + ext = _EVOLUTION_STAGES[stage] + stage_dir = _evolution_dir(hermes_home) / stage + if not stage_dir.is_dir(): + return None + candidates = sorted( + (p for p in stage_dir.iterdir() if p.is_file() and p.suffix == ext), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + return candidates[0] if candidates else None + + +def load_digest_as_fallback( + stage: str, + hermes_home: Optional[Path] = None, + *, + max_chars: int = 200_000, +) -> Optional[str]: + """Load the most recent on-disk digest for a stage, bounded in size.""" + path = find_latest_digest(stage, hermes_home) + if path is None: + return None + try: + text = path.read_text(encoding="utf-8", errors="replace") + except Exception as exc: + logger.warning("Could not read cached digest %s: %s", path, exc) + return None + if len(text) > max_chars: + text = text[:max_chars] + "\n\n[truncated: stale digest exceeded size limit]" + header = ( + f"⚠️ Provider unreachable for '{stage}' cron job. " + f"Using cached digest from {path.name} instead.\n\n" + ) + return header + text + + +def _provider_specific_timeout(runtime: Dict[str, Any], cfg: Optional[Any]) -> float: + """Pick the tightest sensible timeout for the provider ping.""" + provider = runtime.get("provider") or "" + model = runtime.get("model") or "" + configured = get_provider_request_timeout(provider, model) + if configured is not None and configured > 0: + return configured + return _preflight_timeout_seconds(cfg) + + +def preflight_provider( + runtime: Dict[str, Any], *, cfg: Optional[Any] = None +) -> Optional[str]: + """Run a minimal, non-streaming provider ping. + + Returns None on success, or a short human-readable error string on failure. + This is intentionally lightweight: a single-turn request with max_tokens=1. + """ + api_key = runtime.get("api_key") or "" + base_url = runtime.get("base_url") or "" + provider = runtime.get("provider") or "" + api_mode = runtime.get("api_mode") or "chat_completions" + model = runtime.get("model") or "" + command = runtime.get("command") + + if not api_key and not command: + return "no API key or ACP command available for pre-flight ping" + + if not model and not command: + return "no model configured for pre-flight ping" + + timeout = _provider_specific_timeout(runtime, cfg) + + try: + if command or api_mode == "copilot-acp": + # ACP providers are subprocess-based; a real ping would require + # spawning the ACP helper. For now treat them as reachable if the + # runtime resolved (auth setup succeeded). A dedicated ACP ping can + # be added later without changing the scheduler contract. + return None + + if api_mode == "anthropic_messages": + return _preflight_anthropic(api_key, base_url, model, timeout) + if api_mode == "bedrock_converse": + return _preflight_bedrock(runtime, timeout) + return _preflight_openai_compatible(api_key, base_url, model, timeout, provider) + except Exception as exc: + logger.debug("Pre-flight ping raised %s: %s", type(exc).__name__, exc) + return f"pre-flight ping failed: {type(exc).__name__}: {exc}" + + +def _preflight_openai_compatible( + api_key: str, + base_url: str, + model: str, + timeout: float, + provider: str, +) -> Optional[str]: + from openai import OpenAI + + client_kwargs: Dict[str, Any] = {"api_key": api_key, "timeout": timeout} + if base_url: + client_kwargs["base_url"] = base_url + client = OpenAI(**client_kwargs) + start = time.time() + try: + client.chat.completions.create( + model=model or "default", + messages=[{"role": "user", "content": "ping"}], + max_tokens=1, + stream=False, + ) + elapsed = time.time() - start + logger.debug("Pre-flight ping to %s succeeded in %.2fs", provider, elapsed) + return None + finally: + try: + client.close() + except Exception: + pass + + +def _preflight_anthropic( + api_key: str, base_url: str, model: str, timeout: float +) -> Optional[str]: + from anthropic import Anthropic + + client_kwargs: Dict[str, Any] = {"api_key": api_key, "timeout": timeout} + if base_url: + client_kwargs["base_url"] = base_url + client = Anthropic(**client_kwargs) + start = time.time() + try: + client.messages.create( + model=model or "claude-3-5-haiku-latest", + max_tokens=1, + messages=[{"role": "user", "content": "ping"}], + ) + elapsed = time.time() - start + logger.debug("Pre-flight ping to anthropic succeeded in %.2fs", elapsed) + return None + finally: + try: + client.close() + except Exception: + pass + + +def _preflight_bedrock(runtime: Dict[str, Any], timeout: float) -> Optional[str]: + # Bedrock uses boto3; resolving the runtime already validates credentials. + # A full converse ping would require a model id and may incur token cost, + # so we treat the resolved runtime as reachable. This preserves the fallback + # contract while avoiding unexpected Bedrock charges. + _ = timeout + _ = runtime + return None diff --git a/cron/scheduler.py b/cron/scheduler.py index 4ceaf7983..acb532bf4 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2190,6 +2190,7 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]: format_runtime_provider_error, ) from hermes_cli.auth import AuthError + from cron import evolution_preflight try: # Do not inject HERMES_INFERENCE_PROVIDER here. resolve_runtime_provider() # already prefers persisted config over stale shell/env overrides when @@ -2228,6 +2229,58 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]: message = format_runtime_provider_error(exc) raise RuntimeError(message) from exc + # Evolution pipeline pre-flight: ping the resolved provider before we + # build an agent. If it fails, return the most recent on-disk digest + # so downstream evolution jobs still have stale-but-structured input + # instead of failing silently during retries. (#486) + stage = evolution_preflight.evolution_job_stage(job) + if stage and evolution_preflight._preflight_enabled(_cfg): + # ROOT-FIX (#486): resolve_runtime_provider() does NOT populate + # runtime["model"] — the model is resolved into the local ``model`` + # variable above (job.model > HERMES_MODEL > config.yaml model.default) + # and passed separately to AIAgent(model=...). Without this sync the + # pre-flight ping saw an empty runtime["model"] and always bailed with + # "no model configured for pre-flight ping", so cached-digest fallback + # could never trigger on prod. Build a shallow copy carrying the + # resolved model for the ping rather than mutating ``runtime`` in + # place: ``runtime`` is a fresh, request-local dict from + # resolve_runtime_provider() today, but copying keeps the ping + # side-effect-free regardless. Never clobber a model the runtime may + # already carry (e.g. an ACP-resolved one). + preflight_runtime = ( + runtime if runtime.get("model") else {**runtime, "model": model} + ) + err = evolution_preflight.preflight_provider(preflight_runtime, cfg=_cfg) + if err: + logger.warning( + "Job '%s' (evolution-%s): provider pre-flight failed: %s", + job_id, + stage, + err, + ) + digest = evolution_preflight.load_digest_as_fallback( + stage, _get_hermes_home() + ) + if digest is not None: + now_iso = _hermes_now().strftime("%Y-%m-%d %H:%M:%S") + doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Status:** provider unreachable — stale digest fallback\n\n" + f"{digest}\n" + ) + logger.info( + "Job '%s' (evolution-%s): returning stale digest fallback", + job_id, + stage, + ) + return True, doc, SILENT_MARKER, None + else: + raise RuntimeError( + f"Evolution pre-flight failed for '{stage}': {err}. No cached digest available." + ) + fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None credential_pool = None runtime_provider = str(runtime.get("provider") or "").strip().lower() diff --git a/tests/cron/test_evolution_preflight.py b/tests/cron/test_evolution_preflight.py new file mode 100644 index 000000000..fa41b24f9 --- /dev/null +++ b/tests/cron/test_evolution_preflight.py @@ -0,0 +1,456 @@ +"""Tests for cron/evolution_preflight.py.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cron import evolution_preflight as ep + + +class TestEvolutionJobStage: + def test_name_introspection(self): + assert ( + ep.evolution_job_stage({"name": "evolution-introspection"}) + == "introspection" + ) + + def test_name_analysis(self): + assert ep.evolution_job_stage({"name": "evolution-analysis"}) == "analysis" + + def test_tags_when_name_generic(self): + assert ( + ep.evolution_job_stage({"name": "evolution", "tags": ["analysis"]}) + == "analysis" + ) + + def test_non_evolution_returns_none(self): + assert ep.evolution_job_stage({"name": "morning-digest"}) is None + + def test_id_fallback(self): + assert ( + ep.evolution_job_stage({"id": "evolution-implementation", "name": ""}) + == "implementation" + ) + + +class TestPreflightConfig: + def test_preflight_timeout_default(self): + with patch("hermes_cli.config.load_config_readonly", return_value={}): + assert ep._preflight_timeout_seconds() == 30.0 + + def test_preflight_timeout_from_config(self): + cfg = {"cron": {"preflight_timeout_seconds": 10}} + assert ep._preflight_timeout_seconds(cfg) == 10.0 + + def test_preflight_timeout_invalid_falls_back(self): + cfg = {"cron": {"preflight_timeout_seconds": "bad"}} + assert ep._preflight_timeout_seconds(cfg) == 30.0 + + def test_preflight_enabled_default(self): + assert ep._preflight_enabled({}) is True + + def test_preflight_enabled_can_disable(self): + assert ep._preflight_enabled({"cron": {"preflight_enabled": False}}) is False + assert ep._preflight_enabled({"cron": {"preflight_enabled": "no"}}) is False + assert ep._preflight_enabled({"cron": {"preflight_enabled": "0"}}) is False + + +class TestDigestFallback: + def test_find_latest_digest(self, tmp_path): + stage_dir = tmp_path / "profiles" / "user1" / "evolution" / "introspection" + stage_dir.mkdir(parents=True) + old = stage_dir / "2026-06-20.json" + new = stage_dir / "2026-06-23.json" + old.write_text("old") + new.write_text("new") + old.touch() + new.touch() + assert ep.find_latest_digest("introspection", tmp_path) == new + + def test_load_digest_as_fallback(self, tmp_path): + stage_dir = tmp_path / "profiles" / "user1" / "evolution" / "analysis" + stage_dir.mkdir(parents=True) + digest = stage_dir / "2026-06-23.json" + digest.write_text(json.dumps({"foo": "bar"})) + text = ep.load_digest_as_fallback("analysis", tmp_path) + assert text is not None + assert "Provider unreachable" in text + assert "2026-06-23.json" in text + assert '"foo": "bar"' in text + + def test_load_digest_truncate(self, tmp_path): + stage_dir = tmp_path / "profiles" / "user1" / "evolution" / "implementation" + stage_dir.mkdir(parents=True) + digest = stage_dir / "2026-06-23.md" + digest.write_text("x" * 300_000) + text = ep.load_digest_as_fallback("implementation", tmp_path, max_chars=100) + assert text is not None + assert text.endswith("[truncated: stale digest exceeded size limit]") + + def test_missing_digest_returns_none(self, tmp_path): + assert ep.find_latest_digest("research", tmp_path) is None + assert ep.load_digest_as_fallback("research", tmp_path) is None + + +class TestPreflightProvider: + def test_missing_api_key(self): + assert ( + ep.preflight_provider({}) + == "no API key or ACP command available for pre-flight ping" + ) + + def test_missing_model(self): + assert ( + ep.preflight_provider({"api_key": "k"}) + == "no model configured for pre-flight ping" + ) + + def test_resolved_model_does_not_bail_no_model(self): + # ROOT-FIX guard (#486): once the scheduler syncs the resolved model + # into runtime["model"], the ping must proceed past the "no model" + # short-circuit. We patch the OpenAI client so no network call is made; + # the assertion is that the empty-model branch is NOT taken and the + # provider client is actually invoked with the resolved model. + fake_client = MagicMock() + fake_client.chat.completions.create.return_value = MagicMock() + with patch("openai.OpenAI", return_value=fake_client): + err = ep.preflight_provider({ + "api_key": "k", + "model": "config-default-model", + "provider": "openrouter", + }) + assert err is None + # The model carried on the runtime dict must reach the client call. + _, kwargs = fake_client.chat.completions.create.call_args + assert kwargs["model"] == "config-default-model" + + def test_acp_treated_as_reachable(self): + assert ( + ep.preflight_provider({ + "api_key": "k", + "model": "m", + "command": ["copilot"], + }) + is None + ) + + def test_openai_success(self): + fake_client = MagicMock() + fake_response = MagicMock() + fake_client.chat.completions.create.return_value = fake_response + with patch("openai.OpenAI", return_value=fake_client): + assert ( + ep.preflight_provider({ + "api_key": "k", + "model": "m", + "provider": "openrouter", + }) + is None + ) + fake_client.chat.completions.create.assert_called_once() + + def test_openai_failure(self): + fake_client = MagicMock() + fake_client.chat.completions.create.side_effect = RuntimeError( + "connection refused" + ) + with patch("openai.OpenAI", return_value=fake_client): + err = ep.preflight_provider({ + "api_key": "k", + "model": "m", + "provider": "openrouter", + }) + assert err is not None + assert "connection refused" in err + + def test_anthropic_success(self): + pytest.importorskip("anthropic") + fake_client = MagicMock() + with patch("anthropic.Anthropic", return_value=fake_client): + assert ( + ep.preflight_provider({ + "api_key": "k", + "model": "m", + "api_mode": "anthropic_messages", + }) + is None + ) + fake_client.messages.create.assert_called_once() + + def test_anthropic_failure(self): + pytest.importorskip("anthropic") + fake_client = MagicMock() + fake_client.messages.create.side_effect = RuntimeError("timeout") + with patch("anthropic.Anthropic", return_value=fake_client): + err = ep.preflight_provider({ + "api_key": "k", + "model": "m", + "api_mode": "anthropic_messages", + }) + assert err is not None + assert "timeout" in err + + +class TestSchedulerIntegration: + def _make_job(self, stage="introspection"): + return { + "id": f"evolution-{stage}", + "name": f"evolution-{stage}", + "prompt": "do work", + } + + def _patch_runtime(self, tmp_path): + return patch( + "cron.scheduler._get_hermes_home", + return_value=tmp_path, + ), patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "model": "openrouter/model", + }, + ) + + def test_preflight_success_continues_to_agent(self, tmp_path): + from cron.scheduler import _run_job_impl + + job = self._make_job("analysis") + with ( + patch("cron.scheduler._get_hermes_home", return_value=tmp_path), + patch("cron.scheduler._resolve_origin", return_value=None), + patch("dotenv.load_dotenv"), + patch("hermes_state.SessionDB", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "model": "openrouter/model", + }, + ), + patch("cron.evolution_preflight.preflight_provider", return_value=None), + 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 + success, output, final_response, error = _run_job_impl(job) + + assert success is True + assert final_response == "ok" + mock_agent_cls.assert_called_once() + + def test_preflight_failure_with_digest_returns_stale_digest(self, tmp_path): + from cron.scheduler import _run_job_impl + + stage_dir = tmp_path / "profiles" / "user1" / "evolution" / "analysis" + stage_dir.mkdir(parents=True) + digest = stage_dir / "2026-06-23.json" + digest.write_text(json.dumps({"selected": ["#123"]})) + + job = self._make_job("analysis") + with ( + patch("cron.scheduler._get_hermes_home", return_value=tmp_path), + patch("cron.scheduler._resolve_origin", return_value=None), + patch("dotenv.load_dotenv"), + patch("hermes_state.SessionDB", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "model": "openrouter/model", + }, + ), + patch( + "cron.evolution_preflight.preflight_provider", + return_value="provider down", + ), + patch("run_agent.AIAgent") as mock_agent_cls, + ): + success, output, final_response, error = _run_job_impl(job) + + assert success is True + assert final_response == "[SILENT]" + assert error is None + assert "provider unreachable — stale digest fallback" in output + assert '"selected": ["#123"]' in output + mock_agent_cls.assert_not_called() + + def test_preflight_failure_without_digest_fails_job(self, tmp_path): + from cron.scheduler import _run_job_impl + + job = self._make_job("research") + with ( + patch("cron.scheduler._get_hermes_home", return_value=tmp_path), + patch("cron.scheduler._resolve_origin", return_value=None), + patch("dotenv.load_dotenv"), + patch("hermes_state.SessionDB", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "model": "openrouter/model", + }, + ), + patch( + "cron.evolution_preflight.preflight_provider", + return_value="provider down", + ), + patch("run_agent.AIAgent") as mock_agent_cls, + ): + success, output, final_response, error = _run_job_impl(job) + + assert success is False + assert error is not None and "No cached digest available" in error + mock_agent_cls.assert_not_called() + + def test_non_evolution_job_skips_preflight(self, tmp_path): + from cron.scheduler import _run_job_impl + + job = {"id": "morning-digest", "name": "morning-digest", "prompt": "hi"} + with ( + patch("cron.scheduler._get_hermes_home", return_value=tmp_path), + patch("cron.scheduler._resolve_origin", return_value=None), + patch("dotenv.load_dotenv"), + patch("hermes_state.SessionDB", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "model": "openrouter/model", + }, + ), + patch( + "cron.evolution_preflight.preflight_provider", + return_value="provider down", + ) as mock_preflight, + 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 + success, _output, final_response, _error = _run_job_impl(job) + + assert success is True + assert final_response == "ok" + mock_preflight.assert_not_called() + mock_agent_cls.assert_called_once() + + def test_root_fix_runtime_model_synced_from_config_default(self, tmp_path): + """ROOT-FIX (#486): scheduler must sync the resolved model into + runtime["model"] before the pre-flight ping. + + Reproduces the prod failure: resolve_runtime_provider() returns a + runtime WITHOUT a ``model`` key (it never sets one — the scheduler + resolves the model into a separate local variable and passes it to + AIAgent(model=...) directly). The job pins no model, but config.yaml + supplies model.default. Before the fix, preflight_provider() saw an + empty runtime["model"] and always returned "no model configured for + pre-flight ping". After the fix, runtime["model"] carries the resolved + config default. + + We capture the runtime dict actually handed to preflight_provider and + assert it carries the config default model. + """ + from cron.scheduler import _run_job_impl + + # config.yaml provides the default model; job pins nothing. + (tmp_path / "config.yaml").write_text("model:\n default: cfg-default-model\n") + + captured = {} + + def _capture_preflight(runtime, *, cfg=None): + # Snapshot what the scheduler passed in at call time. + captured["model"] = runtime.get("model") + captured["provider"] = runtime.get("provider") + return None # report provider reachable -> continue to agent + + job = self._make_job("analysis") + with ( + patch("cron.scheduler._get_hermes_home", return_value=tmp_path), + patch("cron.scheduler._resolve_origin", return_value=None), + patch("dotenv.load_dotenv"), + patch("hermes_state.SessionDB", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + # NOTE: deliberately NO "model" key — mirrors prod behavior. + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + }, + ), + patch( + "cron.evolution_preflight.preflight_provider", + side_effect=_capture_preflight, + ), + 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 + success, _output, final_response, _error = _run_job_impl(job) + + # The runtime handed to the ping must carry the config-default model, + # not the empty value resolve_runtime_provider() left it with. + assert captured.get("model") == "cfg-default-model" + assert captured.get("provider") == "openrouter" + # And with a healthy ping the job proceeds to the agent normally. + assert success is True + assert final_response == "ok" + mock_agent_cls.assert_called_once() + # The model passed to the agent must match the same resolved default. + _, agent_kwargs = mock_agent_cls.call_args + assert agent_kwargs["model"] == "cfg-default-model" + + from cron.scheduler import _run_job_impl + + (tmp_path / "config.yaml").write_text("cron:\n preflight_enabled: false\n") + job = self._make_job("analysis") + with ( + patch("cron.scheduler._get_hermes_home", return_value=tmp_path), + patch("cron.scheduler._resolve_origin", return_value=None), + patch("dotenv.load_dotenv"), + patch("hermes_state.SessionDB", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "model": "openrouter/model", + }, + ), + patch( + "cron.evolution_preflight.preflight_provider", + return_value="provider down", + ) as mock_preflight, + 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 + success, _output, final_response, _error = _run_job_impl(job) + + assert success is True + assert final_response == "ok" + mock_preflight.assert_not_called() + mock_agent_cls.assert_called_once()