Skip to content

fix(qqbot): notify gateway watcher on reconnect exhaustion instead of silent deathFix/qqbot notify gateway - #72673

Open
cadezhou wants to merge 4 commits into
NousResearch:mainfrom
cadezhou:fix/qqbot-notify-gateway
Open

cadezhou wants to merge 4 commits into
NousResearch:mainfrom
cadezhou:fix/qqbot-notify-gateway

Conversation

@cadezhou

Copy link
Copy Markdown
Contributor

The QQ (qqbot) adapter died silently once its WebSocket reconnect attempts were exhausted (it just called _mark_disconnected()), leaving the listener task dead with nothing watching it — the platform never recovered.

This PR makes all three exhaustion paths in _listen_loop (rate-limited 4008, QQCloseError backoff, generic Exception):

  • set a qq_reconnect_exhausted fatal error (retryable=True) so the gateway reconnect watcher takes over;
  • schedule the notify as a detached task via _schedule_fatal_notify() instead of awaiting inline — the notify chain calls disconnect(), which cancels the running _listen_task, so an inline await would abort the gateway handler before it re-enqueues the adapter.

Tests

  • Added TestReconnectExhaustionHandoff covering all three paths, asserting a retryable qq_reconnect_exhausted is set, _mark_disconnected() is no longer called, and the notify is scheduled as a detached task.
  • tests/gateway/test_qqbot.py passes 169/169.

Closes #29005

kidzhou added 2 commits July 24, 2026 20:36
After reaching the maximum number of reconnection attempts, instead of calling _mark_disconnected() to exit silently, set a retryable fatal error and schedule a notification task. This leaves takeover to the gateway reconnection monitor, preventing platform hang caused by unexpected listener task termination.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/qqbot QQ Bot adapter needs-decision Awaiting maintainer decision before any implementation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 27, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Related: #17814 handles the same exhaustion paths but awaits fatal notification in the listener; this PR schedules it separately to avoid disconnect cancellation. #19414 is a broader repair. Please choose or consolidate the lifecycle behavior; this is not a duplicate.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for covering the three QQBot reconnect-exhaustion exits. The silent-exit premise is present on current main at gateway/platforms/qqbot/adapter.py:590-592, 644-647, and 656-659.

Problems

  • The sibling retryable fatal at gateway/platforms/qqbot/adapter.py:539-544 (qq_quick_disconnect) still returns without notifying the gateway. It can leave the same retryable platform unqueued.
  • The new tests mock _notify_fatal_error at tests/gateway/test_qqbot.py:2290, so they do not exercise the real runner handoff or assert QQBot reaches _failed_platforms. Current GatewayRunner._handle_adapter_fatal_error() already detaches and shields this lifecycle at gateway/run.py:6843-6968 (commit 2ab153218).

Suggested changes

  • Apply the handoff to the quick-disconnect terminal branch and cover it.
  • Add a QQBot + GatewayRunner async regression test asserting teardown completes and QQBot is queued for reconnect, analogous to tests/gateway/test_telegram_network_reconnect.py:103-133.

Automated hermes-sweeper review.

