fix(escalations): atomic claim + self-claim rejection + queue exclusion
Codex review pass on the escalation wedge. Reworks claim_session from read-then-write to a conditional UPDATE so two seniors racing can't both win, blocks the original engineer from claiming their own handoff, and filters self-escalated sessions out of the dashboard escalation queue. Also preassigns the handoff UUID before flush so the compatibility escalation_package payload carries it. Removes legacy frontend pickup state (claiming, handleStartHere) that broke tsc --noEmit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,6 @@ export function HandoffContextScreen({
|
||||
onContinue,
|
||||
onAIAnalysis,
|
||||
onOwnThing,
|
||||
onStartHere,
|
||||
onDismiss,
|
||||
dismissible = false,
|
||||
isProcessing = false,
|
||||
|
||||
@@ -82,7 +82,6 @@ export default function AssistantChatPage() {
|
||||
const [magicHandoff, setMagicHandoff] = useState<HandoffResponse | null>(null)
|
||||
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
|
||||
const [overlayLoading, setOverlayLoading] = useState(false)
|
||||
const [claiming, setClaiming] = useState(false)
|
||||
const [activeOptionKey, setActiveOptionKey] = useState<'continue' | 'ai' | 'own' | null>(null)
|
||||
// Codex correction (locked design): once the magic-moment dissolves, the
|
||||
// AI's `suggested_steps[]` should still be reachable as chips below the
|
||||
@@ -331,52 +330,6 @@ export default function AssistantChatPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [isPickup, urlSessionId, magicState, setSearchParams])
|
||||
|
||||
const handleStartHere = useCallback(async () => {
|
||||
if (!urlSessionId || !magicHandoff) return
|
||||
setClaiming(true)
|
||||
try {
|
||||
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
|
||||
// Drop ?pickup=true and dismiss the magic-moment. The session-load
|
||||
// effect above will then fire because magicState !== 'loading'/'visible'
|
||||
// and selectChat will populate the chat surface — the senior is now
|
||||
// escalated_to_id, so GET succeeds and the conversation_messages render
|
||||
// as chat history.
|
||||
setSearchParams({})
|
||||
setMagicState('dismissed')
|
||||
// Refresh the sidebar list. Pre-claim the session was invisible to
|
||||
// listSessions because escalated_to_id was null (junior didn't
|
||||
// specify a target on /escalate). Post-claim claim_session sets
|
||||
// escalated_to_id = teamadmin.id, so the session is now in scope.
|
||||
// Without this re-fetch the senior lands on a session with no
|
||||
// sidebar entry — looks like the page navigated to a different
|
||||
// session.
|
||||
void loadChats()
|
||||
} catch (e: unknown) {
|
||||
// Race-condition path (locked design): the loser of the simultaneous
|
||||
// Pick Up gets a 409 with structured detail so we can name the
|
||||
// winner and approximate "how long ago." Drop the magic-moment
|
||||
// (the session is no longer theirs to claim) and let them go back
|
||||
// to the queue.
|
||||
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||
const detail = e.response.data?.detail as
|
||||
| { error?: string; claimed_by_name?: string; claimed_at?: string }
|
||||
| undefined
|
||||
if (detail?.error === 'already_claimed') {
|
||||
const name = detail.claimed_by_name || 'another engineer'
|
||||
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
|
||||
toast.info(`Already claimed by ${name} ${when}.`)
|
||||
setSearchParams({})
|
||||
setMagicState('dismissed')
|
||||
return
|
||||
}
|
||||
}
|
||||
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setClaiming(false)
|
||||
}
|
||||
}, [urlSessionId, magicHandoff, setSearchParams])
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (!urlSessionId || !magicHandoff) return
|
||||
setActiveOptionKey('continue')
|
||||
|
||||
Reference in New Issue
Block a user