{"repo":"NousResearch/hermes-agent","public_id":"63243","display_label":"Telegram post-reconnect probe recovery bug fixes","summary":"These issues and pull requests address bugs in the Telegram integration's post-reconnect recovery logic, specifically around probe failures bypassing deduplication guards, misclassified auth errors, and serialization of recovery behavior after reconnection.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64361},"resolution":{"state":"partially_resolved","by":"pr-64361"},"freshness":{"state":"verified","last_verified_at":"2026-07-14T12:02:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.35733333333333334},"fix_edges":[{"issue_number":63243,"pr_number":64361,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":63243,"pr_number":64368,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":67912,"pr_number":64368,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":63243,"title":"bug(telegram): post-reconnect probe routes auth errors into reconnect ladder and bypasses the recovery dedup guard (gap left by #62098)","url":null,"state":"closed"},{"kind":"issue","number":67912,"title":"[Bug]: Telegram post-reconnect probe bypasses recovery single-flight guard","url":null,"state":"closed"},{"kind":"pr","number":63247,"title":"fix(telegram): classify and dedup post-reconnect probe failures (#63243)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64368","number":64368},{"kind":"pr","value":"#62098","number":62098}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64361,"title":"fix(telegram): require getUpdates progress before polling is healthy","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","scripts/release.py","tests/gateway/test_telegram_conflict.py","tests/gateway/test_telegram_network_reconnect.py","tests/gateway/test_telegram_polling_progress.py","tests/gateway/test_telegram_send_path_health.py","tests/test_telegram_polling_progress_ptb.py"],"fix_hunks":[{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -17,6 +17,7 @@\n import html as _html\n import re\n import threading\n+from contextvars import ContextVar\n from datetime import datetime, timezone\n from typing import Dict, List, Optional, Set, Any\n ","@@ -474,6 +475,16 @@ def _rich_normalize_linebreaks(text: str) -> str:\n # reconnect ladder from stalling indefinitely and allows the heartbeat loop to\n # trigger its own recovery path. Refs: NousResearch/hermes-agent#59614\n _UPDATER_START_TIMEOUT = 30.0\n+# A generation is not healthy until the dedicated getUpdates request returns\n+# successfully. This exceeds a normal long-poll cycle for healthy idle bots.\n+_POLLING_PROGRESS_TIMEOUT = 60.0\n+_POLLING_GENERATION_CONTEXT: ContextVar[Optional[int]] = ContextVar(\n+    \"telegram_polling_generation\", default=None\n+)\n+\n+\n+class _PollingLifecycleAbort(RuntimeError):\n+    \"\"\"Internal control flow for polling startup fenced by teardown.\"\"\"\n \n \n class TelegramAdapter(BasePlatformAdapter):","@@ -633,6 +644,11 @@ def __init__(self, config: PlatformConfig):\n         self._polling_error_task: Optional[asyncio.Task] = None\n         self._polling_conflict_count: int = 0\n         self._polling_network_error_count: int = 0\n+        self._polling_generation: int = 0\n+        self._polling_progress_event = asyncio.Event()\n+        self._polling_progress_accepting: bool = False\n+        self._polling_progress_verifier_task: Optional[asyncio.Task] = None\n+        self._polling_teardown_started: bool = False\n         self._polling_error_callback_ref = None\n         self._polling_heartbeat_task: Optional[asyncio.Task] = None\n         # Consecutive heartbeat probes that saw queued updates the running","@@ -648,10 +664,9 @@ def __init__(self, config: PlatformConfig):\n         # error_callback ever fires and the gateway silently stops receiving\n         # messages with the process still alive (#55769).\n         self._polling_not_running_count: int = 0\n-        # After sustained reconnect storms the PTB httpx pool can return\n-        # SendResult(success=True) for sends that never actually transmit.\n-        # _handle_polling_network_error sets this; _verify_polling_after_reconnect\n-        # clears it once getMe() confirms the Bot client is healthy.\n+        # A polling generation stays degraded until the dedicated getUpdates\n+        # request makes successful progress. start_polling() return and getMe()\n+        # success on the general request path are not polling-health signals.\n         # While True, send() short-circuits to a failure so callers\n         # (cron live-adapter branch) fall through to standalone delivery.\n         self._send_path_degraded: bool = False","@@ -1944,6 +1959,139 @@ async def _drain_polling_connections(self) -> None:\n                 self.name, exc_info=True,\n             )\n \n+    def _begin_polling_generation(self) -> tuple[int, asyncio.Event]:\n+        \"\"\"Start accepting progress for a new getUpdates polling generation.\"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            self._polling_progress_accepting = False\n+            self._send_path_degraded = True\n+            progress = getattr(self, \"_polling_progress_event\", None)\n+            if progress is None:\n+                progress = asyncio.Event()\n+                self._polling_progress_event = progress\n+            return getattr(self, \"_polling_generation\", 0), progress\n+\n+        verifier = getattr(self, \"_polling_progress_verifier_task\", None)\n+        if verifier is not None and not verifier.done():\n+            verifier.cancel()\n+        self._polling_progress_verifier_task = None\n+        self._polling_generation = getattr(self, \"_polling_generation\", 0) + 1\n+        self._polling_progress_event = asyncio.Event()\n+        self._polling_progress_accepting = True\n+        self._send_path_degraded = True\n+        return self._polling_generation, self._polling_progress_event\n+\n+    def _record_polling_progress(self, generation: int) -> None:\n+        \"\"\"Record successful getUpdates I/O for the current generation only.\"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n+        if not self._polling_progress_accepting:\n+            return\n+        if generation != self._polling_generation:\n+            return\n+        self._polling_progress_event.set()\n+        self._polling_network_error_count = 0\n+        self._polling_conflict_count = 0\n+        self._send_path_degraded = False\n+\n+    def _instrument_polling_request(self, request):\n+        \"\"\"Wrap one dedicated PTB getUpdates request with progress tracking.\"\"\"\n+        do_request = request.do_request\n+\n+        async def _do_request(*args, **kwargs):\n+            generation = _POLLING_GENERATION_CONTEXT.get()\n+            result = await do_request(*args, **kwargs)\n+            status_code, payload = result\n+            if generation is not None and 200 <= status_code < 300:\n+                try:\n+                    # Use the request's own parser so health observation agrees\n+                    # exactly with PTB's authoritative response handling (e.g.\n+                    # UTF-8 replacement decoding and BOM rejection).\n+                    envelope = request.parse_json_payload(payload)\n+                except Exception:\n+                    # Instrumentation is observational: PTB still parses the\n+                    # untouched payload and owns the resulting exception.\n+                    pass\n+                else:\n+                    if (\n+                        isinstance(envelope, dict)\n+                        and envelope.get(\"ok\") is True\n+                        and \"result\" in envelope\n+                    ):\n+                        self._record_polling_progress(generation)\n+            return result\n+\n+        request.do_request = _do_request\n+        return request\n+\n+    async def _start_polling_once(\n+        self,\n+        app,\n+        *,\n+        drop_pending_updates: bool,\n+        error_callback,\n+    ) -> None:\n+        \"\"\"Start one generation and verify real getUpdates progress.\"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            raise _PollingLifecycleAbort(\"Telegram polling teardown started\")\n+        generation, progress = self._begin_polling_generation()\n+        if not self._polling_progress_accepting:\n+            raise _PollingLifecycleAbort(\"Telegram polling teardown started\")\n+\n+        def _generation_error_callback(error: Exception) -> None:\n+            if getattr(self, \"_polling_teardown_started\", False):\n+                return\n+            if generation != self._polling_generation:\n+                return\n+            if error_callback is not None:\n+                callback_context_token = [redacted:secret-assignment]\n+                try:\n+                    error_callback(error)\n+                finally:\n+                    _POLLING_GENERATION_CONTEXT.reset(callback_context_token)\n+\n+        context_token = [redacted:secret-assignment]\n+        try:\n+            await asyncio.wait_for(\n+                app.updater.start_polling(\n+                    allowed_updates=Update.ALL_TYPES,\n+                    drop_pending_updates=drop_pending_updates,\n+                    error_callback=_generation_error_callback,\n+                ),\n+                timeout=_UPDATER_START_TIMEOUT,\n+            )\n+        finally:\n+            _POLLING_GENERATION_CONTEXT.reset(context_token)\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            self._polling_progress_accepting = False\n+            self._send_path_degraded = True\n+            raise _PollingLifecycleAbort(\"Telegram polling teardown started\")\n+        self._schedule_polling_progress_verifier(generation, progress)\n+\n+    def _schedule_polling_progress_verifier(\n+        self, generation: int, progress: asyncio.Event\n+    ) -> None:\n+        \"\"\"Own exactly one tracked verifier for the current generation.\"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            self._polling_progress_accepting = False\n+            self._send_path_degraded = True\n+            return\n+        previous = getattr(self, \"_polling_progress_verifier_task\", None)\n+        if previous is not None and not previous.done():\n+            previous.cancel()\n+\n+        task = asyncio.get_running_loop().create_task(\n+            self._verify_polling_after_reconnect(generation, progress)\n+        )\n+        self._polling_progress_verifier_task = task\n+        self._background_tasks.add(task)\n+\n+        def _clear_finished_verifier(finished: asyncio.Task) -> None:\n+            self._background_tasks.discard(finished)\n+            if self._polling_progress_verifier_task is finished:\n+                self._polling_progress_verifier_task = None\n+\n+        task.add_done_callback(_clear_finished_verifier)\n+\n     def _get_general_request_drain_lock(self) -> asyncio.Lock:\n         lock = getattr(self, \"_general_request_drain_lock\", None)\n         if lock is None:","@@ -1996,6 +2144,8 @@ def _schedule_polling_recovery(self, error: Exception, *, reason: str) -> None:\n         adapter: the gateway process stays alive and the existing reconnect\n         ladder (``_handle_polling_network_error``) recovers in the background.\n         \"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n         if self.has_fatal_error:\n             return\n         if self._polling_error_task and not self._polling_error_task.done():","@@ -2047,6 +2197,8 @@ async def _start_polling_resilient(self, *, drop_pending_updates: bool, error_ca\n         network error was scheduled for background recovery instead of raising\n         (keeping the gateway process alive).\n         \"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return False\n         if not (self._app and self._app.updater):\n             raise RuntimeError(\"Telegram application/updater not initialized\")\n         try:","@@ -2056,16 +2208,17 @@ async def _start_polling_resilient(self, *, drop_pending_updates: bool, error_ca\n             # TimeoutError (OSError subclass), so the except below classifies\n             # it via _looks_like_network_error and schedules background\n             # recovery instead of blocking connect() indefinitely.\n-            await asyncio.wait_for(\n-                self._app.updater.start_polling(\n-                    allowed_updates=Update.ALL_TYPES,\n-                    drop_pending_updates=drop_pending_updates,\n-                    error_callback=error_callback,\n-                ),\n-                timeout=_UPDATER_START_TIMEOUT,\n+            await self._start_polling_once(\n+                self._app,\n+                drop_pending_updates=drop_pending_updates,\n+                error_callback=error_callback,\n             )\n             return True\n+        except _PollingLifecycleAbort:\n+            return False\n         except Exception as err:\n+            if getattr(self, \"_polling_teardown_started\", False):\n+                return False\n             if self._looks_like_polling_conflict(err):\n                 logger.warning(\n                     \"[%s] Telegram polling bootstrap conflict; gateway stays alive \"","@@ -2094,6 +2247,8 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n         MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so\n         the supervisor restarts the gateway process.\n         \"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n         if self.has_fatal_error:\n             return\n ","@@ -2123,6 +2278,9 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n         )\n         await asyncio.sleep(delay)\n \n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\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","@@ -2153,64 +2311,39 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n         except Exception:\n             pass\n \n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n         await self._drain_polling_connections()\n \n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n+\n         try:\n             if not app:\n                 raise RuntimeError(\"Telegram application was torn down during reconnect\")\n-            # Guard start_polling() with a timeout: when the connection pool is\n-            # in a degraded state (e.g., after _drain_polling_connections()), the\n-            # httpx client may hold a stale socket that neither connects nor times\n-            # out within PTB's internal flow. Bounding start_polling() prevents\n-            # the reconnect ladder from stalling indefinitely and allows the\n-            # heartbeat loop to trigger its own recovery path.\n-            # Refs: NousResearch/hermes-agent#59614\n-            try:\n-                await asyncio.wait_for(\n-                    app.updater.start_polling(\n-                        allowed_updates=Update.ALL_TYPES,\n-                        drop_pending_updates=False,\n-                        error_callback=self._polling_error_callback_ref,\n-                    ),\n-                    timeout=_UPDATER_START_TIMEOUT,\n-                )\n-            except asyncio.TimeoutError:\n-                raise RuntimeError(\n-                    \"start_polling() timed out — connection pool may be wedged\"\n-                )\n+            await self._start_polling_once(\n+                app,\n+                drop_pending_updates=False,\n+                error_callback=self._polling_error_callback_ref,\n+            )\n             logger.info(\n-                \"[%s] Telegram polling resumed after network error (attempt %d)\",\n+                \"[%s] Telegram polling restarted after network error (attempt %d); \"\n+                \"health pending getUpdates progress\",\n                 self.name, attempt,\n             )\n-            self._polling_network_error_count = 0\n-            # start_polling() succeeding IS the recovery signal: the long-poll\n-            # connection is live again, so clear the degraded flag immediately\n-            # rather than blocking all outbound sends for the full\n-            # HEARTBEAT_PROBE_DELAY window. The deferred probe below is a\n-            # defensive re-check — if it later detects a silent wedge (PTB\n-            # running=True but consumer task dead) it re-enters the ladder,\n-            # which re-sets _send_path_degraded. Without this clear here, a\n-            # clean reconnect leaves the flag stuck True until the 60s probe\n-            # (or forever, if the probe is never scheduled), blocking the send\n-            # path even though the bot has fully recovered. See #35205.\n-            self._send_path_degraded = False\n-            # start_polling() returning is necessary but not sufficient:\n-            # PTB's Updater can be left in a state where `running` is True\n-            # but the underlying long-poll task is wedged on a stale httpx\n-            # connection and never makes progress. No error_callback fires\n-            # in that state, so the reconnect ladder won't advance on its\n-            # own. Schedule a deferred probe to detect the wedge and\n-            # re-enter the ladder if needed.\n-            if not self.has_fatal_error:\n-                probe = asyncio.ensure_future(self._verify_polling_after_reconnect())\n-                self._background_tasks.add(probe)\n-                probe.add_done_callback(self._background_tasks.discard)\n+        except _PollingLifecycleAbort:\n+            return\n         except Exception as retry_err:\n+            if getattr(self, \"_polling_teardown_started\", False):\n+                return\n             safe_retry_error = _redact_telegram_error_text(retry_err)\n             logger.warning(\"[%s] Telegram polling reconnect failed: %s\", self.name, safe_retry_error)\n             # start_polling failed — polling is dead and no further error\n             # callbacks will fire, so schedule the next retry ourselves.\n-            if not self.has_fatal_error:\n+            if (\n+                not self.has_fatal_error\n+                and not getattr(self, \"_polling_teardown_started\", False)\n+            ):\n                 task = asyncio.ensure_future(\n                     self._handle_polling_network_error(retry_err)\n                 )","@@ -2240,9 +2373,9 @@ async def _polling_heartbeat_loop(self) -> None:\n         ``_handle_polling_network_error`` — the same path triggered by PTB's own\n         ``error_callback`` — which drains the dead pool and restarts polling.\n \n-        Unlike ``_verify_polling_after_reconnect`` (a one-shot probe scheduled\n-        only after an explicit reconnect), this loop runs for the full lifetime\n-        of the polling connection, so it catches a socket that wedges during\n+        Unlike the generation verifier (a one-shot progress deadline after\n+        every polling start), this loop runs for the full lifetime of the\n+        polling connection, so it catches a socket that wedges later during\n         steady-state operation without any prior error event.\n         \"\"\"\n         HEARTBEAT_INTERVAL = 90   # seconds between probes","@@ -2251,6 +2384,8 @@ async def _polling_heartbeat_loop(self) -> None:\n         while True:\n             try:\n                 await asyncio.sleep(HEARTBEAT_INTERVAL)\n+                if getattr(self, \"_polling_teardown_started\", False):\n+                    return\n                 if self.has_fatal_error:\n                     return\n                 bot = self._app.bot if self._app else None","@@ -2307,6 +2442,8 @@ async def _probe_pending_updates(self, bot, probe_timeout: float) -> None:\n         report a queue against a live consumer. We detect the stopped updater\n         directly and feed the same ladder (#55769).\n         \"\"\"\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n         # Only meaningful in polling mode; in webhook mode Telegram pushes\n         # updates and holds no server-side queue.\n         if self._webhook_mode:","@@ -2340,6 +2477,8 @@ async def _probe_pending_updates(self, bot, probe_timeout: float) -> None:\n             )\n             if self._polling_not_running_count >= 2:\n                 self._polling_not_running_count = 0\n+                if getattr(self, \"_polling_teardown_started\", False):\n+                    return\n                 logger.warning(\n                     \"[%s] Telegram updater is not running (long-poll task \"\n                     \"gone); triggering polling restart\",","@@ -2374,6 +2513,8 @@ async def _probe_pending_updates(self, bot, probe_timeout: float) -> None:\n         )\n         if self._polling_pending_stuck_count >= 2:\n             self._polling_pending_stuck_count = 0\n+            if getattr(self, \"_polling_teardown_started\", False):\n+                return\n             logger.warning(\n                 \"[%s] getUpdates consumer appears wedged (queue not draining); \"\n                 \"triggering polling restart\",","@@ -2386,66 +2527,96 @@ async def _probe_pending_updates(self, bot, probe_timeout: float) -> None:\n                 )\n             )\n \n-    async def _verify_polling_after_reconnect(self) -> None:\n-        \"\"\"Heartbeat probe scheduled after a successful reconnect.\n-\n-        PTB's Updater can survive a botched stop()+start_polling() cycle\n-        with `running=True` but a wedged consumer task. No error callback\n-        fires, so the reconnect ladder doesn't advance on its own. This\n-        probe detects the wedge by:\n-\n-        1. Sleeping HEARTBEAT_PROBE_DELAY so a healthy long-poll has time\n-           to complete at least one cycle.\n-        2. Verifying `Updater.running` is still True.\n-        3. Probing the bot endpoint with a tight asyncio timeout. A\n-           wedged httpx pool fails this probe; a healthy one returns\n-           well under the timeout.\n-\n-        On connectivity failure, re-enter the reconnect ladder (via\n-        ``_schedule_polling_recovery`` so the in-flight guard and task\n-        bookkeeping apply) and let the existing MAX_NETWORK_RETRIES path\n-        ultimately escalate to fatal-error. Auth/validation failures\n-        (``InvalidToken``, ``BadRequest``, ...) are not connectivity symptoms\n-        and must not trigger reconnect churn — same policy as the heartbeat\n-        loop and the pending-update probe (#62098, #63243).\n+    async def _verify_polling_after_reconnect(\n+        self,\n+        generation: Optional[int] = None,\n+        progress: Optional[asyncio.Event] = None,\n+    ) -> None:\n+        \"\"\"Require getUpdates progress, using getMe only to classify failure.\n+\n+        The generation-bound event is set only by a successful response on the\n+        dedicated getUpdates request. A general-path getMe success can classify\n+        connectivity, but cannot heal polling health. Connectivity failures\n+        enter the guarded recovery ladder; auth/validation errors do not churn.\n         \"\"\"\n-        HEARTBEAT_PROBE_DELAY = 60\n         PROBE_TIMEOUT = 10\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n+        if generation is None:\n+            generation = self._polling_generation\n+        if progress is None:\n+            progress = self._polling_progress_event\n \n-        await asyncio.sleep(HEARTBEAT_PROBE_DELAY)\n+        try:\n+            await asyncio.wait_for(\n+                progress.wait(), timeout=_POLLING_PROGRESS_TIMEOUT\n+            )\n+        except asyncio.TimeoutError:\n+            pass\n \n-        if self.has_fatal_error:\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n+        if progress.is_set() or self.has_fatal_error:\n+            return\n+        if not self._polling_progress_accepting:\n+            return\n+        if generation != self._polling_generation:\n             return\n-        if not (self._app and self._app.updater and self._app.updater.running):\n+        if progress is not self._polling_progress_event:\n+            return\n+\n+        app = self._app\n+        if not (app and app.updater and app.updater.running):\n             logger.warning(\n-                \"[%s] Updater not running %ds after reconnect — treating as wedged\",\n-                self.name, HEARTBEAT_PROBE_DELAY,\n+                \"[%s] Updater made no getUpdates progress and is not running\",\n+                self.name,\n             )\n             self._schedule_polling_recovery(\n-                RuntimeError(\"Updater not running after reconnect heartbeat\"),\n-                reason=\"post-reconnect probe: updater not running\",\n+                RuntimeError(\"Updater not running after polling progress deadline\"),\n+                reason=\"polling progress verifier: updater not running\",\n             )\n             return\n \n         try:\n-            await asyncio.wait_for(self._app.bot.get_me(), PROBE_TIMEOUT)\n-            self._send_path_degraded = False\n+            await asyncio.wait_for(app.bot.get_me(), PROBE_TIMEOUT)\n         except Exception as probe_err:\n+            if getattr(self, \"_polling_teardown_started\", False):\n+                return\n+            if self.has_fatal_error or not self._polling_progress_accepting:\n+                return\n+            if generation != self._polling_generation:\n+                return\n+            if progress is not self._polling_progress_event or progress.is_set():\n+                return\n             if not self._looks_like_network_error(probe_err):\n                 logger.warning(\n-                    \"[%s] Post-reconnect probe hit a non-connectivity error\"\n+                    \"[%s] Polling progress verifier hit a non-connectivity error\"\n                     \" (not retrying): %s\",\n                     self.name, _redact_telegram_error_text(probe_err),\n                 )\n                 return\n             logger.warning(\n-                \"[%s] Polling heartbeat probe failed %ds after reconnect: %s\",\n-                self.name, HEARTBEAT_PROBE_DELAY,\n-                _redact_telegram_error_text(probe_err),\n+                \"[%s] Polling progress verifier connectivity probe failed: %s\",\n+                self.name, _redact_telegram_error_text(probe_err),\n             )\n             self._schedule_polling_recovery(\n-                probe_err, reason=\"post-reconnect probe failure\"\n+                probe_err,\n+                reason=\"polling progress verifier connectivity failure\",\n             )\n+            return\n+\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n+        if self.has_fatal_error or not self._polling_progress_accepting:\n+            return\n+        if generation != self._polling_generation:\n+            return\n+        if progress is not self._polling_progress_event or progress.is_set():\n+            return\n+        self._schedule_polling_recovery(\n+            RuntimeError(\"getUpdates made no progress before verifier deadline\"),\n+            reason=\"polling progress verifier: general path healthy but getUpdates stalled\",\n+        )\n \n     def _disarm_ptb_retry_loop(self) -> None:\n         \"\"\"Synchronously stop PTB's internal polling retry loop.","@@ -2511,6 +2682,8 @@ def _disarm_ptb_retry_loop(self) -> None:\n         )\n \n     async def _handle_polling_conflict(self, error: Exception) -> None:\n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n         if self.has_fatal_error and self.fatal_error_code == \"telegram_polling_conflict\":\n             return\n         # Transient 409 Conflict errors arise when the previous gateway process","@@ -2566,7 +2739,11 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n                 pass\n \n             await asyncio.sleep(RETRY_DELAY)\n+            if getattr(self, \"_polling_teardown_started\", False):\n+                return\n             await self._drain_polling_connections()\n+            if getattr(self, \"_polling_teardown_started\", False):\n+                return\n \n             # Capture a stable local reference: self._app can be reassigned to\n             # None by a concurrent disconnect() while we're suspended across","@@ -2578,31 +2755,22 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n             try:\n                 if not app:\n                     raise RuntimeError(\"Telegram application was torn down during conflict reconnect\")\n-                # Same watchdog bound as the network-error ladder: an\n-                # exhausted pool hangs start_polling() on the conflict path\n-                # identically (#59614). Timeout converts to RuntimeError so\n-                # the except below logs a readable message and schedules the\n-                # next conflict attempt instead of wedging attempt N forever.\n-                try:\n-                    await asyncio.wait_for(\n-                        app.updater.start_polling(\n-                            allowed_updates=Update.ALL_TYPES,\n-                            drop_pending_updates=False,\n-                            error_callback=self._polling_error_callback_ref,\n-                        ),\n-                        timeout=_UPDATER_START_TIMEOUT,\n-                    )\n-                except asyncio.TimeoutError:\n-                    raise RuntimeError(\n-                        \"start_polling() timed out — connection pool may be wedged\"\n-                    )\n+                await self._start_polling_once(\n+                    app,\n+                    drop_pending_updates=False,\n+                    error_callback=self._polling_error_callback_ref,\n+                )\n                 logger.info(\n-                    \"[%s] Telegram polling resumed after conflict retry %d/%d\",\n+                    \"[%s] Telegram polling restarted after conflict retry %d/%d; \"\n+                    \"health pending getUpdates progress\",\n                     self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,\n                 )\n-                self._polling_conflict_count = 0  # reset counter on success\n+                return\n+            except _PollingLifecycleAbort:\n                 return\n             except Exception as retry_err:\n+                if getattr(self, \"_polling_teardown_started\", False):\n+                    return\n                 logger.warning(\n                     \"[%s] Telegram polling retry %d/%d failed: %s. \"\n                     \"Scheduling next attempt.\",","@@ -2614,7 +2782,10 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n                 # a fatal error leaves the adapter in a limbo state: the\n                 # gateway process is alive and reports \"connected\" but\n                 # no messages are received or sent.\n-                if self._polling_conflict_count < MAX_CONFLICT_RETRIES:\n+                if (\n+                    self._polling_conflict_count < MAX_CONFLICT_RETRIES\n+                    and not getattr(self, \"_polling_teardown_started\", False)\n+                ):\n                     # We are inside a running coroutine, so the running loop is\n                     # guaranteed to exist. asyncio.get_event_loop() is deprecated\n                     # and raises \"RuntimeError: There is no current event loop in","@@ -2628,6 +2799,9 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n                     return\n                 # Fall through to fatal on the last retry.\n \n+        if getattr(self, \"_polling_teardown_started\", False):\n+            return\n+\n         # Exhausted all retries — declare a fatal error so the gateway\n         # runner can surface this clearly and the user knows to act.\n         message = (","@@ -3084,6 +3258,14 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n             TELEGRAM_WEBHOOK_PORT   Local listen port (default 8443)\n             TELEGRAM_WEBHOOK_SECRET Secret token for update verification\n         \"\"\"\n+        # Explicit connect() is the only operation allowed to reopen polling\n+        # after a completed, serialized teardown. Background recovery never\n+        # clears this fence.\n+        self._polling_teardown_started = False\n+        # Mode selection is re-evaluated on every explicit connection. Keep\n+        # webhook state false unless this connection starts its webhook.\n+        self._webhook_mode = False\n+\n         if not TELEGRAM_AVAILABLE:\n             logger.error(\n                 \"[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot\",","@@ -3246,6 +3428,7 @@ def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict:\n                     **request_kwargs, httpx_kwargs=_with_limits()\n                 )\n \n+            get_updates_request = self._instrument_polling_request(get_updates_request)\n             builder = builder.request(request).get_updates_request(get_updates_request)\n             self._app = builder.build()\n             self._bot = self._app.bot","@@ -3378,6 +3561,8 @@ def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict:\n                     drop_pending_updates=not is_reconnect,\n                 )\n                 self._webhook_mode = True\n+                self._polling_progress_accepting = False\n+                self._send_path_degraded = False\n                 logger.info(\n                     \"[%s] Webhook server listening on 0.0.0.0:%d%s\",\n                     self.name, webhook_port, webhook_path,","@@ -3394,6 +3579,8 @@ def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict:\n                 loop = asyncio.get_running_loop()\n \n                 def _polling_error_callback(error: Exception) -> None:\n+                    if getattr(self, \"_polling_teardown_started\", False):\n+                        return\n                     if self._polling_error_task and not self._polling_error_task.done():\n                         return\n                     if self._looks_like_polling_conflict(error):","@@ -3527,7 +3714,8 @@ def collect(task: Optional[asyncio.Task]) -> None:\n             collect(task)\n         for task in list(self._pending_text_batch_tasks.values()):\n             collect(task)\n-        collect(self._polling_error_task)\n+        collect(getattr(self, \"_polling_error_task\", None))\n+        collect(getattr(self, \"_polling_progress_verifier_task\", None))\n \n         for task in pending_tasks:\n             task.cancel()","@@ -3540,8 +3728,10 @@ def collect(task: Optional[asyncio.Task]) -> None:\n         self._pending_photo_batches.clear()\n         self._pending_text_batch_tasks.clear()\n         self._pending_text_batches.clear()\n-        if self._polling_error_task is not current_task:\n+        if getattr(self, \"_polling_error_task\", None) is not current_task:\n             self._polling_error_task = None\n+        if getattr(self, \"_polling_progress_verifier_task\", None) is not current_task:\n+            self._polling_progress_verifier_task = None\n \n     async def disconnect(self) -> None:\n         \"\"\"Stop polling/webhook, cancel pending delayed deliveries, and disconnect.\"\"\"","@@ -3549,6 +3739,42 @@ async def disconnect(self) -> None:\n         # that wins the race against teardown and prevents new delayed tasks\n         # from being scheduled by late update handlers.\n         self._mark_disconnected()\n+        self._polling_teardown_started = True\n+        self._polling_progress_accepting = False\n+        self._polling_generation = getattr(self, \"_polling_generation\", 0) + 1\n+        self._polling_progress_event = asyncio.Event()\n+        self._send_path_degraded = True\n+\n+        # Recovery can be suspended in stop/drain/start while disconnect begins.\n+        # Cancel and await both polling lifecycle owners immediately after the\n+        # fence, before any other teardown await lets them start a new generation.\n+        current_task = asyncio.current_task()\n+        lifecycle_tasks: list[asyncio.Task] = []\n+        lifecycle_seen: set[int] = set()\n+        for task in (\n+            getattr(self, \"_polling_error_task\", None),\n+            getattr(self, \"_polling_progress_verifier_task\", None),\n+        ):\n+            if not task or task.done() or task is current_task:\n+                continue\n+            marker = id(task)\n+            if marker in lifecycle_seen:\n+                continue\n+            lifecycle_seen.add(marker)\n+            task.cancel()\n+            if asyncio.isfuture(task) or asyncio.iscoroutine(task):\n+                lifecycle_tasks.append(task)\n+        if lifecycle_tasks:\n+            await asyncio.gather(*lifecycle_tasks, return_exceptions=True)\n+        if getattr(self, \"_polling_error_task\", None) is not current_task:\n+            self._polling_error_task = None\n+        if getattr(self, \"_polling_progress_verifier_task\", None) is not current_task:\n+            self._polling_progress_verifier_task = None\n+\n+        # Cancellation callbacks may have run while awaited; the teardown fence\n+        # remains authoritative regardless of their finalizers.\n+        self._polling_progress_accepting = False\n+        self._send_path_degraded = True\n \n         # Cancel deferred post-connect housekeeping (command-menu / DM-topic /\n         # status-indicator Bot API calls) so it cannot fire into a half-torn-down","@@ -3562,10 +3788,11 @@ async def disconnect(self) -> None:\n \n         # Cancel the heartbeat before tearing down the app so the probe task\n         # cannot fire get_me() into a half-shutdown bot client.\n-        if self._polling_heartbeat_task and not self._polling_heartbeat_task.done():\n-            self._polling_heartbeat_task.cancel()\n+        polling_heartbeat_task = getattr(self, \"_polling_heartbeat_task\", None)\n+        if polling_heartbeat_task and not polling_heartbeat_task.done():\n+            polling_heartbeat_task.cancel()\n             try:\n-                await self._polling_heartbeat_task\n+                await polling_heartbeat_task\n             except asyncio.CancelledError:\n                 pass\n         self._polling_heartbeat_task = None"]},{"file":"scripts/release.py","hunks":["@@ -79,6 +79,7 @@\n     \"[redacted:email]\": \"Umi4Life\",  # PR #47377 salvage (agent: emit one-shot fallback switch notice on successful fallback so gateway users see model/provider change; #35419)\n     \"[redacted:email]\": \"austinlaw076\",  # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set)\n     \"[redacted:email]\": \"liuhao1024\",  # PR #56993 salvage (gateway: process-level HERMES_HOME for pid/lock/status identity files; #56986)\n+    \"[redacted:email]\": \"Roseyco-management\",  # PR #63581 salvage (telegram: require getUpdates progress before polling is healthy; #63243, #63766)\n     \"[redacted:email]\": \"gauravsaxena1997\",  # PR #59868 partial salvage (agent: guard response.text against httpx.ResponseNotRead in _summarize_api_error; #59769)\n     \"[redacted:email]\": \"irresi\",  # PR #59048 salvage (gateway: scope reset banners' session info to the serving profile; #59003)\n     \"[redacted:email]\": \"s905060\",  # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169)"]},{"file":"tests/gateway/test_telegram_conflict.py","hunks":["@@ -137,14 +137,13 @@ async def fake_start_polling(**kwargs):\n \n     # First conflict: should retry, NOT be fatal\n     captured[\"error_callback\"](conflict(\"Conflict: terminated by other getUpdates request\"))\n-    await asyncio.sleep(0)\n-    await asyncio.sleep(0)\n-    # Give the scheduled task a chance to run\n-    for _ in range(10):\n-        await asyncio.sleep(0)\n+    await adapter._polling_error_task\n \n     assert adapter.has_fatal_error is False, \"First conflict should not be fatal\"\n-    assert adapter._polling_conflict_count == 0, \"Count should reset after successful retry\"\n+    assert adapter._polling_conflict_count == 1, (\n+        \"Count must remain until the retried generation makes getUpdates progress\"\n+    )\n+    assert adapter._send_path_degraded is True\n \n     # connect() now starts a lifetime _polling_heartbeat_loop task. With\n     # asyncio.sleep mocked to instant above, it must not be left running or it","@@ -152,6 +151,54 @@ async def fake_start_polling(**kwargs):\n     await _cancel_heartbeat(adapter)\n \n \n[redacted:email]\n+async def test_current_generation_conflicts_accumulate_after_start_returns(monkeypatch):\n+    \"\"\"A later async 409 must advance the retry ladder after PTB start returns.\"\"\"\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+    callbacks = []\n+    conflict_tasks = []\n+\n+    async def capture_start(**kwargs):\n+        callbacks.append(kwargs[\"error_callback\"])\n+\n+    updater = SimpleNamespace(\n+        start_polling=AsyncMock(side_effect=capture_start),\n+        stop=AsyncMock(),\n+        running=False,\n+    )\n+    app = SimpleNamespace(updater=updater)\n+    adapter._app = app\n+    adapter._drain_polling_connections = AsyncMock()\n+    monkeypatch.setattr(\"asyncio.sleep\", AsyncMock())\n+\n+    def dispatch_conflict(error):\n+        conflict_tasks.append(\n+            asyncio.create_task(adapter._handle_polling_conflict(error))\n+        )\n+\n+    adapter._polling_error_callback_ref = dispatch_conflict\n+    await adapter._start_polling_once(\n+        app,\n+        drop_pending_updates=False,\n+        error_callback=dispatch_conflict,\n+    )\n+    conflict = type(\"Conflict\", (Exception,), {})\n+\n+    try:\n+        callbacks[0](conflict(\"first async conflict\"))\n+        await conflict_tasks[-1]\n+        assert adapter._polling_conflict_count == 1\n+\n+        callbacks[1](conflict(\"second async conflict\"))\n+        await conflict_tasks[-1]\n+        assert adapter._polling_conflict_count == 2\n+    finally:\n+        verifier = adapter._polling_progress_verifier_task\n+        if verifier is not None and not verifier.done():\n+            verifier.cancel()\n+            await asyncio.gather(verifier, return_exceptions=True)\n+\n+\n @pytest.mark.asyncio\n async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch):\n     \"\"\"After exhausting retries, the conflict should become fatal.\"\"\""]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -35,6 +35,7 @@ def _ensure_telegram_mock():\n \n _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 \n ","@@ -50,6 +51,13 @@ def _make_adapter() -> TelegramAdapter:\n     return TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n \n \n+async def _complete_current_polling_generation(adapter: TelegramAdapter) -> None:\n+    verifier = adapter._polling_progress_verifier_task\n+    adapter._record_polling_progress(adapter._polling_generation)\n+    if verifier is not None:\n+        await verifier\n+\n+\n @pytest.mark.asyncio\n async def test_reconnect_self_schedules_on_start_polling_failure():\n     \"\"\"","@@ -157,9 +165,9 @@ async def test_reconnect_chained_retry_updates_polling_error_task():\n \n \n @pytest.mark.asyncio\n-async def test_reconnect_success_resets_error_count():\n+async def test_reconnect_success_waits_for_progress_to_reset_error_count():\n     \"\"\"\n-    When start_polling() succeeds, _polling_network_error_count should reset to 0.\n+    start_polling() return alone cannot reset the network-error count.\n     \"\"\"\n     adapter = _make_adapter()\n     adapter._polling_network_error_count = 3","@@ -177,7 +185,12 @@ async def test_reconnect_success_resets_error_count():\n     with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n         await adapter._handle_polling_network_error(Exception(\"Bad Gateway\"))\n \n+    assert adapter._polling_network_error_count == 4\n+    assert adapter._send_path_degraded is True\n+\n+    await _complete_current_polling_generation(adapter)\n     assert adapter._polling_network_error_count == 0\n+    assert adapter._send_path_degraded is False\n \n     # Clean up the heartbeat-probe task scheduled after a successful reconnect.\n     pending = [t for t in adapter._background_tasks if not t.done()]","@@ -263,6 +276,8 @@ async def test_reconnect_drains_polling_request_only():\n \n     # Reconnect must still succeed\n     mock_app.updater.start_polling.assert_called_once()\n+    assert adapter._polling_network_error_count == 2\n+    await _complete_current_polling_generation(adapter)\n     assert adapter._polling_network_error_count == 0\n \n ","@@ -283,6 +298,8 @@ async def test_reconnect_continues_if_drain_fails():\n \n     # start_polling must still be called despite drain failure\n     mock_app.updater.start_polling.assert_called_once()\n+    assert adapter._polling_network_error_count == 2\n+    await _complete_current_polling_generation(adapter)\n     assert adapter._polling_network_error_count == 0\n \n ","@@ -339,10 +356,9 @@ async def test_drain_helper_noop_without_app():\n \n \n @pytest.mark.asyncio\n-async def test_heartbeat_probe_no_op_when_polling_healthy():\n+async def test_polling_verifier_exits_on_matching_progress(monkeypatch):\n     \"\"\"\n-    Probe scheduled after a successful reconnect: Updater.running=True and\n-    bot.get_me() returns quickly → recovery confirmed, no further action.\n+    Matching getUpdates progress exits without probing the general path.\n     \"\"\"\n     adapter = _make_adapter()\n ","@@ -355,19 +371,20 @@ async def test_heartbeat_probe_no_op_when_polling_healthy():\n     adapter._app = mock_app\n \n     adapter._handle_polling_network_error = AsyncMock()\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._record_polling_progress(generation)\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0)\n \n-    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._verify_polling_after_reconnect()\n+    await adapter._verify_polling_after_reconnect(generation, progress)\n \n-    mock_app.bot.get_me.assert_awaited_once()\n+    mock_app.bot.get_me.assert_not_awaited()\n     adapter._handle_polling_network_error.assert_not_awaited()\n \n \n @pytest.mark.asyncio\n-async def test_heartbeat_probe_reenters_ladder_when_updater_not_running():\n+async def test_heartbeat_probe_reenters_ladder_when_updater_not_running(monkeypatch):\n     \"\"\"\n-    If Updater.running has flipped to False by the heartbeat delay, treat\n-    as wedged: re-enter the reconnect ladder.\n+    If Updater.running is False at the progress deadline, re-enter recovery.\n     \"\"\"\n     adapter = _make_adapter()\n ","@@ -380,9 +397,10 @@ async def test_heartbeat_probe_reenters_ladder_when_updater_not_running():\n     adapter._app = mock_app\n \n     adapter._handle_polling_network_error = AsyncMock()\n+    generation, progress = adapter._begin_polling_generation()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0)\n \n-    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._verify_polling_after_reconnect()\n+    await adapter._verify_polling_after_reconnect(generation, progress)\n \n     mock_app.bot.get_me.assert_not_called()\n     # Recovery is scheduled through _schedule_polling_recovery (#63243), so","@@ -397,7 +415,7 @@ async def test_heartbeat_probe_reenters_ladder_when_updater_not_running():\n \n \n @pytest.mark.asyncio\n-async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out():\n+async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out(monkeypatch):\n     \"\"\"\n     If bot.get_me() hangs longer than PROBE_TIMEOUT, treat as wedged.\n     Simulates the connection-pool wedge that motivated this fix.","@@ -416,15 +434,16 @@ async def hang_forever(*args, **kwargs):\n     adapter._app = mock_app\n \n     adapter._handle_polling_network_error = AsyncMock()\n+    generation, progress = adapter._begin_polling_generation()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0)\n \n     async def fast_wait_for(coro, timeout):\n         if asyncio.iscoroutine(coro):\n             coro.close()\n         raise asyncio.TimeoutError()\n \n-    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-        with patch(\"plugins.platforms.telegram.adapter.asyncio.wait_for\", new=fast_wait_for):\n-            await adapter._verify_polling_after_reconnect()\n+    with patch(\"plugins.platforms.telegram.adapter.asyncio.wait_for\", new=fast_wait_for):\n+        await adapter._verify_polling_after_reconnect(generation, progress)\n \n     task = adapter._polling_error_task\n     assert task is not None","@@ -433,7 +452,7 @@ async def fast_wait_for(coro, timeout):\n \n \n @pytest.mark.asyncio\n-async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error():\n+async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error(monkeypatch):\n     \"\"\"\n     Any exception raised by bot.get_me() (NetworkError, ConnectionError, etc.)\n     should re-enter the reconnect ladder with the original exception.","@@ -449,9 +468,10 @@ async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error():\n     adapter._app = mock_app\n \n     adapter._handle_polling_network_error = AsyncMock()\n+    generation, progress = adapter._begin_polling_generation()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0)\n \n-    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._verify_polling_after_reconnect()\n+    await adapter._verify_polling_after_reconnect(generation, progress)\n \n     task = adapter._polling_error_task\n     assert task is not None","@@ -466,7 +486,7 @@ async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error():\n \n \n @pytest.mark.asyncio\n-async def test_heartbeat_probe_ignores_auth_errors():\n+async def test_heartbeat_probe_ignores_auth_errors(monkeypatch):\n     \"\"\"\n     Auth/validation failures from the post-reconnect probe must not enter the\n     network-reconnect ladder (#63243): a revoked token would otherwise churn","@@ -487,16 +507,17 @@ async def test_heartbeat_probe_ignores_auth_errors():\n     adapter._app = mock_app\n \n     adapter._handle_polling_network_error = AsyncMock()\n+    generation, progress = adapter._begin_polling_generation()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0)\n \n-    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._verify_polling_after_reconnect()\n+    await adapter._verify_polling_after_reconnect(generation, progress)\n \n     assert adapter._polling_error_task is None\n     adapter._handle_polling_network_error.assert_not_awaited()\n \n \n @pytest.mark.asyncio\n-async def test_heartbeat_probe_defers_to_inflight_recovery():\n+async def test_heartbeat_probe_defers_to_inflight_recovery(monkeypatch):\n     \"\"\"\n     A probe failure while another recovery is mid-flight must not start a\n     second concurrent stop/drain/start_polling sequence (#63243) — overlapping","@@ -517,16 +538,17 @@ async def test_heartbeat_probe_defers_to_inflight_recovery():\n     adapter._polling_error_task = inflight\n \n     adapter._handle_polling_network_error = AsyncMock()\n+    generation, progress = adapter._begin_polling_generation()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0)\n \n-    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._verify_polling_after_reconnect()\n+    await adapter._verify_polling_after_reconnect(generation, progress)\n \n     assert adapter._polling_error_task is inflight\n     adapter._handle_polling_network_error.assert_not_awaited()\n \n \n @pytest.mark.asyncio\n-async def test_heartbeat_probe_skips_when_already_fatal():\n+async def test_heartbeat_probe_skips_when_already_fatal(monkeypatch):\n     \"\"\"\n     If the adapter is already in fatal-error state by the time the probe\n     delay elapses, the probe should bail without further action.","@@ -539,9 +561,10 @@ async def test_heartbeat_probe_skips_when_already_fatal():\n     adapter._app = mock_app\n \n     adapter._handle_polling_network_error = AsyncMock()\n+    generation, progress = adapter._begin_polling_generation()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0)\n \n-    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._verify_polling_after_reconnect()\n+    await adapter._verify_polling_after_reconnect(generation, progress)\n \n     mock_app.bot.get_me.assert_not_called()\n     adapter._handle_polling_network_error.assert_not_awaited()","@@ -1060,4 +1083,3 @@ async def _fake_start_polling(**kwargs):\n     # The reconnect ladder must have advanced past the hung stop().\n     assert drain_called, \"_drain_polling_connections was not called after stop() timeout\"\n     assert start_polling_called, \"start_polling was not called after stop() timeout\"\n-"]},{"file":"tests/gateway/test_telegram_polling_progress.py","hunks":["@@ -0,0 +1,774 @@\n+\"\"\"Behavior contract for generation-safe Telegram polling progress.\"\"\"\n+\n+import asyncio\n+import json\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+from plugins.platforms.telegram import adapter as tg_adapter\n+from plugins.platforms.telegram.adapter import TelegramAdapter\n+\n+\n+class _ControlledRequest:\n+    \"\"\"Minimal PTB request double with controllable completion.\"\"\"\n+\n+    instances = []\n+\n+    @staticmethod\n+    def parse_json_payload(payload):\n+        \"\"\"Match PTB's response authority used by the progress observer.\"\"\"\n+        return json.loads(payload.decode(\"utf-8\", \"replace\"))\n+\n+    def __init__(self, *args, result=None, error=None, entered=None, release=None, **kwargs):\n+        self.result = result\n+        self.error = error\n+        self.entered = entered\n+        self.release = release\n+        self.args = args\n+        self.kwargs = kwargs\n+        type(self).instances.append(self)\n+\n+    async def do_request(self, *args, **kwargs):\n+        if self.entered is not None:\n+            self.entered.set()\n+        if self.release is not None:\n+            await self.release.wait()\n+        if self.error is not None:\n+            raise self.error\n+        return self.result\n+\n+\n+def _make_adapter() -> TelegramAdapter:\n+    return TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+\n+\n+def _mock_polling_app(*, get_me=None):\n+    app = MagicMock()\n+    app.updater = MagicMock()\n+    app.updater.running = True\n+    app.updater.stop = AsyncMock()\n+    app.updater.start_polling = AsyncMock()\n+    app.bot = MagicMock()\n+    app.bot.get_me = get_me or AsyncMock(return_value=MagicMock())\n+    app.running = False\n+    app.shutdown = AsyncMock()\n+    return app\n+\n+\n+class _LifecycleBuilder:\n+    def __init__(self, app):\n+        self.app = app\n+        self.polling_request = None\n+\n+    def token(self, _token):\n+        return self\n+\n+    def request(self, _request):\n+        return self\n+\n+    def get_updates_request(self, request):\n+        self.polling_request = request\n+        return self\n+\n+    def build(self):\n+        return self.app\n+\n+\n+def _lifecycle_app():\n+    app = MagicMock()\n+    app.updater = MagicMock()\n+    app.updater.running = True\n+    app.updater.start_polling = AsyncMock()\n+    app.updater.start_webhook = AsyncMock()\n+    app.updater.stop = AsyncMock()\n+    app.bot = MagicMock()\n+    app.bot.delete_webhook = AsyncMock()\n+    app.initialize = AsyncMock()\n+    app.start = AsyncMock()\n+    app.stop = AsyncMock()\n+    app.shutdown = AsyncMock()\n+    app.running = True\n+    return app\n+\n+\n+def _configure_lifecycle_connect(monkeypatch, adapter, apps):\n+    builders = [_LifecycleBuilder(app) for app in apps]\n+    remaining = iter(builders)\n+\n+    class _Application:\n+        @staticmethod\n+        def builder():\n+            return next(remaining)\n+\n+    async def _no_fallback_ips():\n+        return []\n+\n+    monkeypatch.setattr(tg_adapter, \"Application\", _Application)\n+    monkeypatch.setattr(tg_adapter, \"HTTPXRequest\", _ControlledRequest)\n+    monkeypatch.setattr(tg_adapter, \"discover_fallback_ips\", _no_fallback_ips)\n+    monkeypatch.setattr(tg_adapter, \"resolve_proxy_url\", lambda *args, **kwargs: None)\n+    monkeypatch.setattr(adapter, \"_acquire_platform_lock\", lambda *args, **kwargs: True)\n+    monkeypatch.setattr(adapter, \"_release_platform_lock\", MagicMock())\n+    monkeypatch.setattr(adapter, \"_fallback_ips\", lambda: [])\n+    monkeypatch.setattr(adapter, \"_start_post_connect_housekeeping\", MagicMock())\n+    return builders\n+\n+\n+async def _cancel_task(task):\n+    if task is None or task.done():\n+        return\n+    task.cancel()\n+    await asyncio.gather(task, return_exceptions=True)\n+\n+\n+async def _request_for_generation(generation, request, *args):\n+    \"\"\"Run a direct request double under the production polling context.\"\"\"\n+    generation_context = tg_adapter._POLLING_GENERATION_CONTEXT\n+    token = [redacted:secret-assignment]\n+    try:\n+        return await request.do_request(*args)\n+    finally:\n+        generation_context.reset(token)\n+\n+\n[redacted:email]\n+async def test_polling_disconnect_webhook_reconnect_heals_webhook_send_path(monkeypatch):\n+    adapter = _make_adapter()\n+    polling_app = _lifecycle_app()\n+    webhook_app = _lifecycle_app()\n+    _configure_lifecycle_connect(monkeypatch, adapter, [polling_app, webhook_app])\n+    monkeypatch.delenv(\"TELEGRAM_WEBHOOK_URL\", raising=False)\n+    monkeypatch.delenv(\"TELEGRAM_WEBHOOK_SECRET\", raising=False)\n+\n+    assert await adapter.connect() is True\n+    assert adapter._webhook_mode is False\n+    assert adapter._send_path_degraded is True\n+    await adapter.disconnect()\n+\n+    monkeypatch.setenv(\"TELEGRAM_WEBHOOK_URL\", \"https://example.test/telegram\")\n+    monkeypatch.setenv(\"TELEGRAM_WEBHOOK_SECRET\", \"test-secret\")\n+    try:\n+        assert await adapter.connect(is_reconnect=True) is True\n+        webhook_app.updater.start_webhook.assert_awaited_once()\n+        assert adapter._webhook_mode is True\n+        assert adapter._polling_progress_accepting is False\n+        assert adapter._send_path_degraded is False\n+    finally:\n+        await adapter.disconnect()\n+\n+\n[redacted:email]\n+async def test_webhook_disconnect_polling_reconnect_resets_mode_and_waits_for_progress(\n+    monkeypatch,\n+):\n+    adapter = _make_adapter()\n+    webhook_app = _lifecycle_app()\n+    polling_app = _lifecycle_app()\n+    builders = _configure_lifecycle_connect(\n+        monkeypatch, adapter, [webhook_app, polling_app]\n+    )\n+    heartbeat_started = asyncio.Event()\n+    heartbeat_modes = []\n+\n+    async def heartbeat():\n+        heartbeat_modes.append(adapter._webhook_mode)\n+        heartbeat_started.set()\n+        await asyncio.Event().wait()\n+\n+    monkeypatch.setattr(adapter, \"_polling_heartbeat_loop\", heartbeat)\n+    monkeypatch.setenv(\"TELEGRAM_WEBHOOK_URL\", \"https://example.test/telegram\")\n+    monkeypatch.setenv(\"TELEGRAM_WEBHOOK_SECRET\", \"test-secret\")\n+\n+    assert await adapter.connect() is True\n+    assert adapter._webhook_mode is True\n+    assert adapter._polling_heartbeat_task is None\n+    await adapter.disconnect()\n+\n+    monkeypatch.delenv(\"TELEGRAM_WEBHOOK_URL\")\n+    monkeypatch.delenv(\"TELEGRAM_WEBHOOK_SECRET\")\n+    try:\n+        assert await adapter.connect(is_reconnect=True) is True\n+        assert adapter._webhook_mode is False\n+        assert adapter._polling_heartbeat_task is not None\n+        assert not adapter._polling_heartbeat_task.done()\n+        await asyncio.wait_for(heartbeat_started.wait(), timeout=1)\n+        assert heartbeat_modes == [False]\n+        assert adapter._send_path_degraded is True\n+\n+        generation = adapter._polling_generation\n+        polling_request = builders[1].polling_request\n+        polling_request.result = (200, b'{\"ok\":true,\"result\":[]}')\n+        await _request_for_generation(generation, polling_request, \"getUpdates\")\n+        await asyncio.wait_for(adapter._polling_progress_verifier_task, timeout=1)\n+        assert adapter._send_path_degraded is False\n+    finally:\n+        await adapter.disconnect()\n+\n+\n[redacted:email]\n+async def test_current_polling_generation_success_records_progress():\n+    adapter = _make_adapter()\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 3\n+    request = _ControlledRequest(result=(200, b'{\"ok\":true,\"result\":[]}'))\n+\n+    instrumented = adapter._instrument_polling_request(request)\n+    result = await _request_for_generation(\n+        generation, instrumented, \"https://api.telegram.org/getUpdates\"\n+    )\n+\n+    assert instrumented is request\n+    assert result == (200, b'{\"ok\":true,\"result\":[]}')\n+    assert progress.is_set()\n+    assert adapter._polling_network_error_count == 0\n+    assert adapter._send_path_degraded is False\n+    assert generation > 0\n+\n+\n[redacted:email]\n[redacted:email](\"error_type\", [RuntimeError, asyncio.CancelledError])\n+async def test_unsuccessful_polling_request_does_not_record_progress(error_type):\n+    adapter = _make_adapter()\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 3\n+    request = adapter._instrument_polling_request(\n+        _ControlledRequest(error=error_type(\"request did not complete\"))\n+    )\n+\n+    with pytest.raises(error_type):\n+        await _request_for_generation(\n+            generation, request, \"https://api.telegram.org/getUpdates\"\n+        )\n+\n+    assert not progress.is_set()\n+    assert adapter._polling_network_error_count == 3\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_http_error_response_does_not_record_polling_progress():\n+    adapter = _make_adapter()\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 3\n+    request = adapter._instrument_polling_request(\n+        _ControlledRequest(result=(500, b\"bad\"))\n+    )\n+\n+    result = await _request_for_generation(\n+        generation, request, \"https://api.telegram.org/getUpdates\"\n+    )\n+\n+    assert result == (500, b\"bad\")\n+    assert not progress.is_set()\n+    assert adapter._polling_network_error_count == 3\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_general_request_success_cannot_record_polling_progress(monkeypatch):\n+    class _StopConnect(Exception):\n+        pass\n+\n+    class _Builder:\n+        def __init__(self):\n+            self.general_request = None\n+            self.polling_request = None\n+\n+        def token(self, _token):\n+            return self\n+\n+        def request(self, request):\n+            self.general_request = request\n+            return self\n+\n+        def get_updates_request(self, request):\n+            self.polling_request = request\n+            return self\n+\n+        def build(self):\n+            raise _StopConnect\n+\n+    builder = _Builder()\n+\n+    class _Application:\n+        @staticmethod\n+        def builder():\n+            return builder\n+\n+    _ControlledRequest.instances = []\n+\n+    async def _no_fallback_ips():\n+        return []\n+\n+    monkeypatch.setattr(tg_adapter, \"Application\", _Application)\n+    monkeypatch.setattr(tg_adapter, \"HTTPXRequest\", _ControlledRequest)\n+    monkeypatch.setattr(tg_adapter, \"discover_fallback_ips\", _no_fallback_ips)\n+    monkeypatch.setattr(tg_adapter, \"resolve_proxy_url\", lambda *args, **kwargs: None)\n+\n+    adapter = _make_adapter()\n+    monkeypatch.setattr(adapter, \"_acquire_platform_lock\", lambda *args, **kwargs: True)\n+    monkeypatch.setattr(adapter, \"_fallback_ips\", lambda: [])\n+    _, progress = adapter._begin_polling_generation()\n+\n+    assert await adapter.connect() is False\n+    assert builder.general_request is _ControlledRequest.instances[0]\n+    assert builder.polling_request is _ControlledRequest.instances[1]\n+\n+    builder.general_request.result = (200, b'{\"ok\":true}')\n+    result = await builder.general_request.do_request(\"https://api.telegram.org/sendMessage\")\n+\n+    assert result == (200, b'{\"ok\":true}')\n+    assert not progress.is_set()\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_late_previous_generation_completion_cannot_heal_current_generation():\n+    adapter = _make_adapter()\n+    generation_1, _ = adapter._begin_polling_generation()\n+    entered = asyncio.Event()\n+    release = asyncio.Event()\n+    request = adapter._instrument_polling_request(\n+        _ControlledRequest(\n+            result=(200, b'{\"ok\":true,\"result\":[]}'),\n+            entered=entered,\n+            release=release,\n+        )\n+    )\n+\n+    completion = asyncio.create_task(\n+        _request_for_generation(generation_1, request, \"getUpdates\")\n+    )\n+    await entered.wait()\n+    generation_2, progress_2 = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 4\n+    release.set()\n+\n+    assert await completion == (200, b'{\"ok\":true,\"result\":[]}')\n+    assert generation_2 == generation_1 + 1\n+    assert not progress_2.is_set()\n+    assert adapter._polling_network_error_count == 4\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_old_polling_child_keeps_generation_when_request_entry_is_delayed():\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    release_old_request = asyncio.Event()\n+    start_count = 0\n+    old_child = None\n+    request = adapter._instrument_polling_request(\n+        _ControlledRequest(result=(200, b'{\"ok\":true,\"result\":[]}'))\n+    )\n+\n+    async def start_polling(**_kwargs):\n+        nonlocal start_count, old_child\n+        start_count += 1\n+        if start_count == 1:\n+\n+            async def delayed_old_request():\n+                await release_old_request.wait()\n+                return await request.do_request(\"getUpdates\")\n+\n+            old_child = asyncio.create_task(delayed_old_request())\n+\n+    adapter._app.updater.start_polling = start_polling\n+\n+    await adapter._start_polling_once(\n+        adapter._app,\n+        drop_pending_updates=False,\n+        error_callback=MagicMock(),\n+    )\n+    generation_1 = adapter._polling_generation\n+    await adapter._start_polling_once(\n+        adapter._app,\n+        drop_pending_updates=False,\n+        error_callback=MagicMock(),\n+    )\n+    generation_2 = adapter._polling_generation\n+    progress_2 = adapter._polling_progress_event\n+    verifier_2 = adapter._polling_progress_verifier_task\n+\n+    try:\n+        release_old_request.set()\n+        assert await old_child == (200, b'{\"ok\":true,\"result\":[]}')\n+\n+        assert generation_2 == generation_1 + 1\n+        assert not progress_2.is_set()\n+        assert adapter._send_path_degraded is True\n+    finally:\n+        await _cancel_task(verifier_2)\n+\n+\n[redacted:email]\n+async def test_error_callback_is_bound_to_its_polling_generation():\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    callbacks = []\n+    delegated = MagicMock()\n+\n+    async def capture_start(**kwargs):\n+        callbacks.append(kwargs[\"error_callback\"])\n+\n+    adapter._app.updater.start_polling = capture_start\n+    await adapter._start_polling_once(\n+        adapter._app,\n+        drop_pending_updates=False,\n+        error_callback=delegated,\n+    )\n+    await adapter._start_polling_once(\n+        adapter._app,\n+        drop_pending_updates=False,\n+        error_callback=delegated,\n+    )\n+    verifier = adapter._polling_progress_verifier_task\n+    stale_error = ConnectionError(\"stale generation\")\n+    current_error = ConnectionError(\"current generation\")\n+\n+    try:\n+        callbacks[0](stale_error)\n+        delegated.assert_not_called()\n+\n+        callbacks[1](current_error)\n+        delegated.assert_called_once_with(current_error)\n+    finally:\n+        await _cancel_task(verifier)\n+\n+\n[redacted:email]\n+async def test_cold_start_waits_for_get_updates_progress_before_healing():\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+\n+    started = await adapter._start_polling_resilient(\n+        drop_pending_updates=True,\n+        error_callback=MagicMock(),\n+    )\n+\n+    verifier = adapter._polling_progress_verifier_task\n+    assert started is True\n+    assert adapter._app.updater.running is True\n+    assert adapter._send_path_degraded is True\n+    assert verifier is not None and not verifier.done()\n+    assert verifier in adapter._background_tasks\n+    assert [task for task in adapter._background_tasks if not task.done()] == [verifier]\n+    await _cancel_task(verifier)\n+\n+\n[redacted:email]\n+async def test_matching_get_updates_progress_heals_and_stops_verifier(monkeypatch):\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    recovery = MagicMock()\n+    monkeypatch.setattr(adapter, \"_schedule_polling_recovery\", recovery)\n+\n+    await adapter._start_polling_resilient(\n+        drop_pending_updates=False,\n+        error_callback=MagicMock(),\n+    )\n+    verifier = adapter._polling_progress_verifier_task\n+    request = adapter._instrument_polling_request(\n+        _ControlledRequest(result=(200, b'{\"ok\":true,\"result\":[]}'))\n+    )\n+    await _request_for_generation(\n+        adapter._polling_generation, request, \"getUpdates\"\n+    )\n+    await asyncio.wait_for(verifier, timeout=1)\n+\n+    assert adapter._send_path_degraded is False\n+    assert verifier.done()\n+    recovery.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_general_path_success_without_get_updates_progress_recovers_once(monkeypatch):\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    recovery = MagicMock()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0.01, raising=False)\n+    monkeypatch.setattr(adapter, \"_schedule_polling_recovery\", recovery)\n+\n+    await adapter._start_polling_resilient(\n+        drop_pending_updates=False,\n+        error_callback=MagicMock(),\n+    )\n+    verifier = adapter._polling_progress_verifier_task\n+    await asyncio.wait_for(verifier, timeout=1)\n+\n+    assert adapter._app.bot.get_me.await_count == 1\n+    recovery.assert_called_once()\n+    error = recovery.call_args.args[0]\n+    assert isinstance(error, RuntimeError)\n+    assert str(error) == \"getUpdates made no progress before verifier deadline\"\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n[redacted:email](\n+    (\"probe_error\", \"should_recover\"),\n+    [\n+        (ConnectionError(\"pool wedged\"), True),\n+        (type(\"InvalidToken\", (Exception,), {})(\"token revoked\"), False),\n+    ],\n+)\n+async def test_general_path_error_only_recovers_connectivity_failures(\n+    monkeypatch, probe_error, should_recover\n+):\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app(get_me=AsyncMock(side_effect=probe_error))\n+    recovery = MagicMock()\n+    monkeypatch.setattr(tg_adapter, \"_POLLING_PROGRESS_TIMEOUT\", 0.01, raising=False)\n+    monkeypatch.setattr(adapter, \"_schedule_polling_recovery\", recovery)\n+\n+    await adapter._start_polling_resilient(\n+        drop_pending_updates=False,\n+        error_callback=MagicMock(),\n+    )\n+    await asyncio.wait_for(adapter._polling_progress_verifier_task, timeout=1)\n+\n+    assert recovery.called is should_recover\n+    if should_recover:\n+        assert recovery.call_args.args[0] is probe_error\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n[redacted:email](\"retry_kind\", [\"network\", \"conflict\"])\n+async def test_retry_start_requires_matching_progress_to_heal(monkeypatch, retry_kind):\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    adapter._polling_error_callback_ref = MagicMock()\n+    adapter._polling_network_error_count = 3\n+    monkeypatch.setattr(tg_adapter.asyncio, \"sleep\", AsyncMock())\n+\n+    if retry_kind == \"network\":\n+        await adapter._handle_polling_network_error(ConnectionError(\"offline\"))\n+        assert adapter._polling_network_error_count == 4\n+    else:\n+        await adapter._handle_polling_conflict(\n+            RuntimeError(\"Conflict: terminated by other getUpdates request\")\n+        )\n+        assert adapter._polling_network_error_count == 3\n+        assert adapter._polling_conflict_count == 1\n+\n+    generation = adapter._polling_generation\n+    verifier = adapter._polling_progress_verifier_task\n+    assert generation > 0\n+    assert verifier is not None and not verifier.done()\n+    assert adapter._send_path_degraded is True\n+\n+    request = adapter._instrument_polling_request(\n+        _ControlledRequest(result=(200, b'{\"ok\":true,\"result\":[]}'))\n+    )\n+    await _request_for_generation(generation, request, \"getUpdates\")\n+    await asyncio.wait_for(verifier, timeout=1)\n+    assert adapter._polling_network_error_count == 0\n+    assert adapter._polling_conflict_count == 0\n+    assert adapter._send_path_degraded is False\n+\n+\n[redacted:email]\n+async def test_repeated_starts_replace_verifier_and_stale_verifier_cannot_heal():\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+\n+    await adapter._start_polling_resilient(\n+        drop_pending_updates=False, error_callback=MagicMock()\n+    )\n+    generation_1 = adapter._polling_generation\n+    progress_1 = adapter._polling_progress_event\n+    verifier_1 = adapter._polling_progress_verifier_task\n+\n+    await adapter._start_polling_resilient(\n+        drop_pending_updates=False, error_callback=MagicMock()\n+    )\n+    verifier_2 = adapter._polling_progress_verifier_task\n+    await asyncio.sleep(0)\n+\n+    assert adapter._polling_generation == generation_1 + 1\n+    assert verifier_1.cancelled()\n+    assert verifier_2 is not verifier_1 and not verifier_2.done()\n+    assert [task for task in adapter._background_tasks if not task.done()] == [verifier_2]\n+\n+    progress_1.set()\n+    await asyncio.sleep(0)\n+    assert adapter._send_path_degraded is True\n+    await _cancel_task(verifier_2)\n+\n+\n[redacted:email]\n+async def test_disconnect_fences_verifier_and_late_progress_completion():\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    await adapter._start_polling_resilient(\n+        drop_pending_updates=False, error_callback=MagicMock()\n+    )\n+    generation = adapter._polling_generation\n+    verifier = adapter._polling_progress_verifier_task\n+\n+    entered = asyncio.Event()\n+    release = asyncio.Event()\n+    request = adapter._instrument_polling_request(\n+        _ControlledRequest(\n+            result=(200, b'{\"ok\":true,\"result\":[]}'),\n+            entered=entered,\n+            release=release,\n+        )\n+    )\n+    completion = asyncio.create_task(\n+        _request_for_generation(generation, request, \"getUpdates\")\n+    )\n+    await entered.wait()\n+\n+    await adapter.disconnect()\n+\n+    assert verifier.done()\n+    assert adapter._polling_progress_verifier_task is None\n+    assert adapter._polling_progress_accepting is False\n+    assert adapter._polling_generation > generation\n+    assert adapter._send_path_degraded is True\n+\n+    release.set()\n+    assert await completion == (200, b'{\"ok\":true,\"result\":[]}')\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_disconnect_during_polling_start_returns_false_without_recovery():\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    entered = asyncio.Event()\n+    release = asyncio.Event()\n+    recovery = MagicMock()\n+    adapter._schedule_polling_recovery = recovery\n+\n+    async def blocked_start(**_kwargs):\n+        entered.set()\n+        await release.wait()\n+\n+    adapter._app.updater.start_polling = blocked_start\n+    start = asyncio.create_task(\n+        adapter._start_polling_resilient(\n+            drop_pending_updates=False,\n+            error_callback=MagicMock(),\n+        )\n+    )\n+    await entered.wait()\n+\n+    await adapter.disconnect()\n+    release.set()\n+    result = await start\n+\n+    assert result is False\n+    recovery.assert_not_called()\n+    assert adapter._polling_error_task is None\n+    assert not adapter.has_fatal_error\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_caller_cancellation_during_polling_start_still_propagates():\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    entered = asyncio.Event()\n+    release = asyncio.Event()\n+    recovery = MagicMock()\n+    adapter._schedule_polling_recovery = recovery\n+\n+    async def blocked_start(**_kwargs):\n+        entered.set()\n+        await release.wait()\n+\n+    adapter._app.updater.start_polling = blocked_start\n+    start = asyncio.create_task(\n+        adapter._start_polling_resilient(\n+            drop_pending_updates=False,\n+            error_callback=MagicMock(),\n+        )\n+    )\n+    await entered.wait()\n+\n+    start.cancel()\n+    with pytest.raises(asyncio.CancelledError):\n+        await start\n+\n+    assert start.cancelled()\n+    recovery.assert_not_called()\n+    assert not adapter.has_fatal_error\n+    assert adapter._send_path_degraded is True\n+    await adapter.disconnect()\n+\n+\n[redacted:email]\n+async def test_disconnect_cancels_recovery_before_it_can_rearm_progress(monkeypatch):\n+    adapter = _make_adapter()\n+    adapter._app = _mock_polling_app()\n+    adapter._app.updater.running = False\n+    adapter._polling_error_callback_ref = MagicMock()\n+\n+    drain_entered = asyncio.Event()\n+    release_drain = asyncio.Event()\n+    start_entered = asyncio.Event()\n+    release_start = asyncio.Event()\n+    teardown_paused = asyncio.Event()\n+    release_teardown = asyncio.Event()\n+\n+    async def immediate_backoff(_delay):\n+        return None\n+\n+    async def blocked_drain():\n+        drain_entered.set()\n+        await release_drain.wait()\n+\n+    async def blocked_start_polling(**_kwargs):\n+        start_entered.set()\n+        await release_start.wait()\n+\n+    async def blocked_status_indicator(*, online):\n+        assert online is False\n+        teardown_paused.set()\n+        await release_teardown.wait()\n+\n+    monkeypatch.setattr(tg_adapter.asyncio, \"sleep\", immediate_backoff)\n+    monkeypatch.setattr(adapter, \"_drain_polling_connections\", blocked_drain)\n+    monkeypatch.setattr(\n+        adapter._app.updater, \"start_polling\", blocked_start_polling\n+    )\n+    monkeypatch.setattr(adapter, \"_set_status_indicator\", blocked_status_indicator)\n+\n+    recovery = asyncio.create_task(\n+        adapter._handle_polling_network_error(ConnectionError(\"offline\"))\n+    )\n+    adapter._polling_error_task = recovery\n+    await drain_entered.wait()\n+\n+    disconnect = asyncio.create_task(adapter.disconnect())\n+    await teardown_paused.wait()\n+\n+    try:\n+        # Before the fix, disconnect pauses here before cancelling recovery.\n+        # Releasing the recovery lets it begin a fresh generation after the\n+        # teardown fence, and matching progress can then heal the adapter.\n+        if not recovery.done():\n+            release_drain.set()\n+            await start_entered.wait()\n+\n+        rearmed_after_fence = adapter._polling_progress_accepting\n+        adapter._record_polling_progress(adapter._polling_generation)\n+\n+        assert rearmed_after_fence is False\n+        assert getattr(adapter, \"_polling_teardown_started\", False) is True\n+        assert adapter._polling_progress_accepting is False\n+        assert adapter._send_path_degraded is True\n+        assert recovery.done()\n+    finally:\n+        release_drain.set()\n+        release_start.set()\n+        release_teardown.set()\n+        for task in (recovery, disconnect):\n+            if not task.done():\n+                task.cancel()\n+        await asyncio.gather(recovery, disconnect, return_exceptions=True)"]},{"file":"tests/gateway/test_telegram_send_path_health.py","hunks":["@@ -65,49 +65,32 @@ async def test_send_short_circuits_when_path_degraded():\n \n \n @pytest.mark.asyncio\n-async def test_reconnect_storm_sets_and_heartbeat_clears_flag(monkeypatch):\n-    \"\"\"_handle_polling_network_error sets the flag while reconnecting; if the\n-    reconnect attempt itself raises (polling not yet healthy), the flag stays\n-    True until a later successful heartbeat probe in\n-    _verify_polling_after_reconnect clears it.\"\"\"\n+async def test_get_me_success_without_polling_progress_does_not_heal(monkeypatch):\n+    \"\"\"A responsive general Bot API path is not proof that getUpdates works.\"\"\"\n     adapter = _make_adapter()\n     adapter._app = MagicMock()\n     adapter._app.updater = MagicMock()\n     adapter._app.updater.running = True\n-    adapter._app.updater.stop = AsyncMock()\n-    # First start_polling attempt fails — the reconnect handler must leave the\n-    # flag set (path still unhealthy) and not clear it prematurely.\n-    adapter._app.updater.start_polling = AsyncMock(side_effect=OSError(\"still down\"))\n     adapter._app.bot = MagicMock()\n     adapter._app.bot.get_me = AsyncMock(return_value=MagicMock())\n-    adapter._polling_error_callback_ref = AsyncMock()\n-    monkeypatch.setattr(\n-        \"plugins.platforms.telegram.adapter.Update\", MagicMock(ALL_TYPES=[])\n-    )\n-    # Suppress the self-rescheduled retry so the test doesn't recurse.\n+\n+    generation, progress = adapter._begin_polling_generation()\n+    recovery = MagicMock()\n+    monkeypatch.setattr(adapter, \"_schedule_polling_recovery\", recovery)\n     monkeypatch.setattr(\n-        \"plugins.platforms.telegram.adapter.asyncio.ensure_future\", MagicMock()\n+        \"plugins.platforms.telegram.adapter._POLLING_PROGRESS_TIMEOUT\", 0,\n+        raising=False,\n     )\n+    await adapter._verify_polling_after_reconnect(generation, progress)\n \n-    with patch(\"plugins.platforms.telegram.adapter.asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._handle_polling_network_error(OSError(\"Bad Gateway\"))\n-    # start_polling failed → path still degraded.\n+    adapter._app.bot.get_me.assert_awaited_once()\n+    recovery.assert_called_once()\n     assert adapter._send_path_degraded is True\n \n-    # Now the deferred probe runs against a recovered (running) updater and\n-    # a responsive bot — it clears the flag.\n-    adapter._app.updater.running = True\n-    with patch(\"plugins.platforms.telegram.adapter.asyncio.sleep\", new_callable=AsyncMock):\n-        await adapter._verify_polling_after_reconnect()\n-    assert adapter._send_path_degraded is False\n-\n \n @pytest.mark.asyncio\n-async def test_successful_reconnect_clears_flag_without_probe(monkeypatch):\n-    \"\"\"Regression for #35205: a successful start_polling() clears\n-    _send_path_degraded immediately, so outbound sends are not blocked for\n-    the full HEARTBEAT_PROBE_DELAY window (and never get stuck True if the\n-    deferred probe is never scheduled / never runs).\"\"\"\n+async def test_successful_reconnect_waits_for_get_updates_progress(monkeypatch):\n+    \"\"\"start_polling() return alone cannot heal; matching progress can.\"\"\"\n     adapter = _make_adapter()\n     adapter._app = MagicMock()\n     adapter._app.updater = MagicMock()","@@ -120,17 +103,18 @@ async def test_successful_reconnect_clears_flag_without_probe(monkeypatch):\n     monkeypatch.setattr(\n         \"plugins.platforms.telegram.adapter.Update\", MagicMock(ALL_TYPES=[])\n     )\n-    # Don't let the deferred probe run — prove the clear happens in the\n-    # reconnect handler itself, not in _verify_polling_after_reconnect.\n-    monkeypatch.setattr(\n-        adapter, \"_verify_polling_after_reconnect\", AsyncMock()\n-    )\n-\n     with patch(\"plugins.platforms.telegram.adapter.asyncio.sleep\", new_callable=AsyncMock):\n         await adapter._handle_polling_network_error(OSError(\"Bad Gateway\"))\n \n+    verifier = adapter._polling_progress_verifier_task\n+    assert adapter._send_path_degraded is True\n+    assert adapter._polling_network_error_count == 1\n+    blocked = await adapter.send(\"123\", \"hello\")\n+    assert blocked.success is False\n+\n+    adapter._record_polling_progress(adapter._polling_generation)\n+    await verifier\n     assert adapter._send_path_degraded is False\n     assert adapter._polling_network_error_count == 0\n-    # And send() works again right away.\n     result = await adapter.send(\"123\", \"hello\")\n     assert result.success is True"]},{"file":"tests/test_telegram_polling_progress_ptb.py","hunks":["@@ -0,0 +1,294 @@\n+\"\"\"Integration coverage for polling progress against the installed PTB runtime.\"\"\"\n+\n+import asyncio\n+\n+import pytest\n+\n+pytest.importorskip(\"telegram\")  # PTB is an optional dependency; skip if absent.\n+from telegram.error import Conflict, TelegramError\n+from telegram.request import BaseRequest\n+\n+from gateway.config import PlatformConfig\n+from plugins.platforms.telegram import adapter as tg_adapter\n+from plugins.platforms.telegram.adapter import TelegramAdapter\n+\n+\n+class _GeneralRequest(BaseRequest):\n+    @property\n+    def read_timeout(self):\n+        return 10\n+\n+    async def initialize(self):\n+        return None\n+\n+    async def shutdown(self):\n+        return None\n+\n+    async def do_request(self, url, method, request_data=None, **_kwargs):\n+        if url.endswith(\"/getMe\"):\n+            return (\n+                200,\n+                b'{\"ok\":true,\"result\":{\"id\":1,\"is_bot\":true,'\n+                b'\"first_name\":\"Test\",\"username\":\"test_bot\"}}',\n+            )\n+        return 200, b'{\"ok\":true,\"result\":true}'\n+\n+\n+class _GetUpdatesRequest(BaseRequest):\n+    def __init__(self):\n+        self.initial_conflict_sent = False\n+        self.replacement_enabled = False\n+        self.replacement_progress_sent = False\n+        self.cleanup_calls = 0\n+        self.block = asyncio.Event()\n+\n+    @property\n+    def read_timeout(self):\n+        return 10\n+\n+    async def initialize(self):\n+        return None\n+\n+    async def shutdown(self):\n+        return None\n+\n+    async def do_request(self, url, method, request_data=None, **_kwargs):\n+        parameters = request_data.parameters if request_data is not None else {}\n+        timeout = parameters.get(\"timeout\")\n+        timeout_seconds = (\n+            timeout.total_seconds() if hasattr(timeout, \"total_seconds\") else timeout\n+        )\n+        if timeout_seconds == 0:\n+            self.cleanup_calls += 1\n+            return 200, b'{\"ok\":true,\"result\":[]}'\n+        if not self.initial_conflict_sent:\n+            self.initial_conflict_sent = True\n+            return (\n+                409,\n+                b'{\"ok\":false,\"error_code\":409,'\n+                b'\"description\":\"Conflict: another getUpdates request\"}',\n+            )\n+        if self.replacement_enabled and not self.replacement_progress_sent:\n+            self.replacement_progress_sent = True\n+            return 200, b'{\"ok\":true,\"result\":[]}'\n+        await self.block.wait()\n+        return 200, b'{\"ok\":true,\"result\":[]}'\n+\n+\n+class _EnvelopeRequest(BaseRequest):\n+    def __init__(self, payload):\n+        self.payload = payload\n+\n+    @property\n+    def read_timeout(self):\n+        return 10\n+\n+    async def initialize(self):\n+        return None\n+\n+    async def shutdown(self):\n+        return None\n+\n+    async def do_request(self, url, method, request_data=None, **_kwargs):\n+        return 200, self.payload\n+\n+\n+async def _cancel_task(task):\n+    if task is None or task.done():\n+        return\n+    task.cancel()\n+    await asyncio.gather(task, return_exceptions=True)\n+\n+\n[redacted:email]\n+async def test_real_base_request_invalid_200_body_cannot_record_progress():\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 4\n+    adapter._polling_conflict_count = 3\n+    request = adapter._instrument_polling_request(_EnvelopeRequest(b\"not-json\"))\n+    context_token = [redacted:secret-assignment]\n+\n+    try:\n+        with pytest.raises(TelegramError, match=\"Invalid server response\"):\n+            await request.post(\"https://api.telegram.org/bot-token/getUpdates\")\n+    finally:\n+        tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)\n+\n+    assert not progress.is_set()\n+    assert adapter._polling_network_error_count == 4\n+    assert adapter._polling_conflict_count == 3\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_real_base_request_bom_rejected_by_ptb_cannot_record_progress():\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 4\n+    adapter._polling_conflict_count = 3\n+    request = adapter._instrument_polling_request(\n+        _EnvelopeRequest(b'\\xef\\xbb\\xbf{\"ok\":true,\"result\":[]}')\n+    )\n+    context_token = [redacted:secret-assignment]\n+\n+    try:\n+        with pytest.raises(TelegramError, match=\"Invalid server response\"):\n+            await request.post(\"https://api.telegram.org/bot-token/getUpdates\")\n+    finally:\n+        tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)\n+\n+    assert not progress.is_set()\n+    assert adapter._polling_network_error_count == 4\n+    assert adapter._polling_conflict_count == 3\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_real_base_request_ptb_replacement_decode_records_progress():\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 4\n+    adapter._polling_conflict_count = 3\n+    request = adapter._instrument_polling_request(\n+        _EnvelopeRequest(b'{\"ok\":true,\"result\":[],\"note\":\"\\xff\"}')\n+    )\n+    context_token = [redacted:secret-assignment]\n+\n+    try:\n+        result = await request.post(\"https://api.telegram.org/bot-token/getUpdates\")\n+    finally:\n+        tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)\n+\n+    assert result == []\n+    assert progress.is_set()\n+    assert adapter._polling_network_error_count == 0\n+    assert adapter._polling_conflict_count == 0\n+    assert adapter._send_path_degraded is False\n+\n+\n[redacted:email]\n[redacted:email](\n+    (\"payload\", \"missing_result\"),\n+    [\n+        (b'{\"ok\":false,\"result\":[]}', False),\n+        (b'{\"ok\":true}', True),\n+    ],\n+)\n+async def test_real_base_request_unsuccessful_200_envelope_cannot_record_progress(\n+    payload, missing_result\n+):\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 4\n+    adapter._polling_conflict_count = 3\n+    request = adapter._instrument_polling_request(_EnvelopeRequest(payload))\n+    context_token = [redacted:secret-assignment]\n+\n+    try:\n+        if missing_result:\n+            with pytest.raises(KeyError, match=\"result\"):\n+                await request.post(\"https://api.telegram.org/bot-token/getUpdates\")\n+        else:\n+            assert await request.post(\n+                \"https://api.telegram.org/bot-token/getUpdates\"\n+            ) == []\n+    finally:\n+        tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)\n+\n+    assert not progress.is_set()\n+    assert adapter._polling_network_error_count == 4\n+    assert adapter._polling_conflict_count == 3\n+    assert adapter._send_path_degraded is True\n+\n+\n[redacted:email]\n+async def test_real_base_request_valid_success_envelope_records_progress():\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    generation, progress = adapter._begin_polling_generation()\n+    adapter._polling_network_error_count = 4\n+    adapter._polling_conflict_count = 3\n+    request = adapter._instrument_polling_request(\n+        _EnvelopeRequest(b'{\"ok\":true,\"result\":[]}')\n+    )\n+    context_token = [redacted:secret-assignment]\n+\n+    try:\n+        result = await request.post(\n+            \"https://api.telegram.org/bot-token/getUpdates\"\n+        )\n+    finally:\n+        tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)\n+\n+    assert result == []\n+    assert progress.is_set()\n+    assert adapter._polling_network_error_count == 0\n+    assert adapter._polling_conflict_count == 0\n+    assert adapter._send_path_degraded is False\n+\n+\n[redacted:email]\n+async def test_real_ptb_stop_cleanup_cannot_heal_recovery_generation():\n+    assert tg_adapter.TELEGRAM_AVAILABLE is True\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    polling_request = _GetUpdatesRequest()\n+    app = (\n+        tg_adapter.Application.builder()\n+        .token(\"123456:test-token\")\n+        .request(_GeneralRequest())\n+        .get_updates_request(adapter._instrument_polling_request(polling_request))\n+        .build()\n+    )\n+    adapter._app = app\n+    adapter._polling_network_error_count = 4\n+    adapter._polling_conflict_count = 3\n+    callback_called = asyncio.Event()\n+    recovery_task = None\n+\n+    async def stop_for_recovery():\n+        await app.updater.stop()\n+\n+    def schedule_recovery(error):\n+        nonlocal recovery_task\n+        assert isinstance(error, Conflict)\n+        recovery_task = asyncio.create_task(stop_for_recovery())\n+        callback_called.set()\n+\n+    await app.initialize()\n+    try:\n+        await adapter._start_polling_once(\n+            app,\n+            drop_pending_updates=False,\n+            error_callback=schedule_recovery,\n+        )\n+        generation = adapter._polling_generation\n+        progress = adapter._polling_progress_event\n+        await asyncio.wait_for(callback_called.wait(), timeout=2)\n+        await asyncio.wait_for(recovery_task, timeout=3)\n+\n+        assert polling_request.cleanup_calls == 1\n+        assert not progress.is_set()\n+        assert adapter._polling_network_error_count == 4\n+        assert adapter._polling_conflict_count == 3\n+        assert adapter._send_path_degraded is True\n+\n+        polling_request.replacement_enabled = True\n+        await adapter._start_polling_once(\n+            app,\n+            drop_pending_updates=False,\n+            error_callback=schedule_recovery,\n+        )\n+        replacement_generation = adapter._polling_generation\n+        replacement_progress = adapter._polling_progress_event\n+        await asyncio.wait_for(replacement_progress.wait(), timeout=2)\n+\n+        assert replacement_generation == generation + 1\n+        assert adapter._polling_network_error_count == 0\n+        assert adapter._polling_conflict_count == 0\n+        assert adapter._send_path_degraded is False\n+    finally:\n+        polling_request.block.set()\n+        if app.updater.running:\n+            await app.updater.stop()\n+        await _cancel_task(adapter._polling_progress_verifier_task)\n+        await app.shutdown()\n"]}]},{"kind":"pr","number":64368,"title":"fix(telegram): classify and dedup post-reconnect probe failures (#63243)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_network_reconnect.py"]},{"kind":"pr","number":67913,"title":"fix(telegram): serialize post-reconnect recovery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64368","number":64368},{"kind":"pr","value":"#64368","number":64368},{"kind":"pr","value":"#67913","number":67913},{"kind":"pr","value":"#64368","number":64368}],"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}