{"repo":"NousResearch/hermes-agent","public_id":"68406","display_label":"Telegram fatal recovery handoff during teardown or retry exhaustion","summary":"These members appear to concern the Telegram integration's fatal recovery handoff, with one bug report describing retry exhaustion cancelling the handoff before a background reconnect is queued. The other three are fix-titled items about preserving that handoff from self-cancelling teardown; their exact scope and status are not stated in 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":68447,"merged_at":"2026-07-21T06:41:54+00:00"},"resolution":{"state":"resolved","by":"pr-68447"},"freshness":{"state":"verified","last_verified_at":"2026-07-21T06:41:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":68406,"pr_number":68447,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-21T06:41:54+00:00"}],"members":[{"kind":"issue","number":68406,"title":"[Bug]: Telegram retry exhaustion can cancel fatal handoff before background reconnect is queued","url":null,"state":"closed"},{"kind":"pr","number":68410,"title":"fix(telegram): preserve fatal recovery handoff","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68447","number":68447},{"kind":"pr","value":"#68424","number":68424},{"kind":"pr","value":"#68406","number":68406}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68424,"title":"fix(telegram): preserve fatal recovery handoff from self-cancelling child teardown","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68447","number":68447},{"kind":"pr","value":"#68410","number":68410},{"kind":"pr","value":"#68406","number":68406}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":68447,"title":"fix(telegram): preserve fatal recovery handoff from self-cancelling teardown","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_conflict.py","tests/gateway/test_telegram_network_reconnect.py"],"fix_hunks":[{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -2357,11 +2357,11 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n         if attempt > MAX_NETWORK_RETRIES:\n             message = (\n                 \"Telegram polling could not reconnect after %d network error retries. \"\n-                \"Restarting gateway.\" % MAX_NETWORK_RETRIES\n+                \"Escalating to gateway recovery.\" % MAX_NETWORK_RETRIES\n             )\n             logger.error(\"[%s] %s Last error: %s\", self.name, message, _redact_telegram_error_text(error))\n             self._set_fatal_error(\"telegram_network_error\", message, retryable=True)\n-            await self._notify_fatal_error()\n+            await self._handoff_polling_fatal_error()\n             return\n \n         delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)","@@ -2523,7 +2523,7 @@ async def _polling_heartbeat_loop(self) -> None:\n                             \"gateway reconnect.\" % stuck_for,\n                             retryable=True,\n                         )\n-                        await self._notify_fatal_error()\n+                        await self._handoff_polling_fatal_error()\n                         return\n                 else:\n                     stuck_task_ref = None","@@ -2982,7 +2982,24 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n                 self.name, stop_error, exc_info=True,\n             )\n         if not _already_fatal:\n-            await self._notify_fatal_error()\n+            await self._handoff_polling_fatal_error()\n+\n+    async def _handoff_polling_fatal_error(self) -> None:\n+        \"\"\"Notify the runner without letting child teardown cancel this owner.\n+\n+        The runner bounds adapter cleanup in a child task.  ``disconnect()``\n+        cancels the tracked polling-recovery task and the heartbeat task, so\n+        retaining the current notifier in either field would cancel the fatal\n+        callback before the runner can finish its reconnect or shutdown\n+        decision.  Release only the current owner from whichever field tracks\n+        it; unrelated tasks remain under teardown control.\n+        \"\"\"\n+        current_task = asyncio.current_task()\n+        if self._polling_error_task is current_task:\n+            self._polling_error_task = None\n+        if getattr(self, \"_polling_heartbeat_task\", None) is current_task:\n+            self._polling_heartbeat_task = None\n+        await self._notify_fatal_error()\n \n     async def _create_dm_topic(\n         self,"]},{"file":"tests/gateway/test_telegram_conflict.py","hunks":["@@ -293,6 +293,32 @@ async def failing_start_polling(**kwargs):\n     await _cancel_heartbeat(adapter)\n \n \n[redacted:email]\n+async def test_conflict_exhaustion_hands_off_before_child_disconnect():\n+    \"\"\"The conflict recovery owner must survive its fatal callback handoff.\"\"\"\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+    adapter._polling_conflict_count = 5  # MAX_CONFLICT_RETRIES\n+    disconnect_tasks = []\n+\n+    async def fatal_handler(failed_adapter):\n+        disconnect_task = asyncio.create_task(failed_adapter.disconnect())\n+        disconnect_tasks.append(disconnect_task)\n+        await asyncio.wait({disconnect_task})\n+\n+    adapter.set_fatal_error_handler(fatal_handler)\n+\n+    conflict = type(\"Conflict\", (Exception,), {})\n+    recovery_task = asyncio.create_task(\n+        adapter._handle_polling_conflict(conflict(\"getUpdates conflict\"))\n+    )\n+    adapter._polling_error_task = recovery_task\n+    result = await asyncio.gather(recovery_task, return_exceptions=True)\n+    await asyncio.gather(*disconnect_tasks, return_exceptions=True)\n+\n+    assert result == [None]\n+    assert adapter._polling_error_task is None\n+\n+\n @pytest.mark.asyncio\n async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(monkeypatch):\n     adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))","@@ -730,4 +756,3 @@ async def fake_start_polling(**kwargs):\n     for _ in range(10):\n         await asyncio.sleep(0)\n     await _cancel_heartbeat(adapter)\n-"]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -14,7 +14,7 @@\n \n import pytest\n \n-from gateway.config import PlatformConfig\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n \n \n def _ensure_telegram_mock():","@@ -37,6 +37,7 @@ def _ensure_telegram_mock():\n \n from plugins.platforms.telegram import adapter as tg_adapter  # noqa: E402\n from plugins.platforms.telegram.adapter import TelegramAdapter  # noqa: E402\n+from gateway.run import GatewayRunner  # noqa: E402\n \n \n @pytest.fixture(autouse=True)","@@ -224,6 +225,76 @@ async def test_reconnect_triggers_fatal_after_max_retries():\n     fatal_handler.assert_called_once()\n \n \n[redacted:email]\n+async def test_retry_exhaustion_queues_reconnect_before_child_disconnect(tmp_path):\n+    \"\"\"Fatal teardown must not cancel the gateway's reconnect handoff.\n+\n+    The gateway runs ``disconnect()`` in a bounded child task.  If the current\n+    polling-recovery owner remains in ``_polling_error_task``, Telegram teardown\n+    cancels that parent while it is still awaiting the fatal handler, so the\n+    handler never gets to queue background reconnection.\n+    \"\"\"\n+    config = GatewayConfig(\n+        platforms={\n+            Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+        },\n+        sessions_dir=tmp_path / \"sessions\",\n+    )\n+    runner = GatewayRunner(config)\n+    adapter = _make_adapter()\n+    adapter._polling_network_error_count = 10  # MAX_NETWORK_RETRIES\n+    adapter.set_fatal_error_handler(runner._handle_adapter_fatal_error)\n+    runner.adapters = {Platform.TELEGRAM: adapter}\n+    runner.delivery_router.adapters = runner.adapters\n+\n+    recovery_task = asyncio.create_task(\n+        adapter._handle_polling_network_error(Exception(\"still failing\"))\n+    )\n+    adapter._polling_error_task = recovery_task\n+    result = await asyncio.gather(recovery_task, return_exceptions=True)\n+\n+    assert result == [None]\n+    assert runner.adapters == {}\n+    assert Platform.TELEGRAM in runner._failed_platforms\n+    assert runner._failed_platforms[Platform.TELEGRAM][\"attempts\"] == 0\n+\n+\n[redacted:email]\n+async def test_heartbeat_watchdog_handoff_survives_child_disconnect(tmp_path):\n+    \"\"\"The wedged-recovery heartbeat watchdog must survive its fatal callback.\n+\n+    The heartbeat loop force-escalates a stuck polling-recovery task.  Like\n+    the network/conflict terminal paths, the heartbeat task itself is the\n+    owner that ``disconnect()`` cancels, so the fatal callback must release\n+    ``_polling_heartbeat_task`` before notifying the runner.\n+    \"\"\"\n+    config = GatewayConfig(\n+        platforms={\n+            Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+        },\n+        sessions_dir=tmp_path / \"sessions\",\n+    )\n+    runner = GatewayRunner(config)\n+    adapter = _make_adapter()\n+    adapter.set_fatal_error_handler(runner._handle_adapter_fatal_error)\n+    runner.adapters = {Platform.TELEGRAM: adapter}\n+    runner.delivery_router.adapters = runner.adapters\n+\n+    # Simulate the heartbeat watchdog's fatal-escalation path directly.\n+    adapter._set_fatal_error(\n+        \"telegram_network_error\",\n+        \"Telegram reconnect task wedged; forcing gateway reconnect.\",\n+        retryable=True,\n+    )\n+    heartbeat_task = asyncio.create_task(adapter._handoff_polling_fatal_error())\n+    adapter._polling_heartbeat_task = heartbeat_task\n+    result = await asyncio.gather(heartbeat_task, return_exceptions=True)\n+\n+    assert result == [None]\n+    assert runner.adapters == {}\n+    assert Platform.TELEGRAM in runner._failed_platforms\n+\n+\n # ---------------------------------------------------------------------------\n # Connection pool drain tests (PR #16466 salvage)\n # ---------------------------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}