fix(outbound): hold active-delivery claim so reconnect drain skips live sends#70428
Conversation
Greptile SummaryThis PR fixes a 7-12x outbound duplication bug on WhatsApp cron sends by adding an in-memory active-delivery claim ( Confidence Score: 5/5Safe to merge — the fix is correctly scoped, the release is properly guarded in All findings are P2 or lower. The residual narrow race (the window between No files require special attention. Reviews (1): Last reviewed commit: "fix(outbound): hold active-delivery clai..." | Re-trigger Greptile |
|
Addressed both bot reviews. Pushed Response to Aisle (Medium, CWE-362)This finding is correct. The scenario is:
Fix in const heldActiveClaim = queueId ? tryClaimActiveDelivery(queueId) : false;
if (queueId && !heldActiveClaim) {
return [];
}Rationale for the specific choice of "return
New regression test in
I also reset the two new mocks in the Response to Greptile (5/5)Confirming the read. The claim/release is guarded in Tests run
Scope guardrails
|
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🟡 Process-local delivery claim held across potentially unbounded network send operations (recovery/drain DoS)
Description
Because the claim is held while Impact:
Vulnerable flow (claim spans awaits to network-dependent sends): const claimResult = await withActiveDeliveryClaim(queueId, () =>
deliverOutboundPayloadsWithQueueCleanup(params, queueId),
);Inside RecommendationEnsure the active-delivery claim cannot be held indefinitely by unbounded network operations. Options (combine as appropriate):
import { withTimeout } from "./timeouts"; // example helper
const claimResult = await withActiveDeliveryClaim(queueId, () =>
withTimeout(
deliverOutboundPayloadsWithQueueCleanup(params, queueId),
cfg.outboundSendTimeoutMs ?? 30_000,
),
);
These changes ensure Analyzed PR: #70428 at commit Last updated on: 2026-04-23T02:22:54Z |
…ve sends Reconnect drain (drainPendingDeliveries) matches fresh pending entries by design to preserve crash-replay, but the live delivery path in deliverOutboundPayloads held no in-memory claim while the send was running. A reconnect firing mid-send therefore re-drove the same queue entry and produced duplicate outbound messages (e.g. WhatsApp cron sends going out 7-12x when the 30-minute inbound-silence watchdog fired during delivery). Claim the queueId against the existing entriesInProgress set right after enqueueDelivery and release it in the finally branch around ack/fail. Drain already skips claimed ids via claimRecoveryEntry, so no drain-side change is needed. The claim is process-local on purpose: a crashed owner leaves no claim behind, so startup recovery still reclaims orphaned entries. Fixes openclaw#70386. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
da9e138 to
b71f2f1
Compare
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on #70428. Made-with: Cursor
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b71f2f110d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (claimResult.status === "claimed-by-other-owner") { | ||
| return []; |
There was a problem hiding this comment.
Avoid returning empty success when queue claim is already held
When withActiveDeliveryClaim returns claimed-by-other-owner, this path returns [] as if delivery simply produced no results. That creates a regression for callers that treat empty results as failure (for example src/gateway/server-methods/send.ts throws on no result), so in the enqueue/claim race with reconnect drain a message can be delivered by the drain while the original API call still fails, prompting client retries and possible duplicate user-visible sends. This should return a distinct state/error or synchronize with the active owner instead of an empty successful result.
Useful? React with 👍 / 👎.
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw/openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw/openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
If a reconnect/startup drain observes the newly enqueued queue entry and calls claimRecoveryEntry before the live delivery path reaches tryClaimActiveDelivery, tryClaimActiveDelivery returns false. Previously the live path still proceeded to deliverOutboundPayloadsCore and then ack/fail, which would race the drain's own delivery and ack/fail for the same entry id and produce duplicate outbound messages. Treat a failed claim acquisition as "another in-process owner is already handling this queue entry" and bail out with an empty result array, leaving the queue entry in place for the drain to deliver and clean up. This closes the narrow residual race called out by the Aisle security review on openclaw#70428. Made-with: Cursor
Summary
Fixes #70386. Reconnect drain was re-driving the same queue entry while the
original
deliverOutboundPayloadswas still mid-send, producing 7-12xoutbound duplication on WhatsApp cron sends whenever the 30-minute
inbound-silence watchdog fired during a delivery window.
Root cause
drainPendingDeliveriesis intentionally allowed to replay fresh entries(
retryCount === 0 && lastAttemptAt === undefined) to preserve crash-replay.That contract was safe while the reconnect drain previously required a
specific
"No active ... listener"lastErroron the entry; once the drainselector was widened to match any pending entry for the reconnecting account,
nothing prevented it from firing against an entry whose original
deliverOutboundPayloadscaller was still executing. The live delivery pathpersisted via
enqueueDeliverybut never held an in-memory claim onqueueIdduring the send, so a concurrent reconnect passedclaimRecoveryEntry, passedisEntryEligibleForRecoveryRetry(fresh entriesare drain-eligible by design), and invoked
deliver(...)a second (or Nth)time — once per reconnect that occurred inside the live send window.
Cron run records showed only one
delivered: trueper scheduled run becausethe drain's
deliver()enqueues and acks a new entry path, leaving novisible audit in the scheduler.
Fix
Claim
queueIdagainst the existingentriesInProgressset immediatelyafter
enqueueDelivery, and release in thefinallyblock that wrapsack/fail in
deliverOutboundPayloads. Drain already consults the same setvia
claimRecoveryEntryand skips claimed ids with an"already being recovered" log, so no drain-side logic change is needed.
Two new thin exports on
delivery-queue.ts:tryClaimActiveDelivery(entryId)— returnsfalseif already claimed.releaseActiveDelivery(entryId)— pair infinally.Why the fix is safe
Set. A crashed owner leaves no claimbehind, so
recoverPendingDeliverieson the next startup reclaims anyorphaned entries exactly as before — crash replay for fresh entries is
preserved intentionally.
"already being recovered") forin-progress recovery callers; this reuses it and adds a second legitimate
owner: the live delivery path.
claimRecoveryEntry/releaseRecoveryEntryis unchanged; the new helpers are a stable public wrapper over the same
set so other callers (live sends) can participate without importing a
private symbol.
live send in flight), run the drain, assert
deliveris not called andthe "already being recovered" skip log is emitted; after release, a
follow-up drain delivers exactly once.
Security / runtime controls unchanged
structurally via the in-memory claim set, not by message text, metadata,
or any LLM-facing behavior.
src/plugin-sdk/infra-runtime.ts) selectivelyre-exports only
drainPendingDeliveries; the two new helpers aredeliberately not re-exposed there, so third-party plugin contracts and
docs/.generated/plugin-sdk-api-baseline.*are intentionally untouched.transcript mirroring, and best-effort / abort behavior are all
unmodified — the claim wraps the existing ack/fail flow in a
finallywithout changing any of its branches.
Tests
Added regression test:
src/infra/outbound/delivery-queue.reconnect-drain.test.ts→ "skips entries that an in-flight live delivery has actively claimed".
Exact commands run locally:
pnpm test src/infra/outbound/delivery-queue.reconnect-drain.test.ts— 14 passed (includes new case).
pnpm test src/infra/outbound— 452 passed across 35 files.pnpm test src/gateway/server-restart-sentinel.test.ts src/gateway/server-runtime-services.test.ts src/plugin-sdk/infra-runtime.test.ts— the other consumers of the./delivery-queue.jsmock, 27 passed.pnpm test src/cron/isolated-agent— cron delivery-dispatch consumers ofdeliverOutboundPayloads, 222 passed across 20 files.pnpm check:changed— scoped typecheck/lint/guards green.pnpm tsgo— core prod typecheck green.The
pnpm tsgo:prodfailures on unrelatedqa-lab,qqbot, andtokenjuiceextensions reproduce onorigin/mainat the same SHA and arenot introduced by this change. Same for the pre-existing
plugin-sdk-api-baselinehash drift — this PR does not add any publicplugin-sdk surface.
Checklist
Made with Cursor