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
This commit is contained in:
Hunter
2026-02-02 16:36:43 -06:00
committed by Shadow
parent 42bfcd9c30
commit f7e2b8ff5f
2 changed files with 85 additions and 1 deletions

View File

@@ -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 = {

View File

@@ -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;
}
}