# Balanced auto-assignment: implementation plan **Status:** planned, not started (2026-10-06). **Goal:** however many orders and riders there are, split the orders **equally** among the riders, with riders logging in and out all day. --- ## 0. Where things stand (context for whoever picks this up) ### Already built (2026-10-06) | Fix | Where | |---|---| | Rider search falls back to `GEORADIUS` on Redis < 6.2; `/ready` shows `redis_geo` | `internal/milergeo/` (committed `9b94be1`) | | Only **today's**, non-cancelled open orders count towards the per-rider limit | `internal/assignment/ai_layer.go` → `openStopsToday` | | Only riders with **live GPS** (default 15 min) are offered orders | `ai_layer.go` → `milerHasFreshGPS`, setting `ASSIGNMENT_MAX_GPS_AGE_MINUTES` | | Console cancel (single and bulk) **frees the rider** (closes the assignment) | `controllers/adminController.go` → `closeOpenAssignments` | | **Pending-order sweeper:** retries every unassigned pending order every 5 min (last 72 h) | `internal/assignment/sweeper.go`, started in `main.go` | | **No double assignment:** the order is claimed only if it still has no rider and isn't cancelled | `crm_assignment.go` → `claimBooking` (used by both commit paths) | Tests: `internal/assignment/eligibility_pg_test.go`, `sweeper_test.go`, `controllers/cancel_frees_rider_pg_test.go` (real-Postgres tests skip unless `REGISTRY_TEST_DSN` is set). ### How a rider is chosen today (what this plan changes) 1. Find riders within **10 km** of the pickup (Redis GEO), whose status allows work, with live GPS, holding fewer than `MILER_MAX_ACTIVE_BOOKINGS` (default **3**) of today's open orders. 2. Ask the AI service (`routemate …/decide-assignment`). That endpoint currently returns **404**, so the backend falls back to: ``` score = distance_km + 2 × open_orders_today − 0.5 × rating (lowest wins) ``` **The problem:** distance dominates, so riders near the pickups get more orders and the split is not equal. `MILER_MAX_ACTIVE_BOOKINGS` is only a ceiling and can't make it equal. **A code change is required.** ### Relevant facts found in the code - A rider **can't end duty** while holding Assigned/Accepted orders (`MilerEndDuty`). - If a rider's app dies, their orders **stay with them**: nothing releases an order a rider never accepted. (`InternalReassign` exists in `adminController.go`, but nothing calls it automatically.) - Nothing reacts when a rider **starts duty**: they only get orders from the next retry or sweep. - Each duty session is recorded in `milerdutylogs` (`loginat`, `logoutat`). - The rider app sends GPS about **every 30 s** in the background (`PUT /miler/location`). --- ## 1. The balancing rule Riders come and go, so "same number of orders **today**" isn't fair: a rider logging in at 3 PM would get *every* new order until they catch up. | Rule | With riders joining and leaving | |---|---| | Equal orders today | ❌ late joiners get flooded | | **Equal orders in hand right now** ✅ | fair at every moment; a new rider gets a fair share of *new* orders; a rider who leaves just drops out | **Rule:** each new order goes to the eligible rider holding the **fewest unfinished orders right now**. Ties are broken by: 1. fewest orders **this duty session** (since `milerdutylogs.loginat`); 2. nearest to the pickup; 3. highest rating. Balancing happens **among riders near each pickup** (10 km), so each area balances its own riders. | Situation | Expected | |---|---| | 5 riders, 50 orders | 10 each, at most 1 apart (if the ceiling allows) | | 23 orders, 5 riders | 5, 5, 5, 4, 4 | | 3 riders hold 4 each; 2 riders log in | the next 8 orders go to the 2 new riders, then everyone shares | | A rider goes offline (no GPS) | gets nothing new; the others share | **"In hand"** = assignments `Assigned`/`Accepted` on orders that aren't `Cancelled`, `Delivered` or otherwise finished. Note: for hyperlocal parcels the assignment stays open until delivery, which is correct, because the rider is still carrying it. --- ## 2. Code changes (`doormile_backend`) ### Phase A: equal split (core) · ~½ day | File / function | Change | |---|---| | `internal/assignment/ai_layer.go` → `collectEligibleCandidates` | For each eligible rider, compute **in-hand count** (replaces the today-only count for ranking; the today-only count can stay as the ceiling check) and **session count** (assignments since the latest open `milerdutylogs.loginat`). Store both on `milerCandidate` / `aiCandidate`. | | `ai_layer.go` → `selectMilerWithAI` | Before calling the AI, **keep only candidates with the minimum in-hand count**. The AI or fallback then chooses among them, so balance holds even when the AI endpoint returns. | | `ai_layer.go` → `pickBestFromCandidates` | Replace the weighted formula with ordering by in-hand ↑, session count ↑, distance ↑, rating ↓. | | `internal/assignment/crm_assignment.go` → `commitAssignment`, `customer_assignment.go` → `commitCustomerAssignment` | **Bulk safety:** 50 orders arriving at once run in parallel and would all pick the same "least-loaded" rider. Inside the commit transaction, lock the rider's `milerprofiles` row (`SELECT … FOR UPDATE`), **recount** their in-hand orders, and refuse (try the next candidate) if they're at the ceiling or no longer the least loaded. Keep `claimBooking` as is. | ### Phase B: riders joining and leaving · ~½ day | File / function | Change | |---|---| | `controllers/milerAppController.go` → `MilerStartDuty` | **Rider comes online:** after duty starts (and the GPS is indexed), trigger an immediate sweep of pending orders near them (new helper in `sweeper.go`, e.g. `SweepNear(lat, lon)`). New riders get orders within seconds, not up to 5 min. | | `internal/assignment/sweeper.go` | **Rider stops responding:** release orders that are still **Assigned** (never Accepted) after `ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES` (new, e.g. 10): close that assignment as `Reassigned`, clear `pickupbookings.assignedmileruserid`, set the status back to `Pending_Pickup`, and let the sweeper reassign. **Never release an Accepted order.** Reuse the logic of `InternalReassign` where possible. | ### Phase C: settings (server, no code) | Setting | Recommended | Purpose | |---|---|---| | `MILER_MAX_ACTIVE_BOOKINGS` | **20** on the server (code default stays 3) | safety ceiling only; balancing decides the split | | `ASSIGNMENT_MAX_GPS_AGE_MINUTES` | 15 (exists) | only riders whose app is running | | `ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES` | 10 (new, Phase B) | release orders never accepted | | `ASSIGNMENT_SWEEP_SECONDS` | 300 (exists) | pending-order retry interval | | `ASSIGNMENT_SWEEP_MAX_AGE_HOURS` | 72 (exists) | older pending orders are left alone | --- ## 3. Tests (real Postgres, same pattern as `eligibility_pg_test.go`) 1. 5 riders, 50 orders assigned one by one → 10 each, max − min ≤ 1. 2. **50 orders created concurrently** → still balanced, no rider over the ceiling, every order exactly 1 rider. 3. 3 busy riders + 2 who just started duty → new orders go to the newcomers until level. 4. A rider with stale GPS gets nothing new. 5. An order still Assigned after the timeout is released and goes to the least-loaded rider; an **Accepted** order is never released. 6. Riders outside 10 km are never used. 7. A tie on in-hand count is broken by session count, then distance, then rating. 8. Starting duty triggers assignment of nearby pending orders (Phase B). Plus a unit test for the new ranking (`pickBestFromCandidates`) and the new setting parser. --- ## 4. Rollout 1. Deploy **Phase A** with `MILER_MAX_ACTIVE_BOOKINGS=20`. For one day, compare orders per rider (should be within 1 of each other among riders in the same area). 2. Deploy **Phase B**. 3. If riders are sent too far, add a cap such as "prefer balance, but not more than X km further than the nearest eligible rider" (`ASSIGNMENT_BALANCE_MAX_EXTRA_KM`). --- ## 5. Unchanged The 10 km radius, the live-GPS rule, cancel freeing the rider, the pending-order sweeper, `claimBooking`, manual assignment (`AssignMilerToBooking`), hub batch assign (`HubBatchAssign`, its own cap of 5), the express dispatch agent, and the customer-app flow. --- ## 6. Open points (decide before or during the work) - **Distance vs balance:** do you want the extra-km cap from rollout step 3 from day one? - **The AI endpoint** (`/api/v1/doormile/decide-assignment` on `routemate.workolik.com`) is missing. Once restored, it will choose only among the least-loaded riders (Phase A guarantees this). - **Hub batch assign** has its own greedy logic and cap (5). Should it use the same balancing later?