chatwoot/app/models/concerns/auto_assignment_handler.rb
Tanmay Deep Sharma 3cd8cf43ce
fix: atomically claim conversation to prevent duplicate assignment (#14495)
## Description

Fixes a bug under Assignment V2 where a single conversation could be
reassigned dozens of times in a row by the system, producing long stacks
of "Assigned to X by Automation System via <policy>" activity messages
alternating between agents. After this change each unassigned
conversation is assigned exactly once, even on busy inboxes.

## Fixes # (issue)


## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

## How to reproduce
1. Enable `assignment_v2` on an account with at least 2 online agents in
an inbox.
2. Generate sustained resolve/snooze activity in the inbox (each one
enqueues `AutoAssignment::AssignmentJob` for the whole inbox).
3. Watch any one unassigned conversation while the jobs drain — pre-fix
it picks up multiple back-to-back "Assigned to …" activity rows
alternating between agents.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-21 16:14:28 +05:30

50 lines
2.0 KiB
Ruby

module AutoAssignmentHandler
extend ActiveSupport::Concern
include Events::Types
included do
after_save :run_auto_assignment
end
private
def run_auto_assignment
# Assignment V2: Also trigger assignment when conversation is resolved or snoozed,
# bypassing the open-only condition so the AssignmentJob can redistribute capacity.
return unless conversation_status_changed_to_open? || conversation_status_changed_to_resolved_or_snoozed?
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
# Coalesces bursts of triggers per inbox. Fine if the job runs even when the
# surrounding save rolls back: it only scans the inbox's current unassigned
# conversations, so running it for an uncommitted change is harmless.
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
else
# Use legacy assignment system
# If conversation has a team, only consider team members for assignment
allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
end
end
def conversation_status_changed_to_resolved_or_snoozed?
inbox.auto_assignment_v2_enabled? && saved_change_to_status? && (resolved? || snoozed?)
end
def team_member_ids_with_capacity
return [] if team.blank? || team.allow_auto_assign.blank?
inbox.member_ids_with_assignment_capacity & team.members.ids
end
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
# Assignment V2: Resolved/snoozed conversations still have an assignee, so bypass the
# assignee-blank check below. The AssignmentJob needs to run to rebalance assignments.
return true if conversation_status_changed_to_resolved_or_snoozed?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
end
end