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

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 54bba3cea1
Co-authored-by: widingmarcus-cyber <245375637+widingmarcus-cyber@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Marcus Widing
2026-02-15 16:34:34 +01:00
committed by GitHub
parent 7ea14a1c87
commit ade11ec892
5 changed files with 132 additions and 3 deletions

View File

@@ -1,4 +1,3 @@
import crypto from "node:crypto";
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
import { loadConfig } from "../config/config.js";
import {
@@ -16,6 +15,11 @@ import {
mergeDeliveryContext,
normalizeDeliveryContext,
} from "../utils/delivery-context.js";
import {
buildAnnounceIdFromChildRun,
buildAnnounceIdempotencyKey,
resolveQueueAnnounceId,
} from "./announce-idempotency.js";
import {
isEmbeddedPiRunActive,
queueEmbeddedPiMessage,
@@ -113,6 +117,15 @@ async function sendAnnounce(item: AnnounceQueueItem) {
const origin = item.origin;
const threadId =
origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined;
// Share one announce identity across direct and queued delivery paths so
// gateway dedupe suppresses true retries without collapsing distinct events.
const idempotencyKey = buildAnnounceIdempotencyKey(
resolveQueueAnnounceId({
announceId: item.announceId,
sessionKey: item.sessionKey,
enqueuedAt: item.enqueuedAt,
}),
);
await callGateway({
method: "agent",
params: {
@@ -123,7 +136,7 @@ async function sendAnnounce(item: AnnounceQueueItem) {
to: requesterIsSubagent ? undefined : origin?.to,
threadId: requesterIsSubagent ? undefined : threadId,
deliver: !requesterIsSubagent,
idempotencyKey: crypto.randomUUID(),
idempotencyKey,
},
timeoutMs: 15_000,
});
@@ -163,6 +176,7 @@ function loadRequesterSessionEntry(requesterSessionKey: string) {
async function maybeQueueSubagentAnnounce(params: {
requesterSessionKey: string;
announceId?: string;
triggerMessage: string;
summaryLine?: string;
requesterOrigin?: DeliveryContext;
@@ -199,6 +213,7 @@ async function maybeQueueSubagentAnnounce(params: {
enqueueAnnounce({
key: canonicalKey,
item: {
announceId: params.announceId,
prompt: params.triggerMessage,
summaryLine: params.summaryLine,
enqueuedAt: Date.now(),
@@ -543,8 +558,13 @@ export async function runSubagentAnnounceFlow(params: {
replyInstruction,
].join("\n");
const announceId = buildAnnounceIdFromChildRun({
childSessionKey: params.childSessionKey,
childRunId: params.childRunId,
});
const queued = await maybeQueueSubagentAnnounce({
requesterSessionKey: targetRequesterSessionKey,
announceId,
triggerMessage,
summaryLine: taskLabel,
requesterOrigin: targetRequesterOrigin,
@@ -565,6 +585,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 = buildAnnounceIdempotencyKey(announceId);
await callGateway({
method: "agent",
params: {
@@ -578,7 +602,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,