@@ -42,7 +42,6 @@ import {
4242 holdsLiveFreebuffSlot ,
4343 isFreebuffSessionTimeoutError ,
4444 mergeCompactActiveSession ,
45- releaseFreebuffSlot ,
4645} from '../utils/freebuff-session-api'
4746import {
4847 failedPollDelayMs ,
@@ -212,16 +211,42 @@ async function restartFreebuffSession(
212211 // resetting its store so late deltas cannot land in the next session.
213212 if ( opts . resetChat ) {
214213 stopActiveRun ( 'session-transition' )
215- useChatStore . getState ( ) . reset ( )
216214 }
217215 // Halt the running poll loop before we touch local stores or DELETE the
218216 // slot. Otherwise an in-flight GET could land mid-reset and overwrite
219217 // state, or the next scheduled tick could fire between DELETE and
220218 // restart() with stale assumptions. restart() re-aborts and re-arms
221219 // below; the extra abort here is cheap.
222- controller ?. abort ( )
223- if ( opts . releaseSlot ) await releaseFreebuffSlot ( )
224- await controller ?. restart ( mode )
220+ const currentController = controller
221+ const currentToken = getAuthTokenDetails ( ) . token
222+ const currentSession = useFreebuffSessionStore . getState ( ) . session
223+ const stillCurrent = ( ) =>
224+ controller === currentController &&
225+ getAuthTokenDetails ( ) . token === currentToken &&
226+ useFreebuffSessionStore . getState ( ) . session === currentSession
227+ currentController ?. abort ( )
228+ if ( opts . releaseSlot ) {
229+ try {
230+ await useFreebuffSessionStore . getState ( )
231+ . releaseSlot ( )
232+ } catch ( error ) {
233+ if ( ! stillCurrent ( ) ) throw error
234+ // Keep the chat and held instance: the server may already have credited
235+ // the refund. A retry must use that same instance to recover its receipt.
236+ useChatStore
237+ . getState ( )
238+ . setMessages ( ( messages ) => [
239+ ...messages ,
240+ getSystemMessage (
241+ `Could not confirm the session ended. Retry /end-session. ${ error instanceof Error ? error . message : String ( error ) } ` ,
242+ ) ,
243+ ] )
244+ throw error
245+ }
246+ }
247+ if ( ! stillCurrent ( ) ) return
248+ if ( opts . resetChat ) useChatStore . getState ( ) . reset ( )
249+ await currentController ?. restart ( mode )
225250}
226251
227252/**
@@ -356,7 +381,9 @@ export function markFreebuffSessionCountryBlocked(params: {
356381 controller ?. apply ( { status : 'country_blocked' , ...params } )
357382 // Best-effort DELETE so we don't hold a session row the server is already
358383 // refusing to serve at chat time.
359- releaseFreebuffSlot ( ) . catch ( ( ) => { } )
384+ useFreebuffSessionStore
385+ . getState ( ) . releaseSlot ( )
386+ . catch ( ( ) => { } )
360387}
361388
362389/** Flip into the local `ended` state without an instanceId (server has lost
@@ -382,6 +409,8 @@ export function markFreebuffSessionEnded(): void {
382409interface UseFreebuffSessionResult {
383410 session : FreebuffSessionResponse | null
384411 failure : ReturnType < typeof useFreebuffSessionStore . getState > [ 'failure' ]
412+ lastRefund : number | null
413+ refundPending : boolean
385414}
386415
387416/**
@@ -400,6 +429,26 @@ interface UseFreebuffSessionResult {
400429export function useFreebuffSession ( ) : UseFreebuffSessionResult {
401430 const session = useFreebuffSessionStore ( ( s ) => s . session )
402431 const failure = useFreebuffSessionStore ( ( s ) => s . failure )
432+ const lastRefund = useFreebuffSessionStore ( ( s ) => s . lastRefund )
433+ const pendingRefund = useFreebuffSessionStore ( ( s ) => s . pendingRefund )
434+ useEffect ( ( ) => {
435+ if ( ! pendingRefund ) return
436+ let cancelled = false
437+ let timer : ReturnType < typeof setTimeout >
438+ const poll = async ( ) => {
439+ try {
440+ await useFreebuffSessionStore . getState ( ) . refreshRefund ( )
441+ } catch {
442+ /* Keep the pending receipt for a later retry. */
443+ }
444+ if ( ! cancelled ) timer = setTimeout ( poll , 3000 )
445+ }
446+ timer = setTimeout ( poll , 3000 )
447+ return ( ) => {
448+ cancelled = true
449+ clearTimeout ( timer )
450+ }
451+ } , [ pendingRefund ] )
403452
404453 useEffect ( ( ) => {
405454 const { setSession, setFailure } = useFreebuffSessionStore . getState ( )
@@ -525,7 +574,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
525574 // another model rejects the switch. Two cases:
526575 // - DELIBERATE pick (the explicit-pick marker was set): honor the
527576 // click — end the locked session (usually a stale row from a
528- // crashed CLI; DELETE is keyed on user, not instance ) and
577+ // crashed CLI; read its instance before deleting ) and
529578 // re-claim on the requested model. The marker is consume-once,
530579 // so if the retried POST races another instance back into
531580 // model_locked we take the revert branch instead of looping.
@@ -541,10 +590,21 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
541590 const requested = getFreebuffModel ( explicitPickModel ) . displayName
542591 let released = false
543592 try {
544- await callFreebuffSession ( 'DELETE ' , token , {
593+ const held = await callFreebuffSession ( 'GET ' , token , {
545594 signal : fetchController . signal ,
546595 } )
547- released = true
596+ if (
597+ ! cancelled &&
598+ ! fetchController . signal . aborted &&
599+ generation === restartGeneration &&
600+ held . status === 'active' &&
601+ held . model === next . currentModel
602+ ) {
603+ await useFreebuffSessionStore
604+ . getState ( )
605+ . releaseSlot ( held , fetchController . signal )
606+ released = true
607+ }
548608 } catch {
549609 // DELETE failed — fall through to the revert-with-explanation
550610 // path below rather than stranding the user mid-switch.
@@ -857,12 +917,15 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
857917 // Fire-and-forget DELETE. Only release if we actually held a slot so
858918 // we don't generate spurious DELETEs (e.g. HMR before POST completes).
859919 if ( holdsLiveFreebuffSlot ( current ) ) {
860- callFreebuffSession ( 'DELETE' , token ) . catch ( ( ) => { } )
920+ useFreebuffSessionStore
921+ . getState ( )
922+ . releaseSlot ( )
923+ . catch ( ( ) => { } )
861924 }
862925 setSession ( null )
863926 setFailure ( null )
864927 }
865928 } , [ ] )
866929
867- return { session, failure }
930+ return { session, failure, lastRefund , refundPending : pendingRefund !== null }
868931}
0 commit comments