Skip to content

Commit 7c91d0d

Browse files
committed
fix(auto-reply): release inbound dedupe after dispatch errors
1 parent 66ea85f commit 7c91d0d

4 files changed

Lines changed: 137 additions & 2 deletions

File tree

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2692,6 +2692,54 @@ describe("dispatchReplyFromConfig", () => {
26922692
);
26932693
});
26942694

2695+
it("releases inbound dedupe when dispatch fails before completion", async () => {
2696+
setNoAbort();
2697+
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
2698+
const ctx = buildTestCtx({
2699+
Provider: "whatsapp",
2700+
OriginatingChannel: "whatsapp",
2701+
OriginatingTo: "whatsapp:+15555550124",
2702+
To: "whatsapp:+15555550124",
2703+
AccountId: "default",
2704+
MessageSid: "msg-dup-error",
2705+
SessionKey: "agent:main:whatsapp:direct:+15555550124",
2706+
CommandBody: "hello",
2707+
RawBody: "hello",
2708+
Body: "hello",
2709+
});
2710+
const replyResolver = vi
2711+
.fn<
2712+
(_ctx: MsgContext, _opts?: GetReplyOptions, _cfg?: OpenClawConfig) => Promise<ReplyPayload>
2713+
>()
2714+
.mockRejectedValueOnce(new Error("dispatch failed"))
2715+
.mockResolvedValueOnce({ text: "retry succeeds" });
2716+
2717+
await expect(
2718+
dispatchReplyFromConfig({
2719+
ctx,
2720+
cfg,
2721+
dispatcher: createDispatcher(),
2722+
replyResolver,
2723+
}),
2724+
).rejects.toThrow("dispatch failed");
2725+
2726+
await dispatchReplyFromConfig({
2727+
ctx,
2728+
cfg,
2729+
dispatcher: createDispatcher(),
2730+
replyResolver,
2731+
});
2732+
2733+
expect(replyResolver).toHaveBeenCalledTimes(2);
2734+
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
2735+
expect.objectContaining({
2736+
channel: "whatsapp",
2737+
outcome: "error",
2738+
error: "Error: dispatch failed",
2739+
}),
2740+
);
2741+
});
2742+
26952743
it("passes configOverride to replyResolver when provided", async () => {
26962744
setNoAbort();
26972745
const cfg = emptyConfig;

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import type {
6363
DispatchFromConfigParams,
6464
DispatchFromConfigResult,
6565
} from "./dispatch-from-config.types.js";
66-
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
66+
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
6767
import { resolveReplyRoutingDecision } from "./routing-policy.js";
6868
import { resolveRunTypingPolicy } from "./typing-policy.js";
6969

@@ -255,7 +255,8 @@ export async function dispatchReplyFromConfig(
255255
});
256256
};
257257

258-
if (shouldSkipDuplicateInbound(ctx)) {
258+
const inboundDedupeClaim = claimInboundDedupe(ctx);
259+
if (inboundDedupeClaim.status === "duplicate" || inboundDedupeClaim.status === "inflight") {
259260
recordProcessed("skipped", { reason: "duplicate" });
260261
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
261262
}
@@ -1032,13 +1033,19 @@ export async function dispatchReplyFromConfig(
10321033

10331034
const counts = dispatcher.getQueuedCounts();
10341035
counts.final += routedFinalCount;
1036+
if (inboundDedupeClaim.status === "claimed") {
1037+
commitInboundDedupe(inboundDedupeClaim.key);
1038+
}
10351039
recordProcessed(
10361040
"completed",
10371041
pluginFallbackReason ? { reason: pluginFallbackReason } : undefined,
10381042
);
10391043
markIdle("message_completed");
10401044
return { queuedFinal, counts };
10411045
} catch (err) {
1046+
if (inboundDedupeClaim.status === "claimed") {
1047+
releaseInboundDedupe(inboundDedupeClaim.key);
1048+
}
10421049
recordProcessed("error", { error: String(err) });
10431050
markIdle("message_error");
10441051
throw err;

src/auto-reply/reply/inbound-dedupe.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,36 @@ describe("inbound dedupe", () => {
4040
inboundB.resetInboundDedupe();
4141
}
4242
});
43+
44+
it("shares claim/release state across distinct module instances", async () => {
45+
const inboundA = await importFreshModule<typeof import("./inbound-dedupe.js")>(
46+
import.meta.url,
47+
"./inbound-dedupe.js?scope=claim-a",
48+
);
49+
const inboundB = await importFreshModule<typeof import("./inbound-dedupe.js")>(
50+
import.meta.url,
51+
"./inbound-dedupe.js?scope=claim-b",
52+
);
53+
54+
inboundA.resetInboundDedupe();
55+
inboundB.resetInboundDedupe();
56+
57+
try {
58+
const firstClaim = inboundA.claimInboundDedupe(sharedInboundContext);
59+
expect(firstClaim).toMatchObject({ status: "claimed" });
60+
expect(inboundB.claimInboundDedupe(sharedInboundContext)).toMatchObject({
61+
status: "inflight",
62+
});
63+
if (firstClaim.status !== "claimed") {
64+
throw new Error("expected claimed inbound dedupe result");
65+
}
66+
inboundB.releaseInboundDedupe(firstClaim.key);
67+
expect(inboundA.claimInboundDedupe(sharedInboundContext)).toMatchObject({
68+
status: "claimed",
69+
});
70+
} finally {
71+
inboundA.resetInboundDedupe();
72+
inboundB.resetInboundDedupe();
73+
}
74+
});
4375
});