task (a harmless no-op), and the handler runs to completion and
enqueues us for the reconnect watcher.
"""
self._fatal_notify_task = asyncio.create_task(self._notify_fatal_error())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please apply this handoff to the existing retryable qq_quick_disconnect return at current-main adapter.py:539-544 as well; that branch sets a retryable fatal error but does not notify the runner, so it can still strand QQBot outside _failed_platforms.

adapter._fail_pending = mock.Mock()
adapter._mark_disconnected = mock.Mock()
adapter._set_fatal_error = mock.Mock()
adapter._notify_fatal_error = mock.AsyncMock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These mocks prove only local scheduling. Add a regression that binds a real GatewayRunner._handle_adapter_fatal_error, triggers the listener exhaustion, and asserts QQBot enters runner._failed_platforms; that is the cancellation-sensitive behavior this PR is intended to preserve.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 30, 2026
The quick-disconnect branch in `_listen_loop` called `_set_fatal_error`
and returned without invoking `_schedule_fatal_notify`, so the retryable
fatal never reached the gateway's reconnect watcher and the bot died
silently on permission/config errors. Schedule the notify so all four
exhaustion exits hand off to the gateway consistently, and add adapter-
and runner-level regression tests covering the quick-disconnect exit.
@cadezhou

cadezhou commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed.

  1. Quick-disconnect handoff. _schedule_fatal_notify() now follows _set_fatal_error(..., retryable=True) in the qq_quick_disconnect branch, matching the three sibling exits.

  2. Real-runner regression. New parametrized TestQQBotGatewayHandoffEndToEnd drives GatewayRunner._handle_adapter_fatal_error() for both qq_reconnect_exhausted and qq_quick_disconnect with no mocks on the notify chain, asserting Platform.QQBOT leaves runner.adapters and enters _failed_platforms with attempts == 0.

One note: test_telegram_network_reconnect.py:103-133 is adapter-level and doesn't spin up a GatewayRunner, so I patterned on test_runner_fatal_adapter.py:98-130 instead as the closer analogue. Happy to restructure if you'd prefer.

@ttxs69

ttxs69 commented Aug 29, 2026

Copy link
Copy Markdown

Hi @cadezhou — hat tip first: you diagnosed this silent-death root cause a month before I did, and the core mechanism we both landed on (detached fatal-notify so the gateway watcher takes over, avoiding the disconnect()-awaits-_listen_task deadlock) is the same. I've opened #97857 with an equivalent fix after independently hitting the bug in production, and the triage bot asked us to consolidate.

Technical deltas I found while comparing (all verifiable in your diff):

  1. Unguarded notify task_schedule_fatal_notify() does create_task(self._notify_fatal_error()) without exception handling. The task is fire-and-forget: if the gateway handler raises, the exception surfaces only as "Task exception was never retrieved" at GC time and the notification is silently lost — the platform stays dead exactly as before. Wrapping in a best-effort try/except (debug log) fixes it.
  2. Fatal close-code exits still don't notify — the Bot is {desc} branch (4001/4002/4010–4014/4914/4915) still calls _set_fatal_error(..., retryable=False) and returns without _schedule_fatal_notify(). Nothing polls an installed adapter's fatal flag, so a banned/offline bot leaves the same zombie (non-retryable, but the gateway should still mark it fatal and surface it).
  3. Minor: the quick-disconnect exit returns before the shared _fail_pending("Connection closed") — worth failing pending futures in-branch (defensive parity with sibling adapters).

#97857 covers all of the above and has absorbed your richer qq_reconnect_exhausted error-code context (last close code / trailing exception / rate-limit marker) with credit in the commit message. Happy to help converge on whichever base the maintainers pick — if you'd rather complete this branch yourself, items 1 and 2 above are the ones that matter.

@ttxs69

ttxs69 commented Aug 30, 2026

Copy link
Copy Markdown

One correction to my comparison above, in fairness: alongside the mocked unit tests, this PR does include a mock-free end-to-end handoff test (TestQQBotGatewayHandoffEndToEnd driving the real GatewayRunner._handle_adapter_fatal_error() and asserting the platform leaves runner.adapters and enters _failed_platforms) — that's coverage my PR's tests don't have (mine verify the adapter→handler contract; the runner side relies on the existing watcher tests). If the maintainers consolidate on #97857, porting that e2e test over would make the combined coverage strictly better than either PR alone. Items 1 (notify-task exception guard) and 2 (fatal-close exits) above remain the substantive deltas.

ttxs69 added a commit to ttxs69/hermes-agent that referenced this pull request Aug 30, 2026
…oing zombie

When _listen_loop exhausts MAX_RECONNECT_ATTEMPTS (e.g. a network/DNS
outage longer than the backoff sequence), it called
_mark_disconnected() and returned silently. No fatal error was set, so
the gateway never learned the adapter died: the zombie stayed installed
in self.adapters with is_connected=False forever. Inbound events were
gone (no WebSocket), every send waited 15s in _wait_for_reconnection()
then failed with "Not connected", and the _failed_platforms watcher
never picked it up because it only covers platforms that failed at
startup — only a gateway restart could recover the platform.

The same silent exit applied to the quick-disconnect and fatal
close-code branches, which did call _set_fatal_error() but never
notified the gateway (nothing polls installed adapters post-startup),
and to the rate-limit max-attempts exit.

Now every terminal listen-loop exit goes through _give_up_and_notify():
set an appropriate fatal error, mark disconnected, and schedule
_notify_fatal_error() on a detached task, so the gateway's
_handle_adapter_fatal_error disconnects the zombie and requeues the
platform for background reconnection (retryable) or marks it fatal
(non-retryable). The notification must be detached because the gateway
handler calls adapter.disconnect(), which awaits self._listen_task —
awaiting the listen task from inside itself would deadlock. The
detached wrapper swallows handler exceptions (best-effort notify) so a
gateway-side bug cannot leave an unawaited task error.

Two secondary fixes folded in:
- The quick-disconnect exit returns before the shared
  _fail_pending("Connection closed") — it now fails pending response
  futures itself, matching the sibling-adapter teardown contract (defensive
  parity: nothing populates QQ's pending-response dict today, but if a
  correlation mechanism is added, a handler-less standalone adapter would
  otherwise leave futures unsettled).
- Fatal close-code error codes are slugified (qq_invalid_opcode,
  qq_offline_sandbox_only, qq_invalid_api_version) since they now persist in
  runtime-status
  error_code fields; the human-readable message is unchanged.

Regression tests cover the max-reconnect exit (retryable, notified, no
deadlock when the handler disconnects the adapter), the fatal
close-code exit (non-retryable, notified), the quick-disconnect exit
(pending futures settled), and handler-exception swallowing.

Error-code context (last close code / trailing exception / rate-limit
marker) in the qq_max_reconnect messages absorbed from NousResearch#72673 by
@cadezhou — credit to that PR for the richer diagnostics; it independently
diagnosed the same silent-death root cause a month earlier.

The mock-free GatewayRunner handoff e2e test
(TestQQBotGatewayHandoffEndToEnd) is adapted from NousResearch#72673 by @cadezhou with
error codes adjusted — it proves a retryable fatal reaches the real
_handle_adapter_fatal_error and lands in _failed_platforms with attempts=0,
complementing the adapter-side tests which stop at the handler contract.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists platform/qqbot QQ Bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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.

QQBot adapter does not notify gateway on reconnect exhaustion; Telegram retry state not reflected in runtime status

4 participants