From 40e12e81f88dd00bf9eaa223185fc9796460c221 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Sun, 15 Feb 2026 14:32:41 +0100 Subject: [PATCH] fix(announce): use deterministic idempotency keys to prevent duplicate subagent announces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a subagent completes while the main session is busy, the announce is queued by the application-level announce queue (subagent-announce- queue.ts) and drained via sendAnnounce → callGateway({ method: 'agent' }). However, if the main session is still busy when the drain fires, the gateway-level message queue also captures this callGateway call, creating a second copy. Both queues eventually deliver, causing duplicate announcements. The root cause is that both sendAnnounce and the direct announce path use crypto.randomUUID() as the idempotency key, generating a unique key per call. The gateway's dedup cache (context.dedupe) can never match because each attempt has a different key. Replace the random keys with deterministic ones derived from stable identifiers: - sendAnnounce: announce:{sessionKey}:{enqueuedAt} - direct announce: announce:{childSessionKey}:{childRunId} This allows the gateway dedup cache to recognize the second delivery attempt as a duplicate and return the cached response instead. Fixes #17122 --- src/agents/subagent-announce.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 4d454a04bb7..b966af0c5e6 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,4 +1,4 @@ -import crypto from "node:crypto"; +import path from "node:path"; import { resolveQueueSettings } from "../auto-reply/reply/queue.js"; import { loadConfig } from "../config/config.js"; import { @@ -113,6 +113,13 @@ async function sendAnnounce(item: AnnounceQueueItem) { const origin = item.origin; const threadId = origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined; + // Use a deterministic idempotency key derived from the session key and + // enqueue timestamp so the gateway dedup cache catches duplicates when + // the announce is delivered via both the announce queue drain *and* the + // gateway-level message queue (which re-queues the callGateway "agent" + // call if the main session is still busy). + // See: https://github.com/openclaw/openclaw/issues/17122 + const idempotencyKey = `announce:${item.sessionKey}:${item.enqueuedAt}`; await callGateway({ method: "agent", params: { @@ -123,7 +130,7 @@ async function sendAnnounce(item: AnnounceQueueItem) { to: requesterIsSubagent ? undefined : origin?.to, threadId: requesterIsSubagent ? undefined : threadId, deliver: !requesterIsSubagent, - idempotencyKey: crypto.randomUUID(), + idempotencyKey, }, timeoutMs: 15_000, }); @@ -565,6 +572,10 @@ export async function runSubagentAnnounceFlow(params: { const { entry } = loadRequesterSessionEntry(targetRequesterSessionKey); directOrigin = deliveryContextFromSession(entry); } + // Use a deterministic idempotency key so the gateway dedup cache + // catches duplicates if this announce is also queued by the gateway- + // level message queue while the main session is busy (#17122). + const directIdempotencyKey = `announce:${params.childSessionKey}:${params.childRunId}`; await callGateway({ method: "agent", params: { @@ -578,7 +589,7 @@ export async function runSubagentAnnounceFlow(params: { !requesterIsSubagent && directOrigin?.threadId != null && directOrigin.threadId !== "" ? String(directOrigin.threadId) : undefined, - idempotencyKey: crypto.randomUUID(), + idempotencyKey: directIdempotencyKey, }, expectFinal: true, timeoutMs: 15_000,