Replaces the legacy flowpilot_engine.escalate_session orchestration with
a single canonical path through HandoffManager. Every escalation now
creates a SessionHandoff row, fans out via the SSE bus, persists
AppNotification rows for the bell icon, dispatches to external channels
(Slack/Teams) via notify(), and emails per-user — regardless of whether
the call entered through /escalate (legacy URL) or /handoff (new URL).
The senior-pickup magic-moment screen now works end-to-end from the
EscalateModal bell-icon path the user just tested.
Backend
- HandoffCreateRequest gains optional target_user_id (the equivalent of
the legacy escalated_to_id field). Self-targeting rejected.
- HandoffManager.create_handoff handles intent='escalate' end-to-end:
sets escalation_reason + escalated_to_id, builds the legacy enhanced
AI escalation_package (Sonnet, lazy-imported from flowpilot_engine,
graceful fallback on failure), and merges handoff metadata into it.
Eager-loads session.steps and session.user via selectinload — required
by both the enhanced-package builder and notify() to avoid
MissingGreenlet on async lazy access.
- HandoffManager.finalize_escalation generates SessionDocumentation,
pushes documentation to PSA, and runs notify() — pre-commit so the
AppNotification rows persist atomically with the handoff.
- HandoffManager.dispatch_escalation_notifications keeps only the
fire-and-forget IO (bus publish, per-user emails) — runs post-commit.
Pulls engineer name via a separate User query rather than relying on
session.user lazy access.
- /handoff endpoint passes target_user_id through and calls
finalize_escalation pre-commit.
- /escalate endpoint is now a thin shim: owner-only session lookup,
HandoffManager.create_handoff(intent='escalate'), finalize_escalation,
commit, dispatch_escalation_notifications, return SessionCloseResponse
built from documentation + psa_result. flowpilot_engine.escalate_session
is no longer called by any endpoint.
- pickup_session accepts both 'requesting_escalation' (legacy in-flight
sessions) and 'escalated' (new canonical) so the migration is seamless
for sessions already in the queue.
- Escalation queue list and sidebar count now match either status.
Frontend
- useFlowPilotSession optimistic update flips status to 'escalated'
instead of 'requesting_escalation' so the page state matches the
unified backend response.
Verified end-to-end live: a fresh /escalate call from the junior produces
status='escalated', a SessionHandoff row, a SessionDocumentation, PSA
push attempted (no_psa for this test session), AND a bell-icon
AppNotification for the team admin with link
/pilot/{session_id}?pickup=true. Backend test suite: 1103 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
63 lines
1.7 KiB
Python
63 lines
1.7 KiB
Python
"""Pydantic schemas for session handoffs."""
|
|
from __future__ import annotations
|
|
from typing import Any
|
|
from uuid import UUID
|
|
from datetime import datetime
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class HandoffCreateRequest(BaseModel):
|
|
intent: str = Field(..., pattern="^(park|escalate)$")
|
|
engineer_notes: str | None = None
|
|
priority: str = Field("normal", pattern="^(normal|elevated)$")
|
|
# Optional escalation target — if set, only this user is the named
|
|
# recipient. Notification dispatch fans out to all engineer/admin/owner
|
|
# users in the account either way; this just records the original
|
|
# engineer's preferred recipient on the session for audit/UX.
|
|
target_user_id: UUID | None = None
|
|
|
|
|
|
class HandoffResponse(BaseModel):
|
|
id: UUID
|
|
session_id: UUID
|
|
handed_off_by: UUID
|
|
intent: str
|
|
source_branch_id: UUID | None
|
|
snapshot: dict[str, Any]
|
|
ai_assessment: str | None
|
|
ai_assessment_data: dict[str, Any] | None
|
|
artifacts: list[dict[str, Any]] | None
|
|
engineer_notes: str | None
|
|
priority: str
|
|
claimed_by: UUID | None
|
|
claimed_at: datetime | None
|
|
psa_note_pushed: bool
|
|
notification_sent: bool
|
|
created_at: datetime
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class HandoffClaimRequest(BaseModel):
|
|
pass
|
|
|
|
|
|
class HandoffBriefingResponse(BaseModel):
|
|
briefing: str
|
|
handoff: HandoffResponse
|
|
|
|
|
|
class QueueItemResponse(BaseModel):
|
|
handoff_id: UUID
|
|
session_id: UUID
|
|
intent: str
|
|
problem_summary: str | None
|
|
problem_domain: str | None
|
|
priority: str
|
|
handed_off_by_name: str | None
|
|
engineer_notes: str | None
|
|
branch_count: int = 0
|
|
created_at: datetime
|
|
claimed_by: UUID | None
|
|
claimed_at: datetime | None
|
|
model_config = {"from_attributes": True}
|