fix(qqbot): fix 5 reconnect bugs — zombie state, lost close codes, no cooldown, heartbeat timing, missing heartbeat task - #19414
Conversation
- Add media file upload support to _send_qqbot function
- Support chat_type detection from target format (c2c:/group:/guild:)
- Upload media via QQ Bot v2 API (/v2/users/{openid}/files, /v2/groups/{group_openid}/files)
- Map file extensions to QQ Bot file_type (1=image, 2=video, 3=voice, 4=file)
- Include media in message payload via 'file_info' field
- Update error messages to include qqbot in supported platforms
- Update schema description with qqbot target format examples
… attempts Fix 4 issues in QQBot adapter reconnect logic: 1. [P0] _listen_loop exit now calls _set_fatal_error() to notify Gateway When reconnect attempts are exhausted, the adapter now sets a retryable fatal error so _platform_reconnect_watcher can take over. Previously the listen loop would silently die, leaving QQBot in a zombie state where the process is alive but no messages are received. 2. [P1] CLOSED/ERROR WS events now raise QQCloseError instead of plain RuntimeError, preserving close code/reason for proper error classification. 3. [P1] Added 15s cooldown after reconnect failure to give QQ server time to clean up the old session before the next attempt. 4. [P2] Moved _heartbeat_interval reset inside _reconnect() try block so it only resets after a successful connection, not on failure.
1 similar comment
|
Thanks, Siddharth. Yes, I wanted to make sure this fix was comprehensive — preserving the close code for proper error classification and adding the inter-reconnect cooldown were both necessary to avoid the same issue recurring. Glad it covers all the bases. Let me know if any further adjustments are needed. |
…a support) Keep both QQBot and Feishu native media delivery blocks. Preserve QQBot's full chat_type routing and base64 file upload logic from HEAD, while incorporating Feishu's media support and thread_id parameter from upstream/main. Update error/warning strings to list both platforms.
|
Hi, I've resolved the merge conflict flagged on this PR. Here's the summary: Conflict LocationAll conflicts were in
What was kept from upstream
TestsAdded 50 new tests across 2 files:
Test ResultsFull regression suite: DiffReady for re-review. |
…ping/pong Bug 5: _reconnect() opens a new WebSocket but never recreates _heartbeat_task. The old heartbeat task is orphaned on the dead connection — no heartbeat is sent on the new one. QQ server closes the connection after 60s with code=None (no close code because the server drops it cleanly). Reconnect succeeds, but the death loop repeats every 60s: connect → 60s silence → disconnect → reconnect → repeat. Changes: - _open_ws(): add heartbeat=20 to aiohttp ClientWebSocketResponse for WS-level ping/pong, preventing idle disconnects at the transport layer - _reconnect(): cancel old _heartbeat_task before opening new WebSocket - _reconnect(): create_task(_heartbeat_loop()) after successful reconnect
teknium1
left a comment
There was a problem hiding this comment.
Thanks for identifying the reconnect-exhaustion failure. The zombie-state premise is confirmed on current main: gateway/platforms/qqbot/adapter.py:580-582, 634-637, and 646-649 return without the retryable fatal signal that gateway/run.py:4035-4047 requires to queue recovery.
Problems
tools/send_message_tool.py:1713-1726changes generic unprefixed QQ targets to C2C only. Currenttools/send_message_tool.py:1893-1918deliberately falls back across channel, C2C, and group endpoints, whilewebsite/docs/developer-guide/cron-internals.md:254documentsqqbot:<chat_id>. This would regress existing group/guild targets.tools/send_message_tool.py:1751-1807uploads every attachment, but1823-1824sends only the firstfile_info; subsequent files are silently discarded.tests/gateway/test_qqbot_zombie_fix.py:70-76and158-179test a direct mock call and source text rather than exercising the three listener exhaustion paths.
Suggested changes
- Salvage the fatal-error signaling as a focused adapter fix with async path tests.
- Preserve generic target fallback, or add an end-to-end typed-target migration before requiring prefixes.
- Split media delivery into a separate change and test actual HTTP requests, including multiple attachments.
Automated hermes-sweeper review.
| if not appid or not secret: | ||
| return _error("QQBot: QQ_APP_ID / QQ_CLIENT_SECRET not configured.") | ||
|
|
||
| # Determine chat type from chat_id format or default to c2c |
There was a problem hiding this comment.
This replaces the existing generic channel → C2C → group fallback with a C2C default for every unprefixed QQ target. Current docs accept qqbot:<chat_id> generically, so existing group and guild targets will be routed to the C2C endpoint. Preserve fallback behavior or introduce a complete typed-target migration.
|
|
||
| # Add media if we have uploaded files | ||
| if file_info_list: | ||
| payload["media"] = {"file_info": file_info_list[0]} |
There was a problem hiding this comment.
Every entry in media_files was uploaded above, but this payload references only the first file_info. Additional attachments are uploaded and then silently omitted; either send each file or reject multiple attachments before uploading.
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Two PRs address this complex. #19414 fixes the confirmed QQBot zombie-state cause by signaling retryable fatal errors on reconnect exhaustion, but bundles risky send-message and media changes; #22492 repeats that bundle and adds an extraction-regex change whose broad purpose is already superseded on main, leaving only .log and .toml missing from the shared extension contract.
Related pull requests
- #19414
related— (+750/-33) — keep open, but re-scope before merge: the adapter diff directly fixes the confirmed cause by calling_set_fatal_error(..., retryable=True)on all three reconnect-exhaustion exits and preserves close details, but the bundled send-message rewrite regresses unprefixed group/guild fallback, silently sends only the first uploaded attachment, and lacks async exhaustion-path tests. - #22492
duplicate— (+751/-34) — superseded duplicate; extract only the remaining narrow fix: it reproduces essentially all of #19414 and adds a literal regex extension list, but main now uses sharedMEDIA_DELIVERY_EXTS, where only.logand.tomlremain absent. Despite the keep_open review on #22492, the diff cannot be merged as written because it edits the obsolete inline-regex implementation and duplicates #19414's problematic QQBot changes.
Duplicates
#22492 duplicates substantially all QQBot reconnect, media-delivery, and test changes from #19414; its only distinct change is the MEDIA extension-list edit.
Suggested consolidation
Merge a re-scoped #19414 containing the retryable fatal-error signaling, close-code preservation, and real async tests while preserving existing unprefixed target fallback and removing or correcting the unsafe media rewrite. Add .log and .toml separately to the current shared MEDIA_DELIVERY_EXTS; then #22492 can be closed as a duplicate/superseded implementation despite its keep_open review, because its broad extraction change is already on main and its remaining one-line fix targets an obsolete source of truth.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup19414 ["PRs duplicating each other"]
P19414["PR #19414 (open)"]
P22492["PR #22492 (open)"]
end
class P19414 open
class P22492 open
class P19414 target
click P19414 "https://github.com/NousResearch/hermes-agent/pull/19414"
click P22492 "https://github.com/NousResearch/hermes-agent/pull/22492"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 81 kB of PR diffs, 8 kB of issue/PR text, 6 kB of discussion (6 comments), 1 verify verdict. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
Problem
QQBot adapter enters a zombie state after repeated WebSocket disconnections — the adapter process remains alive but stops receiving messages. This was observed in production logs where the connection dropped (code 4009: Session timed out) and then went silent for 93+ minutes.
Root cause: when
_listen_loopexhausts its reconnect attempts, it simplyreturns without notifying the Gateway's_platform_reconnect_watcher. The watcher never knows the adapter has given up, so no higher-level reconnection is attempted.Fixes by Severity
P0 — Zombie state:
_listen_loopexit silently dies without notifying GatewaySymptom: After
MAX_RECONNECT_ATTEMPTSreconnect failures,_listen_loopreturns but the adapter process stays alive. No messages are received. The_platform_reconnect_watcheringateway/run.pyis unaware the adapter has given up.Fix: All three exit paths in
_listen_loopnow call_set_fatal_error("qq_reconnect_exhausted", ..., retryable=True)before returning. This signals the Gateway's reconnect watcher to take over reconnection at the platform management level.Affected paths:
QQCloseError(CLOSE/CLOSED events) exhaustionExceptionexhaustionP1 — CLOSED/ERROR WebSocket events lose close code and reason
Symptom:
WSMsgType.CLOSEDandWSMsgType.ERRORevents raised a plainRuntimeError("WebSocket closed")with no close code or reason. This prevented proper error classification (e.g., distinguishing a server-side 4009 Session Timeout from a network error).Fix: Changed to raise
QQCloseError(msg.data, msg.extra)instead, preserving the close code and reason for downstream error classification logic.P1 — No cooldown period between reconnect failures
Symptom: After a failed reconnect attempt, the adapter immediately tries again. The QQ server may not have finished cleaning up the old session, causing the new attempt to fail with a session conflict.
Fix: Added a 15-second
await asyncio.sleep(15)in_reconnect()after a failed connection attempt, giving the server time to clean up the old session before the next retry.P2 — Heartbeat interval reset on reconnect failure
Symptom:
_heartbeat_intervalwas reset to30.0at the beginning of_reconnect(), before the connection attempt. If reconnect failed, the interval was already reset, potentially causing incorrect heartbeat timing on the next attempt.Fix: Moved
_heartbeat_interval = 30.0inside thetryblock, afterawait self._open_ws(gateway_url)succeeds. Now it only resets after a confirmed successful connection.P1 — Heartbeat task not recreated on reconnect (60s code=None death loop)
Symptom: After
_reconnect()opens a new WebSocket, the old_heartbeat_taskfrom the dead connection is orphaned — no heartbeat task runs on the new connection. QQ server receives no heartbeat ACK for 60s, then drops the connection withcode=None(no close code because the server terminates cleanly without negotiation). Reconnect succeeds via the Gateway watcher, but the same death loop repeats every ~60s.Production log pattern:
Root cause:
_reconnect()calls_open_ws()to establish a new WebSocket but never callsasyncio.create_task(self._heartbeat_loop()). The_heartbeat_taskattribute still references the old task (which died with the old connection's event loop), so no heartbeat is ever sent on the new connection.Fix (two-part):
WS-level ping/pong via aiohttp
heartbeatparameter: Addedheartbeat=20to theaiohttp.ClientSession.ws_connect()call in_open_ws(). This enables aiohttp's built-in ping/pong at the transport layer, sending a WebSocket PING every 20s. If no PONG arrives within the timeout, aiohttp closes the connection with a proper error — preventing silent idle disconnects.Recreate heartbeat task on reconnect: In
_reconnect():_heartbeat_taskwithcancel()and await it (catchesCancelledError)_open_ws()succeeds: create a fresh_heartbeat_taskviaasyncio.create_task(self._heartbeat_loop())These two changes together ensure that every reconnected WebSocket has its own active heartbeat, and the transport layer itself has a safety net for idle connections.
Changes
gateway/platforms/qqbot/adapter.py_listen_loopfatal error signaling,_reconnectcooldown & heartbeat task recreation, CLOSED/ERROR → QQCloseError, WS-level ping/pongTest Results
Reproduction
Bug 1-4 (zombie state): Production logs showed:
After this fix, the adapter properly signals the Gateway, which triggers
_platform_reconnect_watcherto reinitialize the adapter.Bug 5 (60s death loop): Reproduced in staging by:
_reconnect()triggersWebSocket closed: code=None reason=→ reconnect → repeatAfter fix: heartbeat task recreated on every reconnect, WS-level ping/pong prevents idle timeout. Connection stays alive across reconnects.