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).
This commit is contained in:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user