Skip to content

fix(telegram): hold inbound messages across disconnect instead of destroying them - #83878

Closed
dvbaecker wants to merge 2 commits into
NousResearch:mainfrom
dvbaecker:fix/telegram-preserve-inbound-on-reconnect
Closed

dvbaecker wants to merge 2 commits into
NousResearch:mainfrom
dvbaecker:fix/telegram-preserve-inbound-on-reconnect

Conversation

@dvbaecker

@dvbaecker dvbaecker commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Current main's disconnect drop-guard (#55971) correctly refuses to dispatch buffered Telegram updates into a torn-down session (_should_drop_delayed_delivery / _drop_delayed_deliveries).

The implementation still destroys the inbound event: debug-level return after pop() (or before enqueue) at the text/photo/media-group batch sites, and _cancel_pending_delivery_tasks clears pending maps with no salvage. By then python-telegram-bot has already accepted the update and advanced the polling offset, so Telegram will never redeliver. Result: silent permanent loss — no WARNING, no error, no retry.

Operator signature: messages visible in Telegram (later quoted via reply_to_id) never appear as gateway inbound or in state.db for that turn — consistent with drop-on-enqueue while the drop-guard is true during polling recovery.

Problem

Site Before
_enqueue_text_event drop → debug return
_flush_text_batch pop → drop → return (event gone); cancel after pop also lost
_enqueue_photo_event drop → debug return
_flush_photo_batch pop → drop → return; cancel after pop lost
_queue_media_group_event drop → debug
_flush_media_group_event pop → drop → return; cancel after pop lost
_cancel_pending_delivery_tasks cancel flushes, clear maps, no salvage

Invariant preserved: still never call handle_message into a torn-down session.

Fix

Hold, don't destroy. Lifecycle-track redispatch. Drain on reconnect.

  1. _hold_inbound_event(event, where=...) — WARNING, HELD_INBOUND_MAX=64, identity dedup
  2. All six drop sites hold instead of silent return
  3. text/photo/media-group flush: CancelledError after pop holds
  4. _cancel_pending_delivery_tasks:
  5. _mark_connected schedules one tracked _redispatch_held_inbound(prior=...)
  6. Mid-drain disconnect/cancel re-holds the remainder
  7. Non-retryable fatal discards the hold queue with WARNING (no silent death on permanent auth failure)

Interaction with OOF-156 (connect-failure classification)

Rebased onto current main (zero textual conflicts), which adds retryable=False fatals for InvalidToken/Forbidden at connect time (telegram_auth_error). The two mechanisms compose by design:

Fatal kind Hold queue Why
retryable (telegram_connect_error, network) preserved reconnect is precisely the drain trigger
non-retryable (telegram_auth_error, revoked token) discarded + WARNING, producers fenced no reconnect will ever drain; holding would orphan

Covered by regression tests on both paths.

Scope boundary

PR Failure mode Relation
#72037 (open) Follow-up chunk cancels in-flight flush after pop during normal supersession Orthogonal — shield; this PR does not add shield
#81528 (closed, superseded by #81371, open) Discard buffers at conversation boundaries (fragment leak) Opposite direction — hold queue is reconnect-scoped, not a substitute for boundary discard
#55971 (merged) Drop-guard introduction Contract kept; destructive impl fixed
This PR Drop-guard / teardown / cancel-after-pop destroy inbound Hold + tracked redispatch

Held queue is not cleared by conversation boundaries; that remains #81371's job if fragments must die on /new//stop.

Test plan

16 tests in TestHoldInboundAcrossReconnectno wall-clock races (delay=0 and/or entered/release asyncio.Event):

  • late enqueue held + redispatched on reconnect
  • flush post-pop drop holds (delay=0)
  • flush cancel-after-pop holds (Event sync)
  • teardown salvages pending maps
  • redispatch task cancel+await on teardown (lifecycle)
  • photo + media-group enqueue held
  • identity dedup
  • queue cap drops oldest
  • redispatch aborts cleanly if disconnect returns mid-drain
  • non-retryable fatal discards held (OOF-156 auth path)
  • retryable fatal preserves held for reconnect drain (OOF-156 network path)
  • production terminal step (_enqueue_text_event as _handle_text_message ends) holds when disconnected
  • permanent fatal teardown discards pending, never re-holds
  • permanent fatal late enqueue discards
  • connected hold schedules redispatch (no orphan)
  • redispatch failure re-holds current + remainder
