{"repo":"NousResearch/hermes-agent","public_id":"7794","display_label":"Cron agent timeout, shutdown, and session lifecycle bugs","summary":"These issues and fixes address several related problems in cron job execution: inactivity timeouts firing during active streaming responses, RuntimeErrors when delivering results during interpreter shutdown, and SessionDB being closed before agent teardown completes causing silent data loss.","subsystem":"cron","counts":{"members":18,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":4,"merged_prs":3,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":9012},"resolution":{"state":"partially_resolved","by":"pr-9012,pr-59093,pr-104456"},"freshness":{"state":"verified","last_verified_at":"2026-09-06T17:26:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09019607843137253},"fix_edges":[{"issue_number":8760,"pr_number":9012,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":58720,"pr_number":59093,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":65208,"pr_number":104456,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":102827,"pr_number":104456,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":7794,"title":"[Bug]: Cron inactivity timeout fires during active openai-codex streaming because Codex stream events do not update activity tracker","url":null,"state":"closed"},{"kind":"issue","number":8760,"title":"[Bug]: Cron jobs with long streaming responses fail with idle timeout despite active token generation","url":null,"state":"closed"},{"kind":"issue","number":18004,"title":"Cron inactivity timeout can mark a job failed while the agent thread keeps running","url":null,"state":"open"},{"kind":"issue","number":54755,"title":"cron run_job closes session DB before agent.close(), causing \"Session DB append_message failed: 'NoneType' object has no attribute 'execute'\"","url":null,"state":"open"},{"kind":"issue","number":58720,"title":"Cron delivery fails with RuntimeError('cannot schedule new futures after interpreter shutdown')","url":null,"state":"closed"},{"kind":"issue","number":65208,"title":"Cron inactivity timeout closes SessionDB while the agent worker thread is still running → intermittent silent session-write loss","url":null,"state":"closed"},{"kind":"pr","number":8027,"title":"fix: update activity tracker during Codex Responses streaming","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2773b18b5","number":0},{"kind":"commit","value":"2773b18b56a73650136b14d739c407895d36dd8a","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":8762,"title":"fix(run_agent): refresh activity during streaming responses","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8836","number":8836}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":8836,"title":"fix(run_agent): refresh activity during streaming responses","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9012","number":9012}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":9012,"title":"fix(run_agent): refresh activity during streaming responses","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_streaming.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -4278,6 +4278,7 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta\n             try:\n                 with active_client.responses.stream(**api_kwargs) as stream:\n                     for event in stream:\n+                        self._touch_activity(\"receiving stream response\")\n                         if self._interrupt_requested:\n                             break\n                         event_type = getattr(event, \"type\", \"\")","@@ -4402,6 +4403,7 @@ def _run_codex_create_stream_fallback(self, api_kwargs: dict, client: Any = None\n         collected_text_deltas: list = []\n         try:\n             for event in stream_or_response:\n+                self._touch_activity(\"receiving stream response\")\n                 event_type = getattr(event, \"type\", None)\n                 if not event_type and isinstance(event, dict):\n                     event_type = event.get(\"type\")","@@ -5034,12 +5036,9 @@ def _call_chat_completions():\n             role = \"assistant\"\n             reasoning_parts: list = []\n             usage_obj = None\n-            _first_chunk_seen = False\n             for chunk in stream:\n                 last_chunk_time[\"t\"] = time.time()\n-                if not _first_chunk_seen:\n-                    _first_chunk_seen = True\n-                    self._touch_activity(\"receiving stream response\")\n+                self._touch_activity(\"receiving stream response\")\n \n                 if self._interrupt_requested:\n                     break","@@ -5215,6 +5214,7 @@ def _call_anthropic():\n                     # actively arriving (the chat_completions path\n                     # already does this at the top of its chunk loop).\n                     last_chunk_time[\"t\"] = time.time()\n+                    self._touch_activity(\"receiving stream response\")\n \n                     if self._interrupt_requested:\n                         break"]},{"file":"tests/run_agent/test_streaming.py","hunks":["@@ -291,6 +291,38 @@ def test_on_first_delta_fires_once(self, mock_close, mock_create):\n \n         assert len(first_delta_calls) == 1\n \n+    @patch(\"run_agent.AIAgent._create_request_openai_client\")\n+    @patch(\"run_agent.AIAgent._close_request_openai_client\")\n+    def test_chat_stream_refreshes_activity_on_every_chunk(self, mock_close, mock_create):\n+        \"\"\"Each streamed chat chunk should refresh the activity timestamp.\"\"\"\n+        from run_agent import AIAgent\n+\n+        chunks = [\n+            _make_stream_chunk(content=\"a\"),\n+            _make_stream_chunk(content=\"b\"),\n+            _make_stream_chunk(finish_reason=\"stop\"),\n+        ]\n+\n+        mock_client = MagicMock()\n+        mock_client.chat.completions.create.return_value = iter(chunks)\n+        mock_create.return_value = mock_client\n+\n+        agent = AIAgent(\n+            model=\"test/model\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+        agent.api_mode = \"chat_completions\"\n+        agent._interrupt_requested = False\n+\n+        touch_calls = []\n+        agent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        agent._interruptible_streaming_api_call({})\n+\n+        assert touch_calls.count(\"receiving stream response\") == len(chunks)\n+\n     @patch(\"run_agent.AIAgent._create_request_openai_client\")\n     @patch(\"run_agent.AIAgent._close_request_openai_client\")\n     def test_tool_only_does_not_fire_callback(self, mock_close, mock_create):","@@ -693,6 +725,55 @@ def test_codex_text_delta_fires_callback(self):\n         response = agent._run_codex_stream({}, client=mock_client)\n         assert \"Hello from Codex!\" in deltas\n \n+    def test_codex_stream_refreshes_activity_on_every_event(self):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            model=\"test/model\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+        agent.api_mode = \"codex_responses\"\n+        agent._interrupt_requested = False\n+\n+        touch_calls = []\n+        agent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        mock_event_text_1 = SimpleNamespace(\n+            type=\"response.output_text.delta\",\n+            delta=\"Hello\",\n+        )\n+        mock_event_text_2 = SimpleNamespace(\n+            type=\"response.output_text.delta\",\n+            delta=\" world\",\n+        )\n+        mock_event_done = SimpleNamespace(\n+            type=\"response.completed\",\n+            delta=\"\",\n+        )\n+\n+        mock_stream = MagicMock()\n+        mock_stream.__enter__ = MagicMock(return_value=mock_stream)\n+        mock_stream.__exit__ = MagicMock(return_value=False)\n+        mock_stream.__iter__ = MagicMock(\n+            return_value=iter([mock_event_text_1, mock_event_text_2, mock_event_done])\n+        )\n+        mock_stream.get_final_response.return_value = SimpleNamespace(\n+            output=[SimpleNamespace(\n+                type=\"message\",\n+                content=[SimpleNamespace(type=\"output_text\", text=\"Hello world\")],\n+            )],\n+            status=\"completed\",\n+        )\n+\n+        mock_client = MagicMock()\n+        mock_client.responses.stream.return_value = mock_stream\n+\n+        agent._run_codex_stream({}, client=mock_client)\n+\n+        assert touch_calls.count(\"receiving stream response\") == 3\n+\n     def test_codex_remote_protocol_error_falls_back_to_create_stream(self):\n         from run_agent import AIAgent\n         import httpx","@@ -724,3 +805,102 @@ def test_codex_remote_protocol_error_falls_back_to_create_stream(self):\n \n         assert response is fallback_response\n         mock_fallback.assert_called_once_with({}, client=mock_client)\n+\n+    def test_codex_create_stream_fallback_refreshes_activity_on_every_event(self):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            model=\"test/model\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+        agent.api_mode = \"codex_responses\"\n+\n+        touch_calls = []\n+        agent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        events = [\n+            SimpleNamespace(type=\"response.output_text.delta\", delta=\"Hello\"),\n+            SimpleNamespace(type=\"response.output_item.done\", item=SimpleNamespace(type=\"message\")),\n+            SimpleNamespace(\n+                type=\"response.completed\",\n+                response=SimpleNamespace(\n+                    output=[SimpleNamespace(\n+                        type=\"message\",\n+                        content=[SimpleNamespace(type=\"output_text\", text=\"Hello\")],\n+                    )]\n+                ),\n+            ),\n+        ]\n+\n+        class _FakeCreateStream:\n+            def __iter__(self_inner):\n+                return iter(events)\n+\n+            def close(self_inner):\n+                return None\n+\n+        mock_stream = _FakeCreateStream()\n+\n+        mock_client = MagicMock()\n+        mock_client.responses.create.return_value = mock_stream\n+\n+        agent._run_codex_create_stream_fallback(\n+            {\"model\": \"test/model\", \"instructions\": \"hi\", \"input\": []},\n+            client=mock_client,\n+        )\n+\n+        assert touch_calls.count(\"receiving stream response\") == len(events)\n+\n+\n+class TestAnthropicStreamCallbacks:\n+    \"\"\"Verify Anthropic streaming refreshes activity on every event.\"\"\"\n+\n+    def test_anthropic_stream_refreshes_activity_on_every_event(self):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            model=\"test/model\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+        agent.api_mode = \"anthropic_messages\"\n+        agent._interrupt_requested = False\n+\n+        touch_calls = []\n+        agent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        events = [\n+            SimpleNamespace(\n+                type=\"content_block_delta\",\n+                delta=SimpleNamespace(type=\"text_delta\", text=\"Hello\"),\n+            ),\n+            SimpleNamespace(\n+                type=\"content_block_delta\",\n+                delta=SimpleNamespace(type=\"thinking_delta\", thinking=\"thinking\"),\n+            ),\n+            SimpleNamespace(\n+                type=\"content_block_start\",\n+                content_block=SimpleNamespace(type=\"tool_use\", name=\"terminal\"),\n+            ),\n+        ]\n+\n+        final_message = SimpleNamespace(\n+            content=[],\n+            stop_reason=\"end_turn\",\n+        )\n+\n+        mock_stream = MagicMock()\n+        mock_stream.__enter__ = MagicMock(return_value=mock_stream)\n+        mock_stream.__exit__ = MagicMock(return_value=False)\n+        mock_stream.__iter__ = MagicMock(return_value=iter(events))\n+        mock_stream.get_final_message.return_value = final_message\n+\n+        agent._anthropic_client = MagicMock()\n+        agent._anthropic_client.messages.stream.return_value = mock_stream\n+\n+        agent._interruptible_streaming_api_call({})\n+\n+        assert touch_calls.count(\"receiving stream response\") == len(events)\n"]}]},{"kind":"pr","number":39782,"title":"fix(cron): contain timed-out cron workers (#18004)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54788,"title":"fix(cron): keep SessionDB open until agent teardown completes","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"ece36e1e57408524ce0c307480af932998dc7138","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":58777,"title":"fix(cron): move delivery before agent teardown to fix RuntimeError on shutdown","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5986cdd38","number":0},{"kind":"pr","value":"#59093","number":59093},{"kind":"pr","value":"#58779","number":58779}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58779,"title":"fix(cron): skip delivery/dispatch when the interpreter is shutting down (#58720)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8aab8be50","number":0},{"kind":"commit","value":"6d9eff28b","number":0},{"kind":"pr","value":"#59093","number":59093},{"kind":"pr","value":"#58777","number":58777}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59093,"title":"fix(cron): guard cron delivery against interpreter shutdown + deliver before agent teardown (#58720)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_cron_workdir.py","tests/cron/test_parallel_pool.py","tests/cron/test_run_one_job.py","tests/cron/test_scheduler.py","tests/cron/test_scheduler_shutdown_guard.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -407,6 +407,38 @@ def _shutdown_parallel_pool() -> None:\n atexit.register(_shutdown_parallel_pool)\n \n \n+def _interpreter_shutting_down(exc: Optional[BaseException] = None) -> bool:\n+    \"\"\"True when the Python interpreter is finalizing.\n+\n+    A cron tick can fire while the gateway is tearing down — SIGTERM from\n+    ``hermes update`` / ``hermes gateway stop`` / systemd restart, or an\n+    OOM-kill. Once finalization starts, ``concurrent.futures`` refuses new\n+    work with ``RuntimeError: cannot schedule new futures after interpreter\n+    shutdown`` and asyncio's default executor is gone, so *any* attempt to\n+    schedule delivery (live-adapter, ``asyncio.run``, or a fresh pool) is\n+    doomed and only pollutes ``errors.log`` with a traceback. Callers use\n+    this to skip gracefully with a warning instead of crashing (#58720,\n+    #55924).\n+\n+    ``exc`` lets a caller also treat an already-raised scheduling error as a\n+    shutdown signal: the ``concurrent.futures`` module-global flag can be set\n+    a hair before ``sys.is_finalizing()`` flips, so matching the error text is\n+    a safe fallback for that race.\n+    \"\"\"\n+    if sys.is_finalizing():\n+        return True\n+    if exc is not None:\n+        # Match the SHORT prefix deliberately: CPython emits two shutdown\n+        # variants — \"cannot schedule new futures after interpreter shutdown\"\n+        # (asyncio.run_coroutine_threadsafe / a torn-down default executor) and\n+        # \"cannot schedule new futures after shutdown\" (a plain\n+        # ThreadPoolExecutor). Both are documented in #58720. The common prefix\n+        # catches both; the sibling agent/tool_executor._is_interpreter_shutdown_submit_error\n+        # matches only the fuller \"...after interpreter shutdown\" form.\n+        return \"cannot schedule new futures\" in str(exc).lower()\n+    return False\n+\n+\n # Backward-compatible module override used by tests and emergency monkeypatches.\n _hermes_home: Path | None = None\n ","@@ -1758,16 +1790,37 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                 )\n \n         if not delivered:\n+            # If the interpreter is finalizing (gateway SIGTERM / restart /\n+            # OOM), scheduling any new delivery is futile — asyncio.run and a\n+            # fresh ThreadPoolExecutor both raise \"cannot schedule new futures\n+            # after interpreter shutdown\". Skip gracefully with a warning\n+            # rather than emitting an ERROR traceback on every restart-race\n+            # (#58720, #55924).\n+            if _interpreter_shutting_down():\n+                msg = f\"delivery to {platform_name}:{chat_id} skipped — interpreter is shutting down\"\n+                logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+                target_errors.append(msg)\n+                delivery_errors.extend(target_errors)\n+                continue\n             # Standalone path: run the async send in a fresh event loop (safe from any thread)\n             coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)\n             try:\n                 result = asyncio.run(coro)\n-            except RuntimeError:\n+            except RuntimeError as run_err:\n                 # asyncio.run() checks for a running loop before awaiting the coroutine;\n                 # when it raises, the original coro was never started — close it to\n                 # prevent \"coroutine was never awaited\" RuntimeWarning, then retry in a\n                 # fresh thread that has no running loop.\n                 coro.close()\n+                # If the RuntimeError is the interpreter-finalization signal,\n+                # the fresh-thread fallback would fail identically — skip\n+                # gracefully instead of logging a shutdown-race traceback.\n+                if _interpreter_shutting_down(run_err):\n+                    msg = f\"delivery to {platform_name}:{chat_id} skipped — interpreter is shutting down\"\n+                    logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+                    target_errors.append(msg)\n+                    delivery_errors.extend(target_errors)\n+                    continue\n                 # The thread-pool fallback can itself raise (SMTP ConnectionError,\n                 # future.result timeout, etc.). An exception raised inside this\n                 # `except RuntimeError` block is NOT caught by the sibling","@@ -1784,6 +1837,14 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                     finally:\n                         pool.shutdown(wait=False)\n                 except Exception as e:\n+                    # A shutdown-race here is expected during teardown; downgrade\n+                    # to a warning so it doesn't read as a genuine failure.\n+                    if _interpreter_shutting_down(e):\n+                        msg = f\"delivery to {platform_name}:{chat_id} skipped — interpreter is shutting down\"\n+                        logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+                        target_errors.append(msg)\n+                        delivery_errors.extend(target_errors)\n+                        continue\n                     msg = f\"delivery to {platform_name}:{chat_id} failed: {e}\"\n                     logger.error(\"Job '%s': %s\", job[\"id\"], msg, exc_info=True)\n                     target_errors.extend([msg])","@@ -2322,10 +2383,22 @@ def _guard_job_credential_exfil(job: dict) -> None:\n         raise RuntimeError(f\"Cron job '{job_id}' blocked for safety: {err}\")\n \n \n-def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n+def run_job(\n+    job: dict, *, defer_agent_teardown: Optional[list] = None\n+) -> tuple[bool, str, str, Optional[str]]:\n     \"\"\"\n     Execute a single cron job.\n-    \n+\n+    ``defer_agent_teardown``: when a caller passes a list, ``run_job`` skips\n+    the agent's async-resource teardown (``agent.close()`` +\n+    ``cleanup_stale_async_clients()``) in its ``finally`` block and instead\n+    appends the live agent to that list. The caller is then responsible for\n+    calling ``_teardown_cron_agent(agent)`` AFTER it has delivered the result.\n+    This closes the ordering window in #58720 where delivery ran against a\n+    torn-down async client (defense-in-depth alongside the interpreter-shutdown\n+    guard). When ``None`` (the default) teardown happens inline as before, so\n+    every existing caller is unchanged.\n+\n     Returns:\n         Tuple of (success, full_output_doc, final_response, error_message)\n     \"\"\"","@@ -3140,20 +3213,40 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n         # main OpenAI/httpx client held by this ephemeral cron agent. Without\n         # this, a gateway that ticks cron every N minutes leaks fds per job\n         # until it hits EMFILE (#10200 / \"too many open files\").\n-        try:\n+        #\n+        # When the caller opted to defer teardown (passed a list), hand the live\n+        # agent back instead of closing it here — delivery must run against a\n+        # live async client, and the caller tears down afterwards (#58720).\n+        if defer_agent_teardown is not None:\n             if agent is not None:\n-                agent.close()\n-        except (Exception, KeyboardInterrupt) as e:\n-            logger.debug(\"Job '%s': failed to close agent resources: %s\", job_id, e)\n-        # Each cron run spins up a short-lived worker thread whose event loop\n-        # dies as soon as the ``ThreadPoolExecutor`` shuts down. Any async\n-        # httpx clients cached under that loop are now unusable — reap them\n-        # so their transports don't accumulate in the process-global cache.\n-        try:\n-            from agent.auxiliary_client import cleanup_stale_async_clients\n-            cleanup_stale_async_clients()\n-        except Exception as e:\n-            logger.debug(\"Job '%s': failed to reap stale auxiliary clients: %s\", job_id, e)\n+                defer_agent_teardown.append(agent)\n+        else:\n+            _teardown_cron_agent(agent, job_id)\n+\n+\n+def _teardown_cron_agent(agent, job_id: str) -> None:\n+    \"\"\"Release an ephemeral cron agent's async resources.\n+\n+    Split out of ``run_job``'s ``finally`` so a caller that defers teardown\n+    (to deliver first — #58720) can invoke the identical cleanup AFTER delivery.\n+    Closes the agent (subprocesses, sandboxes, browser daemons, OpenAI/httpx\n+    client) and reaps stale async clients whose loop has since closed. Idempotent\n+    and independently guarded, matching the original inline behavior.\n+    \"\"\"\n+    try:\n+        if agent is not None:\n+            agent.close()\n+    except (Exception, KeyboardInterrupt) as e:\n+        logger.debug(\"Job '%s': failed to close agent resources: %s\", job_id, e)\n+    # Each cron run spins up a short-lived worker thread whose event loop\n+    # dies as soon as the ``ThreadPoolExecutor`` shuts down. Any async\n+    # httpx clients cached under that loop are now unusable — reap them\n+    # so their transports don't accumulate in the process-global cache.\n+    try:\n+        from agent.auxiliary_client import cleanup_stale_async_clients\n+        cleanup_stale_async_clients()\n+    except Exception as e:\n+        logger.debug(\"Job '%s': failed to reap stale auxiliary clients: %s\", job_id, e)\n \n \n def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -> bool:","@@ -3202,40 +3295,72 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -\n         _scope_token = [redacted:secret-assignment]\n             build_profile_secret_scope(_get_hermes_home())\n         )\n+        # Defer the cron agent's async-resource teardown until AFTER delivery.\n+        # run_job normally closes the agent (and reaps stale async clients) in\n+        # its finally block; doing that before _deliver_result runs means the\n+        # live send races a torn-down async client (#58720). Passing a holder\n+        # list makes run_job hand the agent back instead, and we tear it down\n+        # below once delivery is done. Defense-in-depth alongside the\n+        # interpreter-shutdown guard in _deliver_result.\n+        _deferred_agents: list = []\n         try:\n-            success, output, final_response, error = run_job(job)\n+            success, output, final_response, error = run_job(\n+                job, defer_agent_teardown=_deferred_agents\n+            )\n+        except BaseException:\n+            # run_job's finally still hands back the agent when it raises; tear\n+            # it down here so a failed run never leaks its async resources\n+            # (#10200), then re-raise into the outer handler. BaseException\n+            # (not just Exception) so a KeyboardInterrupt/SystemExit mid-run\n+            # still triggers teardown before propagating.\n+            for _deferred_agent in _deferred_agents:\n+                _teardown_cron_agent(_deferred_agent, job[\"id\"])\n+            raise\n         finally:\n             reset_secret_scope(_scope_token)\n \n-        output_file = save_job_output(job[\"id\"], output)\n-        if verbose:\n-            logger.info(\"Output saved to: %s\", output_file)\n-\n-        # Deliver the final response to the origin/target chat.\n-        # If the agent responded with [SILENT], skip delivery (but\n-        # output is already saved above).  Failed jobs always deliver.\n-        deliver_content = final_response if success else _summarize_cron_failure_for_delivery(job, error)\n-        # Treat whitespace-only final responses the same as empty\n-        # responses: do not deliver a blank message, and let the\n-        # empty-response guard below mark the run as a soft failure.\n-        should_deliver = bool(deliver_content.strip())\n-        # Cron silence suppression — see _is_cron_silence_response.  Replaces the\n-        # old `SILENT_MARKER in ...upper()` substring check, which both leaked\n-        # bracketless near-markers (\"SILENT\" / \"NO_REPLY\") and wrongly swallowed\n-        # a real report that merely quoted \"[SILENT]\" mid-sentence (#51438,\n-        # #46917).  Keeps the intentional bracketed-prefix / trailing-line\n-        # tolerance the cron contract relies on.\n-        if should_deliver and success and _is_cron_silence_response(deliver_content):\n-            logger.info(\"Job '%s': agent returned %s — skipping delivery\", job[\"id\"], SILENT_MARKER)\n-            should_deliver = False\n-\n+        # Everything from here through delivery runs with the agent still live\n+        # (deferred teardown). Wrap it ALL in a try/finally so that if any step\n+        # between run_job returning and delivery — save_job_output, the [SILENT]\n+        # / empty-response computation, or _deliver_result itself — raises, the\n+        # deferred agent is still torn down. Otherwise the outer `except` would\n+        # swallow the error and leak the agent's subprocesses/clients (#10200).\n         delivery_error = None\n-        if should_deliver:\n-            try:\n-                delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop)\n-            except Exception as de:\n-                delivery_error = str(de)\n-                logger.error(\"Delivery failed for job %s: %s\", job[\"id\"], de)\n+        try:\n+            output_file = save_job_output(job[\"id\"], output)\n+            if verbose:\n+                logger.info(\"Output saved to: %s\", output_file)\n+\n+            # Deliver the final response to the origin/target chat.\n+            # If the agent responded with [SILENT], skip delivery (but\n+            # output is already saved above).  Failed jobs always deliver.\n+            deliver_content = final_response if success else _summarize_cron_failure_for_delivery(job, error)\n+            # Treat whitespace-only final responses the same as empty\n+            # responses: do not deliver a blank message, and let the\n+            # empty-response guard below mark the run as a soft failure.\n+            should_deliver = bool(deliver_content.strip())\n+            # Cron silence suppression — see _is_cron_silence_response.  Replaces the\n+            # old `SILENT_MARKER in ...upper()` substring check, which both leaked\n+            # bracketless near-markers (\"SILENT\" / \"NO_REPLY\") and wrongly swallowed\n+            # a real report that merely quoted \"[SILENT]\" mid-sentence (#51438,\n+            # #46917).  Keeps the intentional bracketed-prefix / trailing-line\n+            # tolerance the cron contract relies on.\n+            if should_deliver and success and _is_cron_silence_response(deliver_content):\n+                logger.info(\"Job '%s': agent returned %s — skipping delivery\", job[\"id\"], SILENT_MARKER)\n+                should_deliver = False\n+\n+            if should_deliver:\n+                try:\n+                    delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop)\n+                except Exception as de:\n+                    delivery_error = str(de)\n+                    logger.error(\"Delivery failed for job %s: %s\", job[\"id\"], de)\n+        finally:\n+            # Tear down the deferred agent(s) now that save + delivery have run\n+            # (or raised). Must happen on every path so cron agents never leak\n+            # their subprocesses/clients (#10200).\n+            for _deferred_agent in _deferred_agents:\n+                _teardown_cron_agent(_deferred_agent, job[\"id\"])\n \n         # Treat empty final_response as a soft failure so last_status\n         # is not \"ok\" — the agent ran but produced nothing useful.","@@ -3375,6 +3500,17 @@ def _submit_with_guard(job: dict, pool: concurrent.futures.ThreadPoolExecutor):\n             membership is released in the worker's finally block.\n             \"\"\"\n             job_id = job[\"id\"]\n+            # A tick can race gateway teardown: once the interpreter is\n+            # finalizing, ``pool.submit`` raises \"cannot schedule new futures\n+            # after interpreter shutdown\" and crashes the tick. Skip cleanly —\n+            # the job stays due and will fire on the next healthy tick\n+            # (#58720, #55924).\n+            if _interpreter_shutting_down():\n+                logger.warning(\n+                    \"Job '%s' not dispatched — interpreter is shutting down\",\n+                    job.get(\"name\", job_id),\n+                )\n+                return None\n             with _running_lock:\n                 if job_id in _running_job_ids:\n                     logger.info(\"Job '%s' already running — skipping\", job.get(\"name\", job_id))","@@ -3389,7 +3525,20 @@ def _run_and_release(j=job, ctx=_ctx):\n                     with _running_lock:\n                         _running_job_ids.discard(j[\"id\"])\n \n-            return pool.submit(_run_and_release)\n+            try:\n+                return pool.submit(_run_and_release)\n+            except RuntimeError as submit_err:\n+                # Interpreter began finalizing between the guard above and the\n+                # submit — release the in-flight claim we just took and skip.\n+                if _interpreter_shutting_down(submit_err):\n+                    with _running_lock:\n+                        _running_job_ids.discard(job_id)\n+                    logger.warning(\n+                        \"Job '%s' not dispatched — interpreter is shutting down\",\n+                        job.get(\"name\", job_id),\n+                    )\n+                    return None\n+                raise\n \n         # Sequential pass for env-mutating (workdir) jobs.\n         # Queued to a persistent single-thread pool so they run one at a time"]},{"file":"tests/cron/test_cron_workdir.py","hunks":["@@ -220,7 +220,7 @@ def test_workdir_jobs_run_sequentially(self, tmp_path, monkeypatch):\n         calls: list[tuple[str, str]] = []\n         order_lock = threading.Lock()\n \n-        def fake_run_job(job):\n+        def fake_run_job(job, *, defer_agent_teardown=None):\n             # Return a minimal tuple matching run_job's signature.\n             with order_lock:\n                 calls.append((job[\"id\"], threading.current_thread().name))"]},{"file":"tests/cron/test_parallel_pool.py","hunks":["@@ -84,7 +84,7 @@ def test_running_set_prevents_double_dispatch(self, tmp_path, monkeypatch):\n         dispatched = []\n         monkeypatch.setattr(sched, \"get_due_jobs\", lambda: [job])\n         monkeypatch.setattr(sched, \"advance_next_run\", lambda *_a, **_kw: None)\n-        monkeypatch.setattr(sched, \"run_job\", lambda j: dispatched.append(j[\"id\"]) or (True, \"out\", \"resp\", None))\n+        monkeypatch.setattr(sched, \"run_job\", lambda j, **_kw: dispatched.append(j[\"id\"]) or (True, \"out\", \"resp\", None))\n         monkeypatch.setattr(sched, \"save_job_output\", lambda *_a, **_kw: None)\n         monkeypatch.setattr(sched, \"mark_job_run\", lambda *_a, **_kw: None)\n         monkeypatch.setattr(sched, \"_deliver_result\", lambda *_a, **_kw: None)","@@ -117,7 +117,7 @@ def test_sync_true_blocks_and_returns_correct_count(self, tmp_path, monkeypatch)\n \n         monkeypatch.setattr(sched, \"get_due_jobs\", lambda: jobs)\n         monkeypatch.setattr(sched, \"advance_next_run\", lambda *_a, **_kw: None)\n-        monkeypatch.setattr(sched, \"run_job\", lambda j: (True, \"out\", \"resp\", None))\n+        monkeypatch.setattr(sched, \"run_job\", lambda j, **_kw: (True, \"out\", \"resp\", None))\n         monkeypatch.setattr(sched, \"save_job_output\", lambda *_a, **_kw: \"/tmp/out\")\n         monkeypatch.setattr(sched, \"mark_job_run\", lambda *_a, **_kw: None)\n         monkeypatch.setattr(sched, \"_deliver_result\", lambda *_a, **_kw: None)","@@ -147,7 +147,7 @@ def test_sync_false_returns_immediately(self, tmp_path, monkeypatch):\n \n         barrier = threading.Barrier(2, timeout=5)\n \n-        def slow_run(j):\n+        def slow_run(j, *, defer_agent_teardown=None):\n             barrier.wait()  # blocks until test thread also waits\n             return True, \"out\", \"resp\", None\n ","@@ -201,7 +201,7 @@ def test_sequential_job_does_not_block_ticker(self, tmp_path, monkeypatch):\n \n         barrier = threading.Barrier(2, timeout=5)\n \n-        def slow_run(j):\n+        def slow_run(j, *, defer_agent_teardown=None):\n             barrier.wait()\n             return True, \"out\", \"resp\", None\n ","@@ -249,7 +249,7 @@ def test_sequential_running_guard_prevents_double_dispatch(self, tmp_path, monke\n         dispatched = []\n         monkeypatch.setattr(sched, \"get_due_jobs\", lambda: [job])\n         monkeypatch.setattr(sched, \"advance_next_run\", lambda *_a, **_kw: None)\n-        monkeypatch.setattr(sched, \"run_job\", lambda j: dispatched.append(j[\"id\"]) or (True, \"out\", \"resp\", None))\n+        monkeypatch.setattr(sched, \"run_job\", lambda j, **_kw: dispatched.append(j[\"id\"]) or (True, \"out\", \"resp\", None))\n         monkeypatch.setattr(sched, \"save_job_output\", lambda *_a, **_kw: None)\n         monkeypatch.setattr(sched, \"mark_job_run\", lambda *_a, **_kw: None)\n         monkeypatch.setattr(sched, \"_deliver_result\", lambda *_a, **_kw: None)"]},{"file":"tests/cron/test_run_one_job.py","hunks":["@@ -18,7 +18,7 @@ def _patch_pipeline(monkeypatch, *, success=True, output=\"out\", final=\"final res\n     \"\"\"Patch the job pipeline primitives and record the call order.\"\"\"\n     calls = []\n \n-    def fake_run_job(job):\n+    def fake_run_job(job, *, defer_agent_teardown=None):\n         calls.append((\"run_job\", job[\"id\"]))\n         fr = final if silent_marker_in is None else silent_marker_in\n         return (success, output, fr, error)","@@ -103,7 +103,7 @@ def test_run_one_job_failed_job_delivers_error(monkeypatch):\n def test_run_one_job_exception_marks_failure(monkeypatch):\n     \"\"\"If run_job raises, the helper marks the run failed and returns False\n     rather than propagating.\"\"\"\n-    def boom(job):\n+    def boom(job, *, defer_agent_teardown=None):\n         raise RuntimeError(\"kaboom\")\n \n     monkeypatch.setattr(s, \"run_job\", boom)","@@ -136,7 +136,7 @@ def test_run_one_job_installs_secret_scope_under_multiplex(monkeypatch, tmp_path\n \n     scope_during_run = {}\n \n-    def fake_run_job(job):\n+    def fake_run_job(job, *, defer_agent_teardown=None):\n         # This is where resolve_runtime_provider() would read a secret. Prove a\n         # scope is installed and the profile's secret resolves without raising.\n         scope_during_run[\"scope\"] = ss.current_secret_scope()","@@ -160,3 +160,117 @@ def fake_run_job(job):\n     assert scope_during_run[\"base_url\"] == \"https://openrouter.ai/api/v1\"\n     # And it was torn down after run_one_job returned (no leak).\n     assert ss.current_secret_scope() is None\n+\n+\n+def test_run_one_job_delivers_before_agent_teardown(monkeypatch):\n+    \"\"\"Regression for #58720: the cron agent's async-resource teardown\n+    (agent.close + cleanup_stale_async_clients) MUST run AFTER delivery, not\n+    before. run_job defers teardown by appending the live agent to the holder\n+    list; run_one_job tears it down only after _deliver_result has run. If the\n+    order flips, delivery races a torn-down async client and dies with\n+    'cannot schedule new futures after interpreter shutdown'.\n+    \"\"\"\n+    order = []\n+\n+    class FakeAgent:\n+        def close(self):\n+            order.append(\"agent.close\")\n+\n+    def fake_run_job(job, *, defer_agent_teardown=None):\n+        order.append(\"run_job\")\n+        # Mimic run_job's deferral contract: hand the live agent back so the\n+        # caller tears it down after delivery instead of in run_job's finally.\n+        assert defer_agent_teardown is not None, \"run_one_job must defer teardown\"\n+        defer_agent_teardown.append(FakeAgent())\n+        return (True, \"out\", \"final response\", None)\n+\n+    def fake_deliver(job, content, adapters=None, loop=None):\n+        order.append(\"deliver\")\n+        return None\n+\n+    monkeypatch.setattr(s, \"run_job\", fake_run_job)\n+    monkeypatch.setattr(s, \"save_job_output\", lambda jid, out: f\"/tmp/{jid}.txt\")\n+    monkeypatch.setattr(s, \"_deliver_result\", fake_deliver)\n+    monkeypatch.setattr(s, \"mark_job_run\", lambda *a, **k: None)\n+    # cleanup_stale_async_clients is imported lazily inside _teardown_cron_agent;\n+    # stub it so the teardown records its own marker without touching real caches.\n+    import agent.auxiliary_client as aux\n+    monkeypatch.setattr(aux, \"cleanup_stale_async_clients\",\n+                        lambda: order.append(\"cleanup_stale\"))\n+\n+    ok = s.run_one_job({\"id\": \"j8\", \"name\": \"t\"})\n+\n+    assert ok is True\n+    # Delivery must strictly precede agent teardown + stale-client reap.\n+    assert order == [\"run_job\", \"deliver\", \"agent.close\", \"cleanup_stale\"], order\n+\n+\n+def test_run_one_job_tears_down_deferred_agent_when_delivery_raises(monkeypatch):\n+    \"\"\"Even if _deliver_result raises, the deferred agent is still torn down\n+    (no fd/client leak — #10200). Teardown lives in a finally around delivery.\n+    \"\"\"\n+    order = []\n+\n+    class FakeAgent:\n+        def close(self):\n+            order.append(\"agent.close\")\n+\n+    def fake_run_job(job, *, defer_agent_teardown=None):\n+        defer_agent_teardown.append(FakeAgent())\n+        return (True, \"out\", \"final response\", None)\n+\n+    def boom_deliver(job, content, adapters=None, loop=None):\n+        order.append(\"deliver-raise\")\n+        raise RuntimeError(\"send blew up\")\n+\n+    monkeypatch.setattr(s, \"run_job\", fake_run_job)\n+    monkeypatch.setattr(s, \"save_job_output\", lambda jid, out: f\"/tmp/{jid}.txt\")\n+    monkeypatch.setattr(s, \"_deliver_result\", boom_deliver)\n+    monkeypatch.setattr(s, \"mark_job_run\", lambda *a, **k: None)\n+    import agent.auxiliary_client as aux\n+    monkeypatch.setattr(aux, \"cleanup_stale_async_clients\",\n+                        lambda: order.append(\"cleanup_stale\"))\n+\n+    ok = s.run_one_job({\"id\": \"j9\", \"name\": \"t\"})\n+\n+    assert ok is True  # delivery error is recorded, not propagated\n+    assert order == [\"deliver-raise\", \"agent.close\", \"cleanup_stale\"], order\n+\n+\n+def test_run_one_job_tears_down_deferred_agent_when_save_raises(monkeypatch):\n+    \"\"\"#58720 W1: if save_job_output (or the [SILENT]/empty computation) raises\n+    AFTER run_job hands the agent back but BEFORE delivery, the deferred agent\n+    must still be torn down. The outer `except` would otherwise swallow the\n+    error and leak the agent (#10200). Teardown lives in a finally spanning\n+    save→deliver.\n+    \"\"\"\n+    order = []\n+\n+    class FakeAgent:\n+        def close(self):\n+            order.append(\"agent.close\")\n+\n+    def fake_run_job(job, *, defer_agent_teardown=None):\n+        defer_agent_teardown.append(FakeAgent())\n+        return (True, \"out\", \"final response\", None)\n+\n+    def boom_save(jid, out):\n+        order.append(\"save-raise\")\n+        raise RuntimeError(\"disk full\")\n+\n+    monkeypatch.setattr(s, \"run_job\", fake_run_job)\n+    monkeypatch.setattr(s, \"save_job_output\", boom_save)\n+    monkeypatch.setattr(s, \"_deliver_result\",\n+                        lambda *a, **k: order.append(\"deliver\"))\n+    monkeypatch.setattr(s, \"mark_job_run\", lambda *a, **k: None)\n+    import agent.auxiliary_client as aux\n+    monkeypatch.setattr(aux, \"cleanup_stale_async_clients\",\n+                        lambda: order.append(\"cleanup_stale\"))\n+\n+    ok = s.run_one_job({\"id\": \"j10\", \"name\": \"t\"})\n+\n+    # save raised → outer handler marks failure and returns False, but the\n+    # deferred agent was still torn down (no delivery, no leak).\n+    assert ok is False\n+    assert \"deliver\" not in order\n+    assert order == [\"save-raise\", \"agent.close\", \"cleanup_stale\"], order"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -2589,7 +2589,7 @@ def test_claim_runs_before_run_job(self):\n         order = []\n         with patch(\"cron.scheduler.get_due_jobs\", return_value=[self._oneshot()]), \\\n              patch(\"cron.scheduler.claim_dispatch\", side_effect=lambda _id: order.append(\"claim\") or True), \\\n-             patch(\"cron.scheduler.run_job\", side_effect=lambda _j: order.append(\"run\") or (True, \"# out\", \"ok\", None)), \\\n+             patch(\"cron.scheduler.run_job\", side_effect=lambda _j, **_kw: order.append(\"run\") or (True, \"# out\", \"ok\", None)), \\\n              patch(\"cron.scheduler.save_job_output\", return_value=\"/tmp/out.md\"), \\\n              patch(\"cron.scheduler._deliver_result\"), \\\n              patch(\"cron.scheduler.mark_job_run\"):","@@ -3010,7 +3010,7 @@ def test_parallel_jobs_run_concurrently(self):\n         barrier = threading.Barrier(2, timeout=5)\n         call_order = []\n \n-        def mock_run_job(job):\n+        def mock_run_job(job, *, defer_agent_teardown=None):\n             \"\"\"Each job hits a barrier — both must be active simultaneously.\"\"\"\n             call_order.append((\"start\", job[\"id\"]))\n             barrier.wait()  # blocks until both threads reach here","@@ -3044,7 +3044,7 @@ def test_parallel_jobs_isolated_contextvars(self):\n         from gateway.session_context import get_session_env\n         seen = {}\n \n-        def mock_run_job(job):\n+        def mock_run_job(job, *, defer_agent_teardown=None):\n             origin = job.get(\"origin\", {})\n             # run_job sets ContextVars — verify each job sees its own\n             from gateway.session_context import set_session_vars, clear_session_vars","@@ -3084,7 +3084,7 @@ def test_max_parallel_env_var(self, monkeypatch):\n         monkeypatch.setenv(\"HERMES_CRON_MAX_PARALLEL\", \"1\")\n         call_times = []\n \n-        def mock_run_job(job):\n+        def mock_run_job(job, *, defer_agent_teardown=None):\n             import time\n             call_times.append((\"start\", job[\"id\"], time.monotonic()))\n             time.sleep(0.05)"]},{"file":"tests/cron/test_scheduler_shutdown_guard.py","hunks":["@@ -0,0 +1,137 @@\n+\"\"\"Regression coverage for #58720 / #55924 — cron scheduling races\n+interpreter finalization.\n+\n+When the gateway tears down (SIGTERM from ``hermes update`` /\n+``hermes gateway stop`` / systemd restart, or an OOM-kill), a cron tick can\n+still fire. Once the Python interpreter is finalizing, ``concurrent.futures``\n+refuses new work with ``RuntimeError: cannot schedule new futures after\n+interpreter shutdown`` and asyncio's default executor is gone. The cron\n+delivery + dispatch paths used to hit that unguarded, crashing the tick and\n+spraying a traceback into ``errors.log`` on every restart-race.\n+\n+The fix adds ``_interpreter_shutting_down()`` and guards the scheduling\n+sites so they skip gracefully with a warning instead of raising.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import sys\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+\n+class TestInterpreterShuttingDownHelper:\n+    def test_true_when_finalizing(self):\n+        from cron.scheduler import _interpreter_shutting_down\n+\n+        with patch(\"sys.is_finalizing\", return_value=True):\n+            assert _interpreter_shutting_down() is True\n+\n+    def test_false_when_not_finalizing_and_no_exc(self):\n+        from cron.scheduler import _interpreter_shutting_down\n+\n+        with patch(\"sys.is_finalizing\", return_value=False):\n+            assert _interpreter_shutting_down() is False\n+\n+    def test_matches_shutdown_error_text_as_fallback(self):\n+        \"\"\"The concurrent.futures module-global flag can be set a hair before\n+        ``sys.is_finalizing()`` flips — matching the error text catches that\n+        race so a shutdown RuntimeError isn't misread as a real failure.\"\"\"\n+        from cron.scheduler import _interpreter_shutting_down\n+\n+        exc = RuntimeError(\"cannot schedule new futures after interpreter shutdown\")\n+        with patch(\"sys.is_finalizing\", return_value=False):\n+            assert _interpreter_shutting_down(exc) is True\n+\n+    def test_unrelated_error_is_not_shutdown(self):\n+        from cron.scheduler import _interpreter_shutting_down\n+\n+        exc = RuntimeError(\"some other problem\")\n+        with patch(\"sys.is_finalizing\", return_value=False):\n+            assert _interpreter_shutting_down(exc) is False\n+\n+\n+class TestStandaloneDeliverySkipsDuringShutdown:\n+    def _telegram_cfg(self):\n+        from gateway.config import Platform\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        return mock_cfg\n+\n+    def test_standalone_path_skips_without_scheduling(self):\n+        \"\"\"With the interpreter finalizing, the standalone delivery path must\n+        skip BEFORE attempting to schedule the send — no ``_send_to_platform``\n+        call, a graceful warning-level skip, and an error string returned\n+        (not a raised exception).\"\"\"\n+        from cron.scheduler import _deliver_result\n+\n+        job = {\n+            \"id\": \"gov-job\",\n+            \"name\": \"model-governor\",\n+            \"deliver\": \"origin\",\n+            \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+        }\n+        send_mock = AsyncMock(return_value={\"success\": True})\n+        with patch(\"gateway.config.load_gateway_config\", return_value=self._telegram_cfg()), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=send_mock), \\\n+             patch(\"sys.is_finalizing\", return_value=True):\n+            result = _deliver_result(job, \"daily report body\")\n+\n+        send_mock.assert_not_called()\n+        assert result is not None\n+        assert \"shutting down\" in result\n+\n+    def test_normal_delivery_still_works_when_not_finalizing(self):\n+        \"\"\"Guard must not regress the happy path: a normal (non-finalizing)\n+        run still delivers via the standalone send.\"\"\"\n+        from cron.scheduler import _deliver_result\n+\n+        job = {\n+            \"id\": \"gov-job\",\n+            \"name\": \"model-governor\",\n+            \"deliver\": \"origin\",\n+            \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+        }\n+        send_mock = AsyncMock(return_value={\"success\": True})\n+        with patch(\"gateway.config.load_gateway_config\", return_value=self._telegram_cfg()), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=send_mock), \\\n+             patch(\"sys.is_finalizing\", return_value=False):\n+            result = _deliver_result(job, \"daily report body\")\n+\n+        send_mock.assert_called_once()\n+        assert result is None\n+\n+\n+class TestSourceGuardrail:\n+    @pytest.fixture\n+    def source(self) -> str:\n+        from pathlib import Path\n+\n+        return (\n+            Path(__file__).resolve().parents[2] / \"cron\" / \"scheduler.py\"\n+        ).read_text(encoding=\"utf-8\")\n+\n+    def test_helper_defined(self, source):\n+        assert \"def _interpreter_shutting_down(\" in source\n+        assert \"#58720\" in source\n+\n+    def test_helper_guards_dispatch_submit(self, source):\n+        \"\"\"The tick dispatch (``_submit_with_guard``) must consult the guard so\n+        a tick that races teardown skips instead of crashing.\"\"\"\n+        idx_submit = source.find(\"def _submit_with_guard(\")\n+        assert idx_submit >= 0\n+        tail = source[idx_submit:idx_submit + 1600]\n+        assert \"_interpreter_shutting_down(\" in tail\n+\n+    def test_helper_guards_standalone_delivery(self, source):\n+        \"\"\"The standalone delivery path must consult the guard before\n+        scheduling ``asyncio.run`` / a fresh pool.\"\"\"\n+        idx = source.find(\"Standalone path: run the async send\")\n+        assert idx >= 0\n+        # The guard appears shortly before the standalone send comment.\n+        window = source[max(0, idx - 600):idx]\n+        assert \"_interpreter_shutting_down()\" in window\n"]}]},{"kind":"pr","number":65227,"title":"fix(cron): wait for agent cleanup after interrupt before closing SessionDB (#65208)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#104456","number":104456},{"kind":"pr","value":"#103118","number":103118}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":65285,"title":"fix(cron): bound worker wait before SessionDB close (#65208)","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":104456,"title":"SessionDB teardown no longer overlaps a live handle for the same state.db: counted per-path barrier + cron workers own their teardown (#102827, salvage of #103118 by @JoaoMarcos44)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/trace_upload.py","cron/scheduler.py","cron/scheduler_detached_worker.py","gateway/platforms/api_server.py","gateway/run.py","hermes_cli/web_server_lifecycle.py","hermes_cli/web_server_sessions.py","hermes_state_registry.py","tests/cron/test_cleanup_timeout.py","tests/cron/test_scheduler.py","tests/hermes_state/test_shared_session_db_registry.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}