diff --git a/README.md b/README.md index 1548de91..b013fd1c 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ terminal the hint uses the crab identity and the same warning/accent palette as | `clawctl gateway-service start` | Start the OpenClaw gateway in the isolated session and wait for it to listen. Requires setup. | | `clawctl gateway-service status` | Inspect the gateway without starting it. When the gateway is not running, it may start/probe only the already-recorded isolated session to report file-only config readiness; it never provisions a replacement or starts the gateway. | | `clawctl gateway-service stop` | Stop the gateway while retaining the session and its data. | +| `clawctl gateway-service restart` | Stop the gateway and start it again as one lifecycle operation. If the stop cannot be verified, it retains the gateway record and does not start a replacement. If no gateway is running, it starts one. | | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing diff --git a/docs/architecture.md b/docs/architecture.md index 9df8748a..62d3773e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,6 +163,11 @@ OpenClaw resolve its own configuration and prevents a packaging-side default from overriding upstream behavior or sending a user to a port that was never observed. +`gateway-service restart` performs the existing stop and start operations +under one lifecycle lock. A missing or already-exited gateway makes the stop a +no-op and the start continues. If the gateway cannot be confirmed stopped, the +operation retains its ownership record and does not launch a replacement. + ## Diagnostics and build inputs have different trust roles [`HostDiagnosticLog`](../src/OpenClaw.Launcher/HostDiagnosticLog.cs) writes diff --git a/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md index 2c859d27..afdfa022 100644 --- a/docs/mxc-compatibility-evidence.md +++ b/docs/mxc-compatibility-evidence.md @@ -178,8 +178,8 @@ setup. The collector does not enumerate arbitrary agent-profile files. naming Windows Update or a newer Windows version as the remedy. 2. Run `openclaw ` for the upstream OpenClaw CLI, or `clawctl pwsh` for an interactive agent shell. -3. Use `clawctl gateway-service start`, `status`, and `stop` for the managed - gateway. Use `clawctl status` to inspect session ownership and state. +3. Use `clawctl gateway-service start`, `status`, `stop`, and `restart` for the + managed gateway. Use `clawctl status` to inspect session ownership and state. 4. Run `clawctl collect-logs` when reporting a problem, then review the resulting ZIP before sharing it. 5. Run `clawctl teardown --force` to confirm removal of the owned isolated diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1291a04e..e804557e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -96,11 +96,14 @@ then run: clawctl gateway-service start ``` -For an unhealthy gateway, inspect the emitted log tail or run -`clawctl gateway-service stop` before attempting another start. Use the port -reported by status as the observed endpoint. Do not assume the upstream -default port (18789): an explicit OpenClaw `gateway.port` can differ, and -multiple unclassified listeners intentionally do not identify an endpoint. +For an unhealthy gateway, inspect the emitted log tail, then run +`clawctl gateway-service restart` to stop the verified gateway and start its +replacement. If the stop cannot be verified, restart retains the gateway +record and aborts rather than risk starting a second process. Use +`clawctl gateway-service stop` instead when the gateway should remain stopped. +Use the port reported by status as the observed endpoint. Do not assume the +upstream default port (18789): an explicit OpenClaw `gateway.port` can differ, +and multiple unclassified listeners intentionally do not identify an endpoint. ## Status says the default configuration is missing or not ready diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 5052242a..f774e453 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -15,6 +15,7 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStart { get; init; } public required Func> GatewayStatus { get; init; } public required Func> GatewayStop { get; init; } + public required Func> GatewayRestart { get; init; } } internal sealed record SetupOptions(bool Fresh, bool Force); @@ -196,9 +197,17 @@ public static RootCommand Create( outputOptions.NoColor = parsed.GetValue(noColor); return handlers.GatewayStop(token); }); + Command gatewayRestart = new("restart", "Stop the gateway and start it again."); + gatewayRestart.SetAction((parsed, token) => + { + outputOptions.Json = parsed.GetValue(json); + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.GatewayRestart(token); + }); gateway.Subcommands.Add(gatewayStart); gateway.Subcommands.Add(gatewayStatus); gateway.Subcommands.Add(gatewayStop); + gateway.Subcommands.Add(gatewayRestart); RootCommand root = new(RootDescription) { diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 37785940..b5865390 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -511,7 +511,8 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result) Gateway.GatewayState.Stopped => result.Action switch { "stop" => Status(view, StatusKind.Success, "stopped"), - "start" => Status(view, StatusKind.Failure, "exited during startup"), + "start" or "restart" => + Status(view, StatusKind.Failure, "exited during startup"), _ => Status(view, StatusKind.Neutral, "stopped") }, Gateway.GatewayState.Starting => Status(view, StatusKind.Warning, "starting"), diff --git a/src/OpenClaw.Launcher/Gateway/GatewayController.cs b/src/OpenClaw.Launcher/Gateway/GatewayController.cs index 6ae69394..7ab5c801 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayController.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayController.cs @@ -49,6 +49,10 @@ internal sealed record GatewayStartResult( internal sealed record GatewayStopResult( bool Stopped, string Message, string? Detail = null, bool Succeeded = true); +internal sealed record GatewayRestartResult( + GatewayStopResult Stop, + GatewayStartResult? Start); + /// /// Owns this installation's background gateway. /// @@ -176,6 +180,16 @@ public async Task StartAsync( IProgress? progress = null) { using ISessionLockHandle handle = AcquireLock(); + return await StartUnderLockAsync(helperPath, cancellationToken, progress) + .ConfigureAwait(false); + } + + private async Task StartUnderLockAsync( + string helperPath, + CancellationToken cancellationToken, + IProgress? progress, + bool? autostartDisabledOverride = null) + { SessionRecord configured = _requireSetup(); GatewayStateResult existing = _store.Read(); if (existing.Record is null && existing.Fault != GatewayStateFault.Missing) @@ -281,7 +295,8 @@ public async Task StartAsync( // An explicit earlier choice to disable logon recovery survives a // restart, so a later manual start does not quietly re-enable it. - AutostartDisabled = existing.Record?.AutostartDisabled ?? false + AutostartDisabled = + autostartDisabledOverride ?? existing.Record?.AutostartDisabled ?? false }; _store.Write(record); @@ -326,6 +341,34 @@ record = record with { ObservedPorts = observed.ListeningPorts }; }); } + /// + /// Stops the gateway and starts it again without releasing the lifecycle + /// lock between the two operations. + /// + public async Task RestartAsync( + string helperPath, + CancellationToken cancellationToken, + IProgress? progress = null) + { + using ISessionLockHandle handle = AcquireLock(); + Report(progress, GatewayStartStage.Stopping, "Stopping the gateway."); + bool autostartDisabled = _store.Read().Record?.AutostartDisabled ?? false; + GatewayStopResult stopped = await StopUnderLockAsync( + helperPath, + cancellationToken).ConfigureAwait(false); + if (!stopped.Succeeded) + { + return new GatewayRestartResult(stopped, Start: null); + } + + GatewayStartResult started = await StartUnderLockAsync( + helperPath, + cancellationToken, + progress, + autostartDisabled).ConfigureAwait(false); + return new GatewayRestartResult(stopped, started); + } + /// /// Polls until a listener the gateway owns appears, or the budget is spent. /// diff --git a/src/OpenClaw.Launcher/Gateway/GatewayStartProgress.cs b/src/OpenClaw.Launcher/Gateway/GatewayStartProgress.cs index 2edd3947..64c11ec1 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayStartProgress.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayStartProgress.cs @@ -11,6 +11,9 @@ namespace OpenClaw.Launcher.Gateway; /// internal enum GatewayStartStage { + /// The existing gateway is being stopped before replacement. + Stopping, + /// The owned session is being started or confirmed. PreparingSession, @@ -33,6 +36,10 @@ internal enum GatewayStartStage internal sealed record GatewayStartProgress(GatewayStartStage Stage, string Message) : ClawCtlProgress(Message) { + internal static GatewayStartProgress StoppingFirst { get; } = new( + GatewayStartStage.Stopping, + "Stopping the gateway."); + internal static GatewayStartProgress Initial { get; } = new( GatewayStartStage.PreparingSession, "Preparing the isolated session."); diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index b8047a5c..e894b378 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -233,7 +233,7 @@ private static string ResolveClawCtlCommand(string[] args) string? action = args .Skip(index + 1) .FirstOrDefault(candidate => - candidate is "start" or "status" or "stop"); + candidate is "start" or "status" or "stop" or "restart"); return action is null ? argument : $"{argument} {action}"; } @@ -496,6 +496,34 @@ int WriteResult(IClawCtlResult result) return result.ExitCode; } + void AcknowledgeManualGatewayStart(Session.SessionRuntime runtime) + { + new Gateway.AgentGatewayGuidance( + runtime.LifecycleLock, + _ => throw new InvalidOperationException( + "Manual acknowledgement must not check config readiness."), + _ => throw new InvalidOperationException( + "Manual acknowledgement must not inspect the gateway."), + new Gateway.GatewayGuidanceStateStore( + runtime.Paths.GatewayGuidanceStatePath), + getLogonSessionId ?? Gateway.WindowsLogonSession.GetCurrentId, + log, + clock) + .AcknowledgeManualStart(); + } + + int WriteGatewayStartResult(string action, Gateway.GatewayStartResult result) + { + int? port = Gateway.GatewayAddress.ResolvePort(result.Record); + return WriteResult(new GatewayCommandResult( + action, + result.State, + result.Message, + null, + result.State == Gateway.GatewayState.Running ? 0 : 1, + port)); + } + async Task RunSetupCommandAsync( SetupOptions setupOptions, CancellationToken cancellationToken) @@ -739,18 +767,7 @@ runtime.Paths.PackageFamilyName is string packageFamilyName && .Controller; if (!recovery && !retainedRecoveryInvocation) { - new Gateway.AgentGatewayGuidance( - runtime.LifecycleLock, - _ => throw new InvalidOperationException( - "Manual acknowledgement must not check config readiness."), - _ => throw new InvalidOperationException( - "Manual acknowledgement must not inspect the gateway."), - new Gateway.GatewayGuidanceStateStore( - runtime.Paths.GatewayGuidanceStatePath), - getLogonSessionId ?? Gateway.WindowsLogonSession.GetCurrentId, - log, - clock) - .AcknowledgeManualStart(); + AcknowledgeManualGatewayStart(runtime); } // Narration is human guidance, so it is off whenever the @@ -770,14 +787,7 @@ runtime.Paths.PackageFamilyName is string packageFamilyName && .ConfigureAwait(false); } - int? port = Gateway.GatewayAddress.ResolvePort(result.Record); - return WriteResult(new GatewayCommandResult( - "start", - result.State, - result.Message, - null, - result.State == Gateway.GatewayState.Running ? 0 : 1, - port)); + return WriteGatewayStartResult("start", result); }, GatewayStatus = async cancellationToken => { @@ -837,6 +847,41 @@ result.State is Gateway.GatewayState.Running or result.Detail, result.Succeeded ? 0 : 1)); }, + GatewayRestart = async cancellationToken => + { + Session.SessionRuntime runtime = GetSessionRuntime(); + AcknowledgeManualGatewayStart(runtime); + Gateway.GatewayController controller = Gateway.GatewayRuntime + .Create(options, runtime.Paths, runtime, log, clock) + .Controller; + (bool useColor, IDisposable? restore) = PrepareColor(); + Gateway.GatewayRestartResult result; + using (restore) + { + result = await ClawCtlConsole.NarrateAsync( + output, + useColor, + narrate: !outputOptions.Json, + Gateway.GatewayStartProgress.StoppingFirst, + progress => controller.RestartAsync( + runtime.HelperPath, + cancellationToken, + progress)) + .ConfigureAwait(false); + } + + if (result.Start is null) + { + return WriteResult(new GatewayCommandResult( + "restart", + Gateway.GatewayState.Unknown, + result.Stop.Message, + result.Stop.Detail, + 1)); + } + + return WriteGatewayStartResult("restart", result.Start); + }, }, outputOptions); diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index de697291..ef698303 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -37,6 +37,7 @@ private static async Task Main() ("bare clawctl prints help", BareInvocationPrintsHelpAsync), ("--help prints help", HelpOptionPrintsHelpAsync), ("setup --help prints command help", SetupHelpPrintsCommandHelpAsync), + ("gateway-service help includes restart", GatewayServiceHelpIncludesRestartAsync), ("--version reports the launcher", VersionReportsLauncherAssemblyAsync), ("--version wins over trailing arguments", VersionWinsOverTrailingAsync), ("unknown command fails", UnknownCommandFailsAsync), @@ -149,6 +150,19 @@ private static async Task SetupHelpPrintsCommandHelpAsync() fixture.AssertNoInstallationWorkStarted(); } + private static async Task GatewayServiceHelpIncludesRestartAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture + .RunAsync(["gateway-service", "--help"]) + .ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + AssertContains(fixture.Output.ToString(), "restart", fixture); + fixture.AssertNoInstallationWorkStarted(); + } + // This driver's assembly version is 9.9.9.9. The library's built-in action // reports the entry assembly, so if the custom action were ever dropped // this scenario would print 9.9.9.9 instead of the baked build identity. diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index cbcc7dd9..151a6d63 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -88,7 +88,8 @@ public void OnlyTheOwnedCommandsAreExposed() PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }); Assert.Equal( @@ -121,7 +122,8 @@ public async Task GatewayServiceStartInvokesOnlyTheStartHandler() return Task.FromResult(0); }, GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }); int exitCode = await root.Parse("gateway-service start").InvokeAsync(); @@ -149,7 +151,8 @@ public async Task OpenInvokesItsHandlerAndInheritsOutputOptions() PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }, outputOptions); int exitCode = await root.Parse("open --json --no-color").InvokeAsync(); @@ -160,6 +163,56 @@ public async Task OpenInvokesItsHandlerAndInheritsOutputOptions() Assert.True(outputOptions.NoColor); } + [Fact] + public async Task GatewayServiceRestartInvokesOnlyTheRestartHandler() + { + int restarts = 0; + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (_, _) => Task.FromResult(0), + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => + { + restarts++; + return Task.FromResult(0); + } + }); + + int exitCode = await root.Parse("gateway-service restart").InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(1, restarts); + } + + [Fact] + public async Task GatewayServiceRestartRejectsTheRecoveryMarker() + { + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (_, _) => Task.FromResult(0), + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) + }); + + int exitCode = await root + .Parse("gateway-service restart --recovery") + .InvokeAsync(); + + Assert.Equal(1, exitCode); + } + [Theory] [InlineData("gateway-service start", false)] [InlineData("gateway-service start --recovery", true)] @@ -181,7 +234,8 @@ public async Task GatewayServiceStartReportsRecoveryProvenance( return Task.FromResult(0); }, GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }); int exitCode = await root.Parse(commandLine).InvokeAsync(); @@ -199,6 +253,7 @@ public async Task GatewayServiceStartReportsRecoveryProvenance( [InlineData("gateway-service start --json")] [InlineData("gateway-service status --json")] [InlineData("gateway-service stop --json")] + [InlineData("gateway-service restart --json")] public async Task JsonIsAvailableToEveryNonInteractiveCommand(string commandLine) { var outputOptions = new ClawCtlOutputOptions(); @@ -211,7 +266,8 @@ public async Task JsonIsAvailableToEveryNonInteractiveCommand(string commandLine PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }, outputOptions); int exitCode = await root.Parse(commandLine).InvokeAsync(); @@ -243,6 +299,7 @@ public async Task JsonIsRejectedForInteractivePowerShell() [InlineData("gateway-service start --no-color")] [InlineData("gateway-service status --no-color")] [InlineData("gateway-service stop --no-color")] + [InlineData("gateway-service restart --no-color")] public async Task NoColorIsAvailableToEveryCommand(string commandLine) { var outputOptions = new ClawCtlOutputOptions(); @@ -255,7 +312,8 @@ public async Task NoColorIsAvailableToEveryCommand(string commandLine) PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }, outputOptions); int exitCode = await root.Parse(commandLine).InvokeAsync(); @@ -299,7 +357,8 @@ public async Task SetupFreshPassesTheExplicitDestructiveAuthorization() PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }); int exitCode = await root.Parse("setup --fresh").InvokeAsync(); @@ -325,7 +384,8 @@ public async Task SetupFreshForcePassesTheExplicitRecoveryOverride() PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }); int exitCode = await root.Parse("setup --fresh --force").InvokeAsync(); diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs index b9b2bc63..e960de2a 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs @@ -15,7 +15,8 @@ public sealed class ClawCtlHelpTests PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }; private static ClawCtlHelpModel Describe(string commandPath) diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 1d367cbb..6ee69217 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -75,7 +75,8 @@ public void CompletionSuggestsCommandsWithoutResolvingNode() PowerShell = _ => Task.FromResult(0), GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), - GatewayStop = _ => Task.FromResult(0) + GatewayStop = _ => Task.FromResult(0), + GatewayRestart = _ => Task.FromResult(0) }); IEnumerable completions = root diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs index c4b2e4f3..c9cef095 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs @@ -156,6 +156,22 @@ public void AGatewayThatExitedDuringStartupIsNotReportedAsASuccessfulStop() Assert.Contains("[ok] stopped", stopped.ToString(), StringComparison.Ordinal); } + [Fact] + public void AGatewayThatExitedDuringRestartIsReportedAsAStartupFailure() + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteResult(output, new GatewayCommandResult( + "restart", + GatewayState.Stopped, + "The gateway exited during startup.", + null, + 1)); + + Assert.Contains("exited during startup", output.ToString(), StringComparison.Ordinal); + Assert.Contains("[x]", output.ToString(), StringComparison.Ordinal); + } + [Fact] public void StoppedStatusIsNotReportedAsAStartupFailure() { diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartHarness.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartHarness.cs index 32752728..47b65687 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartHarness.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartHarness.cs @@ -6,8 +6,8 @@ namespace OpenClaw.Launcher.Tests.Gateway; /// /// A gateway controller wired to fixture-owned storage, a dictated guest, and a -/// clock the test controls, so start behaviour can be exercised without a real -/// session, a real process, or real elapsed time. +/// clock the test controls, so gateway lifecycle operations can be exercised +/// without a real session, a real process, or real elapsed time. /// internal sealed class GatewayStartHarness : IDisposable { @@ -36,23 +36,39 @@ public GatewayStartHarness() public DateTimeOffset Start { get; } + public AlwaysFreeLock LifecycleLock { get; } = new(); + public GatewayStateStore Store => new(Path.Combine(_root, "gateway.json")); public Task StartAsync( IProgress? progress = null) => CreateController().StartAsync("helper.exe", CancellationToken.None, progress); + public Task RestartAsync( + IProgress? progress = null) => + CreateController().RestartAsync("helper.exe", CancellationToken.None, progress); + /// /// Records a gateway whose identity matches what the fake guest reports, so /// the controller treats it as one it already owns. /// - public void RecordRunningGateway() => + public void RecordRunningGateway(bool autostartDisabled = false) => Store.Write(new GatewayRecord { SandboxId = SandboxId, ProcessId = 1234, ProcessStartTimeUtc = new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero), - StatusPath = "status.json" + StatusPath = "status.json", + AutostartDisabled = autostartDisabled + }); + + public void RecordPendingGateway() => + Store.Write(new GatewayRecord + { + SandboxId = SandboxId, + LaunchPending = true, + ProcessStartTimeUtc = Start, + StartedUtc = Start }); private GatewayController CreateController() @@ -77,7 +93,7 @@ private GatewayController CreateController() _ => { }, () => sessions.GetRecordedStatus().Record ?? throw new SessionException("Run setup."), - new AlwaysFreeLock(), + LifecycleLock, Clock); } diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs index 9ecaf1b2..b0278fb1 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs @@ -59,6 +59,105 @@ public async Task StartWaitsForTheListenerToAppear() Assert.True(harness.Clock.WaitCount >= 2); } + [Fact] + public async Task RestartStopsTheRunningGatewayBeforeStartingItsReplacement() + { + using var harness = new GatewayStartHarness(); + harness.RecordRunningGateway(autostartDisabled: true); + harness.Client.Inspection = Listening(); + + GatewayRestartResult result = await harness.RestartAsync(); + + Assert.True(result.Stop.Stopped); + Assert.Equal(GatewayState.Running, result.Start!.State); + Assert.Equal( + ["inspect:1234", "stop:1234", "start", "inspect:1234"], + harness.Client.Calls); + Assert.True(result.Start!.Record.AutostartDisabled); + Assert.Equal(1, harness.LifecycleLock.TotalAcquisitions); + } + + [Fact] + public async Task RestartStartsWhenNoGatewayWasRunning() + { + using var harness = new GatewayStartHarness(); + harness.Client.Inspection = Listening(); + + GatewayRestartResult result = await harness.RestartAsync(); + + Assert.True(result.Stop.Succeeded); + Assert.False(result.Stop.Stopped); + Assert.Equal(GatewayState.Running, result.Start!.State); + Assert.DoesNotContain( + harness.Client.Calls, + call => call.StartsWith("stop:", StringComparison.Ordinal)); + } + + [Fact] + public async Task RestartStartsWhenTheRecordedGatewayAlreadyExited() + { + using var harness = new GatewayStartHarness(); + harness.RecordRunningGateway(); + harness.Client.InspectionSequence.Enqueue(Gone()); + harness.Client.Inspection = Listening(); + + GatewayRestartResult result = await harness.RestartAsync(); + + Assert.True(result.Stop.Succeeded); + Assert.False(result.Stop.Stopped); + Assert.Equal(GatewayState.Running, result.Start!.State); + Assert.DoesNotContain( + harness.Client.Calls, + call => call.StartsWith("stop:", StringComparison.Ordinal)); + } + + [Fact] + public async Task RestartDoesNotLaunchWhenTheGatewayCannotBeInspected() + { + using var harness = new GatewayStartHarness(); + harness.RecordRunningGateway(); + harness.Client.Inspection = new SessionInspectResult { Error = "no answer" }; + + GatewayRestartResult result = await harness.RestartAsync(); + + Assert.False(result.Stop.Succeeded); + Assert.Null(result.Start); + Assert.DoesNotContain("start", harness.Client.Calls); + } + + [Fact] + public async Task RestartDoesNotLaunchWhenTheStopCannotBeVerified() + { + using var harness = new GatewayStartHarness(); + harness.RecordRunningGateway(); + harness.Client.Inspection = Listening(); + harness.Client.StopResult = new SessionInspectResult + { + ProcessFound = true, + StartTimeMatches = true, + Error = "stop could not be verified" + }; + + GatewayRestartResult result = await harness.RestartAsync(); + + Assert.False(result.Stop.Succeeded); + Assert.Null(result.Start); + Assert.DoesNotContain("start", harness.Client.Calls); + } + + [Fact] + public async Task RestartDoesNotLaunchOverAnUnconfirmedLaunch() + { + using var harness = new GatewayStartHarness(); + harness.RecordPendingGateway(); + + GatewayRestartResult result = await harness.RestartAsync(); + + Assert.False(result.Stop.Succeeded); + Assert.Null(result.Start); + Assert.DoesNotContain("start", harness.Client.Calls); + } + // The budget has to end the wait, or a wedged launch would hold the // terminal open indefinitely. [Fact] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index cf52ec0a..1e838375 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -133,6 +133,7 @@ public async Task JsonFailureStillExitsWhenStandardOutputFails() [InlineData("status --json", "status")] [InlineData("--json status", "status")] [InlineData("gateway-service start --json", "gateway-service start")] + [InlineData("gateway-service restart --json", "gateway-service restart")] public async Task JsonOperationalFailurePreservesTheSelectedCommand( string commandLine, string expectedCommand) diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 390d5e12..10c482ad 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -253,6 +253,28 @@ await Assert.ThrowsAsync( Assert.True(store.IsAcknowledged("logon-manual")); } + [Fact] + public async Task ManualGatewayRestartAcknowledgesBeforeAStartFailure() + { + SessionRuntime runtime = await SetUpSessionAsync().ConfigureAwait(true); + ((FakeMxcSessionClient)runtime.Backend).ExecuteFailure = + new SessionException("gateway restart failed"); + + await Assert.ThrowsAsync( + () => Program.RunControlAsync( + CreateSetupOptions(Path.Combine(_testDirectory, "app")), + ["gateway-service", "restart"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime), + getLogonSessionId: () => "logon-restart")); + + var store = new GatewayGuidanceStateStore( + runtime.Paths.GatewayGuidanceStatePath); + Assert.True(store.IsAcknowledged("logon-restart")); + } + [Fact] public async Task RetainedRecoveryScriptUpgradesWithoutManualAcknowledgement() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs index 31a4dbf4..8ffd3818 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs @@ -6,9 +6,12 @@ internal sealed class AlwaysFreeLock : ISessionLock { public int HeldCount { get; private set; } + public int TotalAcquisitions { get; private set; } + public ISessionLockHandle? TryAcquire(TimeSpan timeout) { HeldCount++; + TotalAcquisitions++; return new Handle(this); }