From 7a8903051d6b0e0b5c2241fbfc0599f078329496 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 15 Jul 2026 13:07:37 +0530 Subject: [PATCH] fix(automations): make awaiting-agent race fix asymmetric to preserve reply cancellation The earlier waiting_since fallback in episode_key_for also matched at fire time, so an agent reply (which clears waiting_since) no longer ended the episode and the rule could fire anyway. Apply the created_at fallback only at arm time (arm_episode_key_for); keep episode_key_for strict so a nil waiting_since at fire time still means the agent replied and the episode is over. --- .../automation_rule_pending_execution.rb | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/models/automation_rule_pending_execution.rb b/app/models/automation_rule_pending_execution.rb index 3ee3bdc2e..17e642aff 100644 --- a/app/models/automation_rule_pending_execution.rb +++ b/app/models/automation_rule_pending_execution.rb @@ -43,7 +43,7 @@ class AutomationRulePendingExecution < ApplicationRecord } def self.schedule(rule:, conversation:, message: nil) - key = episode_key_for(conversation, message) + key = arm_episode_key_for(conversation, message) create!( automation_rule: rule, conversation: conversation, account_id: conversation.account_id, message_id: message&.id, episode_key: key, due_at: rule.execution_delay.minutes.from_now @@ -57,6 +57,15 @@ class AutomationRulePendingExecution < ApplicationRecord row.update!(due_at: rule.execution_delay.minutes.from_now, message_id: message.id) if row.pending? end + # waiting_since is written just after MESSAGE_CREATED dispatches, so it can still be nil when + # an awaiting-agent episode arms. It becomes the starting message's created_at, so use that + # here; the strict fire-time key (episode_key_for) then matches once waiting_since is settled. + def self.arm_episode_key_for(conversation, message) + return episode_key_for(conversation, message) unless message&.incoming? && conversation.waiting_since.blank? + + "awaiting_agent:#{message.created_at.to_i}" + end + # Episode keys identify one qualifying stretch of conversation state; when the recomputed # key no longer matches, the episode ended and the pending action is cancelled at fire time. def self.episode_key_for(conversation, message) @@ -64,10 +73,9 @@ class AutomationRulePendingExecution < ApplicationRecord # Sub-second precision so a resolve→reopen inside one second still ends the episode. "status:#{(conversation.status_changed_at.presence || conversation.created_at).to_f}" elsif message.incoming? - # waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. It is - # written just after MESSAGE_CREATED dispatches, so it can still be nil when this arms; - # fall back to the message's own created_at, which is exactly the value it becomes. - "awaiting_agent:#{(conversation.waiting_since.presence || message.created_at).to_i}" + # waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. + # Strict here: at fire time a nil waiting_since means the agent replied (episode ended). + "awaiting_agent:#{conversation.waiting_since.to_i}" else # A new customer message changes the max incoming id, invalidating this episode. "reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"