QQAdapter.connect() missing is_reconnect parameter bug
NousResearch/hermes-agent · cluster 52914 · dashboard
Data as of
Nothing open on GitHub. Fix merged in #61767. Check it covers your case.
The short answer
- Known?
- Yes. 28 related items grouped here, 11 issues already closed.
- Fix event
- A fix was merged to main in PR #61767. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #61767. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Nearly all members concern a single bug where QQAdapter.connect() lacks the is_reconnect keyword argument required by the base class, causing reconnect failures. One unrelated member addresses filesystem preview confinement in the dashboard.
Open issues0
Open PRs0
Fixes on record
-
#61767 fix(gateway): enforce reconnect contract across adapters
files:
gateway/platforms/qqbot/adapter.py,infographic/gateway-reconnect-contract/infographic.png,plugins/platforms/wecom/callback_adapter.py,scripts/release.py,tests/gateway/test_adapter_connect_is_reconnect_contract.py,tests/gateway/test_qqbot.pyShow the diff
-
gateway/platforms/qqbot/adapter.py@@ -278,8 +278,16 @@ def enforces_own_access_policy(self) -> bool: # Connection lifecycle # ------------------------------------------------------------------ - async def connect(self) -> bool: - """Authenticate, obtain gateway URL, and open the WebSocket.""" + async def connect(self, *, is_reconnect: bool = False) -> bool: + """ + Authenticate, obtain gateway URL, and open the WebSocket. + + Args: + is_reconnect: False on a cold first boot; True when the + reconnect watcher is re-establishing this platform after + an outage. QQBot has no server-side update queue so this + flag is accepted for interface conformance only. + """ if not AIOHTTP_AVAILABLE: message = "QQ startup failed: aiohttp not installed" self._set_fatal_error("qq_missing_dependency", message, retryable=True) -
plugins/platforms/wecom/callback_adapter.py@@ -115,7 +115,13 @@ def _normalize_apps(extra: Dict[str, Any]) -> List[Dict[str, Any]]: # Lifecycle # ------------------------------------------------------------------ - async def connect(self) -> bool: + async def connect(self, *, is_reconnect: bool = False) -> bool: + # ``is_reconnect`` is forwarded by GatewayRunner on every retry per + # the BasePlatformAdapter.connect contract. Callback adapters have + # no server-side queue to preserve, so the flag is accepted-and- + # ignored — but the kwarg MUST be present or the reconnect watcher + # dies with TypeError and the platform silently stays offline. + del is_reconnect if not self._apps: logger.warning("[WecomCallback] No callback apps configured") return False -
scripts/release.py@@ -45,6 +45,8 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "lemonwan", # PR #59430 sibling salvage (adapter reconnect contract guard) + "[redacted:email]": "luxuguang-leo", # PR #52966 salvage (QQBot reconnect contract) "[redacted:email]": "evelynburger", # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity) "[redacted:email]": "SiteupAgencia", # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578) "[redacted:email]": "rainbowgore", # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349) -
tests/gateway/test_adapter_connect_is_reconnect_contract.py@@ -0,0 +1,157 @@ +"""Regression: every platform adapter's ``connect()`` must accept the +``is_reconnect`` keyword-only argument. + +The gateway reconnect watcher forwards ``is_reconnect=True`` to every +adapter on every retry (see ``GatewayRunner._call_adapter_connect`` in +``gateway/run.py``). An adapter whose ``connect()`` signature omits +``is_reconnect`` blows up on the first reconnect attempt with:: + + TypeError: <Foo>Adapter.connect() got an unexpected + keyword argument 'is_reconnect' + +…and never recovers, leaving that platform silently disconnected until +the operator manually restarts the gateway. This exact bug shipped for +``QQAdapter`` and was only discovered after messages stopped flowing on +the QQ channel for hours. + +To prevent this class of bug from regressing, we statically parse every +``adapter.py`` under ``gateway/platforms/`` and ``plugins/platforms/`` +and assert that its ``connect()`` method accepts an ``is_reconnect`` +keyword. Doing this via AST (rather than importing) avoids pulling every +platform's optional third-party SDK (aiohttp, slack_sdk, telegram, +matrix-nio, etc.) into the test environment. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Directories that hold platform adapters. Each entry is a directory +# whose immediate children are either ``adapter.py`` files or +# sub-packages that expose one. +ADAPTER_ROOTS = [ + REPO_ROOT / "gateway" / "platforms", + REPO_ROOT / "plugins" / "platforms", +] + + +def _iter_adapter_files() -> list[Path]: + """Every ``*adapter*.py`` under the two adapter roots. + + We intentionally cast a wide net (any ``adapter.py`` / ``*_adapter.py`` + inside these trees) so a new platform can't sneak in without the + contract check firing. + """ + files: list[Path] = [] + for root in ADAPTER_ROOTS: + if not root.is_dir(): + continue + for path in root.rglob("*.py"): + if path.name == "adapter.py" or path.stem.endswith("_adapter"): + files.append(path) + return sorted(files) + + +def _find_adapter_classes(module: ast.Module) -> list[ast.ClassDef]: + """Classes that look like a platform adapter. + + Heuristic: any class whose name ends in ``Adapter`` and that defines + an ``async def connect`` method. This catches every subclass of + ``BasePlatformAdapter`` in the tree today (QQAdapter, TelegramAdapter, + SlackAdapter, …) without importing the base class. + """ + hits: list[ast.ClassDef] = [] + for node in ast.walk(module): + if not isinstance(node, ast.ClassDef): + continue + if not node.name.endswith("Adapter"): + continue + has_connect = any( + isinstance(item, ast.AsyncFunctionDef) and item.name == "connect" + for item in node.body + ) + if has_connect: + hits.append(node) + return hits + + +def _connect_accepts_is_reconnect(cls: ast.ClassDef) -> bool: + """True iff the class's own ``connect()`` accepts ``is_reconnect``. + + Accepts the kwarg via: + - keyword-only argument named ``is_reconnect`` (the canonical form + used by ``BasePlatformAdapter``), OR + - ``**kwargs`` catch-all (also safe — the kwarg is absorbed). + """ + for item in cls.body: + if not (isinstance(item, ast.AsyncFunctionDef) and item.name == "connect"): + continue + args = item.args + if any(a.arg == "is_reconnect" for a in args.kwonlyargs): + return True + if any(a.arg == "is_reconnect" for a in args.args): + return True + if args.kwarg is not None: # **kwargs + return True + return False + return False + + +ADAPTER_FILES = _iter_adapter_files() + + +def test_adapter_discovery_finds_platforms(): + """Sanity: the discovery walker actually found a meaningful set of + adapters. If this drops to a trivial number, the glob broke and the + contract test below is silently passing on nothing. + """ + assert len(ADAPTER_FILES) >= 20, ( + f"Expected to discover >=20 platform adapter files under " + f"{[str(p) for p in ADAPTER_ROOTS]}, found {len(ADAPTER_FILES)}. " + f"The discovery glob is likely broken." + ) + + [redacted:email]( + "adapter_file", + ADAPTER_FILES, + ids=lambda p: str(p.relative_to(REPO_ROOT)), +) +def test_adapter_connect_accepts_is_reconnect(adapter_file: Path): + """Every ``*Adapter.connect()`` must accept ``is_reconnect``. + + This is the contract enforced by ``BasePlatformAdapter.connect`` and + relied on by ``GatewayRunner._call_adapter_connect``. Violating it + silently disables the affected platform after its first reconnect. + """ + source = adapter_file.read_text(encoding="utf-8") + try: + tree = ast.parse(source, filename=str(adapter_file)) + except SyntaxError as exc: + pytest.fail(f"Could not parse {adapter_file}: {exc}") + + classes = _find_adapter_classes(tree) + if not classes: + pytest.skip( + f"{adapter_file.relative_to(REPO_ROOT)} has no *Adapter class " + f"with an async connect() — nothing to check." + ) + + offenders = [cls.name for cls in classes if not _connect_accepts_is_reconnect(cls)] + + assert not offenders, ( + f"{adapter_file.relative_to(REPO_ROOT)}: the following adapter " + f"class(es) define `async def connect()` WITHOUT accepting the " + f"`is_reconnect` kwarg: {offenders}. " + f"Add `*, is_reconnect: bool = False` to the signature " + f"(matching BasePlatformAdapter.connect). " + f"The gateway reconnect watcher forwards this kwarg on every " + f"retry — an adapter that rejects it silently disconnects after " + f"the first outage." + ) -
tests/gateway/test_qqbot.py@@ -190,6 +190,21 @@ def test_connect_uses_redirect_guard_hook(self): assert kwargs.get("follow_redirects") is True assert kwargs.get("event_hooks", {}).get("response") == [_ssrf_redirect_guard] + def test_connect_accepts_is_reconnect_param(self): + """connect() must accept is_reconnect for interface conformance with + the base adapter, which the reconnect watcher calls with + is_reconnect=True.""" + from gateway.platforms.qqbot import QQAdapter + + adapter = QQAdapter(_make_config(app_id="a", client_secret="b")) + adapter._ensure_token = [redacted:secret-assignment]"stop after client init")) + + # Both forms must not raise TypeError. + connected_default = asyncio.run(adapter.connect()) + connected_explicit = asyncio.run(adapter.connect(is_reconnect=True)) + assert connected_default is False + assert connected_explicit is False + # --------------------------------------------------------------------------- # WebSocket proxy handling
-
-
#52911 fix(relay): accept is_reconnect kwarg in RelayAdapter.connect
files:
gateway/relay/adapter.py,tests/gateway/relay/test_relay_adapter.py
15 PRs closed without merging
From the close comments: duplicate 5 · done elsewhere 4 · superseded by another PR 3 · merged via another change 2 · stale or abandoned 1
- #52922 fix(qqbot): add missing is_reconnect parameter to QQAdapter.connect() · stale or abandoned
-
#52966 fix(qqbot): add is_reconnect param to QQAdapter.connect for gateway reconnect compat · merged via another change →
#61767 -
#53540 fix(qqbot): add missing is_reconnect kwarg to QQAdapter.connect() · done elsewhere →
276542c -
#54719 fix(qqbot): accept is_reconnect kwarg in QQAdapter.connect (closes #54679) · duplicate →
#52922 -
#55419 fix(qqbot): accept is_reconnect kwarg in QQAdapter.connect() · duplicate →
#52966,#52914,#53540,#53546,#53948,#54029,#54037,#54165,#52922
Show 10 more
-
#55494 fix(qqbot): accept is_reconnect kwarg in connect() to fix reconnect · superseded by another PR →
#52966 -
#57266 fix(qqbot): accept is_reconnect kwarg in connect() to match base class contract · superseded by another PR →
#52966 -
#57537 fix(qqbot): accept is_reconnect kwarg in connect() to match base adapter interface · done elsewhere →
276542c,#61767,#52966,#52914 -
#58341 fix(qqbot): add is_reconnect kwarg to QQAdapter.connect() · done elsewhere →
276542c,8416545,#61767 - #58726 fix(dashboard): confine fs preview and git diff reads to the managed root · merged via another change
-
#59317 fix(qqbot): accept is_reconnect kwarg in connect() matching base-class contract (#59272) · done elsewhere →
276542c -
#59436 fix(qqbot): accept is_reconnect kwarg in connect() to match base class signature · duplicate →
#52966,#52914,#52922,#53546,#54029,#54547,#57105 -
#59462 fix(qqbot): accept base-class is_reconnect kwarg in connect() (#57671) · superseded by another PR →
#52966 -
#60641 fix(qqbot): add is_reconnect parameter to QQAdapter.connect() · duplicate →
#52966,#60635,#52914 -
#61298 fix(qqbot): add missing is_reconnect parameter to connect() · duplicate →
#52966,#52922,#54977,#53948,#54037,#60161
Closed issues · 11
- Closed#52914[Bug]: fix(qqbot): QQBot adapter.connect() missing is_reconnect parameter causes infinite retry loop
- Closed#53443fix(qqbot): QQAdapter.connect() missing is_reconnect kwarg causes TypeError on every gateway startup
- Closed#54410[Bug]: hermes agent have help me: QQAdapter.connect() got an unexpected keyword argument 'is_reconnect'
- Closed#54679[Bug]: fix(gateway): QQAdapter.connect() does not accept is_reconnect parameter on reconnect
- Closed#57252QQBot adapter: connect() receives unexpected 'is_reconnect' keyword from gateway
- Closed#57663QQ Bot adapter connect() missing is_reconnect parameter
- Closed#57671[Bug]: QQ Bot adapter connect() missing is_reconnect parameter
- Closed#58049v0.18.0: QQBot adapter fails to connect - QQAdapter.connect() missing is_reconnect parameter
- Closed#58646QQ bot adapter startup failure: QQAdapter.connect() got an unexpected keyword argument 'is_reconnect'
- Closed#59272fix(qqbot): QQAdapter.connect() missing is_reconnect parameter — TypeError on reconnect
- Closed#60635fix(qqbot): QQAdapter.connect() missing is_reconnect parameter, crashes on reconnect
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab