From 2cb358e80d1231ccb68bb0c863daa5e2c7894872 Mon Sep 17 00:00:00 2001 From: arys Date: Mon, 31 Aug 2026 00:07:57 +0500 Subject: [PATCH] fix: track escalation for legacy AI voice handoff path request_handoff() (called by ai_voice_runtime_service for every real call handoff, the only handoff path production calls actually use) reserved an agent from the same routing pool as create_escalation() but never created an EscalationRow, so the Phase 2 no-answer-retry listener (DialEnd/Hangup) could never find it. A failed transfer (no SIP registration, no answer, redirect error) left the agent stuck in RESERVED forever with no retry to the next agent. Now creates an EscalationRow (status=ringing) alongside the agent reservation, releases the agent + marks the escalation failed if the AMI Redirect itself errors immediately, and lets the existing DialEnd/Hangup handler drive no-answer retry / release exactly like the /escalations endpoint already does. Reproduced live: call handed off to extension 2002 with no SIP contact registered -> immediate hangup, cause=3, both pool agents stuck in RESERVED indefinitely (had to release manually via psql). --- services/asterisk_bridge_service/voice_ai.py | 66 ++++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/services/asterisk_bridge_service/voice_ai.py b/services/asterisk_bridge_service/voice_ai.py index 4f4b4c9..1eed1a8 100644 --- a/services/asterisk_bridge_service/voice_ai.py +++ b/services/asterisk_bridge_service/voice_ai.py @@ -311,7 +311,7 @@ def _resolve_handoff_extension( target_level: str | None = None, tenant_id: str | None = None, required_skills: list[str] | None = None, -) -> tuple[str, str, str | None, str | None]: +) -> tuple[str, str, str | None, str | None, str | None]: bridge = _bridge_app() queue_code = _queue_code_for_queue_id(target_queue_id) or str(fallback_queue_code or "").strip() if not queue_code: @@ -327,7 +327,7 @@ def _resolve_handoff_extension( required_skills=required_skills, ) if reserved_agent: - return queue_code, reserved_agent["extension"], level, resolved_tenant_id + return queue_code, reserved_agent["extension"], level, resolved_tenant_id, reserved_agent["agent_id"] raise HTTPException(status_code=409, detail=f"No available {level} agent right now") extension = bridge._transfer_target_map().get(queue_code) @@ -336,7 +336,7 @@ def _resolve_handoff_extension( status_code=400, detail=f"Unknown transfer queue_code: {queue_code}", ) - return queue_code, extension, None, None + return queue_code, extension, None, None, None def _resolve_handoff_channel(session, link: AsteriskCallLinkRow) -> str: @@ -546,11 +546,33 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> if voice_session is None: raise HTTPException(status_code=404, detail="Voice AI session not found") - queue_code, target_extension, resolved_level, resolved_tenant_id = _resolve_handoff_extension( + queue_code, target_extension, resolved_level, resolved_tenant_id, reserved_agent_id = _resolve_handoff_extension( body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id, fallback_queue_code=_queue_code_for_queue_id(link.queue_id), call_id=call_id, ) + escalation: EscalationRow | None = None + if reserved_agent_id and target_extension != "7100": + now_reserved = utc_now_iso() + escalation = EscalationRow( + escalation_id=new_id("esc"), + call_id=call_id, + tenant_id=resolved_tenant_id, + from_level=str(link.current_level or "L1"), + to_level=resolved_level, + reason_code="AI_HANDOFF", + required_skills_json="[]", + priority=3, + status="ringing", + real_agent_id=reserved_agent_id, + assigned_agent_id=target_extension, + attempted_agent_ids_json=json.dumps([reserved_agent_id], ensure_ascii=False), + requested_at=now_reserved, + ) + session.add(escalation) + session.flush() + _append_escalation_timeline(escalation, link, action="escalation.agent_reserved", extra={"agent_id": reserved_agent_id}) + _emit_escalation_event(event_type="AgentReserved", escalation=escalation, link=link, extra={"agent_id": reserved_agent_id}) handoff_metadata = body.metadata or {} actor_user = str(actor.get("user") or actor.get("sub") or "ai-voice-runtime").strip() actor_role = str(actor.get("role") or "admin").strip() or "admin" @@ -597,15 +619,33 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> target_extension, body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id, ) - ami_result = bridge._ami_action( - "Redirect", - { - "Channel": channel, - "Context": bridge._transfer_context(), - "Exten": target_extension, - "Priority": 1, - }, - ) + try: + ami_result = bridge._ami_action( + "Redirect", + { + "Channel": channel, + "Context": bridge._transfer_context(), + "Exten": target_extension, + "Priority": 1, + }, + ) + except Exception as exc: + if escalation is not None: + try: + routing_release_by_agent_id(reserved_agent_id) + except Exception: + LOGGER.warning("bridge.handoff_release_agent_failed call_id=%s agent_id=%s", call_id, reserved_agent_id) + escalation.status = "failed" + escalation.completed_at = utc_now_iso() + session.commit() + _append_escalation_timeline(escalation, link, action="escalation.transfer_failed", extra={"agent_id": reserved_agent_id, "error": str(exc)}) + _emit_escalation_event(event_type="TransferFailed", escalation=escalation, link=link, extra={"agent_id": reserved_agent_id, "error": str(exc)}) + raise HTTPException(status_code=502, detail=f"Failed to redirect call to agent: {exc}") from exc + + if escalation is not None: + set_routing_agent_status(reserved_agent_id, "RINGING") + _append_escalation_timeline(escalation, link, action="escalation.agent_ringing", extra={"agent_id": reserved_agent_id}) + _emit_escalation_event(event_type="AgentRinging", escalation=escalation, link=link, extra={"agent_id": reserved_agent_id}) now = utc_now_iso() link.voice_session_id = ( -- 2.54.0