scripts/run_tests.sh tests/gateway/test_telegram_text_batching.py -q
# 21 passed

scripts/run_tests.sh tests/gateway/ -q
# 5446 passed, 0 failed, 29 skipped

Full suite verified against a pristine-main baseline in the same environment: zero delta introduced by this branch (remaining failures are identical pre-existing environment failures — daytona/fal/hindsight/acp extras not installed — present on both sides).

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/plugins Plugin system and bundled plugins platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 11, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The hold lifecycle still has two event-loss/orphaning gaps:

  • _set_fatal_error(..., retryable=False) clears only the queue present at that instant (plugins/platforms/telegram/adapter.py:940-952). Teardown then salvages all pending text/photo/media-group entries through _hold_inbound_event (:4433-4442), and late enqueues behind the drop guard do the same. A permanent-fatal case consequently retained both a pre-existing pending batch and a late event even though no reconnect can drain them. Please fence all hold producers on retryability and explicitly discard pending/in-flight sources during permanent-fatal teardown.

  • The only drain trigger is _mark_connected (:918-934). A flush cancelled after pop while the adapter is already connected re-holds the event (:9265-9269) but schedules no redispatch; the next event can be delivered while the first remains orphaned. Also, an ordinary handle_message exception during redispatch is logged without re-holding the current event (:1042-1054). Please guarantee a drain whenever a connected path creates a hold, and preserve the current event/remainder on retryable redispatch failure.

The disconnect-to-reconnect positive path, bounded queue, identity dedupe, teardown salvage, and mid-drain disconnect preservation all pass, but the paths above still violate the no-destruction/eventual-redispatch invariant.

Security evidence:

  • trust boundary: PTB has already acknowledged each inbound Telegram update before adapter batching, so the pending maps and held queue are the remaining delivery authority across disconnect and reconnect.
  • source/sink/invariant: Text/photo/media-group enqueue and teardown salvage feed handle_message; every acknowledged event must be delivered, remain bounded with a guaranteed retryable drain, or be explicitly discarded on permanent fatal.
  • current-main reproduction: A disconnect-guarded text event is removed without dispatch or retention on current main, reproducing the claimed permanent-loss path.
  • PR-head or patch-replay validation: The normal disconnect-to-reconnect path now holds and delivers that event, but permanent-fatal teardown retained a pending batch plus a late event, and connected cancel-after-pop left the first event held without a drain task.
  • positive/negative cases: Queue bounds, object-identity dedupe, pending-map salvage, reconnect redispatch, and mid-drain disconnect preservation pass; permanent-fatal cleanup, connected cancel-after-pop, and redispatch exception handling fail.
  • residual bypass search: The same unconditional hold behavior covers text, photo, and media-group producers; only _mark_connected schedules a drain, and ordinary redispatch exceptions do not restore the removed event.
  • reviewer validation: Focused batching and adjacent Telegram lifecycle tests pass, while deterministic lifecycle probes independently reproduce the three residual paths above.

Signed: GPT-5.6-sol-xhigh in Codex

@dvbaecker

Copy link
Copy Markdown
Contributor Author

Addressed in 221a6dfb5:

  • Permanent fatal: hold producers fenced; teardown discards pending maps instead of re-holding
  • Connected-path hold schedules tracked redispatch (cancel-after-pop no longer orphans)
  • Redispatch failure re-holds current + remainder (no tight-loop retry)

Regression tests added for the three residual paths.

@egilewski

Copy link
Copy Markdown
Contributor

looks mergeable

The Telegram adapter now preserves authorized text, photo, and media-group events when delayed delivery races a disconnect, drains them only after a live reconnect, bounds retention, and explicitly discards them on permanent fatal states. The changed producers and teardown paths converge on the existing message-delivery sink; no new authorization, credential, prompt-routing, or data-exposure bypass was found.

