fix(automations): sub-second awaiting-agent keys and discard stale processing rows on edit

- awaiting_agent episode keys use sub-second (.to_f) precision like status keys,
  so a reply then re-wait within the same second is a distinct episode and the
  original armed row no longer matches the later waiting period.
- discard_stale_pending_executions now deletes armed rows (pending and stale
  processing), since the sweep reclaims stale processing rows and would otherwise
  fire them against the edited rule definition.
This commit is contained in:
Tanmay Deep Sharma
2026-07-15 13:26:37 +05:30
parent 7a8903051d
commit d55c5660a3
2 changed files with 10 additions and 5 deletions
+2 -1
View File
@@ -133,7 +133,8 @@ class AutomationRule < ApplicationRecord
end
def discard_stale_pending_executions
pending_executions.pending.delete_all
# Includes stale processing rows: the sweep would otherwise reclaim and fire them.
pending_executions.armed.delete_all
end
def validate_single_condition(condition)
@@ -42,6 +42,9 @@ class AutomationRulePendingExecution < ApplicationRecord
pending.where(due_at: ..Time.current).or(processing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago))
}
# Non-terminal rows still bound to fire (a stale processing row is reclaimed by the sweep).
scope :armed, -> { where(status: [statuses[:pending], statuses[:processing]]) }
def self.schedule(rule:, conversation:, message: nil)
key = arm_episode_key_for(conversation, message)
create!(
@@ -63,7 +66,7 @@ class AutomationRulePendingExecution < ApplicationRecord
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}"
"awaiting_agent:#{message.created_at.to_f}"
end
# Episode keys identify one qualifying stretch of conversation state; when the recomputed
@@ -73,9 +76,10 @@ 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.
# Strict here: at fire time a nil waiting_since means the agent replied (episode ended).
"awaiting_agent:#{conversation.waiting_since.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). Sub-second
# precision so a reply→re-wait inside one second is still a distinct episode.
"awaiting_agent:#{conversation.waiting_since.to_f}"
else
# A new customer message changes the max incoming id, invalidating this episode.
"reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"