{"repo":"NousResearch/hermes-agent","public_id":"47056","display_label":"Cron delivery silently dropped with active user session","summary":"These issues and fixes address a bug where scheduled (cron) message delivery is silently dropped or unconfirmed when a live/gateway user session is active on the same chat. The fixes improve delivery confirmation reliability and add fallback to standalone delivery when the live adapter fails silently.","subsystem":"cron","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50018},"resolution":{"state":"partially_resolved","by":"pr-50018"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T07:59:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":38922,"pr_number":50018,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":43014,"pr_number":50018,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":47056,"pr_number":50018,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":47056,"title":"[Bug]: Cron delivery silently dropped when gateway has active user session (origin delivery)","url":null,"state":"closed"},{"kind":"issue","number":60121,"title":"Cron delivery to Telegram silently dropped when user session is active on same chat","url":null,"state":"closed"},{"kind":"pr","number":47127,"title":"fix(cron): fall through when live delivery is unconfirmed (#47056)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"02a3288de","number":0},{"kind":"pr","value":"#50018","number":50018}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47484,"title":"fix(cron): fall back to standalone delivery when live adapter fails silently","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"02a3288de","number":0},{"kind":"pr","value":"#47056","number":47056},{"kind":"pr","value":"#50018","number":50018},{"kind":"pr","value":"#38922","number":38922}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50018,"title":"fix(cron): make live-adapter delivery confirmation reliable (#38922, #47056, #43014)","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_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -710,6 +710,27 @@ def _send_media_via_adapter(\n             logger.warning(\"Job '%s': failed to send media %s: %s\", job.get(\"id\", \"?\"), media_path, e)\n \n \n+def _confirm_adapter_delivery(send_result) -> bool:\n+    \"\"\"Return True only if ``send_result`` unambiguously confirms delivery.\n+\n+    A live adapter that returns ``None`` (e.g. a swallowed exception, a busy\n+    platform, or a code path that returns early without producing a\n+    ``SendResult``) must NOT be treated as success — doing so causes the\n+    scheduler to log ``\"delivered to <chat> via live adapter\"`` while the\n+    gateway never actually sees the message (#47056).\n+\n+    Likewise, an object missing a ``success`` attribute (e.g. a bare ``dict``\n+    or a partial mock) is a contract violation: it does not actually tell us\n+    whether the send succeeded.  Require an explicit, truthy ``success``\n+    attribute to count as confirmed.\n+    \"\"\"\n+    if send_result is None:\n+        return False\n+    if not hasattr(send_result, \"success\"):\n+        return False\n+    return bool(getattr(send_result, \"success\"))\n+\n+\n def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Optional[str]:\n     \"\"\"\n     Deliver job output to the configured target(s) (origin chat, specific platform, etc.).","@@ -723,11 +744,25 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n     \"\"\"\n     targets = _resolve_delivery_targets(job)\n     if not targets:\n-        if job.get(\"deliver\", \"local\") != \"local\":\n-            msg = f\"no delivery target resolved for deliver={job.get('deliver', 'local')}\"\n-            logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n-            return msg\n-        return None  # local-only jobs don't deliver — not a failure\n+        deliver_value = _normalize_deliver_value(job.get(\"deliver\", \"local\"))\n+        if deliver_value == \"local\":\n+            return None  # local-only jobs don't deliver — not a failure\n+        # deliver=origin with no resolvable origin and no configured home\n+        # channels: treat as local rather than reporting an error.  CLI-created\n+        # jobs never capture a {platform, chat_id} origin, so failing here would\n+        # make every CLI `deliver=origin` (or auto-detect) job emit a spurious\n+        # \"no delivery target resolved\" error on every run (#43014).  The output\n+        # is still persisted in last_output for `cron list`/resume.\n+        if deliver_value == \"origin\":\n+            logger.info(\n+                \"Job '%s': deliver=origin but no origin or home channels — \"\n+                \"skipping delivery (output saved in last_output)\",\n+                job.get(\"name\", job.get(\"id\", \"?\")),\n+            )\n+            return None\n+        msg = f\"no delivery target resolved for deliver={deliver_value}\"\n+        logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+        return msg\n \n     from tools.send_message_tool import _send_to_platform\n     from gateway.config import load_gateway_config, Platform","@@ -817,6 +852,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                 # Send cleaned text (MEDIA tags stripped) — not the raw content\n                 text_to_send = cleaned_delivery_content.strip()\n                 adapter_ok = True\n+                timed_out = False\n                 if text_to_send:\n                     from agent.async_utils import safe_schedule_threadsafe\n                     future = safe_schedule_threadsafe(","@@ -827,19 +863,81 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                         adapter_ok = False\n                         target_errors.append(\"live adapter event loop scheduling failed\")\n                     else:\n+                        send_result = None\n+                        timeout_handled = False\n                         try:\n                             send_result = future.result(timeout=60)\n-                        except TimeoutError as te:\n-                            future.cancel()\n-                            target_errors.append(f\"live adapter send timed out: {te}\")\n-                            raise\n+                        except TimeoutError:\n+                            # #38922: a slow confirmation does NOT necessarily\n+                            # mean the send failed — but we must distinguish two\n+                            # cases via future.cancel()'s return value:\n+                            #\n+                            #   cancel() == False -> the coroutine was already\n+                            #     running on the gateway loop when the timeout\n+                            #     fired; the request is in flight on the wire and\n+                            #     cannot be un-sent.  Re-sending via standalone\n+                            #     would be a guaranteed DUPLICATE, so treat it as\n+                            #     delivered (assume-delivered).\n+                            #\n+                            #   cancel() == True -> the scheduled callback never\n+                            #     started executing (loop wedged/backlogged for\n+                            #     the full 60s), so nothing was sent.  We MUST\n+                            #     fall through to the standalone path or the\n+                            #     message is silently dropped (worse than a\n+                            #     duplicate).\n+                            cancelled = future.cancel()\n+                            if cancelled:\n+                                msg = (\n+                                    f\"live adapter send to {platform_name}:{chat_id} \"\n+                                    \"timed out before the coroutine was dispatched\"\n+                                )\n+                                logger.warning(\n+                                    \"Job '%s': %s, falling back to standalone\",\n+                                    job[\"id\"], msg,\n+                                )\n+                                target_errors.append(msg)\n+                                adapter_ok = False  # fall through to standalone path\n+                                timeout_handled = True\n+                            else:\n+                                timed_out = True\n+                                timeout_handled = True\n+                                logger.warning(\n+                                    \"Job '%s': live adapter send to %s:%s timed out \"\n+                                    \"after 60s; already dispatched (in flight), \"\n+                                    \"assuming delivered (skipping standalone fallback \"\n+                                    \"to avoid duplicate)\",\n+                                    job[\"id\"], platform_name, chat_id,\n+                                )\n                         except Exception as ex:\n+                            # A real send error (not a slow confirmation) — fall\n+                            # through to the standalone path so the message is\n+                            # still delivered.\n                             target_errors.append(f\"live adapter send failed: {ex}\")\n                             raise\n \n-                        if send_result is None or not getattr(send_result, \"success\", True):\n-                            err = getattr(send_result, \"error\", \"unknown\") if send_result else \"no response from adapter\"\n-                            msg = f\"live adapter send to {platform_name}:{chat_id} failed: {err}\"\n+                        if timeout_handled:\n+                            # The timeout branch above already decided the\n+                            # outcome (assume-delivered if in flight, or\n+                            # adapter_ok=False to fall through if never\n+                            # dispatched).  send_result is None, so skip the\n+                            # confirmation/thread-fallback inspection below.\n+                            pass\n+                        elif not _confirm_adapter_delivery(send_result):\n+                            # A ``None`` return or a result object missing an\n+                            # explicit ``success`` attribute is NOT a confirmed\n+                            # delivery (#47056): the scheduler would log\n+                            # \"delivered\" while the gateway never saw it.  Fall\n+                            # through to the standalone path.\n+                            err = (\n+                                getattr(send_result, \"error\", None)\n+                                if send_result is not None\n+                                else \"no response from adapter\"\n+                            )\n+                            shape = type(send_result).__name__ if send_result is not None else \"None\"\n+                            msg = (\n+                                f\"live adapter send to {platform_name}:{chat_id} \"\n+                                f\"returned unconfirmed result ({shape}, error={err})\"\n+                            )\n                             logger.warning(\n                                 \"Job '%s': %s, falling back to standalone\",\n                                 job[\"id\"], msg,","@@ -860,8 +958,13 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                             logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n                             delivery_errors.append(msg)\n \n-                # Send extracted media files as native attachments via the live adapter\n-                if adapter_ok and media_files:\n+                # Send extracted media files as native attachments via the live adapter.\n+                # Skip on an in-flight confirmation timeout: the gateway loop is\n+                # contended, so each media send would also block its 30s budget,\n+                # and the text payload is already assumed delivered (#38922).\n+                # Record the skipped attachments so the drop is visible in the\n+                # job's delivery error rather than silently lost.\n+                if adapter_ok and not timed_out and media_files:\n                     _send_media_via_adapter(\n                         runtime_adapter,\n                         chat_id,","@@ -871,6 +974,13 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                         job,\n                         platform=platform,\n                     )\n+                elif timed_out and media_files:\n+                    msg = (\n+                        f\"{len(media_files)} media attachment(s) not delivered to \"\n+                        f\"{platform_name}:{chat_id} (live adapter confirmation timed out)\"\n+                    )\n+                    logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+                    delivery_errors.append(msg)\n \n                 if adapter_ok:\n                     logger.info(\"Job '%s': delivered to %s:%s via live adapter\", job[\"id\"], platform_name, chat_id)"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -2706,15 +2706,20 @@ def mock_run_job(job):\n \n \n class TestDeliverResultTimeoutCancelsFuture:\n-    \"\"\"When future.result(timeout=60) raises TimeoutError in the live\n-    adapter delivery path, _deliver_result must cancel the orphan\n-    coroutine so it cannot duplicate-send after the standalone fallback.\n+    \"\"\"When future.result(timeout=60) raises TimeoutError in the live adapter\n+    delivery path, the outcome depends on whether the coroutine was already\n+    running.  future.cancel() returning False means it is in flight on the wire\n+    (cannot be un-sent) → treat as DELIVERED and skip the standalone fallback to\n+    avoid a duplicate (#38922).  future.cancel() returning True means it never\n+    started (wedged loop) → nothing was sent, so fall through to standalone or\n+    the message is silently dropped.  Regression for #38922.\n     \"\"\"\n \n-    def test_live_adapter_timeout_cancels_future_and_falls_back(self):\n-        \"\"\"End-to-end: live adapter hangs past the 60s budget, _deliver_result\n-        patches the timeout down to a fast value, confirms future.cancel() fires,\n-        and verifies the standalone fallback path still delivers.\"\"\"\n+    def test_live_adapter_timeout_assumes_delivered_no_duplicate(self):\n+        \"\"\"End-to-end: live adapter confirmation times out past the 60s budget.\n+        The fix (#38922) treats the send as already-dispatched/delivered and\n+        does NOT run the standalone fallback — otherwise the message is sent\n+        twice.\"\"\"\n         from gateway.config import Platform\n         from concurrent.futures import Future\n ","@@ -2730,18 +2735,19 @@ def test_live_adapter_timeout_cancels_future_and_falls_back(self):\n         loop = MagicMock()\n         loop.is_running.return_value = True\n \n-        # A real concurrent.futures.Future so .cancel() has real semantics,\n-        # but we override .result() to raise TimeoutError exactly like the\n-        # 60s wait firing in production.\n+        # A real concurrent.futures.Future, but we override .result() to raise\n+        # TimeoutError exactly like the 60s wait firing in production.  We make\n+        # .cancel() return False to simulate the coroutine being ALREADY RUNNING\n+        # on the gateway loop (in flight on the wire) — the case where the send\n+        # cannot be un-sent and a standalone resend would be a duplicate.\n         captured_future = Future()\n         cancel_calls = []\n-        original_cancel = captured_future.cancel\n \n-        def tracking_cancel():\n+        def in_flight_cancel():\n             cancel_calls.append(True)\n-            return original_cancel()\n+            return False  # already running — cannot be cancelled\n \n-        captured_future.cancel = tracking_cancel\n+        captured_future.cancel = in_flight_cancel\n         captured_future.result = MagicMock(side_effect=TimeoutError(\"timed out\"))\n \n         def fake_run_coro(coro, _loop):","@@ -2767,11 +2773,121 @@ def fake_run_coro(coro, _loop):\n                 loop=loop,\n             )\n \n-        # 1. The orphan future was cancelled on timeout (the bug fix)\n-        assert cancel_calls == [True], \"future.cancel() must fire on TimeoutError\"\n-        # 2. The standalone fallback delivered — no double send, no silent drop\n+        # 1. cancel() was attempted (returned False = in flight).\n+        assert cancel_calls == [True], \"future.cancel() should be attempted on TimeoutError\"\n+        # 2. Delivery is reported successful (no error string returned).\n         assert result is None, f\"expected successful delivery, got error: {result!r}\"\n+        # 3. The standalone fallback must NOT run — that is the #38922 fix:\n+        #    an in-flight confirmation timeout is assume-delivered, not a resend.\n+        standalone_send.assert_not_awaited()\n+\n+    def test_live_adapter_timeout_before_dispatch_falls_back_to_standalone(self):\n+        \"\"\"When the coroutine never started (loop wedged) — future.cancel()\n+        returns True — nothing was sent, so _deliver_result MUST fall through\n+        to the standalone path rather than silently dropping the message.\n+        This is the inverse of the assume-delivered case and guards against the\n+        wedged-loop silent drop.\"\"\"\n+        from gateway.config import Platform\n+        from concurrent.futures import Future\n+\n+        adapter = AsyncMock()\n+        adapter.send.return_value = MagicMock(success=True)\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        captured_future = Future()\n+        cancel_calls = []\n+\n+        def never_dispatched_cancel():\n+            cancel_calls.append(True)\n+            return True  # callback never ran — successfully cancelled\n+\n+        captured_future.cancel = never_dispatched_cancel\n+        captured_future.result = MagicMock(side_effect=TimeoutError(\"timed out\"))\n+\n+        def fake_run_coro(coro, _loop):\n+            coro.close()\n+            return captured_future\n+\n+        job = {\n+            \"id\": \"timeout-undispatched-job\",\n+            \"deliver\": \"origin\",\n+            \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+        }\n+\n+        standalone_send = AsyncMock(return_value={\"success\": True})\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=standalone_send):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        assert cancel_calls == [True], \"future.cancel() should be attempted\"\n+        # The standalone path MUST run — the message was never sent.\n         standalone_send.assert_awaited_once()\n+        assert result is None, f\"standalone should have delivered, got: {result!r}\"\n+\n+    def test_live_adapter_real_exception_falls_back_to_standalone(self):\n+        \"\"\"A non-timeout send Exception (real failure, not a slow confirmation)\n+        must fall through to the standalone path so the message is still\n+        delivered.  Guards the `except Exception: raise` branch — the bug class\n+        where broadening the timeout handler to swallow all exceptions would\n+        silently drop messages.\"\"\"\n+        from gateway.config import Platform\n+        from concurrent.futures import Future\n+\n+        adapter = AsyncMock()\n+        adapter.send.return_value = MagicMock(success=True)\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        captured_future = Future()\n+        captured_future.result = MagicMock(side_effect=RuntimeError(\"adapter exploded\"))\n+\n+        def fake_run_coro(coro, _loop):\n+            coro.close()\n+            return captured_future\n+\n+        job = {\n+            \"id\": \"send-error-job\",\n+            \"deliver\": \"origin\",\n+            \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+        }\n+\n+        standalone_send = AsyncMock(return_value={\"success\": True})\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=standalone_send):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        # A real exception must NOT be assume-delivered: standalone runs.\n+        standalone_send.assert_awaited_once()\n+        assert result is None, f\"standalone should have delivered, got: {result!r}\"\n \n     def test_live_adapter_thread_fallback_records_delivery_error(self):\n         \"\"\"A cron target with an explicit topic must not be marked clean if","@@ -2833,6 +2949,123 @@ def fake_run_coro(coro, _loop):\n         )\n \n \n+class TestDeliverResultLiveAdapterUnconfirmed:\n+    \"\"\"Regression for #47056.\n+\n+    When a live adapter's send() returns ``None`` (swallowed exception / busy\n+    platform) or a result object that lacks an explicit ``success`` attribute\n+    (bare dict / partial object), the scheduler must NOT log \"delivered via\n+    live adapter\" and silently drop the message.  Every unconfirmed shape must\n+    fall through to the standalone delivery path so the message actually\n+    arrives.  The pre-fix check ``send_result is None or not getattr(...,\n+    \"success\", True)`` let a ``.success``-less object default to True = silent\n+    success.\n+    \"\"\"\n+\n+    def _run(self, send_value):\n+        from gateway.config import Platform\n+        from concurrent.futures import Future\n+\n+        adapter = AsyncMock()\n+        adapter.send.return_value = send_value\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        completed_future = Future()\n+        completed_future.set_result(send_value)\n+\n+        def fake_run_coro(coro, _loop):\n+            coro.close()\n+            return completed_future\n+\n+        job = {\n+            \"id\": \"unconfirmed-job\",\n+            \"deliver\": \"origin\",\n+            \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+        }\n+\n+        standalone_send = AsyncMock(return_value={\"success\": True})\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=standalone_send):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+        return result, standalone_send\n+\n+    def test_none_result_falls_through_to_standalone(self):\n+        \"\"\"send() returning None must trigger the standalone fallback, not a\n+        silent \"delivered\" log.\"\"\"\n+        result, standalone_send = self._run(None)\n+        assert result is None, f\"standalone should have delivered, got: {result!r}\"\n+        standalone_send.assert_awaited_once()\n+\n+    def test_result_missing_success_attr_falls_through(self):\n+        \"\"\"A result object with no ``success`` attribute is a contract\n+        violation and must NOT be counted as delivered (it defaulted to True\n+        before the fix).\"\"\"\n+        class _NoSuccess:\n+            pass\n+\n+        result, standalone_send = self._run(_NoSuccess())\n+        assert result is None, f\"standalone should have delivered, got: {result!r}\"\n+        standalone_send.assert_awaited_once()\n+\n+    def test_confirmed_success_does_not_fall_through(self):\n+        \"\"\"A genuine SendResult(success=True) is confirmed — the standalone\n+        path must NOT run (no duplicate).\"\"\"\n+        result, standalone_send = self._run(MagicMock(success=True, raw_response=None))\n+        assert result is None\n+        standalone_send.assert_not_awaited()\n+\n+\n+class TestDeliverOriginUnresolvableIsLocal:\n+    \"\"\"Regression for #43014.\n+\n+    A cron job created in a CLI session has no {platform, chat_id} origin.\n+    With ``deliver=origin`` (or auto-detect / deliver=None) and no configured\n+    platform home channel, delivery is unresolvable — but that is the EXPECTED\n+    state for CLI jobs, not an error.  _deliver_result must return None (treat\n+    as local; output stays in last_output), not the \"no delivery target\n+    resolved\" error string that previously fired on every run.\n+    \"\"\"\n+\n+    def _deliver(self, job, monkeypatch):\n+        import cron.scheduler as sched\n+        # No home channel for any platform → origin is unresolvable.\n+        monkeypatch.setattr(sched, \"_get_home_target_chat_id\", lambda *_: \"\")\n+        return _deliver_result(job, \"CLI bulletin\")\n+\n+    def test_origin_with_no_home_channels_returns_none(self, monkeypatch):\n+        job = {\"id\": \"cli-job\", \"deliver\": \"origin\", \"origin\": \"cli-session-provenance\"}\n+        assert self._deliver(job, monkeypatch) is None\n+\n+    def test_omitted_deliver_autodetect_returns_none(self, monkeypatch):\n+        # deliver key present but None (auto-detect) previously errored with\n+        # \"no delivery target resolved for deliver=None\".\n+        job = {\"id\": \"cli-job\", \"deliver\": None, \"origin\": \"cli-session-provenance\"}\n+        assert self._deliver(job, monkeypatch) is None\n+\n+    def test_explicit_platform_with_no_channel_still_errors(self, monkeypatch):\n+        # A concrete platform target that cannot resolve is still a real error\n+        # (this must NOT be silently swallowed by the origin→local fallback).\n+        job = {\"id\": \"tg-job\", \"deliver\": \"telegram\"}\n+        result = self._deliver(job, monkeypatch)\n+        assert result is not None\n+        assert \"no delivery target resolved\" in result\n+\n+\n class TestSendMediaTimeoutCancelsFuture:\n     \"\"\"Same orphan-coroutine guarantee for _send_media_via_adapter's\n     future.result(timeout=30) call. If this times out mid-batch, the\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}