Security evidence:

  • trust boundary: Telegram updates pass through the existing authorization and group/topic gating before normalized events enter bounded in-memory queues. Replayed events use the existing message-delivery sink; no credential or network trust boundary is added.
  • source/sink/invariant: Every changed text, photo, and media-group producer is reached after the existing intake authorization and gating checks. When delayed dispatch is fenced, the event is held or, for a non-retryable fatal, explicitly discarded; the queue is capped and identity-deduplicated. A reconnect clears the fence before a tracked redispatch task invokes the existing sink, and teardown cancels and awaits that task.
  • current-main reproduction: On current main, delayed text, photo, and media-group events are removed or discarded when the disconnect fence is set, while teardown clears pending maps. That is the silent-loss path replaced here; focused disconnect cases cover it.
  • PR-head or patch-replay validation: The reviewed PR revision and focused test module compile successfully, and focused asynchronous probes covered the existing batching cases plus every added hold/reconnect case, including cancellation, queue limits, permanent-fatal handling, and redispatch error handling.
  • positive/negative cases: Positive cases hold late enqueues and popped flush events, salvage all three pending batch families, and replay them after reconnect or a connected-path hold. Negative cases verify no dispatch while disconnected, oldest-first queue eviction at the cap, cancellation re-holding, disconnect during a drain, permanent-fatal discard, and re-holding current plus remainder on handler error.
  • residual bypass search: Changed batching producers and their command/media call sites converge on the existing gated intake and message-delivery sink; no alternate changed producer bypasses the disconnect fence or accepts an event before existing authorization and group checks.
  • reviewer validation: Independent source review found no additional source-backed security issue.

Not checked:

  • pytest test runner
  • ruff lint

Signed: GPT-5.6-luna-max in Codex

…troying them

