From f7e2b8ff5fa6ff882223967719a4427603fa45ab Mon Sep 17 00:00:00 2001 From: Hunter Date: Mon, 2 Feb 2026 16:36:43 -0600 Subject: [PATCH] fix(discord): autoThread race condition when multiple agents mentioned When multiple agents with autoThread:true are @mentioned in the same message, only the first agent successfully creates a thread. Subsequent agents fail because Discord only allows one thread per message. Previously, the failure was silently caught and the agent would fall back to replying in the parent channel. Now, when thread creation fails, the code re-fetches the message and checks for an existing thread (created by another agent). If found, the agent replies in that thread instead of falling back. Fixes #7508 --- src/discord/monitor/threading.test.ts | 68 +++++++++++++++++++++++++++ src/discord/monitor/threading.ts | 18 ++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts index 2b59bc4536..d00c7f416c 100644 --- a/src/discord/monitor/threading.test.ts +++ b/src/discord/monitor/threading.test.ts @@ -2,6 +2,7 @@ import type { Client } from "@buape/carbon"; import { describe, expect, it } from "vitest"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { + maybeCreateDiscordAutoThread, resolveDiscordAutoThreadContext, resolveDiscordAutoThreadReplyPlan, resolveDiscordReplyDeliveryPlan, @@ -112,6 +113,73 @@ describe("resolveDiscordReplyDeliveryPlan", () => { }); }); +describe("maybeCreateDiscordAutoThread", () => { + it("returns existing thread ID when creation fails due to race condition", async () => { + // First call succeeds (simulating another agent creating the thread) + let callCount = 0; + const client = { + rest: { + post: async () => { + callCount++; + throw new Error("A thread has already been created on this message"); + }, + get: async () => { + // Return message with existing thread (simulating race condition resolution) + return { thread: { id: "existing-thread" } }; + }, + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBe("existing-thread"); + }); + + it("returns undefined when creation fails and no existing thread found", async () => { + const client = { + rest: { + post: async () => { + throw new Error("Some other error"); + }, + get: async () => { + // Message has no thread + return { thread: null }; + }, + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBeUndefined(); + }); +}); + describe("resolveDiscordAutoThreadReplyPlan", () => { it("switches delivery + session context to the created thread", async () => { const client = { diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 962e7cd76b..470962aaf8 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -358,8 +358,24 @@ export async function maybeCreateDiscordAutoThread(params: { return createdId || undefined; } catch (err) { logVerbose( - `discord: autoThread failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`, + `discord: autoThread creation failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`, ); + // Race condition: another agent may have already created a thread on this + // message. Re-fetch the message to check for an existing thread. + try { + const msg = (await params.client.rest.get( + Routes.channelMessage(params.message.channelId, params.message.id), + )) as { thread?: { id?: string } }; + const existingThreadId = msg?.thread?.id ? String(msg.thread.id) : ""; + if (existingThreadId) { + logVerbose( + `discord: autoThread reusing existing thread ${existingThreadId} on ${params.message.channelId}/${params.message.id}`, + ); + return existingThreadId; + } + } catch { + // If the refetch also fails, fall through to return undefined. + } return undefined; } }