Yuanbao adapter singleton loss after WS reconnect
Cluster 58184 · agent. Data as of
2 open items. Fix merged in #58504 on 5 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #58504. 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
- 1 issue and 1 PR.
Next step: read #58504, then check whether your report is the one still open.
Read #58504Pick up a Lab taskThese 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.
Open issues1
- Issue#58184cronjob deliver=origin 不稳定:Yuanbao WS断连时投递失败
Open PRs1
- PR#58206fix(yuanbao): wait for adapter reconnect before delivery
Fixes on record
-
#58504 fix(gateway): SSE buffer cap, yuanbao reconnect singleton, WhatsApp Cloud allowlist env vars (3-PR salvage)
files:
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.pyShow the diff
-
gateway/platforms/whatsapp_cloud.py@@ -225,18 +225,35 @@ def __init__(self, config: PlatformConfig): import os self._reply_prefix: Optional[str] = extra.get("reply_prefix") - self._dm_policy: str = str( - extra.get("dm_policy") - or os.getenv("WHATSAPP_CLOUD_DM_POLICY") - or os.getenv("WHATSAPP_DM_POLICY", "open") - ).strip().lower() + # Allowlist: honor the *documented* WHATSAPP_CLOUD_ALLOWED_USERS (the + # var the setup wizard writes) in addition to WHATSAPP_CLOUD_ALLOW_FROM. + # The adapter historically read only ALLOW_FROM, so an allowlist + # configured via the documented var silently dropped every inbound. self._allow_from: set[str] = self._normalize_allow_ids( self._coerce_allow_list( extra.get("allow_from") or extra.get("allowFrom") or os.getenv("WHATSAPP_CLOUD_ALLOW_FROM") + or os.getenv("WHATSAPP_CLOUD_ALLOWED_USERS") ) ) + # DM policy: explicit config wins; otherwise choose a safe, working + # default -- "open" if the operator opted into allow-all, else + # "allowlist" when an allowlist is configured (so it is actually + # enforced instead of silently dropping), else "open". + _allow_all_optin = str( + os.getenv("WHATSAPP_CLOUD_ALLOW_ALL_USERS", "") + ).strip().lower() in {"true", "1", "yes"} + _default_dm_policy = ( + "open" if _allow_all_optin + else ("allowlist" if self._allow_from else "open") + ) + self._dm_policy: str = str( + extra.get("dm_policy") + or os.getenv("WHATSAPP_CLOUD_DM_POLICY") + or os.getenv("WHATSAPP_DM_POLICY") + or _default_dm_policy + ).strip().lower() self._group_policy: str = str( extra.get("group_policy") or os.getenv("WHATSAPP_CLOUD_GROUP_POLICY") @@ -347,6 +364,17 @@ def _is_dm_allowed(self, sender_id: str) -> bool: return (bare or sender_id) in self._allow_from return super()._is_dm_allowed(sender_id) + def _open_dm_opted_in(self) -> bool: + """Also honor the documented WHATSAPP_CLOUD_ALLOW_ALL_USERS opt-in. + + The shared mixin only checks GATEWAY_ALLOW_ALL_USERS / + WHATSAPP_ALLOW_ALL_USERS; the Cloud adapter's documented open-access + opt-in is WHATSAPP_CLOUD_ALLOW_ALL_USERS, so honor it here too. + """ + if str(os.getenv("WHATSAPP_CLOUD_ALLOW_ALL_USERS", "")).strip().lower() in {"true", "1", "yes"}: + return True + return super()._open_dm_opted_in() + # ------------------------------------------------------------------ lifecycle async def connect(self, *, is_reconnect: bool = False) -> bool: if not check_whatsapp_cloud_requirements(): -
gateway/platforms/yuanbao.py@@ -3868,6 +3868,7 @@ async def _do_reconnect(self) -> bool: "[%s] Reconnected on attempt %d. connectId=%s", adapter.name, attempt + 1, self._connect_id, ) + YuanbaoAdapter.set_active(adapter) return True except asyncio.TimeoutError: -
gateway/run.py@@ -67,6 +67,7 @@ _AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0 _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0 +_GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS = 16 * 1024 * 1024 _TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(?<![\w:/])/([A-Za-z0-9][A-Za-z0-9_-]*)") _TELEGRAM_NOISY_STATUS_RE = re.compile( @@ -16219,6 +16220,10 @@ def _pause_typing_before_finalize( _stream_consumer.on_delta(content) except json.JSONDecodeError: pass + if len(buffer) > _GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS: + raise ValueError( + "Proxy SSE stream exceeded max buffer size without a line boundary" + ) except asyncio.CancelledError: raise -
scripts/release.py@@ -46,9 +46,10 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "[redacted:email]": "JAlmanzarMint", # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890 - "[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). + "[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). "[email protected]": "Jigoooo", # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry) "[redacted:email]": "ostravajih", # PR #58374 salvage (poolside: coerce integer finish_reason and tool_call id to strings) + "[redacted:email]": "sahil-shubham", # PR #58448 salvage (whatsapp_cloud: honor documented WHATSAPP_CLOUD_ALLOWED_USERS / ALLOW_ALL_USERS in the DM intake gate) "[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). "[redacted:email]": "danilofalcao", # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel) "[email protected]": "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) -
tests/gateway/test_proxy_mode.py@@ -334,6 +334,32 @@ async def __aexit__(self, *args): assert "Proxy connection error" in result["final_response"] + @pytest.mark.asyncio + async def test_rejects_proxy_sse_without_line_boundary_after_buffer_cap(self, monkeypatch): + monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642") + monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False) + monkeypatch.setattr("gateway.run._GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS", 16) + runner = _make_runner() + source = _make_source() + + resp = _FakeSSEResponse(status=200, sse_chunks=[b"data: ", b"x" * 20]) + session = _FakeSession(resp) + + with patch("gateway.run._load_gateway_config", return_value={}): + with _patch_aiohttp(session): + with patch("aiohttp.ClientTimeout"): + result = await runner._run_agent_via_proxy( + message="hi", + context_prompt="", + history=[], + source=source, + session_id="test", + ) + + assert "Proxy connection error" in result["final_response"] + assert "exceeded max buffer size" in result["final_response"] + assert result["api_calls"] == 0 + @pytest.mark.asyncio async def test_skips_tool_messages_in_history(self, monkeypatch): monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642") -
tests/gateway/test_whatsapp_cloud_allowed_users.py@@ -0,0 +1,109 @@ +"""Regression tests for PR #58448 salvage: the documented +WHATSAPP_CLOUD_ALLOWED_USERS / WHATSAPP_CLOUD_ALLOW_ALL_USERS env vars +must actually drive the DM intake gate. + +Before the fix, the adapter only read WHATSAPP_CLOUD_ALLOW_FROM and the +dm_policy default was "open" (which fails closed without an allow-all +opt-in), so a wizard-configured install using the documented vars +silently dropped every inbound message. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from gateway.config import Platform + + +def _build_adapter(monkeypatch, env: dict[str, str], extra: dict | None = None): + """Construct a real WhatsAppCloudAdapter through __init__ with env vars.""" + from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter + + for var in ( + "WHATSAPP_CLOUD_ALLOW_FROM", + "WHATSAPP_CLOUD_ALLOWED_USERS", + "WHATSAPP_CLOUD_ALLOW_ALL_USERS", + "WHATSAPP_CLOUD_DM_POLICY", + "WHATSAPP_DM_POLICY", + "GATEWAY_ALLOW_ALL_USERS", + "WHATSAPP_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(var, raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) + + config = MagicMock() + config.extra = { + "phone_number_id": "1234567890", + "access_token": "[redacted:secret-assignment]", + **(extra or {}), + } + return WhatsAppCloudAdapter(config) + + +def _dm_message(sender: str) -> dict: + return {"from": sender, "id": "wamid.test", "type": "text"} + + +def test_allowed_users_env_populates_allowlist_and_enforces_it(monkeypatch): + adapter = _build_adapter( + monkeypatch, {"WHATSAPP_CLOUD_ALLOWED_USERS": "15551234567"} + ) + + # The documented var must populate the allowlist... + assert "15551234567" in adapter._allow_from + # ...and flip the default dm_policy to allowlist so it is enforced. + assert adapter._dm_policy == "allowlist" + # Allowlisted sender passes the intake gate; others are dropped. + assert adapter._is_dm_allowed("15551234567") is True + assert adapter._is_dm_allowed("19998887777") is False + + +def test_allow_all_users_env_opts_into_open_dms(monkeypatch): + adapter = _build_adapter( + monkeypatch, {"WHATSAPP_CLOUD_ALLOW_ALL_USERS": "true"} + ) + + assert adapter._dm_policy == "open" + assert adapter._open_dm_opted_in() is True + assert adapter._is_dm_allowed("19998887777") is True + + +def test_explicit_dm_policy_still_wins_over_derived_default(monkeypatch): + adapter = _build_adapter( + monkeypatch, + { + "WHATSAPP_CLOUD_ALLOWED_USERS": "15551234567", + "WHATSAPP_CLOUD_DM_POLICY": "disabled", + }, + ) + + # Operator's explicit policy beats the allowlist-derived default. + assert adapter._dm_policy == "disabled" + + +def test_unconfigured_default_unchanged(monkeypatch): + adapter = _build_adapter(monkeypatch, {}) + + # No allowlist, no opt-in: default stays "open" (which fails closed + # in the shared mixin without an allow-all opt-in) — pre-fix behavior + # for unconfigured installs is preserved. + assert adapter._dm_policy == "open" + assert adapter._allow_from == set() + assert adapter._open_dm_opted_in() is False + + +def test_allow_from_still_takes_precedence(monkeypatch): + adapter = _build_adapter( + monkeypatch, + { + "WHATSAPP_CLOUD_ALLOW_FROM": "15550000001", + "WHATSAPP_CLOUD_ALLOWED_USERS": "15559999999", + }, + ) + + # Legacy ALLOW_FROM wins when both are set (documented precedence). + assert "15550000001" in adapter._allow_from + assert "15559999999" not in adapter._allow_from -
tests/test_yuanbao_reconnect_set_active.py@@ -0,0 +1,106 @@ +"""test_yuanbao_reconnect_set_active.py - Verify _do_reconnect restores the active singleton. + +Regression test for #58363: after a WS disconnect/reconnect cycle, +``get_active_adapter()`` must return the live adapter (not ``None``). +The original ``_do_reconnect()`` succeeded but never called +``YuanbaoAdapter.set_active()``, leaving the singleton permanently +``None`` until a full gateway restart. +""" + +import sys +import os +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +import pytest +from gateway.platforms.yuanbao import ( + YuanbaoAdapter, + ConnectionManager, + get_active_adapter, +) + + +def _make_adapter(**kwargs): + """Create a minimal YuanbaoAdapter mock.""" + adapter = MagicMock(spec=YuanbaoAdapter) + adapter.name = "yuanbao" + adapter._app_key = "[redacted:secret-assignment]" + adapter._app_secret = "[redacted:secret-assignment]" + adapter._api_domain = "https://test.example.com" + adapter._route_env = None + adapter._bot_id = "test_bot" + adapter._ws_url = "wss://test.example.com/ws" + adapter._mark_connected = MagicMock() + adapter._mark_disconnected = MagicMock() + adapter._release_platform_lock = MagicMock() + return adapter + + [redacted:email] +async def test_do_reconnect_calls_set_active_on_success(): + """After a successful reconnect, set_active(adapter) must be called.""" + adapter = _make_adapter() + cm = ConnectionManager(adapter) + + # Mock the reconnect internals to succeed on first attempt + mock_ws = AsyncMock() + mock_ws.close = AsyncMock() + + with ( + patch.object(cm, "_cleanup_ws", new_callable=AsyncMock) as mock_cleanup, + patch( + "gateway.platforms.yuanbao.SignManager.force_refresh", + new_callable=AsyncMock, + return_value={"bot_id": "test_bot", "token": "[redacted:secret-assignment]"}, + ), + patch("gateway.platforms.yuanbao.websockets.connect", new_callable=AsyncMock, return_value=mock_ws), + patch.object(cm, "_authenticate", new_callable=AsyncMock, return_value=True), + patch.object(cm, "_heartbeat_loop", new_callable=AsyncMock), + patch.object(cm, "_receive_loop", new_callable=AsyncMock), + patch("gateway.platforms.yuanbao.MAX_RECONNECT_ATTEMPTS", 1), + ): + # Clear any existing active instance + YuanbaoAdapter.set_active(None) + assert get_active_adapter() is None + + # Run reconnect + result = await cm._do_reconnect() + + # Reconnect should succeed + assert result is True + + # After successful reconnect, get_active() must return the adapter + assert get_active_adapter() is adapter + + [redacted:email] +async def test_do_reconnect_does_not_set_active_on_failure(): + """When all reconnect attempts fail, set_active should NOT be called.""" + adapter = _make_adapter() + cm = ConnectionManager(adapter) + + with ( + patch.object(cm, "_cleanup_ws", new_callable=AsyncMock), + patch( + "gateway.platforms.yuanbao.SignManager.force_refresh", + new_callable=AsyncMock, + side_effect=Exception("auth failed"), + ), + patch("gateway.platforms.yuanbao.MAX_RECONNECT_ATTEMPTS", 1), + ): + # Clear any existing active instance + YuanbaoAdapter.set_active(None) + assert get_active_adapter() is None + + # Run reconnect - should fail + result = await cm._do_reconnect() + + # Reconnect should fail + assert result is False + + # get_active() should still be None + assert get_active_adapter() is None
-
2 PRs closed without merging
From the close comments: closed without comment 1 · merged via another change 1
Closed issues · 1
- Closed#58363Yuanbao cron delivery silently fails after WS reconnect (three causes)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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 LabAt a glance
- Subsystem
- agent
- Merged PRs
- #58504
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 5 Jul 2026