NousResearch/hermes-agent

Null deliver field crashes cron list command

Cluster 32896 · cron. Data as of

1 open item. Fix merged in #56144 on 1 Jul 2026. Check it covers your case.

The short answer

0open issues
1open PR
10items grouped
Known?
Yes. 10 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #56144. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
0 issues and 1 PR.

Multiple fixes and a bug report addressing a crash or TypeError in the cron list command when a job entry has a null deliver field. Several fixes also touch related null-safety for repeat/schedule fields and BSM secret resolution.

Open issues0

No open issues.

Open PRs1

  • PR#32916fix(cron): guard against null deliver/repeat/schedule in cron list

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56144 fix(cron): null-safe deliver in cron list + re-resolve BSM secrets per run

    merged to main

    files: cron/scheduler.py, hermes_cli/cron.py, tests/cron/test_cron_provider_pin.py, tests/cron/test_scheduler.py, tests/hermes_cli/test_cron.py

    Show the diff
    • cron/scheduler.py
      @@ -2225,12 +2225,23 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
       
           try:
               # Re-read .env and config.yaml fresh every run so provider/key
      -        # changes take effect without a gateway restart.
      -        from dotenv import load_dotenv
      -        try:
      -            load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="utf-8")
      -        except UnicodeDecodeError:
      -            load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="latin-1")
      +        # changes take effect without a gateway restart. Route through
      +        # load_hermes_dotenv (not a bare load_dotenv) and reset the secret-
      +        # source cache first: startup already applied external secrets and
      +        # recorded this HERMES_HOME in _APPLIED_HOMES, so a naive reload would
      +        # re-apply only the .env placeholder and never re-resolve a Bitwarden/
      +        # BSM-backed secret — leaving cron jobs 401'ing on the placeholder
      +        # (#33465). Clearing the cache forces the re-pull; the resolved secret
      +        # overrides the placeholder only when secrets.bitwarden.override_existing
      +        # is set (mirrors startup), and the Bitwarden value-cache keeps the
      +        # forced re-pull off the network. load_hermes_dotenv also handles the
      +        # utf-8/latin-1 encoding fallback internally.
      +        from hermes_cli.env_loader import (
      +            load_hermes_dotenv,
      +            reset_secret_source_cache,
      +        )
      +        reset_secret_source_cache()
      +        load_hermes_dotenv(hermes_home=_get_hermes_home())
       
               delivery_target = _resolve_delivery_target(job)
               if delivery_target:
    • hermes_cli/cron.py
      @@ -137,7 +137,11 @@ def cron_list(show_all: bool = False):
               repeat_completed = repeat_info.get("completed", 0)
               repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞"
       
      -        deliver = job.get("deliver", ["local"])
      +        # `deliver` may be present-but-null in the job record (same pitfall as
      +        # `repeat` above), so coalesce to the default rather than relying on the
      +        # dict-default, which only applies to a missing key. A null value would
      +        # otherwise reach `", ".join(None)` and crash the whole listing (#32896).
      +        deliver = job.get("deliver") or ["local"]
               if isinstance(deliver, str):
                   deliver = [deliver]
               deliver_str = ", ".join(deliver)
    • tests/cron/test_cron_provider_pin.py
      @@ -52,7 +52,8 @@ def _run_with_current_provider(job, current_provider, tmp_path):
           fake_db = MagicMock()
           with patch("cron.scheduler._hermes_home", tmp_path), \
                patch("cron.scheduler._resolve_origin", return_value=None), \
      -         patch("dotenv.load_dotenv"), \
      +         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",
      @@ -252,7 +253,8 @@ def _run_with_current_provider_and_model(job, current_provider, current_model, t
           with patch("cron.scheduler._hermes_home", tmp_path), \
                patch("cron.scheduler._get_hermes_home", return_value=tmp_path), \
                patch("cron.scheduler._resolve_origin", return_value=None), \
      -         patch("dotenv.load_dotenv"), \
      +         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",
    • tests/cron/test_scheduler.py
      @@ -966,7 +966,8 @@ def test_run_job_passes_session_db_and_cron_platform(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1012,7 +1013,8 @@ def test_run_job_suppresses_empty_turn_explainer(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1055,7 +1057,8 @@ def test_run_job_real_report_on_empty_reason_still_delivers(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1095,7 +1098,8 @@ def test_run_job_titles_cron_session_from_job_not_important_hint(self, tmp_path)
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1132,7 +1136,8 @@ def test_run_job_closes_agent_on_failure_to_prevent_fd_leak(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1169,7 +1174,8 @@ def test_run_job_reaps_stale_auxiliary_clients_per_tick(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1197,7 +1203,8 @@ def _make_run_job_patches(self, tmp_path):
               return fake_db, [
                   patch("cron.scheduler._hermes_home", tmp_path),
                   patch("cron.scheduler._resolve_origin", return_value=None),
      -            patch("dotenv.load_dotenv"),
      +            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",
      @@ -1218,7 +1225,7 @@ def test_run_job_passes_enabled_toolsets_to_agent(self, tmp_path):
                   "enabled_toolsets": ["web", "terminal", "file"],
               }
               fake_db, patches = self._make_run_job_patches(tmp_path)
      -        with patches[0], patches[1], patches[2], patches[3], patches[4], \
      +        with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \
                    patch("run_agent.AIAgent") as mock_agent_cls:
                   mock_agent = MagicMock()
                   mock_agent.run_conversation.return_value = {"final_response": "ok"}
      @@ -1252,7 +1259,7 @@ def test_run_job_disabled_toolsets_layer_user_config_on_baseline(self, tmp_path)
                   "enabled_toolsets": ["web", "terminal", "file"],
               }
               fake_db, patches = self._make_run_job_patches(tmp_path)
      -        with patches[0], patches[1], patches[2], patches[3], patches[4], \
      +        with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \
                    patch("run_agent.AIAgent") as mock_agent_cls:
                   mock_agent = MagicMock()
                   mock_agent.run_conversation.return_value = {"final_response": "ok"}
      @@ -1279,7 +1286,7 @@ def test_run_job_enabled_toolsets_resolves_from_platform_config_when_not_set(sel
                   "prompt": "hello",
               }
               fake_db, patches = self._make_run_job_patches(tmp_path)
      -        with patches[0], patches[1], patches[2], patches[3], patches[4], \
      +        with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \
                    patch("run_agent.AIAgent") as mock_agent_cls:
                   mock_agent = MagicMock()
                   mock_agent.run_conversation.return_value = {"final_response": "ok"}
      @@ -1307,7 +1314,7 @@ def test_run_job_per_job_toolsets_win_over_platform_config(self, tmp_path):
               fake_db, patches = self._make_run_job_patches(tmp_path)
               # Even if the user has ``hermes tools`` configured to enable web+file
               # for cron, the per-job override wins.
      -        with patches[0], patches[1], patches[2], patches[3], patches[4], \
      +        with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \
                    patch("run_agent.AIAgent") as mock_agent_cls, \
                    patch(
                        "hermes_cli.tools_config._get_platform_tools",
      @@ -1336,7 +1343,8 @@ def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1412,7 +1420,8 @@ def test_run_job_treats_agent_failure_flag_as_failure(
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1451,7 +1460,8 @@ def test_run_job_completed_true_without_failed_flag_succeeds(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1493,7 +1503,8 @@ def test_run_job_delivers_max_iteration_fallback_summary(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -1613,6 +1624,53 @@ def run_conversation(self, *args, **kwargs):
               assert os.getenv("HERMES_CRON_AUTO_DELIVER_THREAD_ID") is None
               fake_db.close.assert_called_once()
       
      +    def test_run_job_resets_secret_source_cache_before_reload(self, tmp_path, monkeypatch):
      +        """Each run must clear the secret-source cache before re-reading the
      +        env, so a long-running gateway re-resolves Bitwarden/BSM-backed secrets
      +        instead of leaving the startup .env placeholder in place (#33465).
      +
      +        A bare ``load_dotenv`` re-load can't do this: startup already recorded
      +        this HERMES_HOME in ``_APPLIED_HOMES``, so the external-secret pull
      +        no-ops and only the placeholder is re-applied. The scheduler must call
      +        ``reset_secret_source_cache()`` (forcing the re-pull) and route through
      +        ``load_hermes_dotenv`` (which then re-applies external secret sources).
      +        """
      +        job = {"id": "bsm-job", "name": "bsm", "prompt": "hello"}
      +        fake_db = MagicMock()
      +        call_order = []
      +
      +        def _record_reset():
      +            call_order.append("reset")
      +
      +        def _record_load(*args, **kwargs):
      +            call_order.append("load")
      +            return []
      +
      +        with patch("cron.scheduler._hermes_home", tmp_path), \
      +             patch("cron.scheduler._resolve_origin", return_value=None), \
      +             patch("hermes_cli.env_loader.reset_secret_source_cache", _record_reset), \
      +             patch("hermes_cli.env_loader.load_hermes_dotenv", _record_load), \
      +             patch("hermes_state.SessionDB", return_value=fake_db), \
      +             patch(
      +                 "hermes_cli.runtime_provider.resolve_runtime_provider",
      +                 return_value={
      +                     "api_key": "***",
      +                     "base_url": "https://example.invalid/v1",
      +                     "provider": "openrouter",
      +                     "api_mode": "chat_completions",
      +                 },
      +             ), \
      +             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, error = run_job(job)
      +
      +        assert success is True
      +        assert error is None
      +        # reset MUST precede the reload, else _APPLIED_HOMES no-ops the re-pull.
      +        assert call_order[:2] == ["reset", "load"], call_order
      +
           def test_run_job_clears_stale_auto_delivery_thread_id_between_jobs(self, tmp_path, monkeypatch):
               jobs = [
                   {
      @@ -1709,7 +1767,8 @@ def test_bad_config_yaml_is_logged(self, caplog, tmp_path):
               # (>30s wall clock) under load. See PR #33661 follow-up.
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             patch("hermes_cli.env_loader.load_hermes_dotenv"), \
      +             patch("hermes_cli.env_loader.reset_secret_source_cache"), \
                    patch("hermes_cli.runtime_provider.resolve_runtime_provider",
                          return_value={"provider": "openrouter", "api_key": "x",
                                        "base_url": "https://example.invalid",
      @@ -1743,7 +1802,8 @@ def test_bad_prefill_messages_is_logged(self, caplog, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             patch("hermes_cli.env_loader.load_hermes_dotenv"), \
      +             patch("hermes_cli.env_loader.reset_secret_source_cache"), \
                    patch("hermes_cli.runtime_provider.resolve_runtime_provider",
                          return_value={"provider": "openrouter", "api_key": "x",
                                        "base_url": "https://example.invalid",
      @@ -1781,7 +1841,8 @@ def test_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -1814,7 +1875,8 @@ def test_legacy_agent_prefill_messages_file_is_loaded(self, tmp_path, monkeypatc
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -1844,7 +1906,8 @@ def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monke
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -1873,7 +1936,8 @@ def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -1917,7 +1981,8 @@ def test_null_job_model_falls_back_to_env(self, tmp_path, monkeypatch):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -1941,7 +2006,8 @@ def test_null_job_model_falls_back_to_config_default(self, tmp_path, monkeypatch
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -1973,7 +2039,8 @@ def test_explicit_null_model_block_in_config_does_not_overwrite_env(self, tmp_pa
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -1996,7 +2063,8 @@ def test_no_model_anywhere_fails_with_actionable_error(self, tmp_path, monkeypat
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -2025,7 +2093,8 @@ def test_job_model_update_takes_effect_on_next_run(self, tmp_path, monkeypatch):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -2051,7 +2120,8 @@ def test_config_model_as_plain_string(self, tmp_path, monkeypatch):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -2081,7 +2151,8 @@ def test_config_model_alias_key_resolves(self, tmp_path, monkeypatch):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -2105,7 +2176,8 @@ def test_corrupt_config_yaml_does_not_crash_with_job_model(self, tmp_path, monke
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
                          return_value=self._RUNTIME), \
      @@ -2147,7 +2219,8 @@ def _run_conversation(prompt):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -2207,7 +2280,8 @@ def _run_conversation(prompt):
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
                    patch("tools.credential_files._resolve_hermes_home", return_value=tmp_path), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -2245,7 +2319,8 @@ def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
      @@ -2291,7 +2366,8 @@ def _skill_view(name):
       
               with patch("cron.scheduler._hermes_home", tmp_path), \
                    patch("cron.scheduler._resolve_origin", return_value=None), \
      -             patch("dotenv.load_dotenv"), \
      +             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",
    • tests/hermes_cli/test_cron.py
      @@ -122,6 +122,23 @@ def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys):
               out = capsys.readouterr().out
               assert "Repeat:    ∞" in out
       
      +    def test_list_does_not_crash_when_deliver_is_null(self, tmp_cron_dir, capsys):
      +        """A job can be persisted with ``"deliver": null`` (present-but-null).
      +        `cron list` must fall back to the default channel rather than crashing
      +        on ``", ".join(None)`` — same dict-default pitfall as ``repeat`` (#32896).
      +        """
      +        from cron.jobs import load_jobs, save_jobs
      +
      +        create_job(prompt="No deliver", schedule="every 1h")
      +        jobs = load_jobs()
      +        jobs[0]["deliver"] = None
      +        save_jobs(jobs)
      +
      +        cron_command(Namespace(cron_command="list", all=True))
      +
      +        out = capsys.readouterr().out
      +        assert "Deliver:   local" in out
      +
       
       class TestGatewayNotRunningWarning:
           """`cron create` / `cron list` must warn when the gateway (and thus the

6 PRs closed without merging

From the close comments: done elsewhere 3 · merged via another change 1 · superseded by another PR 1 · other 1

  • #32912 fix(cli): handle null `deliver` field in `cron list` legacy entries · superseded by another PR → #32916, #20264
  • #33527 fix(cron): harden ticker thread, null-safe deliver, and BSM env loader · merged via another change → #56144
  • #33870 fix(cron): handle null deliver field in cron list command · done elsewhere → 836732f
  • #41398 fix(cron): tolerate deliver=null in cron list display · done elsewhere → cdd8e7c, #41392
  • #41435 fix(#41392): handle deliver: null in cron list · done elsewhere → #41392, #41398
Show 1 more
  • #41580 fix(cron): tolerate deliver: null in cron list (#41392) · other
Closed issues · 2
  • Closed#32896fix: hermes cron list crashes when job has null deliver field
  • Closed#41392[Bug]: hermes cron list crashes with TypeError: can only join an iterable when any job has deliver: null
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.19: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
cron
Merged PRs
#56144
Closed issues
2
Attempts
6 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.