Compare commits

...

2 Commits

Author SHA1 Message Date
Shadow
c65a74ac73 fix: handle discord auto-thread race (#7517) (thanks @rohanpatriot) 2026-02-13 12:38:34 -06:00
Hunter
bf09ecbadd 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
2026-02-13 12:29:41 -06:00
3 changed files with 143 additions and 1 deletions

View File

@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
- Discord: avoid misrouting numeric guild allowlist entries to `/channels/<guildId>` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
- Discord: reuse an auto-created thread when multiple agents race to create it, so replies stay in the shared thread. (#7517) Thanks @rohanpatriot.
- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow.
- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow.
- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.

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,74 @@ describe("resolveDiscordReplyDeliveryPlan", () => {
});
});
describe("maybeCreateDiscordAutoThread", () => {
it("returns existing thread ID when creation fails due to race condition", async () => {
let getCalls = 0;
const client = {
rest: {
post: async () => {
throw new Error("A thread has already been created on this message");
},
get: async () => {
getCalls += 1;
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");
expect(getCalls).toBe(1);
});
it("returns undefined when creation fails with non-race error", async () => {
let getCalls = 0;
const client = {
rest: {
post: async () => {
throw new Error("Some other error");
},
get: async () => {
getCalls += 1;
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();
expect(getCalls).toBe(0);
});
});
describe("resolveDiscordAutoThreadReplyPlan", () => {
it("switches delivery + session context to the created thread", async () => {
const client = {

View File

@@ -226,6 +226,57 @@ export function sanitizeDiscordThreadName(rawName: string, fallbackId: string):
return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`;
}
const DISCORD_THREAD_ALREADY_EXISTS_HINTS = [
"thread has already been created on this message",
"thread already exists",
];
function extractDiscordErrorMessage(err: unknown): string {
if (!err) {
return "";
}
if (typeof err === "string") {
return err;
}
if (err instanceof Error && typeof err.message === "string") {
return err.message;
}
if (typeof err === "object" && err !== null && "message" in err) {
const message = (err as { message?: unknown }).message;
if (typeof message === "string") {
return message;
}
}
if (typeof err === "number" || typeof err === "boolean") {
return err.toString();
}
if (typeof err === "bigint") {
return err.toString();
}
if (typeof err === "symbol") {
return err.description ?? "symbol";
}
if (typeof err === "function") {
return err.name ? `function ${err.name}` : "function";
}
if (typeof err === "object") {
try {
return JSON.stringify(err) ?? "";
} catch {
return "";
}
}
return "";
}
function isDiscordThreadAlreadyExistsError(err: unknown): boolean {
const message = extractDiscordErrorMessage(err).trim().toLowerCase();
if (!message) {
return false;
}
return DISCORD_THREAD_ALREADY_EXISTS_HINTS.some((hint) => message.includes(hint));
}
type DiscordReplyDeliveryPlan = {
deliverTarget: string;
replyTarget: string;
@@ -358,8 +409,29 @@ 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)}`,
);
if (!isDiscordThreadAlreadyExistsError(err)) {
return undefined;
}
// 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 (refetchErr) {
logVerbose(
`discord: autoThread refetch failed for ${params.message.channelId}/${params.message.id}: ${String(refetchErr)}`,
);
}
return undefined;
}
}