{"repo":"NousResearch/hermes-agent","public_id":"58184","display_label":"Yuanbao adapter singleton loss after WS reconnect","summary":"These issues and PRs focus on a bug where the Yuanbao adapter loses its singleton reference after a WebSocket reconnect, causing cron job delivery to silently fail. Fixes involve restoring the singleton and waiting for reconnection before attempting delivery.","subsystem":"agent","counts":{"members":6,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58504,"merged_at":"2026-07-05T07:41:34+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-05T07:41:34+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":58363,"pr_number":58504,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-05T07:41:34+00:00"}],"members":[{"kind":"issue","number":58184,"title":"cronjob deliver=origin 不稳定：Yuanbao WS断连时投递失败","url":null,"state":"open"},{"kind":"issue","number":58363,"title":"Yuanbao cron delivery silently fails after WS reconnect (three causes)","url":null,"state":"closed"},{"kind":"pr","number":58206,"title":"fix(yuanbao): wait for adapter reconnect before delivery","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"132bb8a16","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":58378,"title":"fix(yuanbao): restore active singleton after WS reconnect","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":58379,"title":"fix(yuanbao): restore adapter singleton after WS reconnect + retry delivery (#58363)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58363","number":58363},{"kind":"pr","value":"#58504","number":58504},{"kind":"pr","value":"#58378","number":58378}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58504,"title":"fix(gateway): SSE buffer cap, yuanbao reconnect singleton, WhatsApp Cloud allowlist env vars (3-PR salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/whatsapp_cloud.py","gateway/platforms/yuanbao.py","gateway/run.py","scripts/release.py","tests/gateway/test_proxy_mode.py","tests/gateway/test_whatsapp_cloud_allowed_users.py","tests/test_yuanbao_reconnect_set_active.py"],"fix_hunks":[{"file":"gateway/platforms/whatsapp_cloud.py","hunks":["@@ -225,18 +225,35 @@ def __init__(self, config: PlatformConfig):\n         import os\n \n         self._reply_prefix: Optional[str] = extra.get(\"reply_prefix\")\n-        self._dm_policy: str = str(\n-            extra.get(\"dm_policy\")\n-            or os.getenv(\"WHATSAPP_CLOUD_DM_POLICY\")\n-            or os.getenv(\"WHATSAPP_DM_POLICY\", \"open\")\n-        ).strip().lower()\n+        # Allowlist: honor the *documented* WHATSAPP_CLOUD_ALLOWED_USERS (the\n+        # var the setup wizard writes) in addition to WHATSAPP_CLOUD_ALLOW_FROM.\n+        # The adapter historically read only ALLOW_FROM, so an allowlist\n+        # configured via the documented var silently dropped every inbound.\n         self._allow_from: set[str] = self._normalize_allow_ids(\n             self._coerce_allow_list(\n                 extra.get(\"allow_from\")\n                 or extra.get(\"allowFrom\")\n                 or os.getenv(\"WHATSAPP_CLOUD_ALLOW_FROM\")\n+                or os.getenv(\"WHATSAPP_CLOUD_ALLOWED_USERS\")\n             )\n         )\n+        # DM policy: explicit config wins; otherwise choose a safe, working\n+        # default -- \"open\" if the operator opted into allow-all, else\n+        # \"allowlist\" when an allowlist is configured (so it is actually\n+        # enforced instead of silently dropping), else \"open\".\n+        _allow_all_optin = str(\n+            os.getenv(\"WHATSAPP_CLOUD_ALLOW_ALL_USERS\", \"\")\n+        ).strip().lower() in {\"true\", \"1\", \"yes\"}\n+        _default_dm_policy = (\n+            \"open\" if _allow_all_optin\n+            else (\"allowlist\" if self._allow_from else \"open\")\n+        )\n+        self._dm_policy: str = str(\n+            extra.get(\"dm_policy\")\n+            or os.getenv(\"WHATSAPP_CLOUD_DM_POLICY\")\n+            or os.getenv(\"WHATSAPP_DM_POLICY\")\n+            or _default_dm_policy\n+        ).strip().lower()\n         self._group_policy: str = str(\n             extra.get(\"group_policy\")\n             or os.getenv(\"WHATSAPP_CLOUD_GROUP_POLICY\")","@@ -347,6 +364,17 @@ def _is_dm_allowed(self, sender_id: str) -> bool:\n             return (bare or sender_id) in self._allow_from\n         return super()._is_dm_allowed(sender_id)\n \n+    def _open_dm_opted_in(self) -> bool:\n+        \"\"\"Also honor the documented WHATSAPP_CLOUD_ALLOW_ALL_USERS opt-in.\n+\n+        The shared mixin only checks GATEWAY_ALLOW_ALL_USERS /\n+        WHATSAPP_ALLOW_ALL_USERS; the Cloud adapter's documented open-access\n+        opt-in is WHATSAPP_CLOUD_ALLOW_ALL_USERS, so honor it here too.\n+        \"\"\"\n+        if str(os.getenv(\"WHATSAPP_CLOUD_ALLOW_ALL_USERS\", \"\")).strip().lower() in {\"true\", \"1\", \"yes\"}:\n+            return True\n+        return super()._open_dm_opted_in()\n+\n     # ------------------------------------------------------------------ lifecycle\n     async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if not check_whatsapp_cloud_requirements():"]},{"file":"gateway/platforms/yuanbao.py","hunks":["@@ -3868,6 +3868,7 @@ async def _do_reconnect(self) -> bool:\n                     \"[%s] Reconnected on attempt %d. connectId=%s\",\n                     adapter.name, attempt + 1, self._connect_id,\n                 )\n+                YuanbaoAdapter.set_active(adapter)\n                 return True\n \n             except asyncio.TimeoutError:"]},{"file":"gateway/run.py","hunks":["@@ -67,6 +67,7 @@\n _AGENT_CACHE_IDLE_TTL_SECS = 3600.0  # evict agents idle for >1h\n _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0\n _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0\n+_GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS = 16 * 1024 * 1024\n _TELEGRAM_COMMAND_MENTION_RE = re.compile(r\"(?<![\\w:/])/([A-Za-z0-9][A-Za-z0-9_-]*)\")\n \n _TELEGRAM_NOISY_STATUS_RE = re.compile(","@@ -16219,6 +16220,10 @@ def _pause_typing_before_finalize(\n                                                 _stream_consumer.on_delta(content)\n                                 except json.JSONDecodeError:\n                                     pass\n+                        if len(buffer) > _GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS:\n+                            raise ValueError(\n+                                \"Proxy SSE stream exceeded max buffer size without a line boundary\"\n+                            )\n \n         except asyncio.CancelledError:\n             raise"]},{"file":"scripts/release.py","hunks":["@@ -46,9 +46,10 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"JAlmanzarMint\",  # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890\n-    \"[redacted:email]\": \"ooiuuii\",  # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings). Also PR #58433 salvage (codex: accept recorded final_text when app-server omits turn/completed).\n+    \"[redacted:email]\": \"ooiuuii\",  # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings). Also PR #58433 salvage (codex: accept recorded final_text when app-server omits turn/completed) and PR #58472 salvage (gateway: cap proxy SSE line buffer at 16MiB).\n     \"Jigoooo@users.noreply.github.com\": \"Jigoooo\",  # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry)\n     \"[redacted:email]\": \"ostravajih\",  # PR #58374 salvage (poolside: coerce integer finish_reason and tool_call id to strings)\n+    \"[redacted:email]\": \"sahil-shubham\",  # PR #58448 salvage (whatsapp_cloud: honor documented WHATSAPP_CLOUD_ALLOWED_USERS / ALLOW_ALL_USERS in the DM intake gate)\n     \"[redacted:email]\": \"msh01\",  # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677).\n     \"[redacted:email]\": \"danilofalcao\",  # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel)\n     \"huanshan5195@users.noreply.github.com\": \"huanshan5195\",  # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + \"max\" reasoning level)"]},{"file":"tests/gateway/test_proxy_mode.py","hunks":["@@ -334,6 +334,32 @@ async def __aexit__(self, *args):\n \n         assert \"Proxy connection error\" in result[\"final_response\"]\n \n+    @pytest.mark.asyncio\n+    async def test_rejects_proxy_sse_without_line_boundary_after_buffer_cap(self, monkeypatch):\n+        monkeypatch.setenv(\"GATEWAY_PROXY_URL\", \"http://host:8642\")\n+        monkeypatch.delenv(\"GATEWAY_PROXY_KEY\", raising=False)\n+        monkeypatch.setattr(\"gateway.run._GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS\", 16)\n+        runner = _make_runner()\n+        source = _make_source()\n+\n+        resp = _FakeSSEResponse(status=200, sse_chunks=[b\"data: \", b\"x\" * 20])\n+        session = _FakeSession(resp)\n+\n+        with patch(\"gateway.run._load_gateway_config\", return_value={}):\n+            with _patch_aiohttp(session):\n+                with patch(\"aiohttp.ClientTimeout\"):\n+                    result = await runner._run_agent_via_proxy(\n+                        message=\"hi\",\n+                        context_prompt=\"\",\n+                        history=[],\n+                        source=source,\n+                        session_id=\"test\",\n+                    )\n+\n+        assert \"Proxy connection error\" in result[\"final_response\"]\n+        assert \"exceeded max buffer size\" in result[\"final_response\"]\n+        assert result[\"api_calls\"] == 0\n+\n     @pytest.mark.asyncio\n     async def test_skips_tool_messages_in_history(self, monkeypatch):\n         monkeypatch.setenv(\"GATEWAY_PROXY_URL\", \"http://host:8642\")"]},{"file":"tests/gateway/test_whatsapp_cloud_allowed_users.py","hunks":["@@ -0,0 +1,109 @@\n+\"\"\"Regression tests for PR #58448 salvage: the documented\n+WHATSAPP_CLOUD_ALLOWED_USERS / WHATSAPP_CLOUD_ALLOW_ALL_USERS env vars\n+must actually drive the DM intake gate.\n+\n+Before the fix, the adapter only read WHATSAPP_CLOUD_ALLOW_FROM and the\n+dm_policy default was \"open\" (which fails closed without an allow-all\n+opt-in), so a wizard-configured install using the documented vars\n+silently dropped every inbound message.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+from gateway.config import Platform\n+\n+\n+def _build_adapter(monkeypatch, env: dict[str, str], extra: dict | None = None):\n+    \"\"\"Construct a real WhatsAppCloudAdapter through __init__ with env vars.\"\"\"\n+    from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter\n+\n+    for var in (\n+        \"WHATSAPP_CLOUD_ALLOW_FROM\",\n+        \"WHATSAPP_CLOUD_ALLOWED_USERS\",\n+        \"WHATSAPP_CLOUD_ALLOW_ALL_USERS\",\n+        \"WHATSAPP_CLOUD_DM_POLICY\",\n+        \"WHATSAPP_DM_POLICY\",\n+        \"GATEWAY_ALLOW_ALL_USERS\",\n+        \"WHATSAPP_ALLOW_ALL_USERS\",\n+    ):\n+        monkeypatch.delenv(var, raising=False)\n+    for key, value in env.items():\n+        monkeypatch.setenv(key, value)\n+\n+    config = MagicMock()\n+    config.extra = {\n+        \"phone_number_id\": \"1234567890\",\n+        \"access_token\": \"[redacted:secret-assignment]\",\n+        **(extra or {}),\n+    }\n+    return WhatsAppCloudAdapter(config)\n+\n+\n+def _dm_message(sender: str) -> dict:\n+    return {\"from\": sender, \"id\": \"wamid.test\", \"type\": \"text\"}\n+\n+\n+def test_allowed_users_env_populates_allowlist_and_enforces_it(monkeypatch):\n+    adapter = _build_adapter(\n+        monkeypatch, {\"WHATSAPP_CLOUD_ALLOWED_USERS\": \"15551234567\"}\n+    )\n+\n+    # The documented var must populate the allowlist...\n+    assert \"15551234567\" in adapter._allow_from\n+    # ...and flip the default dm_policy to allowlist so it is enforced.\n+    assert adapter._dm_policy == \"allowlist\"\n+    # Allowlisted sender passes the intake gate; others are dropped.\n+    assert adapter._is_dm_allowed(\"15551234567\") is True\n+    assert adapter._is_dm_allowed(\"19998887777\") is False\n+\n+\n+def test_allow_all_users_env_opts_into_open_dms(monkeypatch):\n+    adapter = _build_adapter(\n+        monkeypatch, {\"WHATSAPP_CLOUD_ALLOW_ALL_USERS\": \"true\"}\n+    )\n+\n+    assert adapter._dm_policy == \"open\"\n+    assert adapter._open_dm_opted_in() is True\n+    assert adapter._is_dm_allowed(\"19998887777\") is True\n+\n+\n+def test_explicit_dm_policy_still_wins_over_derived_default(monkeypatch):\n+    adapter = _build_adapter(\n+        monkeypatch,\n+        {\n+            \"WHATSAPP_CLOUD_ALLOWED_USERS\": \"15551234567\",\n+            \"WHATSAPP_CLOUD_DM_POLICY\": \"disabled\",\n+        },\n+    )\n+\n+    # Operator's explicit policy beats the allowlist-derived default.\n+    assert adapter._dm_policy == \"disabled\"\n+\n+\n+def test_unconfigured_default_unchanged(monkeypatch):\n+    adapter = _build_adapter(monkeypatch, {})\n+\n+    # No allowlist, no opt-in: default stays \"open\" (which fails closed\n+    # in the shared mixin without an allow-all opt-in) — pre-fix behavior\n+    # for unconfigured installs is preserved.\n+    assert adapter._dm_policy == \"open\"\n+    assert adapter._allow_from == set()\n+    assert adapter._open_dm_opted_in() is False\n+\n+\n+def test_allow_from_still_takes_precedence(monkeypatch):\n+    adapter = _build_adapter(\n+        monkeypatch,\n+        {\n+            \"WHATSAPP_CLOUD_ALLOW_FROM\": \"15550000001\",\n+            \"WHATSAPP_CLOUD_ALLOWED_USERS\": \"15559999999\",\n+        },\n+    )\n+\n+    # Legacy ALLOW_FROM wins when both are set (documented precedence).\n+    assert \"15550000001\" in adapter._allow_from\n+    assert \"15559999999\" not in adapter._allow_from"]},{"file":"tests/test_yuanbao_reconnect_set_active.py","hunks":["@@ -0,0 +1,106 @@\n+\"\"\"test_yuanbao_reconnect_set_active.py - Verify _do_reconnect restores the active singleton.\n+\n+Regression test for #58363: after a WS disconnect/reconnect cycle,\n+``get_active_adapter()`` must return the live adapter (not ``None``).\n+The original ``_do_reconnect()`` succeeded but never called\n+``YuanbaoAdapter.set_active()``, leaving the singleton permanently\n+``None`` until a full gateway restart.\n+\"\"\"\n+\n+import sys\n+import os\n+import asyncio\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n+if _REPO_ROOT not in sys.path:\n+    sys.path.insert(0, _REPO_ROOT)\n+\n+import pytest\n+from gateway.platforms.yuanbao import (\n+    YuanbaoAdapter,\n+    ConnectionManager,\n+    get_active_adapter,\n+)\n+\n+\n+def _make_adapter(**kwargs):\n+    \"\"\"Create a minimal YuanbaoAdapter mock.\"\"\"\n+    adapter = MagicMock(spec=YuanbaoAdapter)\n+    adapter.name = \"yuanbao\"\n+    adapter._app_key = \"[redacted:secret-assignment]\"\n+    adapter._app_secret = \"[redacted:secret-assignment]\"\n+    adapter._api_domain = \"https://test.example.com\"\n+    adapter._route_env = None\n+    adapter._bot_id = \"test_bot\"\n+    adapter._ws_url = \"wss://test.example.com/ws\"\n+    adapter._mark_connected = MagicMock()\n+    adapter._mark_disconnected = MagicMock()\n+    adapter._release_platform_lock = MagicMock()\n+    return adapter\n+\n+\n[redacted:email]\n+async def test_do_reconnect_calls_set_active_on_success():\n+    \"\"\"After a successful reconnect, set_active(adapter) must be called.\"\"\"\n+    adapter = _make_adapter()\n+    cm = ConnectionManager(adapter)\n+\n+    # Mock the reconnect internals to succeed on first attempt\n+    mock_ws = AsyncMock()\n+    mock_ws.close = AsyncMock()\n+\n+    with (\n+        patch.object(cm, \"_cleanup_ws\", new_callable=AsyncMock) as mock_cleanup,\n+        patch(\n+            \"gateway.platforms.yuanbao.SignManager.force_refresh\",\n+            new_callable=AsyncMock,\n+            return_value={\"bot_id\": \"test_bot\", \"token\": \"[redacted:secret-assignment]\"},\n+        ),\n+        patch(\"gateway.platforms.yuanbao.websockets.connect\", new_callable=AsyncMock, return_value=mock_ws),\n+        patch.object(cm, \"_authenticate\", new_callable=AsyncMock, return_value=True),\n+        patch.object(cm, \"_heartbeat_loop\", new_callable=AsyncMock),\n+        patch.object(cm, \"_receive_loop\", new_callable=AsyncMock),\n+        patch(\"gateway.platforms.yuanbao.MAX_RECONNECT_ATTEMPTS\", 1),\n+    ):\n+        # Clear any existing active instance\n+        YuanbaoAdapter.set_active(None)\n+        assert get_active_adapter() is None\n+\n+        # Run reconnect\n+        result = await cm._do_reconnect()\n+\n+        # Reconnect should succeed\n+        assert result is True\n+\n+        # After successful reconnect, get_active() must return the adapter\n+        assert get_active_adapter() is adapter\n+\n+\n[redacted:email]\n+async def test_do_reconnect_does_not_set_active_on_failure():\n+    \"\"\"When all reconnect attempts fail, set_active should NOT be called.\"\"\"\n+    adapter = _make_adapter()\n+    cm = ConnectionManager(adapter)\n+\n+    with (\n+        patch.object(cm, \"_cleanup_ws\", new_callable=AsyncMock),\n+        patch(\n+            \"gateway.platforms.yuanbao.SignManager.force_refresh\",\n+            new_callable=AsyncMock,\n+            side_effect=Exception(\"auth failed\"),\n+        ),\n+        patch(\"gateway.platforms.yuanbao.MAX_RECONNECT_ATTEMPTS\", 1),\n+    ):\n+        # Clear any existing active instance\n+        YuanbaoAdapter.set_active(None)\n+        assert get_active_adapter() is None\n+\n+        # Run reconnect - should fail\n+        result = await cm._do_reconnect()\n+\n+        # Reconnect should fail\n+        assert result is False\n+\n+        # get_active() should still be None\n+        assert get_active_adapter() is None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}