From 77b7e4bb295e2c32df587c17e4e1a35ab0a2f428 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:26:07 -0700 Subject: [PATCH 1/2] fix(setup): retry restart after reload owner handoff Extract the guarded serving-owner recovery from a6190ba0935d20c97ecaf69bdc4f0871ebb63589 in PR #1447 (feat(setup): guide native Gateway installation and onboarding), onto main 273b0182745a3093c0e09f306ca8a1fff6ef3c5a. Production recovery is unchanged. Omit native-package documentation and fixture isolation already present on main via fb8b9e736f7473705ea14d8e97f6117bcbaddf68. Retain focused tests and setup documentation; add a coordinator-contention non-retry case and clarify provenance limitations. Related to #1498. Restart-intent coordinator contention remains unresolved. No guard bypass or broader retry policy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b57fee8a-96bb-4dd3-8352-588f901fffa8 --- docs/SETUP_ENGINE_REDESIGN.md | 15 ++++ src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 18 ++++ .../SetupStepsTests.cs | 89 +++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 11b055d0c..610930e1f 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -10,6 +10,21 @@ The Setup Engine is a **config-driven system** for provisioning an OpenClaw WSL The bundled `default-config.json` ships with the tray executable and provides secure defaults (loopback bind, WSL isolation, systemd enabled). Defaults can be overridden via config file or environment variables. +WSL wizard completion restores `gateway.reload.mode` before explicitly restarting +the Gateway. With Gateway 2026.9.6, restoring hybrid reload can initiate a systemd +restart for accumulated wizard changes before the explicit restart records its +owner intent. During that gap the live-owner lease is absent, so the CLI correctly +refuses to signal a process. `SetupWizardRunner` recognizes only that exact +serving-owner refusal, waits for verified managed endpoint ownership using the +existing bounded provenance probe, and retries the normal CLI restart once. +The probe allows up to 30 one-second retry delays, plus probe duration, for +`NoListener` and `UnknownListener` tagged `ListenerSnapshotChanged`. Other +unknown/conflicting listeners, other restart errors, and a repeated refusal still +fail setup. Listener provenance does not prove owner-lease or coordinator +readiness; the retried CLI command retains those guards. Restart-intent recording +contention is a separate failure and is not retried here. There is no direct +systemd restart fallback or ownership bypass. + > **Status note (2026-07-06):** Current default setup includes `WindowsNodeBootstrapContextStep`, which injects Windows-node context into the WSL workspace `AGENTS.md` after onboarding. --- diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index cc66d93b3..8fef4b403 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -19,6 +19,8 @@ public sealed class SetupWizardRunner TimeSpan.FromMilliseconds(500); internal const string StartupMigrationLeaseDiagnostic = "OpenClaw startup migrations are already running for this state directory;"; + internal const string RestartServingOwnerDiagnostic = + "GATEWAY_RESTART_PREPARATION_REFUSED: Cannot verify a live serving Gateway owner for the selected service. Gateway was not signaled."; private static readonly Regex s_normalizeKeyRegex = new("[^a-z0-9]+", RegexOptions.Compiled); // Progress steps can repeat while background work runs; keep bounded caps @@ -614,6 +616,22 @@ internal async Task RestoreReloadModeAsync() await StartGatewayStep.RestartAndWaitForHealthAsync( _ctx, CancellationToken.None); + if (!restartResult.IsSuccess && + restartResult.Message?.Contains(RestartServingOwnerDiagnostic, StringComparison.Ordinal) == true) + { + // Restoring hybrid reload can initiate a supervisor restart before the CLI + // records its intent. Never bypass that CLI ownership gate or adopt a listener. + _ctx.Logger.Warn( + "Gateway restart owner was unavailable after restoring reload. Rechecking managed ownership before one restart retry."); + var retryOwnership = await VerifyExpectedManagedGatewayAsync( + "before retrying gateway restart"); + if (!retryOwnership.IsSuccess) + return retryOwnership; + + restartResult = await StartGatewayStep.RestartAndWaitForHealthAsync( + _ctx, + CancellationToken.None); + } if (!restartResult.IsSuccess) { return StepResult.Fail( diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 3a568a07f..9c4c88803 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -4266,6 +4266,95 @@ public async Task SetupWizard_RestoreReloadModeRestartsAndVerifiesGateway() AssertReloadRestorationCompleted(commands); } + [Fact] + public async Task SetupWizard_RestartOwnerGapReverifiesOwnershipBeforeOneRetry() + { + var restarts = 0; + var inspections = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("config set gateway.reload.mode") => Ok(), + var value when value.Contains("openclaw gateway restart") => Restart(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.EndpointProvenanceProbe = (_, _) => + { + inspections++; + return Task.FromResult(new GatewayEndpointProvenance( + inspections == 1 + ? GatewayEndpointProvenanceKind.NoListener + : GatewayEndpointProvenanceKind.ExpectedManagedGateway, + ctx.Config.GatewayPort)); + }; + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(2, restarts); + Assert.Equal(3, inspections); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("systemctl")); + return; + + CommandResult Restart() + { + if (++restarts == 1) + return Fail(SetupWizardRunner.RestartServingOwnerDiagnostic); + Assert.Equal(2, inspections); + return Ok(); + } + } + + [Theory] + [InlineData(GatewayEndpointProvenanceKind.UnknownListener)] + [InlineData(GatewayEndpointProvenanceKind.ConflictingOpenClawGateway)] + public async Task SetupWizard_RestartOwnerGapDoesNotRetryAnUntrustedListener( + GatewayEndpointProvenanceKind kind) + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("config set gateway.reload.mode") + ? Ok() + : Fail(SetupWizardRunner.RestartServingOwnerDiagnostic)); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.EndpointProvenanceProbe = (_, _) => Task.FromResult( + new GatewayEndpointProvenance(kind, ctx.Config.GatewayPort)); + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.False(result.IsSuccess); + Assert.Contains("ownership verification failed", result.Message); + Assert.Single(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Theory] + [InlineData(SetupWizardRunner.RestartServingOwnerDiagnostic, 2)] + [InlineData("GATEWAY_RESTART_PREPARATION_REFUSED: Cannot verify the selected service command.", 1)] + [InlineData("StateDatabaseCoordinatorContentionError: another OpenClaw process owns state-lifecycle. GATEWAY_RESTART_PREPARATION_REFUSED: Cannot record restart intent for the serving Gateway. Gateway was not signaled.", 1)] + [InlineData("Unrelated restart failure", 1)] + public async Task SetupWizard_RestartRetryRemainsBoundedAndSpecific(string error, int expectedRestarts) + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("config set gateway.reload.mode") ? Ok() : Fail(error)); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + TrustManagedEndpoint(ctx); + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.False(result.IsSuccess); + Assert.Contains(error, result.Message); + Assert.Equal(expectedRestarts, + commands.WslCalls.Count(call => call.Command.Contains("openclaw gateway restart"))); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("systemctl")); + } + [Fact] public async Task SetupWizard_RestoreReloadModeRetriesExactStartupMigrationLeaseContention() { From 2a685afc057411d695f7093c9b9c0bf3a8a7b923 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:19:52 -0700 Subject: [PATCH 2/2] docs(setup): qualify guarded restart timing explanation Address Scott and adversarial review on PR #1515: the rejected owner-lease predicate is not exposed, and differing service snapshots do not establish admission-time lease state. Keep the accepted guarded retry and distinct coordinator-contention failure unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b57fee8a-96bb-4dd3-8352-588f901fffa8 --- docs/SETUP_ENGINE_REDESIGN.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 610930e1f..26ed96f5a 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -11,10 +11,14 @@ The Setup Engine is a **config-driven system** for provisioning an OpenClaw WSL The bundled `default-config.json` ships with the tray executable and provides secure defaults (loopback bind, WSL isolation, systemd enabled). Defaults can be overridden via config file or environment variables. WSL wizard completion restores `gateway.reload.mode` before explicitly restarting -the Gateway. With Gateway 2026.9.6, restoring hybrid reload can initiate a systemd -restart for accumulated wizard changes before the explicit restart records its -owner intent. During that gap the live-owner lease is absent, so the CLI correctly -refuses to signal a process. `SetupWizardRunner` recognizes only that exact +the Gateway. Gateway 2026.9.6 may refuse the guarded restart when it cannot verify +a live serving owner; the rejected owner-lease predicate is not exposed by the +public Gateway CLI. A reload-triggered supervisor transition is one possible +timing explanation, not an established cause of every refusal. Observed service +states differ: local diagnostics captured `activating/auto-restart` with no +MainPID, while hosted generic refusals captured an `active/running` unit and a +live PID. Neither snapshot establishes the admission-time owner-lease predicate. +`SetupWizardRunner` recognizes only the exact serving-owner refusal, waits for verified managed endpoint ownership using the existing bounded provenance probe, and retries the normal CLI restart once. The probe allows up to 30 one-second retry delays, plus probe duration, for