Ownership and round-robin
Ownership answers "whose list does this appear on?". Getting it wrong either buries a lead with an inactive rep or floods one rep while others idle.
Implementation:
libs/morpheus-data-layer/morpheus_data_layer/repositories/sales_rep_round_robin.py,
plus the ownership block in each re-engagement handler.
The two properties
| HubSpot contact property | Meaning |
|---|---|
|
The record’s actual owner. Written only when Morpheus deliberately reassigns. |
|
Who this call is routed to. Can differ from the record owner — for example a deal’s BDR handling a no-show while the record stays with its original owner. |
Both hold a CRM owner id from GET /crm/v3/owners, mirrored locally on
sales_reps.hubspotOwnerId.
The reassignment test
Every re-engagement handler runs the same three-part test. Ownership is sticky unless it is not real:
needs_owner = not current_owner or current_owner == DIALER_FALLBACK_HUBSPOT_OWNER_ID
if not needs_owner:
needs_owner = _rep_repo.get_active_by_hubspot_owner_id(
session, current_owner, sub_id
) is None
| Case | Interpretation |
|---|---|
No owner |
Nobody is working this contact — assign one |
Owner is |
The AI fallback owner. A record "owned" by the dialer is effectively unowned by a human. |
Owner is not an active |
A departed or deactivated rep. Their leads must not be stranded. |
Otherwise |
A real, active rep owns this. Leave it alone. |
When reassignment happens, both hubspot_owner_id and call_assigned_to are set to
the new rep. When it does not, hubspot_owner_id is not written at all — this is
the guard that stops automation from stealing a human’s contacts.
The AI fallback owner
DIALER_FALLBACK_HUBSPOT_OWNER_ID = "86817215"
(lambdas/morpheus_hubspot/utils/constants.py, duplicated as AI_OWNER_ID in
lambdas/phone_agent/utils/constants.py).
It plays three roles:
-
Owner of last resort when the dialer sync cannot find any qualifying rep.
-
Owner of every AI call engagement — always, even when the contact and follow-up task belong to a human rep, so the CRM records who actually made the call.
-
A sentinel that means "not a human". Every re-engagement segment treats it as unowned, and the email-click segment uses it to decide that a prior call does not suppress a human follow-up.
That third role is why the constant appears in five different handlers. Changing it requires changing every reference, and the HubSpot portal’s owner list.
Round-robin selection
select_next_sales_rep_round_robin(session, subscription_id, *, require_nooks):
flowchart TD
A["list active reps\nrequire_hubspot=True\nrequire_nooks=?"] --> B{"any reps?"}
B -->|no| B1["log warning, return None"]
B -->|yes| C["pg_advisory_xact_lock(subscription hash)"]
C --> D["read cursor from configs\ndialer_follow_up_rep_rr:<subscriptionId>"]
D --> E["chosen = reps[cursor % len(reps)]"]
E --> F["write cursor + 1"]
F --> G["return chosen\n(lock released at commit)"]
Concurrency safety
The cursor is a row in configs, so two Lambdas running the same handler could read
it simultaneously and pick the same rep. A PostgreSQL transaction-scoped advisory
lock prevents that:
key = int.from_bytes(subscription_id.bytes[0:8], "big", signed=False) % (2**63)
session.execute(text("SELECT pg_advisory_xact_lock(:k)"), {"k": key})
The lock key is derived from the first eight bytes of the subscription UUID, so
different tenants never block each other. Being transaction-scoped, the lock is
released automatically when the caller’s get_session() block commits — there is
no unlock call to forget.
The cursor is advanced in the same transaction as the read, so the increment is atomic with the selection.
Eligibility
list_active_for_subscription() filters on:
-
isActive = true -
require_hubspot=True— always, since the point is to assign a HubSpot owner, sohubspotOwnerIdmust be present -
require_nooks— when true, also requiresnooksUserId
require_nooks=True is used only by the dialer sync, and only for Nooks sequence
enrolment. That handler tries require_nooks=True first and falls back to
require_nooks=False, so a missing Nooks configuration degrades to HubSpot-only
assignment rather than failing.
Every re-engagement handler passes require_nooks=False.
No reps available
An empty pool logs at WARNING and returns None. Handlers diverge on what happens
next:
| Handler | Behaviour with no rep |
|---|---|
Email click, supplier, no-show, lost deal |
Log a warning naming the contact and continue. The contact is still enrolled on a list — with no assignee — so the enrolment is not lost. |
|
Log at |
|
Retry without the Nooks requirement, then fall back to owner |
Cursor state
SELECT key, value FROM configs WHERE key LIKE 'dialer_follow_up_rep_rr%';
The key embeds the subscription id (dialer_follow_up_rep_rr:<uuid>) because
configs.key is globally unique, not unique per subscription. Resetting the value
is harmless — it only changes who is next.
|
Fairness is over selections, not over workload. The cursor advances once per
assignment regardless of how much work each contact represents, and adding or
removing an active rep changes |
Deal-owner routing
Two segments prefer the deal’s owner over round-robin, because a deal already has a human accountable for it:
| Segment | Routing |
|---|---|
No-show |
The deal’s BDR owner ( |
Lost deal |
Same pattern, using the deal’s owner. |
In both cases the deal owner still has to pass the active-rep test. A deal owned by a departed rep falls through to round-robin.
Sales rep record
sales_reps column |
Purpose |
|---|---|
|
Unique together — the same person can be a rep in multiple workspaces with different CRM ids |
|
Optional link to a Supabase user; a rep can exist before they have an account |
|
CRM owner id — assigns contacts and tasks |
|
Nooks user id — assigns Nooks tasks and sequences |
|
HubSpot list id used for the rep’s sequences |
|
Today’s dynamic call list, rewritten daily by |
|
The eligibility flag for every selector on this page |
Deactivating a rep (isActive = false) has three immediate effects: they drop out of
round-robin, their existing contacts become reassignable on the next re-engagement
run, and they stop receiving a daily call list.