First half of the Escalation Mode notification dual-path. WebSocket/SSE
push is the second half (next commit) — email handles offline seniors,
push handles online ones for the magic-moment demo.
HandoffManager.dispatch_escalation_notifications:
- Pulls active engineer/admin/owner-role users in the same account_id
(excludes the escalator + viewers + soft-deleted)
- Sends via existing EmailService.send_notification_email, concurrent
via asyncio.gather; per-message failures don't block the rest
- Wrapped in try/except: any exception is logged + swallowed. Handoff
creation is authoritative; notification is advisory. This is the
graceful-degradation regression both eng + codex reviews flagged as
critical (handoff must succeed even if SMTP is down).
Endpoint wiring (POST /ai-sessions/{id}/handoff):
- Dispatch fires AFTER db.commit() — never email about a rolled-back
handoff. Trust-erosion bug if we got that wrong.
- Only fires for intent=escalate. Park is private to the escalator.
Tests (4 new):
- emails-engineer-recipients-in-account: viewer excluded, escalator
excluded, only the engineer/admin teammates get the message
- skipped-for-park-intent: park doesn't fan out
- graceful-degradation-when-email-raises: RuntimeError from the email
service does NOT bubble out of dispatch
- endpoint-dispatches-on-escalate: end-to-end wiring through POST
Per-channel delivery records (replacing the dead `notification_sent`
boolean per Codex correction) is a v1.x story — for now application
logs are the audit trail. See
docs/plans/2026-04-27-escalation-mode-wedge-design.md.
20 tests green across handoff_manager + session_handoffs_api +
flowpilot_analytics_escalations. No regressions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
130 lines
4.5 KiB
Python
130 lines
4.5 KiB
Python
"""Handoff endpoints — unified park/escalate.
|
|
|
|
POST /ai-sessions/{id}/handoff — Create handoff
|
|
GET /ai-sessions/{id}/handoffs — Handoff history
|
|
POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session
|
|
GET /ai-sessions/queue — Team queue
|
|
"""
|
|
import logging
|
|
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
|
from app.models.user import User
|
|
from app.models.ai_session import AISession
|
|
from app.models.session_handoff import SessionHandoff
|
|
from app.services.handoff_manager import HandoffManager
|
|
from app.schemas.session_handoff import (
|
|
HandoffCreateRequest,
|
|
HandoffResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Queue endpoint needs its own router (no session_id prefix)
|
|
queue_router = APIRouter(prefix="/ai-sessions", tags=["session-handoffs"])
|
|
|
|
# Session-scoped endpoints
|
|
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"])
|
|
|
|
|
|
@router.post("/handoff", response_model=HandoffResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_handoff(
|
|
session_id: UUID,
|
|
body: HandoffCreateRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> HandoffResponse:
|
|
"""Create a handoff (park or escalate)."""
|
|
result = await db.execute(
|
|
select(AISession).where(
|
|
AISession.id == session_id,
|
|
AISession.user_id == current_user.id,
|
|
)
|
|
)
|
|
session = result.scalar_one_or_none()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
manager = HandoffManager(db)
|
|
try:
|
|
handoff = await manager.create_handoff(
|
|
session_id=session_id,
|
|
intent=body.intent,
|
|
engineer_notes=body.engineer_notes,
|
|
user_id=current_user.id,
|
|
priority=body.priority,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
await db.commit()
|
|
|
|
# Best-effort notification dispatch AFTER commit so we never email about
|
|
# a rolled-back handoff. Failures are swallowed inside the manager —
|
|
# handoff creation is authoritative; notifications are advisory.
|
|
if handoff.intent == "escalate":
|
|
await manager.dispatch_escalation_notifications(handoff)
|
|
|
|
return HandoffResponse.model_validate(handoff)
|
|
|
|
|
|
@router.get("/handoffs", response_model=list[HandoffResponse])
|
|
async def list_handoffs(
|
|
session_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> list[HandoffResponse]:
|
|
"""Get handoff history for a session."""
|
|
result = await db.execute(
|
|
select(SessionHandoff)
|
|
.where(SessionHandoff.session_id == session_id)
|
|
.order_by(SessionHandoff.created_at.desc())
|
|
)
|
|
handoffs = result.scalars().all()
|
|
return [HandoffResponse.model_validate(h) for h in handoffs]
|
|
|
|
|
|
@router.post("/handoffs/{handoff_id}/claim", response_model=HandoffResponse)
|
|
async def claim_handoff(
|
|
session_id: UUID,
|
|
handoff_id: UUID,
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> HandoffResponse:
|
|
"""Claim a handed-off session.
|
|
|
|
Role-gated to engineer/admin/owner — viewers cannot claim. The race-condition
|
|
story (two seniors clicking Pick Up simultaneously) depends on auth gating
|
|
for audit integrity. Codex review flagged this as wedge-relevant; locked
|
|
in-scope for Escalation Mode v1.
|
|
"""
|
|
manager = HandoffManager(db)
|
|
try:
|
|
handoff = await manager.claim_session(
|
|
handoff_id=handoff_id,
|
|
claiming_user_id=current_user.id,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
await db.commit()
|
|
return HandoffResponse.model_validate(handoff)
|
|
|
|
|
|
@queue_router.get("/queue")
|
|
async def get_queue(
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> list[dict]:
|
|
"""Get team queue of parked + escalated sessions."""
|
|
manager = HandoffManager(db)
|
|
return await manager.get_queue(
|
|
team_id=current_user.team_id,
|
|
account_id=current_user.account_id,
|
|
)
|