src/auto-reply/reply/inbound-dedupe.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { logVerbose, shouldLogVerbose } from "../../globals.js";
22
import { resolveGlobalDedupeCache, type DedupeCache } from "../../infra/dedupe.js";
33
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
4+
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
45
import {
56
normalizeOptionalLowercaseString,
67
normalizeOptionalString,
@@ -15,11 +16,22 @@ const DEFAULT_INBOUND_DEDUPE_MAX = 5000;
1516
* message cannot bypass dedupe by entering through a different chunk copy.
1617
*/
1718
const INBOUND_DEDUPE_CACHE_KEY = Symbol.for("openclaw.inboundDedupeCache");
19+
const INBOUND_DEDUPE_INFLIGHT_KEY = Symbol.for("openclaw.inboundDedupeInflight");
1820

1921
const inboundDedupeCache: DedupeCache = resolveGlobalDedupeCache(INBOUND_DEDUPE_CACHE_KEY, {
2022
ttlMs: DEFAULT_INBOUND_DEDUPE_TTL_MS,
2123
maxSize: DEFAULT_INBOUND_DEDUPE_MAX,
2224
});
25+
const inboundDedupeInFlight = resolveGlobalSingleton(
26+
INBOUND_DEDUPE_INFLIGHT_KEY,
27+
() => new Set<string>(),
28+
);
29+
30+
export type InboundDedupeClaimResult =
31+
| { status: "invalid" }
32+
| { status: "duplicate"; key: string }
33+
| { status: "inflight"; key: string }
34+
| { status: "claimed"; key: string };
2335

2436
const resolveInboundPeerId = (ctx: MsgContext) =>
2537
ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? ctx.SessionKey;
@@ -79,6 +91,42 @@ export function shouldSkipDuplicateInbound(
7991
return skipped;
8092
}
8193

94+
export function claimInboundDedupe(
95+
ctx: MsgContext,
96+
opts?: { cache?: DedupeCache; now?: number; inFlight?: Set<string> },
97+
): InboundDedupeClaimResult {
98+
const key = buildInboundDedupeKey(ctx);
99+
if (!key) {
100+
return { status: "invalid" };
101+
}
102+
const cache = opts?.cache ?? inboundDedupeCache;
103+
if (cache.peek(key, opts?.now)) {
104+
return { status: "duplicate", key };
105+
}
106+
const inFlight = opts?.inFlight ?? inboundDedupeInFlight;
107+
if (inFlight.has(key)) {
108+
return { status: "inflight", key };
109+
}
110+
inFlight.add(key);
111+
return { status: "claimed", key };
112+
}
113+
114+
export function commitInboundDedupe(
115+
key: string,
116+
opts?: { cache?: DedupeCache; now?: number; inFlight?: Set<string> },
117+
): void {
118+
const cache = opts?.cache ?? inboundDedupeCache;
119+
cache.check(key, opts?.now);
120+
const inFlight = opts?.inFlight ?? inboundDedupeInFlight;
121+
inFlight.delete(key);
122+
}
123+
124+
export function releaseInboundDedupe(key: string, opts?: { inFlight?: Set<string> }): void {
125+
const inFlight = opts?.inFlight ?? inboundDedupeInFlight;
126+
inFlight.delete(key);
127+
}
128+
82129
export function resetInboundDedupe(): void {
83130
inboundDedupeCache.clear();
131+
inboundDedupeInFlight.clear();
84132
}

0 commit comments

Comments
 (0)