@@ -93,6 +93,44 @@ void ApplyStep(JsonElement payload)
9393
9494 setWizardState ( "complete" ) ;
9595 SaveState ( "complete" ) ;
96+
97+ // Re-arm gateway.reload.mode=hybrid (the gateway default) now that
98+ // the wizard's burst of config writes is done. The pre-seed in
99+ // OpenClawCliGatewayConfigurationPreparer.PrepareAsync set this to
100+ // "hot" so the wizard's mid-flow config writes wouldn't fire a
101+ // service restart that cancels the in-flight wizard.next (see that
102+ // file's comment for the full root-cause analysis). Leaving "hot"
103+ // permanent would silently suppress restart-required config changes
104+ // later (e.g., port/bind/auth changes from the tray Settings page
105+ // would update openclaw.json but the running gateway would keep its
106+ // old settings). Resetting to "hybrid" restores normal behavior.
107+ //
108+ // This write itself does not trigger a reload: gateway.reload is a
109+ // { prefix: "gateway.reload", kind: "none" } rule in
110+ // src/gateway/config-reload-plan.ts:50. Best-effort fire-and-forget;
111+ // failures only log because we don't want to block the wizard
112+ // complete transition on a config write blip.
113+ try
114+ {
115+ var resetClient = ( ( App ) Microsoft . UI . Xaml . Application . Current ) . GatewayClient ?? Props . GatewayClient ;
116+ if ( resetClient is not null )
117+ {
118+ _ = resetClient . SetConfigAsync ( "gateway.reload.mode" , "hybrid" )
119+ . ContinueWith ( t =>
120+ {
121+ if ( t . IsFaulted )
122+ Logger . Warn ( $ "[GatewayWizard] Failed to reset gateway.reload.mode to hybrid: { t . Exception ? . GetBaseException ( ) . Message } ") ;
123+ else if ( t . Result )
124+ Logger . Info ( "[GatewayWizard] Reset gateway.reload.mode to hybrid after wizard completion" ) ;
125+ else
126+ Logger . Warn ( "[GatewayWizard] Reset gateway.reload.mode returned false (config.set declined)" ) ;
127+ } , TaskScheduler . Default ) ;
128+ }
129+ }
130+ catch ( Exception resetEx )
131+ {
132+ Logger . Warn ( $ "[GatewayWizard] Could not schedule gateway.reload.mode reset: { resetEx . Message } ") ;
133+ }
96134 return ;
97135 }
98136
@@ -331,6 +369,10 @@ async void SubmitStep()
331369 ApplyStep ( response ) ;
332370 }
333371 }
372+ catch ( OperationCanceledException ex )
373+ {
374+ await TryRecoverFromServiceRestartAsync ( app , stepId , ex ) ;
375+ }
334376 catch ( Exception ex )
335377 {
336378 // SECURITY: Log full exception, show a sanitized version of the gateway
@@ -392,6 +434,17 @@ async void SkipStep()
392434 var response = await client . SendWizardRequestAsync ( "wizard.next" , parameters ) ;
393435 ApplyStep ( response ) ;
394436 }
437+ catch ( OperationCanceledException ex )
438+ {
439+ // Sibling-defect recovery: SkipStep hits the same WS close 1012
440+ // restart race as SubmitStep when the user clicks the Skip button
441+ // (vs picking __skip__ + Continue). Route through the same recovery
442+ // helper so a transient disconnect or service-restart mid-skip
443+ // doesn't leave the wizard surface stuck. See SubmitStep's catch
444+ // for the underlying root-cause analysis.
445+ var app2 = ( App ) Microsoft . UI . Xaml . Application . Current ;
446+ await TryRecoverFromServiceRestartAsync ( app2 , stepId , ex ) ;
447+ }
395448 catch ( Exception ex )
396449 {
397450 Logger . Error ( $ "[GatewayWizard] Skip step failed: { ex } ") ;
@@ -409,6 +462,112 @@ async void SkipStep()
409462 }
410463 }
411464
465+ // Recovery helper shared by SubmitStep and SkipStep.
466+ //
467+ // Empirical bug (logs at 2026-05-16 14:47:44, channels-skip path on a
468+ // fresh openclaw.json): the gateway commits the wizard's collected
469+ // config to disk between stages (writeWizardConfigFile at
470+ // src/wizard/setup.ts:750). On a fresh install that snapshot adds new
471+ // gateway.bind / gateway.tailscale.* / gateway.controlUi.* /
472+ // auth.profiles.* paths, all of which match the catch-all
473+ // { prefix: "gateway", kind: "restart" } rule in
474+ // src/gateway/config-reload-plan.ts:126. The chokidar config-reload
475+ // watcher in src/gateway/config-reload.ts queues a service restart
476+ // (WS close 1012, "gateway restarting", restartExpectedMs: 1500 — see
477+ // src/cli/gateway-cli/run-loop.ts:558).
478+ // OpenClawGatewayClient.ClearPendingRequests then raises
479+ // OperationCanceledException from the pending wizard TCS.
480+ //
481+ // The primary fix for this is the gateway.reload.mode=hot pre-seed in
482+ // OpenClawCliGatewayConfigurationPreparer.PrepareAsync; this helper is
483+ // the defense-in-depth path that still runs if any other event drops
484+ // the wizard.next mid-flight (gateway crash, manual systemctl restart,
485+ // intermittent WSL network blip, etc.). Mirrors the macOS tray pattern
486+ // in apps/macos/Sources/OpenClaw/OnboardingWizard.swift:171-186
487+ // (restartIfSessionLost): wait for reconnect, clear stale session,
488+ // re-issue wizard.start. If the gateway-side session is still alive
489+ // (transient WS disconnect, no full restart), fall back to wizard.status
490+ // — mirrors StartWizard at the top of this file (lines 238-253).
491+ async Task TryRecoverFromServiceRestartAsync ( App app , string failingStepId , OperationCanceledException originalEx )
492+ {
493+ Logger . Info ( $ "[GatewayWizard] wizard.next cancelled by service-restart-like event ({ originalEx . Message } ); waiting for reconnect to restart wizard…") ;
494+ setErrorMsg ( "" ) ;
495+ setWizardState ( "loading" ) ;
496+ SaveState ( "loading" ) ;
497+
498+ IOperatorGatewayClient ? client = null ;
499+ var reconnected = false ;
500+ for ( int wait = 0 ; wait < 20 ; wait ++ )
501+ {
502+ client = app . GatewayClient ?? Props . GatewayClient ;
503+ if ( client ? . IsConnectedToGateway == true ) { reconnected = true ; break ; }
504+ await Task . Delay ( 1000 ) ;
505+ }
506+
507+ if ( ! reconnected || client == null )
508+ {
509+ Logger . Warn ( "[GatewayWizard] Gateway did not reconnect within 20s after service restart" ) ;
510+ var msg = LocalizationHelper . GetString ( "Onboarding_Wizard_ErrorGatewayDisconnectedDetail" ) ;
511+ if ( string . IsNullOrEmpty ( msg ) || msg == "Onboarding_Wizard_ErrorGatewayDisconnectedDetail" )
512+ msg = "Gateway is restarting. Try again in a moment." ;
513+ setErrorMsg ( msg ) ;
514+ setWizardState ( "error" ) ;
515+ SaveState ( "error" , msg ) ;
516+ return ;
517+ }
518+
519+ // Clear the stale session before restarting so wizard.start does not
520+ // get rejected with "wizard already running" if the server preserved
521+ // the in-memory session across our WS close (transient blip).
522+ Props . WizardSessionId = null ;
523+ Props . WizardStepPayload = null ;
524+
525+ try
526+ {
527+ Logger . Info ( "[GatewayWizard] Reconnected; calling wizard.start to recover from service restart" ) ;
528+ var restartResponse = await client . SendWizardRequestAsync ( "wizard.start" ) ;
529+ ApplyStep ( restartResponse ) ;
530+ }
531+ catch ( InvalidOperationException sessionStillRunning ) when ( sessionStillRunning . Message . Contains ( "already running" , StringComparison . OrdinalIgnoreCase ) )
532+ {
533+ // Transient disconnect, NOT a full service restart: the gateway-side
534+ // wizard session is still alive. wizard.status returns {status,error?}
535+ // only — no step payload — but ApplyStep falls through to
536+ // setWizardState("active") preserving whatever stale step the UI was
537+ // showing. Mirrors StartWizard's pattern at lines 238-253.
538+ Logger . Info ( "[GatewayWizard] Wizard session still running after reconnect, fetching current status…" ) ;
539+ try
540+ {
541+ var statusResponse = await client . SendWizardRequestAsync ( "wizard.status" ) ;
542+ ApplyStep ( statusResponse ) ;
543+ }
544+ catch
545+ {
546+ Logger . Warn ( "[GatewayWizard] Could not resume existing wizard session after reconnect, marking offline" ) ;
547+ setWizardState ( "offline" ) ;
548+ SaveState ( "offline" ) ;
549+ }
550+ }
551+ catch ( InvalidOperationException unknownOrMissing ) when (
552+ unknownOrMissing . Message . Contains ( "unknown method" , StringComparison . OrdinalIgnoreCase ) ||
553+ unknownOrMissing . Message . Contains ( "not found" , StringComparison . OrdinalIgnoreCase ) )
554+ {
555+ Logger . Warn ( $ "[GatewayWizard] wizard.start after reconnect hit unknown-method/not-found: { unknownOrMissing . Message } ") ;
556+ setWizardState ( "offline" ) ;
557+ SaveState ( "offline" ) ;
558+ }
559+ catch ( Exception restartEx )
560+ {
561+ Logger . Error ( $ "[GatewayWizard] wizard.start after service restart failed: { restartEx } ") ;
562+ var fallback = LocalizationHelper . GetString ( "Onboarding_Wizard_StepError" ) ;
563+ if ( fallback == "Onboarding_Wizard_StepError" ) fallback = WizardErrorFormatter . GenericFallbackMessage ;
564+ var msg = WizardErrorFormatter . FormatStepError ( restartEx , failingStepId , fallback ) ;
565+ setErrorMsg ( msg ) ;
566+ setWizardState ( "error" ) ;
567+ SaveState ( "error" , msg ) ;
568+ }
569+ }
570+
412571 // Always render exactly the same element tree structure.
413572 // Use empty strings for unused fields to keep a consistent child count.
414573 string displayTitle = "" ;
0 commit comments