fix(escalations): atomic claim + self-claim rejection + queue exclusion
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 4m59s
CI / backend (pull_request) Successful in 10m22s
CI / e2e (pull_request) Successful in 10m46s

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:
2026-04-30 16:21:20 -04:00
parent ab5e0deaf7
commit f10649abc2
10 changed files with 248 additions and 134 deletions

View File

@@ -90,7 +90,6 @@ export function HandoffContextScreen({
onContinue,
onAIAnalysis,
onOwnThing,
onStartHere,
onDismiss,
dismissible = false,
isProcessing = false,

View File

@@ -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')