diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index ac7f761..0ae98c3 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -65,6 +65,11 @@ func AssignCRMMiler(bookingID int) { "booking_id", bookingID, "max_retries", maxRetries, ) + + // Terminal failure — same handoff as the B2C path. Both entry points must + // publish, or failures arriving via the CRM console stay invisible to the + // DispatchAgent. + publishAssignmentFailed(bookingID, reasonNoMilerAvailable) } // tryAssign performs a single attempt: queries Redis GEO, scores candidates, commits. diff --git a/internal/assignment/customer_assignment.go b/internal/assignment/customer_assignment.go index 1c0e72b..f8030ee 100644 --- a/internal/assignment/customer_assignment.go +++ b/internal/assignment/customer_assignment.go @@ -58,6 +58,11 @@ func AssignCustomerMiler(bookingID int) { "booking_id", bookingID, "max_retries", maxRetries, ) + + // Terminal failure — hand off to the AI layer's DispatchAgent, which owns + // what happens next (coverage sweep, escalation). Reached only after every + // retry is exhausted, so it fires at most once per booking. + publishAssignmentFailed(bookingID, reasonNoMilerAvailable) } // tryCustomerAssign performs one full attempt: GEO miler search → provider selection → commit. diff --git a/internal/assignment/failure_event.go b/internal/assignment/failure_event.go new file mode 100644 index 0000000..e74dc42 --- /dev/null +++ b/internal/assignment/failure_event.go @@ -0,0 +1,79 @@ +package assignment + +import ( + "encoding/json" + "time" + + "doormile/db" + "doormile/models" + "doormile/utils" +) + +// assignmentFailedSubject is the JetStream subject the AI layer's DispatchAgent +// binds its durable consumer to. It must be present on the ASSIGNMENTS stream +// before anything publishes here — JetStream drops messages on subjects the +// stream does not cover, silently and with a successful-looking publish. +const assignmentFailedSubject = "booking.assignment_failed" + +// Reasons carried on the failure event. Kept coarse on purpose: the consumer +// branches on booking_id and coordinates, and treats reason as diagnostic only. +const ( + reasonNoMilerAvailable = "NO_MILER_AVAILABLE" +) + +// publishAssignmentFailed emits booking.assignment_failed after auto-assignment +// has genuinely given up on a booking. +// +// Call this only from the terminal branch of an assignment entry point — after +// every retry is exhausted — never from inside a single attempt. The per-attempt +// paths (no eligible candidates, decision-engine escalation) fire up to +// maxRetries times for a booking that may still succeed on a later attempt, and +// publishing there would report failures that never happened. +// +// Mirrors publishAssignment/publishCustomerAssignment: non-fatal, so a NATS +// outage degrades to a log line rather than breaking the assignment goroutine. +func publishAssignmentFailed(bookingID int, reason string) { + if db.Js == nil { + return + } + + payload := map[string]interface{}{ + "booking_id": bookingID, + "reason": reason, + "failed_at": time.Now().UnixMilli(), + } + + // Pickup coordinates drive the consumer's coverage sweep. They aren't in + // scope at the call site, so re-read the booking — one indexed lookup on a + // path that only runs after retries have already been exhausted. If the + // read fails we still publish: the consumer treats missing coordinates as + // "skip the sweep" and degrades safely rather than losing the event. + var booking models.PickupBooking + if err := db.DB.Select("pickuplatitude", "pickuplongitude", "bookingno"). + First(&booking, bookingID).Error; err != nil { + utils.Warn("Assignment: could not load booking for failure event; publishing without coordinates", + "booking_id", bookingID, "error", err) + } else { + payload["booking_no"] = booking.Bookingno + if booking.Pickuplatitude != 0 || booking.Pickuplongitude != 0 { + payload["lat"] = booking.Pickuplatitude + payload["lon"] = booking.Pickuplongitude + } + } + + data, err := json.Marshal(payload) + if err != nil { + utils.Warn("Assignment: failed to marshal assignment-failed payload", + "booking_id", bookingID, "error", err) + return + } + + if _, err := db.Js.Publish(assignmentFailedSubject, data); err != nil { + utils.Warn("Assignment: NATS publish failed for assignment-failed event", + "booking_id", bookingID, "error", err) + return + } + + utils.Info("Assignment: published "+assignmentFailedSubject, + "booking_id", bookingID, "reason", reason) +}