@@ -188,6 +188,7 @@ final class NodeAppModel {
188188 @ObservationIgnored private var backgroundGraceTaskTimer : Task < Void , Never > ?
189189 private var backgroundReconnectSuppressed = false
190190 private var backgroundReconnectLeaseUntil : Date ?
191+ @ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
191192 private var lastSignificantLocationWakeAt : Date ?
192193 @ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator ( )
193194 private var watchExecApprovalPromptsByID : [ String : ExecApprovalPrompt ] = [ : ]
@@ -214,6 +215,7 @@ final class NodeAppModel {
214215 private static let watchExecApprovalBridgeStateKey = " watch.execApproval.bridge.state.v1 "
215216 private static let backgroundAliveLastSuccessAtMsKey = " gateway.backgroundAlive.lastSuccessAtMs "
216217 private static let backgroundAliveLastTriggerKey = " gateway.backgroundAlive.lastTrigger "
218+ private static let foregroundResumeHealthTimeoutSeconds = 1
217219
218220 var cameraHUDText : String ?
219221 var cameraHUDKind : CameraHUDKind ?
@@ -417,9 +419,7 @@ final class NodeAppModel {
417419 self . isBackgrounded = false
418420 self . endBackgroundConnectionGracePeriod ( reason: " scene_foreground " )
419421 self . clearBackgroundReconnectSuppression ( reason: " scene_foreground " )
420- if self . operatorConnected {
421- self . startGatewayHealthMonitor ( )
422- }
422+ var shouldStartGatewayHealthMonitor = self . operatorConnected
423423 if phase == . active {
424424 self . voiceWake. resumeAfterExternalAudioCapture ( wasSuspended: self . backgroundVoiceWakeSuspended)
425425 self . backgroundVoiceWakeSuspended = false
@@ -444,6 +444,8 @@ final class NodeAppModel {
444444 // iOS may suspend network sockets in background without a clean close.
445445 // On foreground, force a fresh handshake to avoid "connected but dead" states.
446446 if backgroundedFor >= 3.0 {
447+ shouldStartGatewayHealthMonitor = false
448+ self . foregroundGatewayResumeCheckInFlight = true
447449 Task { [ weak self] in
448450 guard let self else { return }
449451 let operatorWasConnected = await MainActor . run { self . operatorConnected }
@@ -452,31 +454,26 @@ final class NodeAppModel {
452454 let healthy = await ( try ? self . operatorGateway. request (
453455 method: " health " ,
454456 paramsJSON: nil ,
455- timeoutSeconds: 2 ) ) != nil
457+ timeoutSeconds: Self . foregroundResumeHealthTimeoutSeconds ) ) != nil
456458 if healthy {
457- await MainActor . run { self . startGatewayHealthMonitor ( ) }
459+ await MainActor . run {
460+ self . foregroundGatewayResumeCheckInFlight = false
461+ self . startGatewayHealthMonitor ( )
462+ }
458463 return
459464 }
460465 }
461466
462- await self . operatorGateway. disconnect ( )
463- await self . nodeGateway. disconnect ( )
464467 await MainActor . run {
465- guard !self . isAppleReviewDemoModeEnabled else { return }
466- self . setOperatorConnected ( false )
467- self . gatewayConnected = false
468- // Foreground recovery must actively restart the saved gateway config.
469- // Disconnecting stale sockets alone can leave us idle if the old
470- // reconnect tasks were suppressed or otherwise got stuck in background.
471- self . gatewayStatusText = " Reconnecting… "
472- self . talkMode. updateGatewayConnected ( false )
473- if let cfg = self . activeGatewayConnectConfig {
474- self . applyGatewayConnectConfig ( cfg)
475- }
468+ self . foregroundGatewayResumeCheckInFlight = false
476469 }
470+ await self . restartGatewaySessionsAfterForegroundStaleConnection ( )
477471 }
478472 }
479473 }
474+ if shouldStartGatewayHealthMonitor {
475+ self . startGatewayHealthMonitor ( )
476+ }
480477 @unknown default :
481478 self . isBackgrounded = false
482479 self . endBackgroundConnectionGracePeriod ( reason: " scene_unknown " )
@@ -786,6 +783,12 @@ final class NodeAppModel {
786783
787784 func refreshGatewayOverviewIfConnected( ) async {
788785 guard await self . isOperatorConnected ( ) else { return }
786+ if self . foregroundGatewayResumeCheckInFlight {
787+ GatewayDiagnostics . log ( " gateway overview refresh deferred reason=foreground_resume_check " )
788+ try ? await Task . sleep (
789+ nanoseconds: UInt64 ( Self . foregroundResumeHealthTimeoutSeconds) * 1_000_000_000 )
790+ guard await self . isOperatorConnected ( ) , !self . foregroundGatewayResumeCheckInFlight else { return }
791+ }
789792 await self . refreshBrandingFromGateway ( )
790793 await self . refreshAgentsFromGateway ( )
791794 }
@@ -1986,12 +1989,33 @@ extension NodeAppModel {
19861989 }
19871990
19881991 func resetGatewaySessionsForForcedReconnect( ) async {
1989- self . nodeGatewayTask? . cancel ( )
1992+ let nodeGatewayTask = self . nodeGatewayTask
1993+ let operatorGatewayTask = self . operatorGatewayTask
1994+ nodeGatewayTask? . cancel ( )
19901995 self . nodeGatewayTask = nil
1991- self . operatorGatewayTask? . cancel ( )
1996+ operatorGatewayTask? . cancel ( )
19921997 self . operatorGatewayTask = nil
19931998 await self . operatorGateway. disconnect ( )
19941999 await self . nodeGateway. disconnect ( )
2000+ // Foreground recovery reuses the same config immediately after reset.
2001+ // Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state.
2002+ if let operatorGatewayTask {
2003+ await operatorGatewayTask. value
2004+ }
2005+ if let nodeGatewayTask {
2006+ await nodeGatewayTask. value
2007+ }
2008+ }
2009+
2010+ private func restartGatewaySessionsAfterForegroundStaleConnection( ) async {
2011+ await self . resetGatewaySessionsForForcedReconnect ( )
2012+ guard !self . isAppleReviewDemoModeEnabled else { return }
2013+ self . setOperatorConnected ( false )
2014+ self . gatewayConnected = false
2015+ self . gatewayStatusText = " Reconnecting… "
2016+ self . talkMode. updateGatewayConnected ( false )
2017+ guard let cfg = self . activeGatewayConnectConfig else { return }
2018+ self . applyGatewayConnectConfig ( cfg, forceReconnect: true )
19952019 }
19962020
19972021 func disconnectGateway( ) {
@@ -4826,6 +4850,10 @@ extension NodeAppModel {
48264850 ( self . nodeGatewayTask != nil , self . operatorGatewayTask != nil )
48274851 }
48284852
4853+ func _test_restartGatewaySessionsAfterForegroundStaleConnection( ) async {
4854+ await self . restartGatewaySessionsAfterForegroundStaleConnection ( )
4855+ }
4856+
48294857 func _test_handleSuccessfulBootstrapGatewayOnboarding( ) async {
48304858 await self . handleSuccessfulBootstrapGatewayOnboarding (
48314859 url: URL ( string: " wss://gateway.example " ) !,
0 commit comments