{"repo":"NousResearch/hermes-agent","public_id":"49260","display_label":"Signal live adapter silent delivery failures and send acknowledgement validation","summary":"The three members all concern the Signal live adapter reporting success (ok status, null error) for cron-job messages that may not reach the recipient. Two are fix-style changes about rejecting per-recipient failures and validating send acknowledgements, and one is an issue describing the symptom; the titles alone do not show whether any fix is merged or effective.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":49312,"merged_at":"2026-06-19T23:59:39+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-19T23:59:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":49260,"pr_number":49312,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-19T23:59:39+00:00"}],"members":[{"kind":"issue","number":49260,"title":"Live adapter delivers silently for Signal cron jobs (status ok/null error but message never reaches user)","url":null,"state":"closed"},{"kind":"pr","number":49275,"title":"fix(signal): validate live-adapter send acknowledgements","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49312","number":49312},{"kind":"pr","value":"#49260","number":49260}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":49312,"title":"fix(signal): reject silent per-recipient delivery failures in live adapter (#49260)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","gateway/platforms/signal.py","scripts/release.py","tests/gateway/test_signal.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -810,6 +810,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n         # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.\n         runtime_adapter = (adapters or {}).get(platform)\n         delivered = False\n+        target_errors = []\n         if runtime_adapter is not None and loop is not None and getattr(loop, \"is_running\", lambda: False)():\n             send_metadata = {\"thread_id\": thread_id} if thread_id else None\n             try:","@@ -824,18 +825,26 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                     )\n                     if future is None:\n                         adapter_ok = False\n+                        target_errors.append(\"live adapter event loop scheduling failed\")\n                     else:\n                         try:\n                             send_result = future.result(timeout=60)\n-                        except TimeoutError:\n+                        except TimeoutError as te:\n                             future.cancel()\n+                            target_errors.append(f\"live adapter send timed out: {te}\")\n                             raise\n-                        if send_result and not getattr(send_result, \"success\", True):\n-                            err = getattr(send_result, \"error\", \"unknown\")\n+                        except Exception as ex:\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                             logger.warning(\n-                                \"Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone\",\n-                                job[\"id\"], platform_name, chat_id, err,\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                         elif (\n                             send_result","@@ -867,9 +876,12 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                     logger.info(\"Job '%s': delivered to %s:%s via live adapter\", job[\"id\"], platform_name, chat_id)\n                     delivered = True\n             except Exception as e:\n+                err_msg = f\"live adapter delivery to {platform_name}:{chat_id} failed: {e}\"\n+                if not any(err_msg in err for err in target_errors):\n+                    target_errors.append(err_msg)\n                 logger.warning(\n-                    \"Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone\",\n-                    job[\"id\"], platform_name, chat_id, e,\n+                    \"Job '%s': %s, falling back to standalone\",\n+                    job[\"id\"], err_msg,\n                 )\n \n         if not delivered:","@@ -889,13 +901,15 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n             except Exception as e:\n                 msg = f\"delivery to {platform_name}:{chat_id} failed: {e}\"\n                 logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n-                delivery_errors.append(msg)\n+                target_errors.extend([msg])\n+                delivery_errors.extend(target_errors)\n                 continue\n \n             if result and result.get(\"error\"):\n                 msg = f\"delivery error: {result['error']}\"\n                 logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n-                delivery_errors.append(msg)\n+                target_errors.extend([msg])\n+                delivery_errors.extend(target_errors)\n                 continue\n \n             logger.info(\"Job '%s': delivered to %s:%s\", job[\"id\"], platform_name, chat_id)"]},{"file":"gateway/platforms/signal.py","hunks":["@@ -796,7 +796,16 @@ async def _rpc(\n                     logger.debug(\"Signal RPC error (%s): %s\", method, err)\n                 return None\n \n-            return data.get(\"result\")\n+            result = data.get(\"result\")\n+            if isinstance(result, dict) and raise_on_rate_limit:\n+                results = result.get(\"results\")\n+                if isinstance(results, list):\n+                    for r in results:\n+                        if isinstance(r, dict) and r.get(\"type\") == \"RATE_LIMIT_FAILURE\":\n+                            retry_after = r.get(\"retryAfterSeconds\")\n+                            raise SignalRateLimitError(\"Rate limit exceeded for recipient\", retry_after=retry_after)\n+\n+            return result\n \n         except SignalRateLimitError:\n             raise","@@ -960,6 +969,29 @@ def format_message(self, content: str) -> str:\n         # Our send() override bypasses this entirely.\n         return content\n \n+    def _validate_send_result(self, result: Any) -> tuple[bool, Optional[str]]:\n+        \"\"\"Validate signal-cli send response results.\n+\n+        Returns (success, error_message).\n+        \"\"\"\n+        if not result or not isinstance(result, dict):\n+            return True, None\n+\n+        results = result.get(\"results\")\n+        if isinstance(results, list):\n+            for r in results:\n+                if not isinstance(r, dict):\n+                    continue\n+                rtype = r.get(\"type\")\n+                if rtype and rtype != \"SUCCESS\":\n+                    return False, str(rtype)\n+                if \"success\" in r and not r.get(\"success\"):\n+                    fail = r.get(\"failure\")\n+                    if fail:\n+                        return False, str(fail)\n+                    return False, \"Recipient delivery failed\"\n+        return True, None\n+\n     # ------------------------------------------------------------------\n     # Sending\n     # ------------------------------------------------------------------","@@ -992,9 +1024,13 @@ async def send(\n         else:\n             params[\"recipient\"] = [await self._resolve_recipient(chat_id)]\n \n+        logger.info(\"[Signal] Sending response (%d chars) to %s\", len(plain_text), chat_id)\n         result = await self._rpc(\"send\", params)\n \n         if result is not None:\n+            success, err_msg = self._validate_send_result(result)\n+            if not success:\n+                return SendResult(success=False, error=err_msg, raw_response=result)\n             self._track_sent_timestamp(result)\n             # Signal has no editable message identifier. Returning None keeps the\n             # stream consumer on the non-edit fallback path instead of pretending","@@ -1171,14 +1207,33 @@ async def send_multiple_images(\n                     )\n                     _rpc_duration = time.monotonic() - _rpc_t0\n                     if result is not None:\n-                        self._track_sent_timestamp(result)\n-                        await scheduler.report_rpc_duration(_rpc_duration, n)\n-                        logger.info(\n-                            \"Signal batch %d/%d: %d attachments sent in %.1fs \"\n-                            \"(attempt %d/%d)\",\n-                            idx + 1, len(att_batches), n, _rpc_duration,\n-                            attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,\n-                        )\n+                        success, err_msg = self._validate_send_result(result)\n+                        if success:\n+                            self._track_sent_timestamp(result)\n+                            await scheduler.report_rpc_duration(_rpc_duration, n)\n+                            logger.info(\n+                                \"Signal batch %d/%d: %d attachments sent in %.1fs \"\n+                                \"(attempt %d/%d)\",\n+                                idx + 1, len(att_batches), n, _rpc_duration,\n+                                attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,\n+                            )\n+                        else:\n+                            logger.error(\n+                                \"Signal: RPC send failed for batch %d/%d (%d attachments, \"\n+                                \"attempt %d/%d, rpc_duration=%.1fs): %s\",\n+                                idx + 1, len(att_batches), n,\n+                                attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,\n+                                _rpc_duration, err_msg,\n+                            )\n+                            # Retry transient (non-rate-limit) failures once\n+                            if attempt < SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:\n+                                backoff = 2.0 ** attempt\n+                                logger.info(\n+                                    \"Signal: retrying batch %d/%d after %.1fs backoff\",\n+                                    idx + 1, len(att_batches), backoff,\n+                                )\n+                                await asyncio.sleep(backoff)\n+                                continue\n                     else:\n                         # Assume the server didn't accept the batch, don't deduce tokens\n                         logger.error(","@@ -1277,6 +1332,9 @@ async def send_image(\n \n         result = await self._rpc(\"send\", params)\n         if result is not None:\n+            success, err_msg = self._validate_send_result(result)\n+            if not success:\n+                return SendResult(success=False, error=err_msg, raw_response=result)\n             self._track_sent_timestamp(result)\n             return SendResult(success=True)\n         return SendResult(success=False, error=\"RPC send with attachment failed\")","@@ -1316,6 +1374,9 @@ async def _send_attachment(\n \n         result = await self._rpc(\"send\", params)\n         if result is not None:\n+            success, err_msg = self._validate_send_result(result)\n+            if not success:\n+                return SendResult(success=False, error=err_msg, raw_response=result)\n             self._track_sent_timestamp(result)\n             return SendResult(success=True)\n         return SendResult(success=False, error=f\"RPC send {media_label.lower()} failed\")"]},{"file":"scripts/release.py","hunks":["@@ -48,6 +48,7 @@\n     \"[redacted:email]\": \"salesondemandio\",\n     \"[redacted:email]\": \"victor-kyriazakos\",\n     \"87440198+JoaoMarcos44@users.noreply.github.com\": \"JoaoMarcos44\",\n+    \"[redacted:email]\": \"JoaoMarcos44\",\n     \"286497132+srojk34@users.noreply.github.com\": \"srojk34\",\n     \"59806492+sitkarev@users.noreply.github.com\": \"sitkarev\",\n     \"[redacted:email]\": \"omegazheng\","]},{"file":"tests/gateway/test_signal.py","hunks":["@@ -1009,6 +1009,97 @@ async def test_send_returns_none_message_id_for_non_dict(self, monkeypatch):\n         assert result.message_id is None\n \n \n+class TestSignalSendResultValidation:\n+    \"\"\"Verify that send() validates recipient-level delivery results.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_success_when_results_has_success(self, monkeypatch):\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, _ = _stub_rpc({\n+            \"timestamp\": 1712345678000,\n+            \"results\": [\n+                {\n+                    \"recipientAddress\": {\"number\": \"+155****4567\"},\n+                    \"type\": \"SUCCESS\"\n+                }\n+            ]\n+        })\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        result = await adapter.send(chat_id=\"+155****4567\", content=\"hello\")\n+        assert result.success is True\n+\n+    @pytest.mark.asyncio\n+    async def test_send_failure_when_results_has_failure_type(self, monkeypatch):\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, _ = _stub_rpc({\n+            \"timestamp\": 1712345678000,\n+            \"results\": [\n+                {\n+                    \"recipientAddress\": {\"number\": \"+155****4567\"},\n+                    \"type\": \"UNREGISTERED_FAILURE\"\n+                }\n+            ]\n+        })\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        result = await adapter.send(chat_id=\"+155****4567\", content=\"hello\")\n+        assert result.success is False\n+        assert result.error == \"UNREGISTERED_FAILURE\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_failure_when_results_has_success_false(self, monkeypatch):\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, _ = _stub_rpc({\n+            \"timestamp\": 1712345678000,\n+            \"results\": [\n+                {\n+                    \"recipientAddress\": {\"number\": \"+155****4567\"},\n+                    \"success\": False,\n+                    \"failure\": \"Some connection error\"\n+                }\n+            ]\n+        })\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        result = await adapter.send(chat_id=\"+155****4567\", content=\"hello\")\n+        assert result.success is False\n+        assert result.error == \"Some connection error\"\n+\n+    @pytest.mark.asyncio\n+    async def test_rpc_raises_rate_limit_on_results_failure(self, monkeypatch):\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_client = AsyncMock()\n+        mock_response = MagicMock()\n+        mock_response.status_code = 200\n+        mock_response.json.return_value = {\n+            \"jsonrpc\": \"2.0\",\n+            \"result\": {\n+                \"timestamp\": 1712345678000,\n+                \"results\": [\n+                    {\n+                        \"recipientAddress\": {\"number\": \"+155****4567\"},\n+                        \"type\": \"RATE_LIMIT_FAILURE\",\n+                        \"retryAfterSeconds\": 15\n+                    }\n+                ]\n+            },\n+            \"id\": \"1\"\n+        }\n+        mock_client.post = AsyncMock(return_value=mock_response)\n+        adapter.client = mock_client\n+\n+        from gateway.platforms.signal_rate_limit import SignalRateLimitError\n+        with pytest.raises(SignalRateLimitError) as exc_info:\n+            await adapter._rpc(\"send\", {\"recipient\": [\"+155****4567\"]}, raise_on_rate_limit=True)\n+\n+        assert \"Rate limit exceeded for recipient\" in str(exc_info.value)\n+        assert exc_info.value.retry_after == 15\n+\n+\n # ---------------------------------------------------------------------------\n # stop_typing() delegates to _stop_typing_indicator (#4647)\n # ---------------------------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}