Version
v2026.4.14 (regression from v2026.3.13-1)
Summary
Every WhatsApp socket reconnect now re-delivers all pending delivery-queue entries in full, including entries that are currently being delivered for the first time. On installations where the 30-minute inbound-silence watchdog fires regularly, this causes every outbound cron message to be sent 7–12 times.
Steps to reproduce
- Configure a cron job with
announce or isolated-agent delivery targeting WhatsApp.
- Ensure the WhatsApp watchdog fires at its 30-minute timeout during a cron delivery window (i.e., no inbound messages are received to reset the timer).
- Observe the cron fires at a scheduled time — the outbound message is sent N+1 times, where N is the number of reconnects that occur while the original delivery is in-flight.
Root cause
In login-BmQuD5Uw.js (v2026.4.14), drainReconnectQueue was replaced by drainPendingDeliveries with this selectEntry:
// v2026.4.14 — NEW (broken)
drainPendingDeliveries({
drainKey: `whatsapp:${normalizedAccountId}`,
selectEntry: (entry) => ({
match: entry.channel === "whatsapp" &&
normalizeReconnectAccountId(entry.accountId) === normalizedAccountId,
bypassBackoff: isNoListenerReconnectError(entry.lastError)
})
})
The old drainReconnectQueue (now a deprecated shim) required entry.lastError to match "No active WhatsApp Web listener" before replaying an entry. A fresh entry with no lastError would not match and was left alone.
The new code matches any pending WhatsApp entry. Combined with isEntryEligibleForRecoveryRetry returning { eligible: true } for fresh entries (retryCount === 0 && lastAttemptAt === undefined), a delivery that was just enqueued milliseconds ago is immediately picked up and re-delivered by the drain — while the original deliverOutboundPayloadsCore call is still running. There is no in-memory claim held by the original delivery path, so claimRecoveryEntry does not protect against this.
Evidence
Railway logs for Apr 19–21 show two distinct burst patterns per cron window for the same destination JID hash:
13:00:20.860 Sending message → sha256:3e25bf2f15db ← original delivery
13:00:21.267 Sending message → sha256:3e25bf2f15db ← drain burst (4 sends in ~80ms)
13:00:21.289 Sending message → sha256:3e25bf2f15db
13:00:21.304 Sending message → sha256:3e25bf2f15db
13:00:21.320 Sending message → sha256:3e25bf2f15db
13:00:31.257 Sending message → sha256:3e25bf2f15db ← second drain (reconnect #2)
13:00:31.272 Sending message → sha256:3e25bf2f15db
13:00:31.287 Sending message → sha256:3e25bf2f15db
Each burst has its own correlationId, confirming they are separate sendMessageWhatsApp invocations, not retries within a single send.
The cron/runs/*.jsonl records show only one delivered: true entry per run — the duplicate sends are invisible to the cron scheduler because the drain's deliver() call enqueues and acks a new entry, leaving no trace in run records.
Proposed fix
Restore the guard that prevents fresh entries from being drain-eligible:
selectEntry: (entry) => ({
match: entry.channel === "whatsapp" &&
normalizeReconnectAccountId(entry.accountId) === normalizedAccountId &&
(entry.retryCount > 0 || entry.lastAttemptAt !== undefined), // ← don't drain in-flight entries
bypassBackoff: isNoListenerReconnectError(entry.lastError)
})
Or add a minimum-age guard in isEntryEligibleForRecoveryRetry so entries younger than a configurable threshold (e.g. 10s) are not yet drain-eligible.
Workaround
Pin to v2026.3.13-1.
Version
v2026.4.14 (regression from v2026.3.13-1)
Summary
Every WhatsApp socket reconnect now re-delivers all pending delivery-queue entries in full, including entries that are currently being delivered for the first time. On installations where the 30-minute inbound-silence watchdog fires regularly, this causes every outbound cron message to be sent 7–12 times.
Steps to reproduce
announceor isolated-agent delivery targeting WhatsApp.Root cause
In
login-BmQuD5Uw.js(v2026.4.14),drainReconnectQueuewas replaced bydrainPendingDeliverieswith thisselectEntry:The old
drainReconnectQueue(now a deprecated shim) requiredentry.lastErrorto match"No active WhatsApp Web listener"before replaying an entry. A fresh entry with nolastErrorwould not match and was left alone.The new code matches any pending WhatsApp entry. Combined with
isEntryEligibleForRecoveryRetryreturning{ eligible: true }for fresh entries (retryCount === 0 && lastAttemptAt === undefined), a delivery that was just enqueued milliseconds ago is immediately picked up and re-delivered by the drain — while the originaldeliverOutboundPayloadsCorecall is still running. There is no in-memory claim held by the original delivery path, soclaimRecoveryEntrydoes not protect against this.Evidence
Railway logs for Apr 19–21 show two distinct burst patterns per cron window for the same destination JID hash:
Each burst has its own
correlationId, confirming they are separatesendMessageWhatsAppinvocations, not retries within a single send.The
cron/runs/*.jsonlrecords show only onedelivered: trueentry per run — the duplicate sends are invisible to the cron scheduler because the drain'sdeliver()call enqueues and acks a new entry, leaving no trace in run records.Proposed fix
Restore the guard that prevents fresh entries from being drain-eligible:
Or add a minimum-age guard in
isEntryEligibleForRecoveryRetryso entries younger than a configurable threshold (e.g. 10s) are not yet drain-eligible.Workaround
Pin to v2026.3.13-1.