fix: handle discord auto-thread race (#7517) (thanks @rohanpatriot)

This commit is contained in:
Shadow
2026-02-13 12:38:34 -06:00
parent bf09ecbadd
commit c65a74ac73
3 changed files with 66 additions and 8 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

@@ -115,16 +115,14 @@ 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;
let getCalls = 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)
getCalls += 1;
return { thread: { id: "existing-thread" } };
},
},
@@ -146,16 +144,18 @@ describe("maybeCreateDiscordAutoThread", () => {
});
expect(result).toBe("existing-thread");
expect(getCalls).toBe(1);
});
it("returns undefined when creation fails and no existing thread found", async () => {
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 () => {
// Message has no thread
getCalls += 1;
return { thread: null };
},
},
@@ -177,6 +177,7 @@ describe("maybeCreateDiscordAutoThread", () => {
});
expect(result).toBeUndefined();
expect(getCalls).toBe(0);
});
});

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;
@@ -360,6 +411,9 @@ export async function maybeCreateDiscordAutoThread(params: {
logVerbose(
`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 {
@@ -373,8 +427,10 @@ export async function maybeCreateDiscordAutoThread(params: {
);
return existingThreadId;
}
} catch {
// If the refetch also fails, fall through to return undefined.
} catch (refetchErr) {
logVerbose(
`discord: autoThread refetch failed for ${params.message.channelId}/${params.message.id}: ${String(refetchErr)}`,
);
}
return undefined;
}