Summary
Inbound-message automations are started inside the webhook processing callback but are not awaited. The Promise registered with Next.js after() can therefore resolve while triggered automations are still running.
Environment
- Repository version:
0.8.0
- Commit:
b8677608015790194c14ab7c85943ce26c7f9ef0
- Reproduction level: route-level Vitest characterization using synthetic data
- Repeated executions: 3
Steps to reproduce
A focused characterization test:
- Mocks
runAutomationsForTrigger() with controlled pending Promises.
- Delivers one valid synthetic inbound WhatsApp message to the exported webhook
POST handler.
- Captures and awaits the callback registered through Next.js
after().
- Checks the automation state immediately after that callback resolves.
npm test -- src/app/api/whatsapp/webhook/route.automation-lifecycle-repro.test.ts
No production credentials, phone numbers, customer content, or external services are used.
Expected behavior
When the callback registered with after() resolves, all automation work started for that inbound delivery should have settled, including rejection handling.
Actual behavior
The callback resolves after starting three automation dispatches while all controlled automation Promises remain pending:
afterCallbackResolved: true
automationStarted: true
automationCompleted: false
automationDispatches: 3
The result was identical in three consecutive executions.
Technical evidence
The automation loop starts runAutomationsForTrigger() and attaches only a rejection callback, without awaiting the returned Promise:
|
// Fire any automations that react to this webhook event. All dispatches |
|
// run here (not earlier) so the contact, conversation, and inbound |
|
// message all exist before any step — including send_message — runs. |
|
// Fire-and-forget: a slow or failing automation must not block the |
|
// webhook's 200 OK response to Meta. |
|
const inboundText = contentText ?? message.text?.body ?? '' |
|
const automationTriggers: ( |
|
| 'new_contact_created' |
|
| 'first_inbound_message' |
|
| 'new_message_received' |
|
| 'keyword_match' |
|
| 'interactive_reply' |
|
)[] = [] |
|
// Content-level triggers are suppressed when a flow consumed the |
|
// message — see the comment block above. |
|
if (!flowConsumed) { |
|
automationTriggers.push('new_message_received', 'keyword_match') |
|
// Interactive tap → fire the interactive_reply trigger too (only |
|
// meaningful when a button/list reply actually arrived). Enables |
|
// automation-only chained menus; when a Flow owns the menu it will |
|
// have consumed the reply and this is skipped. |
|
if (interactiveReplyId) { |
|
automationTriggers.push('interactive_reply') |
|
} |
|
} |
|
// new_contact_created fires only when the webhook just auto-created the |
|
// contact row. first_inbound_message fires whenever this is the contact's |
|
// first-ever customer-sent message — a superset that also catches |
|
// manually-imported contacts sending for the first time. We dispatch both |
|
// so users can pick whichever semantic they want; an automation that |
|
// listens to only one trigger runs only when that trigger matches. |
|
if (contactOutcome.wasCreated) automationTriggers.unshift('new_contact_created') |
|
if (isFirstInboundMessage) automationTriggers.unshift('first_inbound_message') |
|
for (const triggerType of automationTriggers) { |
|
runAutomationsForTrigger({ |
|
accountId, |
|
triggerType, |
|
contactId: contactRecord.id, |
|
context: { |
|
message_text: inboundText, |
|
conversation_id: conversation.id, |
|
// Only set on interactive taps; drives the interactive_reply |
|
// trigger's exact-id match. |
|
interactive_reply_id: interactiveReplyId ?? undefined, |
|
}, |
|
}).catch((err) => console.error('[automations] dispatch failed:', err)) |
|
} |
In the same handler, AI processing is awaited:
|
// AI auto-reply. Runs only for plain-text inbound the deterministic |
|
// flow runner did NOT consume (flows win over the LLM), and only when |
|
// the account has enabled it. Awaited inside `after()` (same reason as |
|
// the webhook dispatch below); `dispatchInboundToAiReply` owns its |
|
// eligibility gates + try/catch and never throws. |
|
if (!flowConsumed && !interactiveReplyId && inboundText.trim()) { |
|
await dispatchInboundToAiReply({ |
|
accountId, |
|
conversationId: conversation.id, |
|
contactId: contactRecord.id, |
|
configOwnerUserId, |
|
}) |
|
} |
Public webhook delivery is also awaited, with a comment explicitly explaining that detached work inside after() may be frozen before it completes:
|
// message.received webhook (public API). Awaited — not fire-and-forget |
|
// — because we're inside the route's `after()` block, which only keeps |
|
// the function alive for promises it can see; a detached promise could |
|
// be frozen before it delivers. `dispatchWebhookEvent` early-exits |
|
// when the account has no matching endpoint and never throws. |
|
// (conversation.created is emitted earlier, right after the thread is |
|
// opened.) |
|
await dispatchWebhookEvent(supabaseAdmin(), accountId, 'message.received', { |
|
conversation_id: conversation.id, |
|
contact_id: contactRecord.id, |
|
whatsapp_message_id: message.id, |
|
content_type: contentType, |
|
text: contentText, |
|
}) |
Impact
On runtimes that stop or freeze work after the Promise registered with after() resolves, inbound-triggered automations can be interrupted even though the webhook was acknowledged successfully. This can produce intermittent missing automation actions and incomplete logs without causing Meta to retry the delivery.
Scope and limitations
This reproduction confirms the Promise lifecycle boundary at the route-logic/unit-integration level. Supabase, Meta, and downstream services are mocked. It proves that automation Promises are not included in the work tracked by after(); it does not measure how frequently a deployment platform interrupts that work in production.
Suggested acceptance criteria
- The Promise registered with
after() does not resolve until every triggered automation settles.
- One failing automation does not prevent other matching automations from completing.
- Rejections remain observable in logs without becoming unhandled rejections.
- A regression test covers multiple pending automation dispatches and a rejected dispatch.
Summary
Inbound-message automations are started inside the webhook processing callback but are not awaited. The Promise registered with Next.js
after()can therefore resolve while triggered automations are still running.Environment
0.8.0b8677608015790194c14ab7c85943ce26c7f9ef0Steps to reproduce
A focused characterization test:
runAutomationsForTrigger()with controlled pending Promises.POSThandler.after().No production credentials, phone numbers, customer content, or external services are used.
Expected behavior
When the callback registered with
after()resolves, all automation work started for that inbound delivery should have settled, including rejection handling.Actual behavior
The callback resolves after starting three automation dispatches while all controlled automation Promises remain pending:
The result was identical in three consecutive executions.
Technical evidence
The automation loop starts
runAutomationsForTrigger()and attaches only a rejection callback, without awaiting the returned Promise:wacrm/src/app/api/whatsapp/webhook/route.ts
Lines 751 to 797 in b867760
In the same handler, AI processing is awaited:
wacrm/src/app/api/whatsapp/webhook/route.ts
Lines 799 to 811 in b867760
Public webhook delivery is also awaited, with a comment explicitly explaining that detached work inside
after()may be frozen before it completes:wacrm/src/app/api/whatsapp/webhook/route.ts
Lines 813 to 826 in b867760
Impact
On runtimes that stop or freeze work after the Promise registered with
after()resolves, inbound-triggered automations can be interrupted even though the webhook was acknowledged successfully. This can produce intermittent missing automation actions and incomplete logs without causing Meta to retry the delivery.Scope and limitations
This reproduction confirms the Promise lifecycle boundary at the route-logic/unit-integration level. Supabase, Meta, and downstream services are mocked. It proves that automation Promises are not included in the work tracked by
after(); it does not measure how frequently a deployment platform interrupts that work in production.Suggested acceptance criteria
after()does not resolve until every triggered automation settles.