{"repo":"NousResearch/hermes-agent","public_id":"20243","display_label":"Discord auto-thread failure falling back to inline reply","summary":"The three members share one theme: when Discord auto-thread creation fails, the bot appears to silently fall back to replying inline in the channel. One is a bug report, and two are pull requests with identical titles referencing #20243 that propose changing this behavior. Titles alone do not show the actual outcome or status.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56108,"merged_at":"2026-07-01T07:12:18+00:00"},"resolution":{"state":"resolved","by":"pr-56108"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T07:12:18+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":20243,"pr_number":56108,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-01T07:12:18+00:00"}],"members":[{"kind":"issue","number":20243,"title":"[Bug]: Discord auto-thread failure silently falls back to inline channel reply","url":null,"state":"closed"},{"kind":"pr","number":20260,"title":"fix(discord): auto-thread failure must not silently fall back to inline reply (#20243)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56108","number":56108}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":56108,"title":"fix(discord): auto-thread failure must not silently fall back to inline reply (#20243)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/discord/adapter.py","tests/gateway/test_discord_channel_controls.py","tests/gateway/test_discord_double_dispatch.py","tests/gateway/test_discord_free_response.py"],"fix_hunks":[{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -5020,7 +5020,11 @@ async def _create_thread(\n     async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n         \"\"\"Create a thread from a user message for auto-threading.\n \n-        Returns the created thread object, or ``None`` on failure.\n+        Returns the created thread object, or ``None`` on failure. Both the\n+        primary ``message.create_thread`` and the seed-message fallback are\n+        retried once after a short backoff so transient connect errors\n+        (e.g. ``Cannot connect to host discord.com:443``) don't immediately\n+        burn through to the caller's failure path (#20243).\n         \"\"\"\n         # Build a short thread name from the message. Strip Discord mention\n         # syntax (users / roles / channels) so thread titles don't end up","@@ -5035,28 +5039,44 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n         if len(content) > 80:\n             thread_name = thread_name[:77] + \"...\"\n \n-        try:\n-            thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)\n-            return thread\n-        except Exception as direct_error:\n-            display_name = getattr(getattr(message, \"author\", None), \"display_name\", None) or \"unknown user\"\n-            reason = f\"Auto-threaded from mention by {display_name}\"\n+        display_name = getattr(getattr(message, \"author\", None), \"display_name\", None) or \"unknown user\"\n+        reason = f\"Auto-threaded from mention by {display_name}\"\n+\n+        last_direct_error: Exception | None = None\n+        last_fallback_error: Exception | None = None\n+\n+        for attempt in range(2):\n             try:\n-                seed_msg = await message.channel.send(f\"\\U0001f9f5 Thread created by Hermes: **{thread_name}**\")\n-                thread = await seed_msg.create_thread(\n-                    name=thread_name,\n-                    auto_archive_duration=1440,\n-                    reason=reason,\n-                )\n+                thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)\n                 return thread\n-            except Exception as fallback_error:\n-                logger.warning(\n-                    \"[%s] Auto-thread creation failed. Direct error: %s. Fallback error: %s\",\n-                    self.name,\n-                    direct_error,\n-                    fallback_error,\n-                )\n-                return None\n+            except Exception as direct_error:\n+                last_direct_error = direct_error\n+                try:\n+                    seed_msg = await message.channel.send(\n+                        f\"\\U0001f9f5 Thread created by Hermes: **{thread_name}**\"\n+                    )\n+                    thread = await seed_msg.create_thread(\n+                        name=thread_name,\n+                        auto_archive_duration=1440,\n+                        reason=reason,\n+                    )\n+                    return thread\n+                except Exception as fallback_error:\n+                    last_fallback_error = fallback_error\n+                    if attempt == 0:\n+                        # Brief backoff before the second attempt — most failures\n+                        # in this path are transient connect errors that recover\n+                        # within a second or two.\n+                        await asyncio.sleep(0.75)\n+                        continue\n+\n+        logger.warning(\n+            \"[%s] Auto-thread creation failed after retry. Direct error: %s. Fallback error: %s\",\n+            self.name,\n+            last_direct_error,\n+            last_fallback_error,\n+        )\n+        return None\n \n     async def create_handoff_thread(\n         self,","@@ -5742,6 +5762,26 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =\n                     # event is dropped before it can trigger a second agent run.\n                     # Fixes #51057.\n                     self._dedup.is_duplicate(str(thread.id))\n+                else:\n+                    # Auto-threading is the configured routing target for this\n+                    # message; if it fails we must NOT silently fall back to an\n+                    # inline parent-channel reply (#20243). That breaks\n+                    # thread-first Discord workflows by dumping a new task into\n+                    # a shared channel. Surface a short visible error so the\n+                    # user can retry once Discord recovers, and skip agent\n+                    # invocation for this message.\n+                    try:\n+                        await message.channel.send(\n+                            \"⚠️ Hermes could not create a Discord thread for \"\n+                            \"this message, so the request was not processed. Please retry.\"\n+                        )\n+                    except Exception as notify_error:\n+                        logger.warning(\n+                            \"[%s] Failed to notify user of auto-thread failure: %s\",\n+                            self.name,\n+                            notify_error,\n+                        )\n+                    return\n \n         referenced_attachments = []\n         reference = getattr(message, \"reference\", None)"]},{"file":"tests/gateway/test_discord_channel_controls.py","hunks":["@@ -140,6 +140,11 @@ async def test_non_ignored_channel_processes_normally(adapter, monkeypatch):\n     monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"500,600\")\n     monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n \n+    # Stub auto-thread creation so this test focuses on ignored-channel\n+    # routing only — auto-thread failures now correctly skip agent invocation\n+    # (#20243), which would otherwise mask the assertion below.\n+    adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))\n+\n     message = make_message(channel=FakeTextChannel(channel_id=700), content=\"hello\")\n     await adapter._handle_message(message)\n ","@@ -167,6 +172,11 @@ async def test_ignored_channels_empty_string_ignores_nothing(adapter, monkeypatc\n     monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"\")\n     monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n \n+    # Stub auto-thread creation so this test focuses on ignored-channel\n+    # routing only — auto-thread failures now correctly skip agent invocation\n+    # (#20243), which would otherwise mask the assertion below.\n+    adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))\n+\n     message = make_message(channel=FakeTextChannel(channel_id=500), content=\"hello\")\n     await adapter._handle_message(message)\n ","@@ -281,6 +291,71 @@ async def test_no_thread_with_auto_thread_disabled_is_noop(adapter, monkeypatch)\n     adapter.handle_message.assert_awaited_once()\n \n \n+# ── auto-thread failure must not silently fall back to inline (#20243) ──\n+\n+\n[redacted:email]\n+async def test_auto_thread_failure_skips_agent_and_notifies_user(adapter, monkeypatch):\n+    \"\"\"Auto-thread creation failure must not trigger an inline parent-channel reply.\n+\n+    Before #20243, ``effective_channel = auto_threaded_channel or message.channel``\n+    silently routed the response back to the parent channel when thread creation\n+    failed, breaking thread-first Discord workflows. The fix surfaces a short\n+    visible error to the parent channel and skips agent invocation entirely so\n+    the user can retry.\n+    \"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_AUTO_THREAD\", \"true\")\n+    monkeypatch.delenv(\"DISCORD_NO_THREAD_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_IGNORED_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    adapter._auto_create_thread = AsyncMock(return_value=None)\n+\n+    channel = FakeTextChannel(channel_id=800)\n+    channel.send = AsyncMock()\n+    message = make_message(channel=channel, content=\"hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_awaited_once()\n+    # Agent must NOT be invoked when the routing target failed.\n+    adapter.handle_message.assert_not_awaited()\n+    # User gets a visible explanation in the parent channel instead of a silent\n+    # inline reply.\n+    channel.send.assert_awaited_once()\n+    sent_text = channel.send.await_args.args[0]\n+    assert \"could not create\" in sent_text.lower()\n+    assert \"thread\" in sent_text.lower()\n+\n+\n[redacted:email]\n+async def test_auto_thread_failure_notify_error_does_not_crash(adapter, monkeypatch):\n+    \"\"\"If even the failure-notification send raises, we still skip the agent.\n+\n+    ``message.channel.send`` itself can fail (the same connect issue that\n+    killed thread creation often kills plain sends too). The handler should\n+    swallow the secondary error and still avoid invoking the agent.\n+    \"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_AUTO_THREAD\", \"true\")\n+    monkeypatch.delenv(\"DISCORD_NO_THREAD_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_IGNORED_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    adapter._auto_create_thread = AsyncMock(return_value=None)\n+\n+    channel = FakeTextChannel(channel_id=800)\n+    channel.send = AsyncMock(side_effect=RuntimeError(\"Cannot connect to host discord.com:443\"))\n+    message = make_message(channel=channel, content=\"hello\")\n+\n+    # No exception must propagate.\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_awaited_once()\n+    adapter.handle_message.assert_not_awaited()\n+    channel.send.assert_awaited_once()\n+\n+\n # ── config.py bridging ───────────────────────────────────────────────\n \n "]},{"file":"tests/gateway/test_discord_double_dispatch.py","hunks":["@@ -226,11 +226,19 @@ async def fake_auto_create_thread(message):\n \n     @pytest.mark.asyncio\n     async def test_no_dedup_seed_when_thread_creation_fails(self, adapter, monkeypatch):\n-        \"\"\"When _auto_create_thread returns None, no pre-seeding occurs.\"\"\"\n+        \"\"\"When _auto_create_thread returns None, no pre-seeding occurs.\n+\n+        Auto-thread failure is now fail-closed (#20243): the agent is NOT\n+        invoked and the user gets a visible notice instead of a silent inline\n+        reply. This test's contract is specifically about dedup pre-seeding —\n+        the phantom thread id must not leak into the dedup cache when creation\n+        fails.\n+        \"\"\"\n         monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n         monkeypatch.setenv(\"DISCORD_AUTO_THREAD\", \"true\")\n \n         channel = _TextChannel(channel_id=100)\n+        channel.send = AsyncMock()\n         phantom_thread_id = 55555\n \n         async def fake_auto_create_thread_fail(message):","@@ -243,8 +251,9 @@ async def fake_auto_create_thread_fail(message):\n         user_msg = _make_message(msg_id=42, channel=channel, content=\"hello\")\n         await adapter._handle_message(user_msg)\n \n-        # The message was still dispatched (no thread, but message goes through)\n-        adapter.handle_message.assert_awaited_once()\n+        # Fail-closed: the agent must NOT run when the required thread route\n+        # could not be created (#20243).\n+        adapter.handle_message.assert_not_awaited()\n \n         # The phantom thread id should NOT be in the dedup cache\n         assert str(phantom_thread_id) not in adapter._dedup._seen, ("]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -202,6 +202,10 @@ async def test_discord_defaults_to_require_mention(adapter, monkeypatch):\n async def test_discord_free_response_in_server_channels(adapter, monkeypatch):\n     monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n     monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+    # Auto-thread failures now correctly skip agent invocation (#20243), and\n+    # FakeTextChannel has no real ``create_thread``. Disable auto-thread so the\n+    # routing assertion below stays focused on free-response gating.\n+    monkeypatch.setenv(\"DISCORD_AUTO_THREAD\", \"false\")\n \n     message = make_message(channel=FakeTextChannel(channel_id=123), content=\"hello from channel\")\n ","@@ -334,6 +338,10 @@ async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(ad\n async def test_discord_accepts_and_strips_bot_mentions_when_required(adapter, monkeypatch):\n     monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"true\")\n     monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+    # Auto-thread failures now correctly skip agent invocation (#20243).\n+    # FakeTextChannel can't satisfy the real ``create_thread`` API, so disable\n+    # auto-thread to keep this test focused on mention-strip behaviour.\n+    monkeypatch.setenv(\"DISCORD_AUTO_THREAD\", \"false\")\n \n     bot_user = adapter._client.user\n     message = make_message(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}