You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(whatsapp): add ACK timeout sweep and retry - #1384
Fix#1344 and address Qodo review feedback on PR #1384:
Add ACK timeout sweep (ACK_TIMEOUT_MS = 5 minutes, ACK_SWEEP_INTERVAL_MS = 60 seconds) marking stale outbound 'queued', 'seen', or 'typing' rows as 'failed' with error = 'ack_timeout', while excluding active in-flight sends and starting the ACK timeout window when socket submission finishes.
Sweep unfinished pre-restart / unsubmitted outbound rows on startup in initFromDb() without prematurely sweeping runtime-submitted rows within the 5-minute ACK window.
Allow late server ACKs in messages.update (status 2/3/4) to promote 'failed' rows to 'sent', 'delivered', or 'read', including ACKs referencing prior retry attempt external_message_id values.
Add in-place retry (retryMessage / POST /api/whatsapp/messages/:id/retry and UI Retry button on the Sent tab with localized status toast) that blocks in Offline mode, honours feat: add WhatsApp message templates, automated rules, and safety controls #1315 safety controls, associates recentBodies with messageId after validation passes, and preserves rate-limit tracking on timeout sweep.
What Changed
Add a 5-minute WhatsApp ACK timeout sweep (sweepAckTimeouts, running every 60s while connected, on socket open, during scheduled automation sweeps, and on initFromDb() startup for unfinished pre-restart or unsubmitted rows) that marks non-in-flight outbound queued, seen, or typing rows as failed with error = 'ack_timeout' after refreshing typing_at when socket submission completes.
Update advanceStatus and messages.update handling so late server ACKs (status 2/3/4, including prior retry-attempt external_message_id values) promote failed rows to sent, delivered, or read, clear error and failed_at, backfill earlier status timestamps, and ignore lower-rank out-of-order updates.
Add in-place outbound retry (retryMessage, POST /api/whatsapp/messages/:messageId/retry, and a Sent-tab Retry action in the WhatsApp dashboard) that coalesces concurrent retries, enforces Offline mode and outbound safety controls without self-blocking on the retried row, and documents and tests the timeout, late-ACK, startup-sweep, and retry contracts.
Risk Assessment
✅ Low: The ACK timeout sweep, startup sweep, late ACK promotion, and in-place retry changes are well-bounded, preserve rate-limit and duplicate-body invariants across edge cases, enforce Offline mode and permission checks, and have comprehensive behavioral test coverage.
Testing
Ran the focused WhatsApp service, schema, middleware, RTL, and offline-mode test suites, then stood up an isolated FloCafe server and drove all 7 ACK timeout, startup sweep, late server ACK, and in-place retry scenarios live against the Express API, SQLite database, and Playwright Chromium on the /whatsapp Sent tab, capturing 6 UI screenshots and a full JSON verification transcript.
Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario
Result
Live
Evidence
Outbound message without a server ACK remains in typing inside the 5-minute window and is swept to failed (ack_timeout) after ACK_TIMEOUT_MS while preserving external_message_id and duplicate-body / r…
Active in-flight outbound send is excluded from sweepAckTimeouts while socket.sendMessage is pending, and typing_at is refreshed when socket submission finishes so the 5-minute ACK timeout window star…
✅ pass
live
~/.no-mistakes/evidence/01M5X46FX1KZ7AXZAS3Q7WYPPS/whatsapp-ack-timeout-live-verification.json; npm run test:whatsapp-service
Startup initFromDb() sweeps unfinished pre-restart queued and typing outbound rows to failed (ack_timeout) without prematurely sweeping runtime-submitted rows within the 5-minute ACK window
✅ pass
live
~/.no-mistakes/evidence/01M5X46FX1KZ7AXZAS3Q7WYPPS/whatsapp-ack-timeout-live-verification.json; npm run test:whatsapp-service
Late Baileys messages.update server ACKs (status 2/3/4) promote failed rows to sent, delivered, or read (including via a prior retry attempt external_message_id), backfill timestamps via COALESCE, and…
Staff clicks Retry on a failed outbound row in the WhatsApp Sent tab (POST /api/whatsapp/messages/:id/retry), showing a localized Queued toast and re-sending the message in place without creating dupl…
Retrying a message is blocked in Offline mode (503 offline_mode API, localized Offline mode toast, and disabled Retry button in UI), rejects non-failed rows (400 not_failed), and enforces reports.view…
Retrying an outbound message enforces #1315 safety controls (quiet hours, weekly automated cap, and duplicate-body check against other recent messages) while allowing self-retry and not poisoning rece…
✅ pass
live
~/.no-mistakes/evidence/01M5X46FX1KZ7AXZAS3Q7WYPPS/whatsapp-ack-timeout-live-verification.json; npm run test:whatsapp-service
Evidence: Live API, database, and UI verification transcript for all 7 WhatsApp ACK timeout and retry scenarios
Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario
Result
Live
Evidence
Outbound message without a server ACK remains in typing inside the 5-minute window and is swept to failed (ack_timeout) after ACK_TIMEOUT_MS while preserving external_message_id and duplicate-body / r…
Active in-flight outbound send is excluded from sweepAckTimeouts while socket.sendMessage is pending, and typing_at is refreshed when socket submission finishes so the 5-minute ACK timeout window star…
✅ pass
live
~/.no-mistakes/evidence/01M5X46FX1KZ7AXZAS3Q7WYPPS/whatsapp-ack-timeout-live-verification.json; npm run test:whatsapp-service
Startup initFromDb() sweeps unfinished pre-restart queued and typing outbound rows to failed (ack_timeout) without prematurely sweeping runtime-submitted rows within the 5-minute ACK window
✅ pass
live
~/.no-mistakes/evidence/01M5X46FX1KZ7AXZAS3Q7WYPPS/whatsapp-ack-timeout-live-verification.json; npm run test:whatsapp-service
Late Baileys messages.update server ACKs (status 2/3/4) promote failed rows to sent, delivered, or read (including via a prior retry attempt external_message_id), backfill timestamps via COALESCE, and…
Staff clicks Retry on a failed outbound row in the WhatsApp Sent tab (POST /api/whatsapp/messages/:id/retry), showing a localized Queued toast and re-sending the message in place without creating dupl…
Retrying a message is blocked in Offline mode (503 offline_mode API, localized Offline mode toast, and disabled Retry button in UI), rejects non-failed rows (400 not_failed), and enforces reports.view…
Retrying an outbound message enforces #1315 safety controls (quiet hours, weekly automated cap, and duplicate-body check against other recent messages) while allowing self-retry and not poisoning rece…
✅ pass
live
~/.no-mistakes/evidence/01M5X46FX1KZ7AXZAS3Q7WYPPS/whatsapp-ack-timeout-live-verification.json; npm run test:whatsapp-service
npm run test:whatsapp-service
npm run test:whatsapp-schema
npm run test:whatsapp-middleware
npm run test:rtl-kds-server-whatsapp
npm run test:offline-mode
Live FloCafe server + Playwright Chromium verification on /whatsapp Sent tab and /api/whatsapp/* (whatsapp-ack-timeout-live-verification.json)
Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.
• Fail outbound messages left awaiting an ACK, while accepting late and out-of-order ACKs.
• Retry failed messages in place through a protected API and Sent-tab action.
• Cover timeout and retry behavior with isolated tests, and document the new workflow.
Diagram
graph TD
A["Sent tab"] --> B["Retry API"] --> C{"Safety passed?"} --> D["Message row"] --> E["WhatsApp socket"] --> F["ACK handler"]
G["Timeout sweep"] -->|mark failed| D
F -->|promote status| D
D -->|refresh| A
Loading
High-Level Assessment
The following are alternative approaches to this PR:
1. Create a new row for each retry
➕ Preserves a separate record of every send attempt.
➖ Requires grouping attempts in message history and complicates duplicate prevention and ACK attribution.
2. Schedule a timer per pending message
➕ Could detect timeouts closer to the five-minute threshold.
➖ Adds timer lifecycle and restart-recovery complexity for each message.
Recommendation: Keep the periodic sweep and in-place retry: they fit the existing message-history model and make concurrent retries easier to coalesce. If per-attempt audit history becomes a requirement, add a separate attempt record rather than duplicating message rows.
Files changed (6) +882 / -45
Enhancement (2) +139 / -4
page.tsxOffer retry for failed Sent-tab messages+68/-2
Offer retry for failed Sent-tab messages
• Adds a retry action with loading and availability states, maps retry errors to feedback, and polls sent messages so status changes appear without a manual refresh.
whatsapp.tsRecover timed-out sends and retry failed rows in place+428/-41
Recover timed-out sends and retry failed rows in place
• Adds startup and periodic ACK timeout sweeps, late-ACK status promotion, and coalesced retries that reset an existing failed row. Retries retain sending safeguards, exclude their own row from relevant limits, and respect Offline mode.
whatsapp-service.test.tsCover ACK timeout, late ACK, and retry behavior+302/-0
Cover ACK timeout, late ACK, and retry behavior
• Adds mocked-socket regression cases for timeout boundaries, startup recovery, late and out-of-order ACKs, Offline mode, quiet hours, and concurrent in-place retries without duplicate sends.
sweepAckTimeouts() marks an old typing row failed without checking whether its original send is
still in flight. When that send takes longer than five minutes, a retry can wait on its recipient
lock, then requeue and transmit the same row after the original send completes.
+ UPDATE whatsapp_messages+ SET status = 'failed', error = 'ack_timeout', failed_at = ?+ WHERE id = ? AND direction = 'outbound' AND status IN ('queued', 'seen', 'typing')+ AND datetime(COALESCE(typing_at, seen_at, queued_at)) <= datetime(?)
ⓘ Recommendations generated based on similar findings in past PRs
Evidence
The sweep selects rows solely by status and transition time. The send remains in typing across
asynchronous socket operations, while retry uses the same per-recipient lock and requires only that
the row still be failed when it acquires that lock.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
A send still running at the timeout can be marked failed and retried after its original transmission succeeds.
## Fix Focus Areas
- main/services/whatsapp.ts[872-916]
- main/services/whatsapp.ts[1963-2000]
- main/services/whatsapp.ts[2038-2073]
## Recommended Fix
Track which message IDs have an active send and exclude them from the ACK sweep until the send attempt completes. Start the ACK timeout when submission finishes, then allow retry only after that attempt is no longer active.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
2. Late delivery receipts vanish after retry✓ Resolved
Description
The retry update sets external_message_id to NULL before the new attempt has an ID, discarding
the only stored link to the first submission. If an ACK for that submission arrives during or after
retry, findMessageByExternalId() cannot find the row, so its delivery status is never recorded.
ⓘ Recommendations generated based on similar findings in past PRs
Evidence
ACK lookup searches only the current external_message_id. Retry clears that column, and the new ID
is not stored until the later socket.sendMessage call returns.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
Retry discards the original external message ID, making late ACKs for the first attempt unmatchable.
## Fix Focus Areas
- main/services/whatsapp.ts[1963-2000]
- main/services/whatsapp.ts[924-929]
- main/services/whatsapp.ts[1110-1132]
## Recommended Fix
Persist external IDs for prior attempts, for example in an attempt-to-row mapping, and resolve ACKs through that mapping. Define how an ACK from an earlier attempt updates a row while a newer attempt is underway.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
3. Retries can repeat another recent send✓ Resolved
Description
sendMessageInternal() skips isDuplicateBody() entirely for retries and calls
recordRecentBody() instead. If an older failed row is retried after a separate message with the
same body was recently sent to that phone, the retry bypasses the duplicate-body safety check that
would reject a new send.
ⓘ Recommendations generated based on similar findings in past PRs
Evidence
Normal sends consult the per-phone recent-body cache and reject matches. The new retry branch does
not consult it, even when its matching entry came from another message.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
Retry bypasses duplicate-body protection for messages other than the retried row.
## Fix Focus Areas
- main/services/whatsapp.ts[629-658]
- main/services/whatsapp.ts[1909-1919]
## Recommended Fix
Make duplicate tracking identify its source message or attempt. Exclude only the retried message's own prior entry when checking a retry, and continue rejecting matching bodies from other recent sends.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
4. A rejected retry blocks a later send✓ Resolved
Description
sendMessageInternal() records a retry's body in recentBodies before completing bill and
milestone validation. If the retry is rejected because its bill is no longer paid, a subsequent new
message with the same body and phone is treated as a duplicate despite no retry being sent.
ⓘ Recommendations generated based on similar findings in past PRs
Evidence
The new retry branch inserts the body into the cache before the unpaid-bill return. Later normal
sends check that cache and return content_blocked on a matching body.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
A rejected retry leaves a recent-body entry that blocks a later send.
## Fix Focus Areas
- main/services/whatsapp.ts[1909-1919]
- main/services/whatsapp.ts[1921-1959]
- main/services/whatsapp.ts[1963-2020]
## Recommended Fix
Move retry body recording until the checks that can reject the attempt have passed, and remove its entry if submission subsequently fails before a message is sent.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
sweepAckTimeouts() calls clearRecentSendTracking() for a failed row, removing its body from
recentBodies and unconditionally deleting the phone’s lastSendByPhone timestamp, even if that
timestamp belongs to a newer send. When an ACK is lost despite delivery, an ordinary non-retry send
can repeat the body within the 10-minute window; if an older row times out after a newer send, the
next send also bypasses the newer send’s 30-second minimum gap.
ⓘ Recommendations generated based on similar findings in past PRs
Evidence
clearRecentSendTracking removes the body from recentBodies and deletes the phone’s sole
lastSendByPhone entry, which a newer successful send may have overwritten and the minimum-gap
check reads. The retry path at lines 1909-1919 does not call isDuplicateBody, so clearing this
state is unnecessary for retries and instead disables duplicate-body and minimum-gap protections for
ordinary sends.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
The ACK timeout sweep removes duplicate-body tracking and a per-phone latest-send timestamp when a row times out. An ACK may be lost after delivery, and an older row’s timeout may also erase the timestamp for a newer send.
## Fix Focus Areas
- main/services/whatsapp.ts[660-668]
- main/services/whatsapp.ts[904-916]
- main/services/whatsapp.ts[2074-2082]
## Recommended Fix
Remove the `clearRecentSendTracking(row.phone_e164, row.body)` call from the sweep loop: retries already bypass `isDuplicateBody`, and leaving the tracking intact preserves protection for ordinary sends, including the latest timestamp set by a newer message. If nothing else uses `clearRecentSendTracking`, delete it; if the sweep no longer needs `phone_e164` and `body`, drop them from its SELECT.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
View medium (1) 6. Turning Offline mode off fails messages sent seconds ago 🐞 Bug≡ Correctness
Description
initFromDb() sets includeAllUnfinished when the socket is null and no send locks remain, causing
sweepAckTimeouts to skip its five-minute cutoff for outbound rows awaiting an ACK. This happens
both on startup and when Offline mode is turned off after suspendForOfflineMode() clears the
socket, so a message submitted moments earlier can be marked failed/ack_timeout and offered for
retry even if it was delivered.
ⓘ Recommendations generated based on similar findings in past PRs
Evidence
The settings route calls whatsapp.initFromDb() when Offline mode is switched off, after
suspendForOfflineMode() sets the socket to null; sendMessageWithLock also clears its send lock
after a send finishes. Those conditions enable includeAllUnfinished, whose sweep query has no age
condition. The send path can leave a submitted message unfinished while recording its external ID,
and retryMessage accepts failed rows, so the same premature timeout can occur on startup or
through the Offline-mode-off path and make a possibly delivered message retryable.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
`initFromDb()` can mark recently submitted messages awaiting an ACK as `ack_timeout` and retryable because `includeAllUnfinished` bypasses the age cutoff. This affects both process startup and the runtime path that turns Offline mode off.
## Fix Focus Areas
- main/services/whatsapp.ts[872-903]
- main/services/whatsapp.ts[3160-3177]
- main/routes/settings.ts[1740-1744]
## Recommended Fix
Apply the normal ACK age cutoff to submitted messages on every `initFromDb()` call, including startup and the Offline-mode-off path. If startup requires unconditional cleanup, limit it to rows known to have been interrupted before submission; distinguish the first process-start call from later initialization with a module-level flag or an explicit startup option rather than inferring startup from socket and lock state.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
7. Retry success says a receipt was sent✓ Resolved
Description
retrySentMessage shows tSend('success'), which reads "Receipt sent via WhatsApp.", for every
kind of retried row (manual replies, follow-ups, Z-reports). A successful retry also only means the
message was handed to the socket; the ACK is still pending. Staff get a toast that misnames the
message and overstates delivery, even though the row may time out again.
Success toast is receipt-specific and overstates delivery; correcting the message is a deterministic
UX fix.
ⓘ Recommendations generated based on similar findings in past PRs
Evidence
The whatsapp.send.success translation is receipt-specific. sendMessageInternal returns `ok:
true straight after socket.sendMessage`, without waiting for the ACK.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
The retry action reuses the receipt-specific success message, and it claims the message was sent when it has only been queued for sending.
## Fix Focus Areas
- frontend/src/app/(dashboard)/whatsapp/page.tsx[509-511]
- frontend/src/lib/i18n/messages/en.json[3408-3418]
## Recommended Fix
Add a key such as `whatsapp.sent.retryQueued` ("Message re-queued for sending.") to every locale file, and use it in `retrySentMessage` in place of `tSend('success')`.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Context sources
⚠️ Tickets: not configured — No jira, linear integration is connected for the linked tickets
khaira777
changed the title
fix(whatsapp): add ACK timeout sweep and retry
fix(whatsapp): add ACK timeout sweep and in-place message retry
Oct 26, 2026
khaira777
changed the title
fix(whatsapp): add ACK timeout sweep and in-place message retry
fix(whatsapp): add ACK timeout sweep and retry
Oct 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix #1344 and address Qodo review feedback on PR #1384:
What Changed
sweepAckTimeouts, running every 60s while connected, on socket open, during scheduled automation sweeps, and oninitFromDb()startup for unfinished pre-restart or unsubmitted rows) that marks non-in-flight outboundqueued,seen, ortypingrows asfailedwitherror = 'ack_timeout'after refreshingtyping_atwhen socket submission completes.advanceStatusandmessages.updatehandling so late server ACKs (status2/3/4, including prior retry-attemptexternal_message_idvalues) promotefailedrows tosent,delivered, orread, clearerrorandfailed_at, backfill earlier status timestamps, and ignore lower-rank out-of-order updates.retryMessage,POST /api/whatsapp/messages/:messageId/retry, and a Sent-tab Retry action in the WhatsApp dashboard) that coalesces concurrent retries, enforces Offline mode and outbound safety controls without self-blocking on the retried row, and documents and tests the timeout, late-ACK, startup-sweep, and retry contracts.Risk Assessment
✅ Low: The ACK timeout sweep, startup sweep, late ACK promotion, and in-place retry changes are well-bounded, preserve rate-limit and duplicate-body invariants across edge cases, enforce Offline mode and permission checks, and have comprehensive behavioral test coverage.
Testing
Ran the focused WhatsApp service, schema, middleware, RTL, and offline-mode test suites, then stood up an isolated FloCafe server and drove all 7 ACK timeout, startup sweep, late server ACK, and in-place retry scenarios live against the Express API, SQLite database, and Playwright Chromium on the /whatsapp Sent tab, capturing 6 UI screenshots and a full JSON verification transcript.
Evidence: Live API, database, and UI verification transcript for all 7 WhatsApp ACK timeout and retry scenarios
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
npm run test:whatsapp-servicenpm run test:whatsapp-schemanpm run test:whatsapp-middlewarenpm run test:rtl-kds-server-whatsappnpm run test:offline-modeLive FloCafe server + Playwright Chromium verification on /whatsapp Sent tab and /api/whatsapp/* (whatsapp-ack-timeout-live-verification.json)✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.