fix(announce): use deterministic idempotency keys to prevent duplicate subagent announces

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
This commit is contained in:
Marcus Widing
2026-02-15 14:32:41 +01:00
committed by Gustavo Madeira Santana
parent 7ea14a1c87
commit 40e12e81f8

View File

@@ -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,