diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index 592d8eb08..f3400a7cc 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -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) diff --git a/app/models/automation_rule_pending_execution.rb b/app/models/automation_rule_pending_execution.rb index 17e642aff..a8e3a6dc7 100644 --- a/app/models/automation_rule_pending_execution.rb +++ b/app/models/automation_rule_pending_execution.rb @@ -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}"