@@ -66,6 +66,8 @@ import { modelLabel } from "../model-display";
6666const SEARCH_SIDECAR_MODELS = [ "gpt-5.6-luna" , "gpt-5.4-mini" , "gpt-5.4" , "gpt-5.5" , "gpt-5.3-codex-spark" , "gpt-5.6-sol" , "gpt-5.6-terra" ] ;
6767const VISION_SIDECAR_MODELS = [ "gpt-5.6-luna" , "gpt-5.4-mini" , "gpt-5.4" , "gpt-5.5" , "gpt-5.6-sol" , "gpt-5.6-terra" ] ;
6868const REASONING_LEVELS = [ "low" , "medium" , "high" ] ;
69+ const UPDATE_CHECK_MAX_AUTO_RETRIES = 2 ;
70+ const UPDATE_CHECK_RETRY_BASE_MS = 800 ;
6971
7072function defaultUpdateChannel ( version : string | undefined ) : UpdateChannel {
7173 return version ?. includes ( "-preview." ) ? "preview" : "latest" ;
@@ -99,12 +101,22 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
99101 const [ updateRestart , setUpdateRestart ] = useState ( true ) ;
100102 const [ updateLoading , setUpdateLoading ] = useState ( false ) ;
101103 const updateRetryRef = useRef ( 0 ) ;
104+ const updateRetryTimerRef = useRef < number | null > ( null ) ;
105+ const updateRequestEpochRef = useRef ( 0 ) ;
102106 const [ updateCheck , setUpdateCheck ] = useState < UpdateCheckData | null > ( null ) ;
103107 const [ updateError , setUpdateError ] = useState < string | null > ( null ) ;
104108 const [ updateJob , setUpdateJob ] = useState < UpdateJob | null > ( null ) ;
105109 const [ reconnecting , setReconnecting ] = useState ( false ) ;
106110 const [ error , setError ] = useState ( false ) ;
107111
112+ useEffect ( ( ) => ( ) => {
113+ updateRequestEpochRef . current += 1 ;
114+ if ( updateRetryTimerRef . current !== null ) {
115+ window . clearTimeout ( updateRetryTimerRef . current ) ;
116+ updateRetryTimerRef . current = null ;
117+ }
118+ } , [ ] ) ;
119+
108120 useEffect ( ( ) => {
109121 const fetchData = async ( ) => {
110122 try {
@@ -309,45 +321,67 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
309321 }
310322 } ;
311323
312- const fetchUpdateCheck = async ( channel : UpdateChannel ) => {
324+ const fetchUpdateCheck = async ( channel : UpdateChannel , resetRetry = false ) => {
325+ if ( resetRetry ) updateRetryRef . current = 0 ;
326+ if ( updateRetryTimerRef . current !== null ) {
327+ window . clearTimeout ( updateRetryTimerRef . current ) ;
328+ updateRetryTimerRef . current = null ;
329+ }
330+ const requestEpoch = ++ updateRequestEpochRef . current ;
313331 setUpdateLoading ( true ) ;
314332 setUpdateError ( null ) ;
315333 setUpdateCheck ( null ) ;
316334 try {
317335 const res = await fetch ( `${ apiBase } /api/update/check?tag=${ channel } ` ) ;
318336 const data = await res . json ( ) as UpdateCheckData | { error ?: string } ;
319337 if ( ! res . ok ) throw new Error ( "error" in data && data . error ? data . error : "update check failed" ) ;
320- setUpdateCheck ( data as UpdateCheckData ) ;
321- updateRetryRef . current = 0 ;
338+ if ( requestEpoch !== updateRequestEpochRef . current ) return ;
339+
340+ const check = data as UpdateCheckData ;
341+ setUpdateCheck ( check ) ;
342+ if (
343+ check . reason === "latest_unavailable"
344+ && updateRetryRef . current < UPDATE_CHECK_MAX_AUTO_RETRIES
345+ ) {
346+ const retry = ++ updateRetryRef . current ;
347+ updateRetryTimerRef . current = window . setTimeout ( ( ) => {
348+ if ( requestEpoch !== updateRequestEpochRef . current ) return ;
349+ updateRetryTimerRef . current = null ;
350+ void fetchUpdateCheck ( channel ) ;
351+ } , UPDATE_CHECK_RETRY_BASE_MS * retry ) ;
352+ return ;
353+ }
354+
355+ if ( check . reason !== "latest_unavailable" ) updateRetryRef . current = 0 ;
356+ setUpdateLoading ( false ) ;
322357 } catch ( err ) {
358+ if ( requestEpoch !== updateRequestEpochRef . current ) return ;
323359 setUpdateError ( err instanceof Error ? err . message : String ( err ) ) ;
324- } finally {
325360 setUpdateLoading ( false ) ;
361+ }
362+ } ;
326363
327- setUpdateCheck ( prev => {
328- if ( ! prev || prev . reason !== "latest_unavailable" ) return prev ;
329- const max = 2 ;
330- if ( updateRetryRef . current >= max ) return prev ;
331- updateRetryRef . current += 1 ;
332- setTimeout ( ( ) => { void fetchUpdateCheck ( channel ) ; } , 800 * updateRetryRef . current ) ;
333- return prev ;
334- } ) ;
364+ const closeUpdateDialog = ( ) => {
365+ updateRequestEpochRef . current += 1 ;
366+ if ( updateRetryTimerRef . current !== null ) {
367+ window . clearTimeout ( updateRetryTimerRef . current ) ;
368+ updateRetryTimerRef . current = null ;
335369 }
370+ setUpdateLoading ( false ) ;
371+ setUpdateOpen ( false ) ;
336372 } ;
337373
338374 const openUpdateDialog = ( ) => {
339- updateRetryRef . current = 0 ;
340375 const channel = defaultUpdateChannel ( health ?. version ) ;
341376 setUpdateChannel ( channel ) ;
342377 setUpdateRestart ( true ) ;
343378 setUpdateOpen ( true ) ;
344- fetchUpdateCheck ( channel ) ;
379+ void fetchUpdateCheck ( channel , true ) ;
345380 } ;
346381
347382 const changeUpdateChannel = ( channel : UpdateChannel ) => {
348- updateRetryRef . current = 0 ;
349383 setUpdateChannel ( channel ) ;
350- fetchUpdateCheck ( channel ) ;
384+ void fetchUpdateCheck ( channel , true ) ;
351385 } ;
352386
353387 const runUpdate = async ( ) => {
@@ -363,7 +397,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
363397 if ( ! res . ok || ! data . job ) throw new Error ( data . error ?? "update failed to start" ) ;
364398 setUpdateJob ( data . job ) ;
365399 setReconnecting ( false ) ;
366- setUpdateOpen ( false ) ;
400+ closeUpdateDialog ( ) ;
367401 } catch ( err ) {
368402 setUpdateError ( err instanceof Error ? err . message : String ( err ) ) ;
369403 }
@@ -666,7 +700,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
666700 < div className = "modal-card" >
667701 < div className = "modal-head" >
668702 < h3 id = "update-title" > { t ( "dash.updateTitle" ) } </ h3 >
669- < button type = "button" className = "btn-icon" onClick = { ( ) => setUpdateOpen ( false ) } aria-label = { t ( "common.cancel" ) } >
703+ < button type = "button" className = "btn-icon" onClick = { closeUpdateDialog } aria-label = { t ( "common.cancel" ) } >
670704 < IconX />
671705 </ button >
672706 </ div >
@@ -711,7 +745,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
711745 type = "button"
712746 className = "btn btn-ghost btn-sm"
713747 disabled = { updateLoading }
714- onClick = { ( ) => { void fetchUpdateCheck ( updateChannel ) ; } }
748+ onClick = { ( ) => { void fetchUpdateCheck ( updateChannel , true ) ; } }
715749 style = { { marginLeft : 12 } }
716750 >
717751 < IconRefresh /> { t ( "dash.updateRetry" ) }
@@ -738,7 +772,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
738772 </ div >
739773 ) }
740774 < div className = "modal-actions" >
741- < button type = "button" className = "btn btn-ghost" onClick = { ( ) => setUpdateOpen ( false ) } > { t ( "common.cancel" ) } </ button >
775+ < button type = "button" className = "btn btn-ghost" onClick = { closeUpdateDialog } > { t ( "common.cancel" ) } </ button >
742776 < button
743777 type = "button"
744778 className = "btn btn-primary"
0 commit comments