NousResearch/hermes-agent

Signal live adapter silent delivery failures and send acknowledgement validation

Cluster 49260 · gateway. Data as of

Nothing open on GitHub. Fix merged in #49312 on 19 Jun 2026. Check it covers your case.

The short answer

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

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #49312 fix(signal): reject silent per-recipient delivery failures in live adapter (#49260)

    merged to main

    files: cron/scheduler.py, gateway/platforms/signal.py, scripts/release.py, tests/gateway/test_signal.py

    Show the diff
    • cron/scheduler.py
      @@ -810,6 +810,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
               # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.
               runtime_adapter = (adapters or {}).get(platform)
               delivered = False
      +        target_errors = []
               if runtime_adapter is not None and loop is not None and getattr(loop, "is_running", lambda: False)():
                   send_metadata = {"thread_id": thread_id} if thread_id else None
                   try:
      @@ -824,18 +825,26 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
                           )
                           if future is None:
                               adapter_ok = False
      +                        target_errors.append("live adapter event loop scheduling failed")
                           else:
                               try:
                                   send_result = future.result(timeout=60)
      -                        except TimeoutError:
      +                        except TimeoutError as te:
                                   future.cancel()
      +                            target_errors.append(f"live adapter send timed out: {te}")
                                   raise
      -                        if send_result and not getattr(send_result, "success", True):
      -                            err = getattr(send_result, "error", "unknown")
      +                        except Exception as ex:
      +                            target_errors.append(f"live adapter send failed: {ex}")
      +                            raise
      +
      +                        if send_result is None or not getattr(send_result, "success", True):
      +                            err = getattr(send_result, "error", "unknown") if send_result else "no response from adapter"
      +                            msg = f"live adapter send to {platform_name}:{chat_id} failed: {err}"
                                   logger.warning(
      -                                "Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone",
      -                                job["id"], platform_name, chat_id, err,
      +                                "Job '%s': %s, falling back to standalone",
      +                                job["id"], msg,
                                   )
      +                            target_errors.append(msg)
                                   adapter_ok = False  # fall through to standalone path
                               elif (
                                   send_result
      @@ -867,9 +876,12 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
                           logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id)
                           delivered = True
                   except Exception as e:
      +                err_msg = f"live adapter delivery to {platform_name}:{chat_id} failed: {e}"
      +                if not any(err_msg in err for err in target_errors):
      +                    target_errors.append(err_msg)
                       logger.warning(
      -                    "Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone",
      -                    job["id"], platform_name, chat_id, e,
      +                    "Job '%s': %s, falling back to standalone",
      +                    job["id"], err_msg,
                       )
       
               if not delivered:
      @@ -889,13 +901,15 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
                   except Exception as e:
                       msg = f"delivery to {platform_name}:{chat_id} failed: {e}"
                       logger.error("Job '%s': %s", job["id"], msg)
      -                delivery_errors.append(msg)
      +                target_errors.extend([msg])
      +                delivery_errors.extend(target_errors)
                       continue
       
                   if result and result.get("error"):
                       msg = f"delivery error: {result['error']}"
                       logger.error("Job '%s': %s", job["id"], msg)
      -                delivery_errors.append(msg)
      +                target_errors.extend([msg])
      +                delivery_errors.extend(target_errors)
                       continue
       
                   logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id)
    • gateway/platforms/signal.py
      @@ -796,7 +796,16 @@ async def _rpc(
                           logger.debug("Signal RPC error (%s): %s", method, err)
                       return None
       
      -            return data.get("result")
      +            result = data.get("result")
      +            if isinstance(result, dict) and raise_on_rate_limit:
      +                results = result.get("results")
      +                if isinstance(results, list):
      +                    for r in results:
      +                        if isinstance(r, dict) and r.get("type") == "RATE_LIMIT_FAILURE":
      +                            retry_after = r.get("retryAfterSeconds")
      +                            raise SignalRateLimitError("Rate limit exceeded for recipient", retry_after=retry_after)
      +
      +            return result
       
               except SignalRateLimitError:
                   raise
      @@ -960,6 +969,29 @@ def format_message(self, content: str) -> str:
               # Our send() override bypasses this entirely.
               return content
       
      +    def _validate_send_result(self, result: Any) -> tuple[bool, Optional[str]]:
      +        """Validate signal-cli send response results.
      +
      +        Returns (success, error_message).
      +        """
      +        if not result or not isinstance(result, dict):
      +            return True, None
      +
      +        results = result.get("results")
      +        if isinstance(results, list):
      +            for r in results:
      +                if not isinstance(r, dict):
      +                    continue
      +                rtype = r.get("type")
      +                if rtype and rtype != "SUCCESS":
      +                    return False, str(rtype)
      +                if "success" in r and not r.get("success"):
      +                    fail = r.get("failure")
      +                    if fail:
      +                        return False, str(fail)
      +                    return False, "Recipient delivery failed"
      +        return True, None
      +
           # ------------------------------------------------------------------
           # Sending
           # ------------------------------------------------------------------
      @@ -992,9 +1024,13 @@ async def send(
               else:
                   params["recipient"] = [await self._resolve_recipient(chat_id)]
       
      +        logger.info("[Signal] Sending response (%d chars) to %s", len(plain_text), chat_id)
               result = await self._rpc("send", params)
       
               if result is not None:
      +            success, err_msg = self._validate_send_result(result)
      +            if not success:
      +                return SendResult(success=False, error=err_msg, raw_response=result)
                   self._track_sent_timestamp(result)
                   # Signal has no editable message identifier. Returning None keeps the
                   # stream consumer on the non-edit fallback path instead of pretending
      @@ -1171,14 +1207,33 @@ async def send_multiple_images(
                           )
                           _rpc_duration = time.monotonic() - _rpc_t0
                           if result is not None:
      -                        self._track_sent_timestamp(result)
      -                        await scheduler.report_rpc_duration(_rpc_duration, n)
      -                        logger.info(
      -                            "Signal batch %d/%d: %d attachments sent in %.1fs "
      -                            "(attempt %d/%d)",
      -                            idx + 1, len(att_batches), n, _rpc_duration,
      -                            attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
      -                        )
      +                        success, err_msg = self._validate_send_result(result)
      +                        if success:
      +                            self._track_sent_timestamp(result)
      +                            await scheduler.report_rpc_duration(_rpc_duration, n)
      +                            logger.info(
      +                                "Signal batch %d/%d: %d attachments sent in %.1fs "
      +                                "(attempt %d/%d)",
      +                                idx + 1, len(att_batches), n, _rpc_duration,
      +                                attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
      +                            )
      +                        else:
      +                            logger.error(
      +                                "Signal: RPC send failed for batch %d/%d (%d attachments, "
      +                                "attempt %d/%d, rpc_duration=%.1fs): %s",
      +                                idx + 1, len(att_batches), n,
      +                                attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
      +                                _rpc_duration, err_msg,
      +                            )
      +                            # Retry transient (non-rate-limit) failures once
      +                            if attempt < SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
      +                                backoff = 2.0 ** attempt
      +                                logger.info(
      +                                    "Signal: retrying batch %d/%d after %.1fs backoff",
      +                                    idx + 1, len(att_batches), backoff,
      +                                )
      +                                await asyncio.sleep(backoff)
      +                                continue
                           else:
                               # Assume the server didn't accept the batch, don't deduce tokens
                               logger.error(
      @@ -1277,6 +1332,9 @@ async def send_image(
       
               result = await self._rpc("send", params)
               if result is not None:
      +            success, err_msg = self._validate_send_result(result)
      +            if not success:
      +                return SendResult(success=False, error=err_msg, raw_response=result)
                   self._track_sent_timestamp(result)
                   return SendResult(success=True)
               return SendResult(success=False, error="RPC send with attachment failed")
      @@ -1316,6 +1374,9 @@ async def _send_attachment(
       
               result = await self._rpc("send", params)
               if result is not None:
      +            success, err_msg = self._validate_send_result(result)
      +            if not success:
      +                return SendResult(success=False, error=err_msg, raw_response=result)
                   self._track_sent_timestamp(result)
                   return SendResult(success=True)
               return SendResult(success=False, error=f"RPC send {media_label.lower()} failed")
    • scripts/release.py
      @@ -48,6 +48,7 @@
           "[redacted:email]": "salesondemandio",
           "[redacted:email]": "victor-kyriazakos",
           "[email protected]": "JoaoMarcos44",
      +    "[redacted:email]": "JoaoMarcos44",
           "[email protected]": "srojk34",
           "[email protected]": "sitkarev",
           "[redacted:email]": "omegazheng",
    • tests/gateway/test_signal.py
      @@ -1009,6 +1009,97 @@ async def test_send_returns_none_message_id_for_non_dict(self, monkeypatch):
               assert result.message_id is None
       
       
      +class TestSignalSendResultValidation:
      +    """Verify that send() validates recipient-level delivery results."""
      +
      +    @pytest.mark.asyncio
      +    async def test_send_success_when_results_has_success(self, monkeypatch):
      +        adapter = _make_signal_adapter(monkeypatch)
      +        mock_rpc, _ = _stub_rpc({
      +            "timestamp": 1712345678000,
      +            "results": [
      +                {
      +                    "recipientAddress": {"number": "+155****4567"},
      +                    "type": "SUCCESS"
      +                }
      +            ]
      +        })
      +        adapter._rpc = mock_rpc
      +        adapter._stop_typing_indicator = AsyncMock()
      +
      +        result = await adapter.send(chat_id="+155****4567", content="hello")
      +        assert result.success is True
      +
      +    @pytest.mark.asyncio
      +    async def test_send_failure_when_results_has_failure_type(self, monkeypatch):
      +        adapter = _make_signal_adapter(monkeypatch)
      +        mock_rpc, _ = _stub_rpc({
      +            "timestamp": 1712345678000,
      +            "results": [
      +                {
      +                    "recipientAddress": {"number": "+155****4567"},
      +                    "type": "UNREGISTERED_FAILURE"
      +                }
      +            ]
      +        })
      +        adapter._rpc = mock_rpc
      +        adapter._stop_typing_indicator = AsyncMock()
      +
      +        result = await adapter.send(chat_id="+155****4567", content="hello")
      +        assert result.success is False
      +        assert result.error == "UNREGISTERED_FAILURE"
      +
      +    @pytest.mark.asyncio
      +    async def test_send_failure_when_results_has_success_false(self, monkeypatch):
      +        adapter = _make_signal_adapter(monkeypatch)
      +        mock_rpc, _ = _stub_rpc({
      +            "timestamp": 1712345678000,
      +            "results": [
      +                {
      +                    "recipientAddress": {"number": "+155****4567"},
      +                    "success": False,
      +                    "failure": "Some connection error"
      +                }
      +            ]
      +        })
      +        adapter._rpc = mock_rpc
      +        adapter._stop_typing_indicator = AsyncMock()
      +
      +        result = await adapter.send(chat_id="+155****4567", content="hello")
      +        assert result.success is False
      +        assert result.error == "Some connection error"
      +
      +    @pytest.mark.asyncio
      +    async def test_rpc_raises_rate_limit_on_results_failure(self, monkeypatch):
      +        adapter = _make_signal_adapter(monkeypatch)
      +        mock_client = AsyncMock()
      +        mock_response = MagicMock()
      +        mock_response.status_code = 200
      +        mock_response.json.return_value = {
      +            "jsonrpc": "2.0",
      +            "result": {
      +                "timestamp": 1712345678000,
      +                "results": [
      +                    {
      +                        "recipientAddress": {"number": "+155****4567"},
      +                        "type": "RATE_LIMIT_FAILURE",
      +                        "retryAfterSeconds": 15
      +                    }
      +                ]
      +            },
      +            "id": "1"
      +        }
      +        mock_client.post = AsyncMock(return_value=mock_response)
      +        adapter.client = mock_client
      +
      +        from gateway.platforms.signal_rate_limit import SignalRateLimitError
      +        with pytest.raises(SignalRateLimitError) as exc_info:
      +            await adapter._rpc("send", {"recipient": ["+155****4567"]}, raise_on_rate_limit=True)
      +
      +        assert "Rate limit exceeded for recipient" in str(exc_info.value)
      +        assert exc_info.value.retry_after == 15
      +
      +
       # ---------------------------------------------------------------------------
       # stop_typing() delegates to _stop_typing_indicator (#4647)
       # ---------------------------------------------------------------------------

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #49275 fix(signal): validate live-adapter send acknowledgements · superseded by another PR → #49312, #49260
Closed issues · 1
  • Closed#49260Live adapter delivers silently for Signal cron jobs (status ok/null error but message never reaches user)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

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

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#49312
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
19 Jun 2026

For your agent

The same facts, typed.