The disconnect drop-guard (NousResearch#55971) correctly prevents dispatch into a
torn-down session. Destroying the event was wrong: by enqueue/flush time
python-telegram-bot has already acked the update and advanced the polling
offset, so Telegram never redelivers. Result: silent permanent loss, no
log, no error.

Hold inbound events (text/photo/media-group) when the drop-guard fires,
salvage pending batch maps on teardown, cancel+await the redispatch task
in the delivery cancel map (lifecycle-tracked), and redispatch from
_mark_connected after reconnect. Cap the hold queue (default 64), dedupe
by object identity, discard on non-retryable fatal. Cancel-after-pop in
flush paths also holds.

Distinct from NousResearch#72037 (cancel-after-pop during follow-up supersession) and
NousResearch#81528 (boundary discard). Tests use delay=0 and entered/release Events —
no wall-clock races; includes production terminal-step coverage.
…cted drain

Address review on NousResearch#83878:

- Permanent fatal fences all hold producers and discards pending maps on
  teardown instead of re-populating a queue that can never drain.
- Any hold created while connected schedules a tracked redispatch (cancel-
  after-pop no longer orphans until a future reconnect).
- Redispatch failures re-hold current + remainder without tight-looping.

Regression coverage for the three residual paths, plus the interaction
with OOF-156's connect-failure classification: the retryable network
path (telegram_connect_error) must NOT clear the hold queue — reconnect
is precisely what drains it; only non-retryable fatals discard.
@dvbaecker
dvbaecker force-pushed the fix/telegram-preserve-inbound-on-reconnect branch from 221a6df to 0e1c2e2 Compare August 13, 2026 17:51
@dvbaecker

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (1a796a124), zero conflicts.

One upstream change since this PR opened is worth calling out: OOF-156 (91bc82233) now classifies InvalidToken/Forbidden at connect time as retryable=False (telegram_auth_error). The hold mechanism composes with that by design rather than fighting it:

Fatal kind Hold queue Why
retryable (telegram_connect_error, network) preserved reconnect is precisely the drain trigger
non-retryable (telegram_auth_error) discarded + WARNING, producers fenced no reconnect will ever drain

Added a regression test pinning both sides of that interaction (test_retryable_fatal_preserves_held_for_reconnect_drain), so a future "simplification" that clears the queue on every fatal cannot silently kill reconnect-drain.

Verification on the rebased branch:

scripts/run_tests.sh tests/gateway/test_telegram_text_batching.py -q   # 21 passed
scripts/run_tests.sh tests/gateway/ -q                                 # 5446 passed, 0 failed, 29 skipped

Full suite run against a pristine-main baseline in the same environment: zero delta introduced by this branch.

Anything else needed to move this forward?

kshitijk4poor pushed a commit that referenced this pull request Aug 15, 2026
…cted drain

Address review on #83878:

- Permanent fatal fences all hold producers and discards pending maps on
  teardown instead of re-populating a queue that can never drain.
- Any hold created while connected schedules a tracked redispatch (cancel-
  after-pop no longer orphans until a future reconnect).
- Redispatch failures re-hold current + remainder without tight-looping.

Regression coverage for the three residual paths, plus the interaction
with OOF-156's connect-failure classification: the retryable network
path (telegram_connect_error) must NOT clear the hold queue — reconnect
is precisely what drains it; only non-retryable fatals discard.
kshitijk4poor added a commit that referenced this pull request Aug 15, 2026
Pre-existing inconsistency: _flush_media_group_event used return
in CancelledError while _flush_text_batch and _flush_photo_batch
used raise. Changed to raise for consistency and to properly
propagate task cancellation. Made more visible by the hold-queue
changes in #83878.
@kshitijk4poor

Copy link
Copy Markdown
Contributor

Merged via #86399 — your commits cherry-picked with authorship preserved via rebase-merge.

Your fix for the silent message-loss bug is now on main. The disconnect drop-guard (#55971) was correct to prevent dispatch into a torn-down session, but destroying the event was wrong — PTB had already acked the update, so Telegram never redelivered. Your hold-and-redispatch mechanism solves this cleanly.

A small follow-up on top: standardized the media-group flush CancelledError handler from return to raise for consistency with the text/photo flush paths (pre-existing inconsistency your PR made more visible).

Thanks for the thorough work — 16 tests with no wall-clock races, comprehensive lifecycle coverage including the permanent-fatal path.

skappafrost pushed a commit to skappafrost/hermes-agent that referenced this pull request Aug 15, 2026
…cted drain

Address review on NousResearch#83878:

- Permanent fatal fences all hold producers and discards pending maps on
  teardown instead of re-populating a queue that can never drain.
- Any hold created while connected schedules a tracked redispatch (cancel-
  after-pop no longer orphans until a future reconnect).
- Redispatch failures re-hold current + remainder without tight-looping.

Regression coverage for the three residual paths, plus the interaction
with OOF-156's connect-failure classification: the retryable network
path (telegram_connect_error) must NOT clear the hold queue — reconnect
is precisely what drains it; only non-retryable fatals discard.
skappafrost pushed a commit to skappafrost/hermes-agent that referenced this pull request Aug 15, 2026
Pre-existing inconsistency: _flush_media_group_event used return
in CancelledError while _flush_text_batch and _flush_photo_batch
used raise. Changed to raise for consistency and to properly
propagate task cancellation. Made more visible by the hold-queue
changes in NousResearch#83878.
bobaba76 pushed a commit to bobaba76/hermes-agent that referenced this pull request Aug 27, 2026
…cted drain

Address review on NousResearch#83878:

- Permanent fatal fences all hold producers and discards pending maps on
  teardown instead of re-populating a queue that can never drain.
- Any hold created while connected schedules a tracked redispatch (cancel-
  after-pop no longer orphans until a future reconnect).
- Redispatch failures re-hold current + remainder without tight-looping.

Regression coverage for the three residual paths, plus the interaction
with OOF-156's connect-failure classification: the retryable network
path (telegram_connect_error) must NOT clear the hold queue — reconnect
is precisely what drains it; only non-retryable fatals discard.
bobaba76 pushed a commit to bobaba76/hermes-agent that referenced this pull request Aug 27, 2026
Pre-existing inconsistency: _flush_media_group_event used return
in CancelledError while _flush_text_batch and _flush_photo_batch
used raise. Changed to raise for consistency and to properly
propagate task cancellation. Made more visible by the hold-queue
changes in NousResearch#83878.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…cted drain

Address review on NousResearch#83878:

- Permanent fatal fences all hold producers and discards pending maps on
  teardown instead of re-populating a queue that can never drain.
- Any hold created while connected schedules a tracked redispatch (cancel-
  after-pop no longer orphans until a future reconnect).
- Redispatch failures re-hold current + remainder without tight-looping.

Regression coverage for the three residual paths, plus the interaction
with OOF-156's connect-failure classification: the retryable network
path (telegram_connect_error) must NOT clear the hold queue — reconnect
is precisely what drains it; only non-retryable fatals discard.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
Pre-existing inconsistency: _flush_media_group_event used return
in CancelledError while _flush_text_batch and _flush_photo_batch
used raise. Changed to raise for consistency and to properly
propagate task cancellation. Made more visible by the hold-queue
changes in NousResearch#83878.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P1 High — major feature broken, no workaround platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants