After a prolonged outage the in-process network-error ladder escalates to
fatal and GatewayRunner._platform_reconnect_watcher rebuilds a fresh adapter
that reconnects through the bootstrap path. That path called
start_polling(drop_pending_updates=True), discarding every update Telegram
queued during the outage — all messages sent while the bot was down were
silently lost. The in-process ladder and 409-conflict handler already passed
drop_pending_updates=False; only bootstrap did not distinguish a cold first
boot from a reconnect.
Thread an is_reconnect signal from the watcher through
_connect_adapter_with_timeout into adapter.connect(). The base
BasePlatformAdapter.connect() gains a keyword-only is_reconnect=False so every
adapter inherits a tolerant signature (no per-platform breakage when the
runner forwards the kwarg). Telegram translates is_reconnect into
drop_pending_updates=not is_reconnect on both the polling and webhook bootstrap
calls. Cold boot still drops the stale queue; a watcher reconnect preserves it.
Fixes#46621.
Co-authored-by: annguyenNous <annguyen@nousresearch.com>
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
Co-authored-by: Kewe63 <Kewe63@users.noreply.github.com>
Two related race conditions in gateway/platforms/base.py that could
produce duplicate agent runs or silently drop messages. Neither is
specific to any one platform — all adapters inherit this logic.
R5 (HIGH) — duplicate agent spawn on turn chain
In _process_message_background, the pending-drain path deleted
_active_sessions[session_key] before awaiting typing_task.cancel()
and then recursively awaiting _process_message_background for the
queued event. During the typing_task await, a fresh inbound message
M3 could pass the Level-1 guard (entry now missing), set its own
Event, and spawn a second _process_message_background for the same
session_key — two agents running simultaneously, duplicate responses,
duplicate tool calls.
Fix: keep the _active_sessions entry populated and only clear() the
Event. The guard stays live, so any concurrent inbound message takes
the busy-handler path (queue + interrupt) as intended.
R6 (MED-HIGH) — message dropped during finally cleanup
The finally block has two await points (typing_task, stop_typing)
before it deletes _active_sessions. A message arriving in that
window passes the guard (entry still live), lands in
_pending_messages via the busy-handler — and then the unconditional
del removes the guard with that message still queued. Nothing
drains it; the user never gets a reply.
Fix: before deleting _active_sessions in finally, pop any late
pending_messages entry and spawn a drain task for it. Only delete
_active_sessions when no pending is waiting.
Tests: tests/gateway/test_pending_drain_race.py — three regression
cases. Validated: without the fix, two of the three fail exactly
where the races manifest (duplicate-spawn guard loses identity,
late-arrival 'LATE' message not in processed list).