{"repo":"NousResearch/hermes-agent","public_id":"55992","display_label":"Telegram adapter reconnect problems after network errors","summary":"The members concern Telegram polling or the adapter becoming broken after a network error and restart or reconnect. One is an issue report, and the others are pull requests that reference race conditions and a NoneType guard, with two citing #55992. Their outcomes are not established by the titles.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56200,"merged_at":"2026-07-01T09:03:58+00:00"},"resolution":{"state":"resolved","by":"pr-56200"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T09:03:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":55992,"pr_number":56200,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-01T09:03:58+00:00"}],"members":[{"kind":"issue","number":55992,"title":"Telegram polling silently dies after network error + self-restart","url":null,"state":"closed"},{"kind":"pr","number":56028,"title":"fix(gateway): guard NoneType in Telegram reconnect after network error (#55992)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56224","number":56224},{"kind":"pr","value":"#56036","number":56036},{"kind":"pr","value":"#55992","number":55992}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56036,"title":"fix(telegram): close reconnect races that leave adapter half-destroyed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56224","number":56224}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56200,"title":"fix(telegram): close reconnect races that leave the adapter half-destroyed (#55992)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","plugins/platforms/telegram/adapter.py","tests/gateway/test_runner_fatal_adapter.py","tests/gateway/test_telegram_network_reconnect.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -3654,6 +3654,23 @@ async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> Non\n         If the error is retryable (e.g. network blip, DNS failure), queue the\n         platform for background reconnection instead of giving up permanently.\n         \"\"\"\n+        # Snapshot the current owner of this platform slot before doing\n+        # anything else. If it's neither this adapter nor empty, a different\n+        # adapter has already taken over (e.g. this is a delayed notification\n+        # from a background retry chain that raced with, and lost to, a\n+        # reconnect that already succeeded). Acting on a stale notification\n+        # would overwrite an already-healthy platform's runtime status and\n+        # incorrectly re-queue it for reconnection, so bail out before any of\n+        # that happens.\n+        existing = self.adapters.get(adapter.platform)\n+        if existing is not None and existing is not adapter:\n+            logger.debug(\n+                \"Ignoring stale fatal error from a superseded %s adapter instance: %s\",\n+                adapter.platform.value,\n+                adapter.fatal_error_code or \"unknown\",\n+            )\n+            return\n+\n         logger.error(\n             \"Fatal %s adapter error (%s): %s\",\n             adapter.platform.value,","@@ -3677,13 +3694,15 @@ async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> Non\n             error_message=adapter.fatal_error_message,\n         )\n \n-        existing = self.adapters.get(adapter.platform)\n         if existing is adapter:\n-            try:\n-                await adapter.disconnect()\n-            finally:\n-                self.adapters.pop(adapter.platform, None)\n-                self.delivery_router.adapters = self.adapters\n+            # Claim this adapter for teardown before awaiting disconnect() —\n+            # a second fatal-error notification for the same adapter (e.g.\n+            # from a concurrent recovery path) would otherwise still see\n+            # itself as \"existing\" during the await below and disconnect()\n+            # the same object twice.\n+            self.adapters.pop(adapter.platform, None)\n+            self.delivery_router.adapters = self.adapters\n+            await adapter.disconnect()\n \n         # Queue retryable failures for background reconnection\n         if adapter.fatal_error_retryable:"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -1773,16 +1773,24 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n         )\n         await asyncio.sleep(delay)\n \n+        # Capture a stable local reference: self._app can be reassigned to None\n+        # by a concurrent disconnect() while we're suspended across the awaits\n+        # below, and re-reading self._app after that point would silently swap\n+        # in None mid-sequence instead of failing fast in one place.\n+        app = self._app\n+\n         try:\n-            if self._app and self._app.updater and self._app.updater.running:\n-                await self._app.updater.stop()\n+            if app and app.updater and app.updater.running:\n+                await app.updater.stop()\n         except Exception:\n             pass\n \n         await self._drain_polling_connections()\n \n         try:\n-            await self._app.updater.start_polling(\n+            if not app:\n+                raise RuntimeError(\"Telegram application was torn down during reconnect\")\n+            await app.updater.start_polling(\n                 allowed_updates=Update.ALL_TYPES,\n                 drop_pending_updates=False,\n                 error_callback=self._polling_error_callback_ref,","@@ -1824,6 +1832,12 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n                 )\n                 self._background_tasks.add(task)\n                 task.add_done_callback(self._background_tasks.discard)\n+                # This chained retry IS the in-flight recovery attempt — it\n+                # must replace the reentrancy guard, otherwise the heartbeat\n+                # loop, the pending-updates probe, and the PTB error callback\n+                # all see _polling_error_task as \"done\" and can each start a\n+                # second, concurrent recovery for the same outage.\n+                self._polling_error_task = task\n \n     async def _polling_heartbeat_loop(self) -> None:\n         \"\"\"Detect dead Telegram TCP sockets (CLOSE-WAIT) by periodic probing.","@@ -2151,8 +2165,17 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n             await asyncio.sleep(RETRY_DELAY)\n             await self._drain_polling_connections()\n \n+            # Capture a stable local reference: self._app can be reassigned to\n+            # None by a concurrent disconnect() while we're suspended across\n+            # the awaits above (same race #55992 fixed on the network path).\n+            # Re-reading self._app after that point would raise\n+            # AttributeError deep inside start_polling instead of failing fast\n+            # here, where the except below reschedules or escalates to fatal.\n+            app = self._app\n             try:\n-                await self._app.updater.start_polling(\n+                if not app:\n+                    raise RuntimeError(\"Telegram application was torn down during conflict reconnect\")\n+                await app.updater.start_polling(\n                     allowed_updates=Update.ALL_TYPES,\n                     drop_pending_updates=False,\n                     error_callback=self._polling_error_callback_ref,"]},{"file":"tests/gateway/test_runner_fatal_adapter.py","hunks":["@@ -1,3 +1,4 @@\n+import asyncio\n from unittest.mock import AsyncMock\n \n import pytest","@@ -98,3 +99,96 @@ async def test_runner_queues_retryable_runtime_fatal_for_reconnection(monkeypatc\n     assert runner._exit_with_failure is False\n     assert Platform.WHATSAPP in runner._failed_platforms\n     assert runner._failed_platforms[Platform.WHATSAPP][\"attempts\"] == 0\n+\n+\n[redacted:email]\n+async def test_concurrent_fatal_notifications_disconnect_same_adapter_once(monkeypatch, tmp_path):\n+    \"\"\"\n+    Two fatal-error notifications for the same still-installed adapter (e.g.\n+    from two concurrent recovery paths racing on the same underlying outage)\n+    must result in exactly one disconnect() call.\n+\n+    Regression test for the TOCTOU race in _handle_adapter_fatal_error: the\n+    old code only removed the adapter from self.adapters in a `finally` block\n+    *after* awaiting disconnect(), so a second concurrent call could still see\n+    itself as \"existing\" and disconnect() the same object twice — the\n+    concrete origin of the \"'NoneType' object has no attribute 'updater'\"\n+    crash when the adapter's own teardown code re-reads self._app afterwards.\n+    \"\"\"\n+    config = GatewayConfig(\n+        platforms={\n+            Platform.WHATSAPP: PlatformConfig(enabled=True, token=\"token\")\n+        },\n+        sessions_dir=tmp_path / \"sessions\",\n+    )\n+    runner = GatewayRunner(config)\n+    adapter = _RuntimeRetryableAdapter()\n+    adapter._set_fatal_error(\n+        \"whatsapp_bridge_exited\",\n+        \"WhatsApp bridge process exited unexpectedly (code 1).\",\n+        retryable=True,\n+    )\n+\n+    runner.adapters = {Platform.WHATSAPP: adapter}\n+    runner.delivery_router.adapters = runner.adapters\n+    runner.stop = AsyncMock()\n+\n+    disconnect_calls = 0\n+    release_second_call = asyncio.Event()\n+\n+    async def slow_disconnect():\n+        nonlocal disconnect_calls\n+        disconnect_calls += 1\n+        # Yield control so the second concurrent notification can run its\n+        # \"existing is adapter\" check before this call finishes tearing down.\n+        release_second_call.set()\n+        await asyncio.sleep(0)\n+        adapter._mark_disconnected()\n+\n+    monkeypatch.setattr(adapter, \"disconnect\", slow_disconnect)\n+\n+    await asyncio.gather(\n+        runner._handle_adapter_fatal_error(adapter),\n+        runner._handle_adapter_fatal_error(adapter),\n+    )\n+\n+    assert disconnect_calls == 1\n+\n+\n[redacted:email]\n+async def test_stale_fatal_notification_from_superseded_adapter_is_ignored(monkeypatch, tmp_path):\n+    \"\"\"\n+    A delayed fatal-error notification from an adapter instance that has\n+    since been replaced by a different, already-installed adapter (e.g. a\n+    background retry chain on the old instance finally giving up after a\n+    reconnect on a new instance already succeeded) must be ignored: it must\n+    not disconnect the new adapter, must not re-queue an already-healthy\n+    platform for reconnection, and must not shut the gateway down.\n+    \"\"\"\n+    config = GatewayConfig(\n+        platforms={\n+            Platform.WHATSAPP: PlatformConfig(enabled=True, token=\"token\")\n+        },\n+        sessions_dir=tmp_path / \"sessions\",\n+    )\n+    runner = GatewayRunner(config)\n+\n+    old_adapter = _RuntimeRetryableAdapter()\n+    old_adapter._set_fatal_error(\n+        \"whatsapp_bridge_exited\",\n+        \"stale failure from a superseded adapter instance\",\n+        retryable=True,\n+    )\n+\n+    new_adapter = _RuntimeRetryableAdapter()\n+    new_adapter.disconnect = AsyncMock()\n+    runner.adapters = {Platform.WHATSAPP: new_adapter}\n+    runner.delivery_router.adapters = runner.adapters\n+    runner.stop = AsyncMock()\n+\n+    await runner._handle_adapter_fatal_error(old_adapter)\n+\n+    new_adapter.disconnect.assert_not_awaited()\n+    assert runner.adapters[Platform.WHATSAPP] is new_adapter\n+    assert Platform.WHATSAPP not in runner._failed_platforms\n+    runner.stop.assert_not_awaited()"]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -117,6 +117,43 @@ async def test_reconnect_does_not_self_schedule_when_fatal_error_set():\n     )\n \n \n[redacted:email]\n+async def test_reconnect_chained_retry_updates_polling_error_task():\n+    \"\"\"\n+    When start_polling() fails and the handler self-schedules a retry, that\n+    retry task must become the new `_polling_error_task` — otherwise the\n+    reentrancy guard used by the heartbeat loop, the pending-updates probe,\n+    and the PTB error callback goes stale while a recovery is still in\n+    flight, letting a second concurrent recovery start for the same outage.\n+\n+    Regression test for the race behind the \"half-destroyed adapter\" bug\n+    (gateway reports connected but silently stops processing messages).\n+    \"\"\"\n+    adapter = _make_adapter()\n+    adapter._polling_network_error_count = 1\n+\n+    mock_updater = MagicMock()\n+    mock_updater.running = True\n+    mock_updater.stop = AsyncMock()\n+    mock_updater.start_polling = AsyncMock(side_effect=Exception(\"Timed out\"))\n+\n+    mock_app = MagicMock()\n+    mock_app.updater = mock_updater\n+    adapter._app = mock_app\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        await adapter._handle_polling_network_error(Exception(\"Bad Gateway\"))\n+\n+    assert adapter._polling_error_task is not None\n+    assert not adapter._polling_error_task.done()\n+\n+    adapter._polling_error_task.cancel()\n+    try:\n+        await adapter._polling_error_task\n+    except (asyncio.CancelledError, Exception):\n+        pass\n+\n+\n @pytest.mark.asyncio\n async def test_reconnect_success_resets_error_count():\n     \"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}