From 7b8ab0aa45a6fd08fd29b8a760bb7fb214da56c3 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Mon, 21 Sep 2026 16:21:54 -0700 Subject: [PATCH 1/5] feat: set up on first launch and start an eligible gateway `openclaw` now provisions this installation when its setup marker is absent, then forwards its arguments unchanged. Only an absent marker is provisioned: unreadable, incomplete, foreign, newer-schema, preparing, and tearing-down state keep their explicit recovery messages. Where the launcher previously printed a `clawctl gateway-service start` hint, it now starts the managed gateway. A failed start warns on standard error and leaves the OpenClaw exit code untouched. CLAWCTL_AUTO_SETUP and CLAWCTL_AUTO_GATEWAY_START each restore the previous behavior exactly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e9ba17a2-0bca-4454-96e3-f42ba540a268 --- AGENTS.md | 2 +- README.md | 76 ++- docs/architecture.md | 35 +- docs/clawctl-output-style.md | 15 +- docs/mxc-compatibility-evidence.md | 41 +- docs/troubleshooting.md | 73 ++- src/AGENTS.md | 2 +- src/OpenClaw.Launcher/ClawCtlConsole.cs | 43 ++ .../Gateway/AgentGatewayGuidance.cs | 87 ++- .../Gateway/AgentPostflight.cs | 173 ++++++ .../Gateway/AutoBehaviorPolicy.cs | 50 ++ src/OpenClaw.Launcher/HostOptions.cs | 36 ++ .../OpenClawRuntimeEnvironment.cs | 4 + src/OpenClaw.Launcher/Program.cs | 580 ++++-------------- .../Session/SetupOrchestrator.cs | 484 +++++++++++++++ .../AgentImplicitSetupTests.cs | 347 +++++++++++ .../Gateway/AgentGatewayGuidanceTests.cs | 364 ++++++----- .../Gateway/AutoBehaviorPolicyTests.cs | 181 ++++++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 101 ++- .../Session/SetupOrchestratorTests.cs | 308 ++++++++++ 20 files changed, 2317 insertions(+), 685 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/AgentPostflight.cs create mode 100644 src/OpenClaw.Launcher/Gateway/AutoBehaviorPolicy.cs create mode 100644 src/OpenClaw.Launcher/Session/SetupOrchestrator.cs create mode 100644 tests/OpenClaw.Launcher.Tests/AgentImplicitSetupTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/AutoBehaviorPolicyTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs diff --git a/AGENTS.md b/AGENTS.md index ee0e81a1..7b3c9d65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ The task defines scope and authorization; its chosen workflow owns execution, re - **One owner per responsibility.** An owner makes a decision or changes authoritative state. Callers consume its operations and recorded facts; adapters translate contracts, and caches or projections derive from the owner with an explicit invalidation lifecycle. - **Thin Windows host, upstream OpenClaw.** This repository packages and launches the pinned upstream application; it does not reimplement OpenClaw commands, parse `openclaw` arguments, copy the application payload, or invent a second configuration owner. - **One release trust chain.** Upstream revision, Node.js version and archive, MXC runtime lock, application inventory, package identity, payload metadata, signing policy, and x64/ARM64 outputs are coordinated inputs. Change every producer, validator, consumer, test, and document together. -- **Explicit isolation lifecycle.** `clawctl setup` provisions and records the isolated session; `openclaw` starts only that recorded session. Do not add implicit provisioning or direct execution outside the session. +- **Explicit isolation lifecycle.** `clawctl setup` provisions and records the isolated session. `openclaw` provisions only when the setup marker is absent, then starts that recorded session; every unreadable, incomplete, foreign, newer-schema, preparing, tearing-down, or otherwise degraded state remains an explicit `clawctl setup` or `clawctl teardown` recovery path. Do not add other implicit provisioning or direct execution outside the session. ## Working agreement diff --git a/README.md b/README.md index 7520fbe9..5dc6f99e 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ packages. - Windows 11 on a build that supports isolated agent sessions, on x64 or ARM64. OpenClaw always runs inside an isolated session, so a machine that cannot host - one is not supported: `clawctl setup` and `openclaw` both fail with a message - naming this requirement and the diagnostic log path. Install the latest - Windows updates (Settings > Windows Update), or install a newer Windows - version, and run `clawctl setup` again. + one is not supported: both `clawctl setup` and `openclaw` fail with a message + naming this requirement and the diagnostic log path. `openclaw` first + attempts automatic setup. Install the latest Windows updates (Settings > + Windows Update), or install a newer Windows version, and run `clawctl setup` + again. - Developer Mode is required only for the local loose-layout development loop, not for the signed MSIX. @@ -53,13 +54,17 @@ own package-management commands. Every argument, including an empty argument list, is forwarded unchanged to `node openclaw.mjs`, and the launcher returns the exact child exit code. -`openclaw` runs only inside the isolated agent session recorded by -`clawctl setup`. It does not run OpenClaw on the host, and it fails rather than -falling back when the recorded session is missing, owned by another -installation, or unavailable. +`openclaw` runs only inside an isolated agent session. When the setup marker is +absent, it provisions the installation before forwarding its arguments, so a +clean machine can start with `openclaw`, including `openclaw --help` and +`openclaw --version`. It does not run OpenClaw on the host. Unreadable, +incomplete, foreign, newer-schema, preparing, or tearing-down setup markers, +session mismatches, and stale agent Node.js runtimes remain explicit recovery +states with their `clawctl setup` or `clawctl teardown` guidance; automatic +setup never repairs them. Before launching, the host resolves the bundled Node.js executable previously -prepared by `clawctl setup` and checks its PE product version and executable +prepared during setup and checks its PE product version and executable architecture against the packaged archive without a separate Node.js process. The runtime directory is prepended to the child's `PATH` so Node.js, npm, and npx subprocesses use the bundled @@ -88,18 +93,33 @@ the read-only application directory the workspace. After each successful interactive `openclaw` invocation, the launcher performs a file-only readiness check inside the isolated session. If the default -`.openclaw\openclaw.json` has `gateway.mode` set to `local` but the managed -gateway has never started or has stopped, it suggests -`clawctl gateway-service start` on standard error. It does not suggest a second -start when gateway status is starting, unhealthy, or unknown. The check stops -for the current Windows logon after that start command is invoked or a running -gateway is observed; a new Windows logon enables it again. On an interactive -terminal the hint uses the crab identity and the same warning/accent palette as -`clawctl`; redirected and explicitly color-disabled output remains plain. +`.openclaw\openclaw.json` has `gateway.mode` set to `local` and the managed +gateway is `NotStarted` or `Stopped`, it starts the gateway when readiness is +`startup eligible`, narrating progress on standard error. It does nothing for +a non-zero OpenClaw exit code, redirected output, non-eligible readiness, or a +`Starting`, `Unhealthy`, or `Unknown` gateway. An observed `Running` gateway is +acknowledged. A failed or unverified start writes a standard-error warning and +a retry command, `clawctl gateway-service start`, without changing the +OpenClaw exit code; the next eligible run retries. A successful start is +acknowledged for the current Windows logon. See [environment +variables](#environment-variables) to restore the prior start hint. + +### Environment variables + +These host-side variables are not passed to the OpenClaw child. Automatic +behavior is enabled by default. Set either variable to `0`, `false`, `no`, or +`off` (case-insensitive and with surrounding whitespace ignored) to suppress +its automatic behavior; any other value, including an unset variable, leaves +it enabled. + +| Variable | Suppressed behavior | +| --- | --- | +| `CLAWCTL_AUTO_SETUP` | Restores the previous clean-machine failure: `openclaw` reports that it has not been set up and directs you to `clawctl setup`; it provisions, records, and registers nothing. | +| `CLAWCTL_AUTO_GATEWAY_START` | Restores the previous `clawctl gateway-service start` hint instead of automatically starting an eligible gateway. | ### `clawctl` -`clawctl` owns setup and the isolated-session operations: +`clawctl` owns explicit setup, recovery, and the isolated-session operations: | Command | Behavior | |---|---| @@ -260,10 +280,11 @@ an upgrade once nothing is still running from it; a launch holds its root for its whole lifetime, and a root that is still held is left whole for a later setup. -Run setup before using `openclaw`, `clawctl pwsh`, or gateway-service start. -There is no session-free mode: `openclaw` runs inside the session recorded by -setup, and both entry points fail with the same message on a machine that -cannot host one. See +Run `clawctl setup` before using `clawctl pwsh` or gateway-service start. +`openclaw` provisions a clean installation automatically, but there is no +session-free mode: it runs only inside the recorded session and leaves degraded +setup state to explicit recovery. Both setup paths fail with the same message +on a machine that cannot host a session. See [MXC compatibility evidence](docs/mxc-compatibility-evidence.md) for the session model, gateway health criteria, and diagnostics limits. @@ -272,16 +293,19 @@ The launcher places Node.js in a Windows job configured with runs; if the launcher exits or is terminated, Windows terminates Node.js and its child processes when the job handle closes. -Prepare the bundled runtime once, then use `openclaw`: +On a clean installation, start directly with `openclaw`: ```powershell -clawctl setup openclaw ``` +Run `clawctl setup` first when you want the explicit setup, recovery, or +`--fresh` path. + When an MSIX update changes the bundled Node.js version, run `clawctl setup` -again before launching OpenClaw. Previously extracted versions are left in -place so an update does not remove a running process's runtime. +before launching OpenClaw. Automatic setup does not repair a stale agent Node.js +runtime after an update. Previously extracted versions are left in place so an +update does not remove a running process's runtime. ## Selecting the OpenClaw revision diff --git a/docs/architecture.md b/docs/architecture.md index 8733f44f..e3140f68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,12 +62,17 @@ flowchart LR ## Setup creates an explicit, isolated execution boundary -An ordinary `openclaw` invocation does not provision a session or install a -runtime. It can use only the recorded, usable isolated session created by -explicit setup. Failing instead of provisioning during an application launch -keeps a foreground command from unexpectedly downloading, extracting, or -creating an agent environment, and makes setup state observable and -recoverable. +`clawctl setup` creates and records the isolated session. An `openclaw` +invocation whose setup marker is absent provisions that same boundary before +forwarding its arguments, including for `--help` and `--version`. It never runs +OpenClaw on the host. + +Automatic setup is deliberately narrow. An unreadable, incomplete, foreign, +newer-schema, preparing, or tearing-down marker, a session mismatch, and a +stale agent Node.js runtime remain explicit recovery states with their existing +`clawctl setup` or `clawctl teardown` guidance. Automatic setup does not repair +degraded state. The lifecycle lock double-checks an absent marker so concurrent +first launches provision once. [`SetupStateStore`](../src/OpenClaw.Launcher/Session/SetupStateStore.cs) persists the setup phase and its durable outcome. [`SessionRuntime`](../src/OpenClaw.Launcher/Session/SessionRuntime.cs) @@ -221,14 +226,16 @@ flowchart LR 1. Windows activates an alias; the one NativeAOT host identifies the agent or control application identity. -2. `clawctl setup` explicitly creates or repairs the isolated session, - stages the helper, and installs the agent-owned Node runtime. -3. `openclaw` loads the recorded session and sends a JSON launch request to - the staged helper through the MXC adapter; it never provisions implicitly. -4. The helper replays the argument vector inside the agent session with the - agent runtime first on that child process's path. -5. If configured, `gateway-service` records its launch configuration and the - logon task restarts the gateway through the same session boundary. +2. `clawctl setup` explicitly creates or repairs the isolated session, stages + the helper, and installs the agent-owned Node runtime. When the setup marker + is absent, `openclaw` performs that provisioning first. +3. For every other marker state, `openclaw` preserves the explicit recovery + result rather than repairing it automatically. +4. `openclaw` loads the recorded session and sends a JSON launch request to + the staged helper through the MXC adapter. +5. The helper replays the argument vector inside the agent session with the + agent runtime first on that child process's path. If eligible, a successful + interactive launch starts the managed gateway through this same boundary. ## Source map diff --git a/docs/clawctl-output-style.md b/docs/clawctl-output-style.md index 6e4ddf79..81ef31c3 100644 --- a/docs/clawctl-output-style.md +++ b/docs/clawctl-output-style.md @@ -21,10 +21,17 @@ should describe that environment in terms an operator can act on, while presents onboarding as mandatory nor launches it automatically. - End neutral states with the command that moves the user forward, such as `Run: clawctl setup`. -- When a gateway is not running, show the file-only agent config readiness - immediately after the gateway row. Suggest `clawctl gateway-service start` - only when readiness is `startup eligible`; absent, incomplete, unavailable, - and unknown config states need diagnosis or configuration instead. +- When a successful interactive `openclaw` launch finds a `NotStarted` or + `Stopped` gateway with `startup eligible` readiness, narrate the managed + gateway start on standard error. Do nothing for non-zero child exits, + redirected output, non-eligible readiness, or `Starting`, `Unhealthy`, and + `Unknown` gateway states; acknowledge an observed running gateway. +- A failed or unverified automatic gateway start must not change the OpenClaw + exit code. Write a warning to standard error, followed by + `clawctl gateway-service start` on its own line so width-constrained wrapping + cannot break the retry command. Leave the guidance unacknowledged so a later + eligible launch retries. `CLAWCTL_AUTO_GATEWAY_START=0` (or `false`, `no`, + or `off`) restores the start hint. - Keep a successful `clawctl pwsh` launch silent so the user reaches the shell prompt directly. Its help text explains which commands are available inside the session. diff --git a/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md index afdfa022..5d0bea7c 100644 --- a/docs/mxc-compatibility-evidence.md +++ b/docs/mxc-compatibility-evidence.md @@ -23,7 +23,7 @@ into that command line. ## Session ownership and routing -`clawctl setup` is the required lifecycle entry point. It writes package-local +`clawctl setup` is the explicit lifecycle entry point. It writes package-local setup state and records the session that this installation owns in `session.json`. Ownership is never inferred from a machine account or profile: unrelated agent accounts may exist, and teardown must remain safe and @@ -41,10 +41,16 @@ the ordinary setup route. It refuses unpackaged execution, never follows reparse points, and stops without a wipe or replacement setup when teardown is incomplete. It does not onboard OpenClaw or start the gateway. -`openclaw` always runs inside the isolated session recorded by `clawctl setup`. -There is no environment variable, option, or automatic fallback that runs -OpenClaw on the host. Before touching any recorded state, both `clawctl setup` -and `openclaw` require that this machine can host a session: an MXC runtime +`openclaw` always runs inside the isolated session recorded for this +installation. When its setup marker is absent, it provisions that session +before launching; the lifecycle lock double-checks the marker so concurrent +first launches provision only once. It leaves unreadable, incomplete, foreign, +newer-schema, preparing, and tearing-down markers, session mismatches, and +stale agent Node.js runtimes for their explicit `clawctl setup` or +`clawctl teardown` recovery paths. There is no environment variable, option, +or automatic fallback that runs OpenClaw on the host. Before touching any +recorded state, both `clawctl setup` and `openclaw` require that this machine +can host a session: an MXC runtime that is unavailable, a backend that reports no isolation-session support, a host build measured as unsupported, or a missing package identity all fail with the same message, which names Windows Update or a newer Windows version as the @@ -130,18 +136,19 @@ stable reason. A running gateway omits readiness and avoids the helper call. After an `openclaw` child exits, the launcher uses the helper's file-only readiness result before checking the managed gateway record and liveness. A -successful interactive call gets a start suggestion only for `NotStarted` or -`Stopped`; unsuccessful or redirected calls, and `Starting`, `Unhealthy`, or -`Unknown` gateway states, remain silent. The advisory path cannot change the -OpenClaw exit code. - -The suggestion remains eligible until a manual -`clawctl gateway-service start` invocation or an observed `Running` state. -Acknowledgement is package-local and keyed to the Windows token authentication -ID, so it suppresses later checks only for the current Windows logon. The -sign-in recovery command carries a hidden provenance marker and does not count -as a manual acknowledgement; a later OpenClaw invocation that observes its -running gateway does. +successful interactive call starts the managed gateway only for `NotStarted` or +`Stopped` with `startup eligible` readiness; unsuccessful or redirected calls, +and `Starting`, `Unhealthy`, or `Unknown` gateway states, remain silent. An +observed `Running` gateway is acknowledged. A failed or unverified start warns +on standard error and prints `clawctl gateway-service start` on its own retry +line; it does not change the OpenClaw exit code or acknowledge the guidance, so +the next eligible launch retries. + +A successful automatic or manual start, or an observed running gateway, +acknowledges the guidance for the current Windows logon. Acknowledgement is +package-local and keyed to the Windows token authentication ID. Setting +`CLAWCTL_AUTO_GATEWAY_START` to `0`, `false`, `no`, or `off` restores the +previous start hint instead of automatically starting the gateway. Agent entrypoint startup captures and restores console state and initializes UTF-8 just as the control entrypoint does. Postflight rendering treats diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 52e95742..eeb8a3e1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -27,16 +27,35 @@ clawctl setup For another stated prerequisite, correct that prerequisite and rerun setup. +## Automatic setup failed during `openclaw` + +**Check.** Preserve the complete automatic-setup error from standard error. It +includes the setup failure reason and directs you to `clawctl setup`. + +**Likely cause.** The setup marker was absent, so `openclaw` attempted its +one-time provisioning path. Automatic setup does not write a ready marker when +that provisioning fails. + +**Fix.** Run: + +```powershell +clawctl setup +``` + +Correct the reported prerequisite or recovery condition, then run `openclaw` +again. + ## `openclaw` says setup is required, incomplete, or its setup marker is unusable **Check.** Preserve the exact setup error. It distinguishes a missing, unreadable, incompatible, incomplete, or foreign-package setup marker from a missing recorded session. -**Likely cause.** Running `openclaw` requires both the explicit, package-local -setup marker and the matching recorded isolated session. A session created by -an older management operation is not authorization to run OpenClaw, and the -runtime deliberately does not turn a stale marker into provisioning. +**Likely cause.** Running `openclaw` requires the package-local setup marker +and matching recorded isolated session. An absent marker normally triggers +automatic setup, but an unreadable, incomplete, foreign, newer-schema, +preparing, or tearing-down marker, a session mismatch, or a stale agent Node.js +runtime deliberately remains an explicit recovery state. **Fix.** Run: @@ -46,7 +65,7 @@ clawctl setup If the message instead says teardown is incomplete, finish it with `clawctl teardown` before setting up again. Do not expect `openclaw`, status, -or a gateway command to provision a replacement session implicitly. +or a gateway command to repair degraded setup state implicitly. ## The session is unavailable or will not start @@ -105,6 +124,50 @@ 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. +## Automatic gateway start failed after `openclaw` + +**Check.** Read the standard-error warning and the following +`clawctl gateway-service start` retry command. The OpenClaw exit code still +describes the OpenClaw child, not the postflight start attempt. + +**Likely cause.** The gateway was `NotStarted` or `Stopped` and configuration +was `startup eligible`, but the managed start did not complete or could not be +verified. The launcher deliberately leaves the guidance unacknowledged so a +later eligible interactive launch can retry. + +**Fix.** Run: + +```powershell +clawctl gateway-service start +``` + +If it fails, use the reported gateway status and diagnostic detail to correct +the condition before retrying. + +## Opting out of automatic setup or gateway start + +**Check.** Inspect the host environment for `CLAWCTL_AUTO_SETUP` or +`CLAWCTL_AUTO_GATEWAY_START`. + +**Likely cause.** Setting either variable to `0`, `false`, `no`, or `off` +(case-insensitive, with surrounding whitespace ignored) suppresses that +automatic behavior. Unset and unrecognized values leave it enabled. + +**Fix.** To restore the previous clean-machine setup failure, set: + +```powershell +$env:CLAWCTL_AUTO_SETUP = '0' +``` + +`openclaw` then reports that setup is required and provisions nothing. To +restore the previous gateway-start hint, set: + +```powershell +$env:CLAWCTL_AUTO_GATEWAY_START = '0' +``` + +The variables are host-side only and are not passed to the OpenClaw child. + ## Status says the default configuration is missing or not ready **Check.** Look at the readiness state and reason from `clawctl status` or diff --git a/src/AGENTS.md b/src/AGENTS.md index 69384d51..094c5964 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -11,7 +11,7 @@ Read the repository-root `AGENTS.md` before this file. This scope owns the three ## Invariants - `openclaw` arguments belong to upstream. Forward the original vector unchanged and keep System.CommandLine scoped to `clawctl`. -- Sessions are mandatory and explicit. `clawctl setup` provisions and records one; `openclaw` starts only the recorded session and never provisions implicitly. +- Sessions are mandatory. `clawctl setup` provisions and records one; `openclaw` provisions only when its setup marker is absent, then starts the recorded session. Unreadable, incomplete, foreign, newer-schema, preparing, tearing-down, and other degraded state remains an explicit `clawctl setup` or `clawctl teardown` recovery path. - Keep packaged `app\openclaw.mjs` immutable and execute it from the package inside the session. Install Node.js in the agent profile and prepend that runtime to the agent process path. Mirror only packages discovered to carry `.node`, `.dll`, or `.exe` artifacts into agent LocalState, preserving each owning package directory; never hard-code the package set. - The launch request names the staged native root and places the preload on the agent Node.js argument vector so OpenClaw's reconstructed agent CLI retains it. The preload appends itself to the agent account's `NODE_OPTIONS` for ordinary child processes; invoking-host values must not leak into the guest. - Every launch using a staged native root holds it for its entire lifetime. Leave a held superseded root intact for a later setup to reclaim; a successful rename or delete is not proof that no process is using it. diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 7cadc0cf..05fe0332 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -1,4 +1,5 @@ using System.Globalization; +using OpenClaw.Launcher.Gateway; using OpenClaw.SessionProtocol; using Spectre.Console; using Spectre.Console.Rendering; @@ -115,6 +116,48 @@ internal static void WriteGatewayHint( Render(output, paragraph, useColor, unicode); } + internal static Task NarrateGatewayStartAsync( + TextWriter output, + bool useColor, + bool narrate, + Func, Task> start) => + NarrateAsync( + output, + useColor, + narrate, + GatewayStartProgress.Initial, + start); + + internal static void WriteGatewayStartWarning( + TextWriter output, + string detail, + bool useColor = false, + bool? useUnicode = null) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentException.ThrowIfNullOrWhiteSpace(detail); + + bool unicode = useUnicode ?? SupportsUnicode(output); + var paragraph = new Paragraph(); + if (unicode) + { + paragraph.Append($"{IdentityMark} ", WarningStyle); + } + + paragraph.Append("Warning:", WarningStyle); + paragraph.Append($" {detail}"); + Render(output, paragraph, useColor, unicode); + + // The retry command is rendered separately so the width-constrained + // paragraph above cannot wrap it across lines, which would make it + // unusable to copy. + var retry = new Paragraph(); + retry.Append("Retry with "); + retry.Append("clawctl gateway-service start", AccentStyle); + retry.Append("."); + Render(output, retry, useColor, unicode); + } + internal static void WriteVersion(TextWriter output, bool useColor = false) { ArgumentNullException.ThrowIfNull(output); diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs index 3c1e3859..b370b767 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -10,6 +10,13 @@ internal sealed class AgentGatewayGuidance internal const string Hint = "Hint: The OpenClaw gateway is not running. " + "Run clawctl gateway-service start to start it."; + + /// + /// Kept on its own line, and short enough not to wrap, so the command + /// stays selectable and greppable in a narrow terminal. + /// + internal const string RetryGuidance = + "Retry with clawctl gateway-service start."; internal static readonly TimeSpan AdvisoryTimeout = TimeSpan.FromSeconds(5); private readonly ISessionLock _lifecycleLock; @@ -20,6 +27,13 @@ internal sealed class AgentGatewayGuidance private readonly Action _log; private readonly TimeProvider _clock; private readonly Action _writeHint; + private readonly Func< + TextWriter, + Func, Task>, + Task> _narrateGatewayStart; + private readonly Func, Task> _startGateway; + private readonly Action _writeGatewayStartFailure; + private readonly Func _readEnvironmentVariable; public AgentGatewayGuidance( ISessionLock lifecycleLock, @@ -29,7 +43,14 @@ public AgentGatewayGuidance( Func getLogonSessionId, Action log, TimeProvider? clock = null, - Action? writeHint = null) + Action? writeHint = null, + Func< + TextWriter, + Func, Task>, + Task>? narrateGatewayStart = null, + Func, Task>? startGateway = null, + Action? writeGatewayStartFailure = null, + Func? readEnvironmentVariable = null) { _lifecycleLock = lifecycleLock; _checkReadiness = checkReadiness; @@ -39,6 +60,18 @@ public AgentGatewayGuidance( _log = log; _clock = clock ?? TimeProvider.System; _writeHint = writeHint ?? (writer => writer.WriteLine(Hint)); + _narrateGatewayStart = narrateGatewayStart ?? + ((TextWriter _, Func, Task> start) => + start(new Progress())); + _startGateway = startGateway ?? (_ => throw new InvalidOperationException( + "Gateway start is not configured.")); + _writeGatewayStartFailure = writeGatewayStartFailure ?? + ((writer, message) => + { + writer.WriteLine(message); + writer.WriteLine(RetryGuidance); + }); + _readEnvironmentVariable = readEnvironmentVariable ?? Environment.GetEnvironmentVariable; } public async Task EvaluateAsync( @@ -59,7 +92,7 @@ public async Task EvaluateAsync( SessionConfigReadinessResult readiness = await _checkReadiness(readinessTimeout.Token).ConfigureAwait(false); _log($"Agent config readiness is {readiness.State} ({readiness.Reason})."); - if (readiness.State != SessionConfigReadinessState.StartupEligible) + if (readiness.State is not SessionConfigReadinessState.StartupEligible) { return; } @@ -76,11 +109,19 @@ public async Task EvaluateAsync( return; } - if (status.State is GatewayState.NotStarted or GatewayState.Stopped && - interactive && - openClawExitCode == 0) + switch (AutoBehaviorPolicy.DecideGatewayAction( + openClawExitCode, + interactive, + readiness.State.GetValueOrDefault(), + status.State, + _readEnvironmentVariable)) { - WriteHintIfUnacknowledged(logonSessionId, error); + case GatewayAutoAction.Hint: + WriteHintIfUnacknowledged(logonSessionId, error); + break; + case GatewayAutoAction.Start: + await StartGatewayAsync(logonSessionId, error).ConfigureAwait(false); + break; } } catch (Exception exception) when ( @@ -142,4 +183,38 @@ private void Acknowledge( ?? throw new SessionBusyException(AdvisoryTimeout); _state.Write(logonSessionId, acknowledgement, _clock.GetUtcNow()); } + + private async Task StartGatewayAsync(string logonSessionId, TextWriter error) + { + try + { + GatewayStartResult result = await _narrateGatewayStart(error, _startGateway) + .ConfigureAwait(false); + if (result.State == GatewayState.Running || result.AlreadyRunning) + { + Acknowledge( + logonSessionId, + GatewayGuidanceAcknowledgement.GatewayObservedRunning); + return; + } + + WriteGatewayStartFailure( + error, + $"Gateway start finished in {result.State} state: {result.Message}"); + } + catch (Exception exception) when ( + exception is MxcException or SessionException or SessionLaunchException or + OperationCanceledException or + IOException or UnauthorizedAccessException or InvalidOperationException or + ObjectDisposedException or Win32Exception) + { + WriteGatewayStartFailure( + error, + $"Gateway start failed: {exception.Message}"); + _log($"Gateway auto-start failed: {exception.GetType().Name}: {exception.Message}"); + } + } + + private void WriteGatewayStartFailure(TextWriter error, string detail) => + _writeGatewayStartFailure(error, detail); } diff --git a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs new file mode 100644 index 00000000..66aacaef --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs @@ -0,0 +1,173 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// +/// Builds and runs the work that follows an openclaw child process. +/// +/// +/// This lives beside the gateway rather than in Program so the agent +/// entry point stays a thin adapter, and so the console-capability resolution +/// the postflight needs is composed once instead of inline in a launch path. +/// +internal sealed class AgentPostflight +{ + private readonly AgentGatewayGuidance _guidance; + + internal AgentPostflight(AgentGatewayGuidance guidance) => _guidance = guidance; + + /// + /// Composes the postflight for a launch that has already resolved its + /// session, console capabilities, and environment reader. + /// + public static AgentPostflight Create( + HostOptions options, + SessionRuntime runtime, + SessionRecord record, + bool interactive, + Func readEnvironmentVariable, + Action log, + TimeProvider? clock, + Func? getLogonSessionId, + Func? errorIsProcessConsoleWriter, + Func? errorIsInteractive, + Func? supportsUnicode) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + ArgumentNullException.ThrowIfNull(log); + + GatewayController gateway = GatewayRuntime + .Create(options, runtime.Paths, runtime, log, clock) + .Controller; + return new AgentPostflight(new AgentGatewayGuidance( + runtime.LifecycleLock, + cancellationToken => runtime.Executor.CheckConfigReadinessAsync( + record, + runtime.RequireStagedHelper(record), + cancellationToken), + cancellationToken => gateway.GetStatusAsync( + runtime.HelperPath, + cancellationToken), + new GatewayGuidanceStateStore(runtime.Paths.GatewayGuidanceStatePath), + getLogonSessionId ?? WindowsLogonSession.GetCurrentId, + log, + clock, + target => + { + using GatewayOutput output = PrepareGatewayOutput( + target, + interactive, + readEnvironmentVariable, + log, + errorIsProcessConsoleWriter, + errorIsInteractive, + supportsUnicode); + ClawCtlConsole.WriteGatewayHint( + target, + output.UseColor, + output.UseUnicode); + }, + async (target, start) => + { + using GatewayOutput output = PrepareGatewayOutput( + target, + interactive, + readEnvironmentVariable, + log, + errorIsProcessConsoleWriter, + errorIsInteractive, + supportsUnicode); + return await ClawCtlConsole.NarrateGatewayStartAsync( + target, + output.UseColor, + interactive, + start).ConfigureAwait(false); + }, + progress => gateway.StartAsync( + runtime.HelperPath, + CancellationToken.None, + progress), + (target, detail) => + { + using GatewayOutput output = PrepareGatewayOutput( + target, + interactive, + readEnvironmentVariable, + log, + errorIsProcessConsoleWriter, + errorIsInteractive, + supportsUnicode); + ClawCtlConsole.WriteGatewayStartWarning( + target, + detail, + output.UseColor, + output.UseUnicode); + }, + readEnvironmentVariable)); + } + + private static GatewayOutput PrepareGatewayOutput( + TextWriter target, + bool interactive, + Func readEnvironmentVariable, + Action log, + Func? errorIsProcessConsoleWriter, + Func? errorIsInteractive, + Func? supportsUnicode) + { + bool selectedStreamIsInteractive = + errorIsInteractive?.Invoke() ?? + WindowsHostConsole.Instance.IsInteractiveOutput(target); + bool processConsoleWriter = + errorIsProcessConsoleWriter?.Invoke() ?? + ReferenceEquals(target, Console.Error); + IDisposable? restore = null; + bool useColor = ClawCtlColorPolicy.PrepareForegroundOutput( + noColor: false, + json: false, + processConsoleWriter, + interactive, + selectedStreamIsInteractive, + readEnvironmentVariable, + () => WindowsHostConsole.Instance.TryEnableVirtualTerminalProcessing( + target, + log, + out restore)); + bool useUnicode = supportsUnicode?.Invoke() ?? + (interactive && Console.OutputEncoding.CodePage == 65001); + log( + $"Gateway output capabilities: interactive={interactive}, " + + $"processStderr={processConsoleWriter}, " + + $"stderrConsole={selectedStreamIsInteractive}, " + + $"color={useColor}, unicode={useUnicode}."); + return new GatewayOutput(useColor, useUnicode, restore); + } + + private sealed class GatewayOutput( + bool useColor, + bool useUnicode, + IDisposable? restore) : IDisposable + { + public bool UseColor { get; } = useColor; + public bool UseUnicode { get; } = useUnicode; + + public void Dispose() => restore?.Dispose(); + } + + /// + /// Runs the postflight. It reports the OpenClaw exit code unchanged: this + /// path exists to help the user, never to fail their command. + /// + public async Task RunAsync( + int openClawExitCode, + bool interactive, + TextWriter error) + { + await _guidance.EvaluateAsync(openClawExitCode, interactive, error) + .ConfigureAwait(false); + return openClawExitCode; + } +} diff --git a/src/OpenClaw.Launcher/Gateway/AutoBehaviorPolicy.cs b/src/OpenClaw.Launcher/Gateway/AutoBehaviorPolicy.cs new file mode 100644 index 00000000..31a000be --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/AutoBehaviorPolicy.cs @@ -0,0 +1,50 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +internal enum GatewayAutoAction +{ + Silent, + Hint, + Start, +} + +internal static class AutoBehaviorPolicy +{ + public static bool IsAutomaticSetupEnabled( + Func readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + + return !IsSuppressed( + readEnvironmentVariable(OpenClawRuntimeEnvironment.AutoSetupVariable)); + } + + public static GatewayAutoAction DecideGatewayAction( + int openClawExitCode, + bool interactive, + SessionConfigReadinessState readiness, + GatewayState gatewayState, + Func readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + + bool eligible = openClawExitCode == 0 && + interactive && + readiness == SessionConfigReadinessState.StartupEligible && + gatewayState is GatewayState.NotStarted or GatewayState.Stopped; + if (!eligible) + { + // A state that may still be healthy must not trigger a competing start. + return GatewayAutoAction.Silent; + } + + return IsSuppressed( + readEnvironmentVariable(OpenClawRuntimeEnvironment.AutoGatewayStartVariable)) + ? GatewayAutoAction.Hint + : GatewayAutoAction.Start; + } + + private static bool IsSuppressed(string? value) => + value?.Trim().ToUpperInvariant() is "0" or "FALSE" or "NO" or "OFF"; +} diff --git a/src/OpenClaw.Launcher/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 5c14526f..e4d284c9 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -33,4 +33,40 @@ internal static HostOptions Parse( packagedNodeArchivePath, [.. arguments]); } + + /// + /// Resolves the packaged application directory, failing with the packaged + /// entry point's path when it is absent. + /// + /// + /// already checked for + /// openclaw.mjs. Re-checking here makes a never-resolved directory + /// and one removed since startup fail through the same message. + /// + public string RequirePackagedApplicationDirectory() + { + string entryPoint = Path.Combine( + PackagedApplicationDirectory ?? Path.Combine(AppContext.BaseDirectory, "app"), + "openclaw.mjs"); + if (PackagedApplicationDirectory is null || !File.Exists(entryPoint)) + { + throw new FileNotFoundException( + "The packaged OpenClaw entry point was not found.", + entryPoint); + } + + return PackagedApplicationDirectory; + } + + public string RequirePackagedNodeArchivePath() + { + if (PackagedNodeArchivePath is null || !File.Exists(PackagedNodeArchivePath)) + { + throw new FileNotFoundException( + "The packaged Node.js runtime archive was not found.", + PackagedNodeArchivePath ?? Path.Combine(AppContext.BaseDirectory, "runtime")); + } + + return PackagedNodeArchivePath; + } } diff --git a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs index e6b56e29..827bebc3 100644 --- a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs +++ b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs @@ -16,6 +16,10 @@ internal static class OpenClawRuntimeEnvironment public const string NoAutoUpdateVariable = "OPENCLAW_NO_AUTO_UPDATE"; public const string GatewayIsolationVariable = "CLAWCTL_GATEWAY_ISOLATION"; + // Host-side policy reads; never flow into the OpenClaw child environment. + public const string AutoSetupVariable = "CLAWCTL_AUTO_SETUP"; + public const string AutoGatewayStartVariable = "CLAWCTL_AUTO_GATEWAY_START"; + /// /// The packaged application root whose native dependency packages were /// mirrored, and the agent-owned root holding those copies. diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 9b2d120c..5c5b552e 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -153,7 +153,8 @@ startup.InstallationLifecycle is null readEnvironmentVariable: startup.ReadEnvironmentVariable, error: error, errorIsProcessConsoleWriter: - startup.UsesProcessConsoleWriters ? () => true : null) + startup.UsesProcessConsoleWriters ? () => true : null, + installationLifecycle: startup.InstallationLifecycle) .ConfigureAwait(false); } catch (Exception exception) @@ -288,9 +289,10 @@ internal static async Task RunAgentAsync( TimeProvider? clock = null, Func? errorIsProcessConsoleWriter = null, Func? errorIsInteractive = null, - Func? supportsUnicode = null) + Func? supportsUnicode = null, + Session.IInstallationLifecycle? installationLifecycle = null) { - string applicationDirectory = GetPackagedApplicationDirectory(options); + string applicationDirectory = options.RequirePackagedApplicationDirectory(); log("Using the OpenClaw application directly from the package."); Mxc.MxcReadinessReport readiness = await (probeReadiness ?? @@ -300,18 +302,30 @@ internal static async Task RunAgentAsync( readiness); log("The isolated-session backend is available."); + bool interactive = + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(); + TextWriter errorWriter = error ?? Console.Error; + Func environmentReader = + readEnvironmentVariable ?? Environment.GetEnvironmentVariable; + Session.SessionRuntime runtime = (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + await EnsureSetupForLaunchAsync( + options, + runtime, + installationLifecycle, + applicationDirectory, + interactive, + environmentReader, + errorWriter, + log, + errorIsProcessConsoleWriter, + errorIsInteractive).ConfigureAwait(false); Session.SessionRecord record = await runtime.StartForExecutionAsync(CancellationToken.None) .ConfigureAwait(false); string agentNodePath = runtime.RequireAgentNodePath( - GetPackagedNodeArchivePath(options)); - bool interactive = - (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(); - TextWriter errorWriter = error ?? Console.Error; - Func environmentReader = - readEnvironmentVariable ?? Environment.GetEnvironmentVariable; + options.RequirePackagedNodeArchivePath()); int exitCode = await runtime.Executor.ExecuteAsync( record, new Session.SessionExecutionRequest( @@ -330,63 +344,101 @@ await runtime.StartForExecutionAsync(CancellationToken.None) NativeRootPath = runtime.GetAgentNativeRoot() }, CancellationToken.None).ConfigureAwait(false); - Gateway.GatewayController gateway = Gateway.GatewayRuntime - .Create(options, runtime.Paths, runtime, log, clock) - .Controller; - var guidance = new Gateway.AgentGatewayGuidance( - runtime.LifecycleLock, - cancellationToken => runtime.Executor.CheckConfigReadinessAsync( - record, - runtime.RequireStagedHelper(record), - cancellationToken), - cancellationToken => gateway.GetStatusAsync( - runtime.HelperPath, - cancellationToken), - new Gateway.GatewayGuidanceStateStore( - runtime.Paths.GatewayGuidanceStatePath), - getLogonSessionId ?? Gateway.WindowsLogonSession.GetCurrentId, + return await Gateway.AgentPostflight.Create( + options, + runtime, + record, + interactive, + environmentReader, log, clock, - target => - { - bool selectedStreamIsInteractive = - errorIsInteractive?.Invoke() ?? - WindowsHostConsole.Instance.IsInteractiveOutput(target); - bool processConsoleWriter = - errorIsProcessConsoleWriter?.Invoke() ?? - ReferenceEquals(target, Console.Error); - IDisposable? restore = null; - bool useColor = ClawCtlColorPolicy.PrepareForegroundOutput( - noColor: false, - json: false, - processConsoleWriter, - interactive, - selectedStreamIsInteractive, - environmentReader, - () => WindowsHostConsole.Instance.TryEnableVirtualTerminalProcessing( - target, - log, - out restore)); - bool useUnicode = supportsUnicode?.Invoke() ?? - (interactive && Console.OutputEncoding.CodePage == 65001); - log( - $"Gateway hint capabilities: interactive={interactive}, " + - $"processStderr={processConsoleWriter}, " + - $"stderrConsole={selectedStreamIsInteractive}, " + - $"color={useColor}, unicode={useUnicode}."); - using (restore) - { - ClawCtlConsole.WriteGatewayHint( - target, - useColor, - useUnicode); - } - }); - await guidance.EvaluateAsync( - exitCode, + getLogonSessionId, + errorIsProcessConsoleWriter, + errorIsInteractive, + supportsUnicode) + .RunAsync(exitCode, interactive, errorWriter) + .ConfigureAwait(false); + } + + /// + /// Provisions this installation, when it has never been set up, before an + /// openclaw launch needs the session. + /// + /// + /// + /// Narration goes to standard error. Standard output belongs to the + /// OpenClaw child, so piping openclaw --version must not pick up a + /// setup message. + /// + /// + /// Only an absent setup marker is provisioned. Anything else is left for + /// to report with the + /// command that resolves it. + /// + /// + private static async Task EnsureSetupForLaunchAsync( + HostOptions options, + Session.SessionRuntime runtime, + Session.IInstallationLifecycle? installationLifecycle, + string applicationDirectory, + bool interactive, + Func environmentReader, + TextWriter errorWriter, + Action log, + Func? errorIsProcessConsoleWriter, + Func? errorIsInteractive) + { + if (installationLifecycle is null) + { + log("Automatic setup is unavailable: no installation lifecycle was supplied."); + return; + } + + if (!Gateway.AutoBehaviorPolicy.IsAutomaticSetupEnabled(environmentReader)) + { + // The operator opted out, so an installation that was never set up + // must fail exactly as it did before automatic setup existed. + log( + "Automatic setup is disabled by " + + $"{OpenClawRuntimeEnvironment.AutoSetupVariable}."); + return; + } + + if (!Session.SetupOrchestrator.NeedsProvisioning(runtime)) + { + return; + } + + IDisposable? restore = null; + bool useColor = ClawCtlColorPolicy.PrepareForegroundOutput( + noColor: false, + json: false, + errorIsProcessConsoleWriter?.Invoke() ?? + ReferenceEquals(errorWriter, Console.Error), interactive, - errorWriter).ConfigureAwait(false); - return exitCode; + errorIsInteractive?.Invoke() ?? + WindowsHostConsole.Instance.IsInteractiveOutput(errorWriter), + environmentReader, + () => WindowsHostConsole.Instance.TryEnableVirtualTerminalProcessing( + errorWriter, + log, + out restore)); + using (restore) + { + _ = await ClawCtlConsole.NarrateAsync( + errorWriter, + useColor, + narrate: true, + new ClawCtlProgress("Setting up OpenClaw for first use."), + progress => Session.SetupOrchestrator.EnsureAsync( + options, + runtime, + installationLifecycle, + applicationDirectory, + log, + progress, + CancellationToken.None)).ConfigureAwait(false); + } } /// @@ -543,7 +595,7 @@ async Task RunSetupCommandAsync( useColor, narrate: !outputOptions.Json, new ClawCtlProgress("Checking isolated-session support."), - progress => RunSetupAsync( + progress => Session.SetupOrchestrator.RunAsync( setupOptions, options, GetSessionRuntime, @@ -560,7 +612,7 @@ async Task RunSetupCommandAsync( { return WriteResult(new SetupCommandResult( 1, - GetPackagedApplicationDirectory(options), + options.RequirePackagedApplicationDirectory(), null, null, false, @@ -667,9 +719,9 @@ await Gateway.GatewayRuntime.Create( Session.SessionRecord record = await runtime.StartForExecutionAsync(cancellationToken) .ConfigureAwait(false); - string applicationDirectory = GetPackagedApplicationDirectory(options); + string applicationDirectory = options.RequirePackagedApplicationDirectory(); string nodePath = runtime.RequireAgentNodePath( - GetPackagedNodeArchivePath(options)); + options.RequirePackagedNodeArchivePath()); IReadOnlyDictionary dashboardEnvironment = BuildRuntimeEnvironment( runtime, @@ -771,7 +823,8 @@ InvalidOperationException or ExitCode: 0))); } - string applicationDirectory = GetPackagedApplicationDirectory(options); + string applicationDirectory = + options.RequirePackagedApplicationDirectory(); string openClawScript = PowerShellCompletion.ReadPackagedOpenClawScript(applicationDirectory); string combinedScript = PowerShellCompletion.BuildScript(openClawScript); @@ -967,357 +1020,6 @@ internal static Task LaunchBrowserAsync( return Task.CompletedTask; } - private static async Task RunSetupAsync( - SetupOptions setupOptions, - HostOptions options, - Func getSessionRuntime, - Session.IInstallationLifecycle lifecycle, - Action log, - IProgress progress, - CancellationToken cancellationToken) - { - string applicationDirectory = GetPackagedApplicationDirectory(options); - log("Confirmed the packaged OpenClaw application is present."); - // Throws with the reason and its remediation when this machine cannot - // host a session. There is no session-free setup to fall back to, so - // nothing is reported as ready before this succeeds. - await lifecycle.EnsureSessionSupportedAsync(cancellationToken) - .ConfigureAwait(false); - - FreshSetupWarning? warning = null; - bool localStateCleared = false; - try - { - Session.SessionRuntime runtime = getSessionRuntime(); - if (setupOptions.Fresh) - { - progress.Report(new ClawCtlProgress("Inspecting the existing installation.")); - // Resolve every required package input before removing state. - _ = lifecycle.ValidatePackageRuntime(options, runtime); - - using Session.ISessionLockHandle handle = lifecycle.AcquireLifecycleLock(runtime); - FreshDiagnosticReport report = WriteFreshDiagnosticReport(runtime, log); - Session.TeardownResult teardownResult; - try - { - progress.Report(new ClawCtlProgress("Removing existing OpenClaw resources.")); - teardownResult = await lifecycle.TeardownAsync( - options, runtime, log, lockAlreadyHeld: true, cancellationToken) - .ConfigureAwait(false); - } - catch (Exception exception) when ( - setupOptions.Force && - exception is Session.SessionException or Mxc.MxcException) - { - teardownResult = new Session.TeardownResult( - false, - "Teardown failed before external cleanup could be confirmed.", - exception.Message); - } - if (!teardownResult.Succeeded) - { - if (!setupOptions.Force) - { - return new SetupCommandResult( - 1, - applicationDirectory, - null, - null, - false, - Error: teardownResult.Detail ?? teardownResult.Message, - Fresh: true); - } - - warning = new FreshSetupWarning( - teardownResult.Message, - teardownResult.Detail); - } - - Session.IInstallationStateCleaner cleaner = lifecycle.CreateStateCleaner(runtime); - try - { - progress.Report(new ClawCtlProgress("Clearing package-local state.")); - cleaner.Clear(); - localStateCleared = true; - } - catch (Exception cleanupException) - { - try - { - RestoreFreshDiagnosticReport(report, log); - } - catch (Exception restoreException) - { - throw new Session.SessionException( - "Fresh setup local cleanup failed and the pre-reset " + - $"diagnostic report could not be restored. Cleanup: {cleanupException.Message} " + - $"Report: {restoreException.Message}", - new AggregateException(cleanupException, restoreException)); - } - - throw; - } - - RestoreFreshDiagnosticReport(report, log); - log("Fresh setup cleared package-owned local state."); - if (!teardownResult.Succeeded) - { - const string residualWarning = - "WARNING: Forced fresh setup did not prove a pristine machine because owned external cleanup remains unresolved. " + - "Review the pre-reset report for residual sandbox or gateway identifiers. " + - "A later setup reset cannot remove resources whose ownership record was cleared; " + - "remove them through the backend's administrative cleanup path before treating this machine as pristine."; - log(residualWarning); - } - - return await RunSetupCoreAsync( - runtime, - options, - lifecycle, - applicationDirectory, - log, - lockAlreadyHeld: true, - warning, - localStateCleared, - fresh: true, - progress, - cancellationToken) - .ConfigureAwait(false); - } - - return await RunSetupCoreAsync( - runtime, - options, - lifecycle, - applicationDirectory, - log, - lockAlreadyHeld: false, - warning, - localStateCleared, - fresh: false, - progress, - cancellationToken) - .ConfigureAwait(false); - } - catch (Session.SessionException exception) - { - log($"Isolated session setup is unavailable: {exception.Message}"); - return new SetupCommandResult( - 1, - applicationDirectory, - null, - null, - false, - warning, - Error: exception.Message, - Fresh: setupOptions.Fresh, - LocalStateCleared: localStateCleared); - } - catch (IOException exception) - { - log($"Fresh setup local cleanup failed: {exception.Message}"); - return new SetupCommandResult( - 1, - applicationDirectory, - null, - null, - false, - warning, - Error: exception.Message, - Fresh: setupOptions.Fresh, - LocalStateCleared: localStateCleared); - } - catch (UnauthorizedAccessException exception) - { - log($"Fresh setup local cleanup was denied: {exception.Message}"); - return new SetupCommandResult( - 1, - applicationDirectory, - null, - null, - false, - warning, - Error: exception.Message, - Fresh: setupOptions.Fresh, - LocalStateCleared: localStateCleared); - } - catch (OperationCanceledException) - { - string retryCommand = setupOptions.Fresh - ? "clawctl setup --fresh" - : "clawctl setup"; - log($"Setup was cancelled before it completed. Retry `{retryCommand}`."); - return new SetupCommandResult( - 1, - applicationDirectory, - null, - null, - false, - warning, - Error: - "Setup was cancelled and may be incomplete. " + - $"Run `{retryCommand}` to retry.", - Fresh: setupOptions.Fresh, - LocalStateCleared: localStateCleared); - } - } - - internal static async Task RunSetupCoreAsync( - Session.SessionRuntime runtime, - HostOptions options, - Session.IInstallationLifecycle lifecycle, - string applicationDirectory, - Action log, - bool lockAlreadyHeld, - FreshSetupWarning? warning, - bool localStateCleared, - bool fresh, - IProgress progress, - CancellationToken cancellationToken) - { - progress.Report(new ClawCtlProgress("Preparing the isolated session.")); - using Session.ISessionLockHandle? handle = lockAlreadyHeld - ? null - : runtime.AcquireLifecycleLock(); - runtime.SetupState.Write(new Session.SetupRecord - { - ApplicationId = runtime.ApplicationId, - Phase = Session.SetupPhase.Preparing - }); - Session.SessionStartResult session = await runtime.Coordinator - .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); - IEnumerable supersededSandboxIds = (session.Record.SupersededSandboxIds ?? []) - .Append(session.Record.SupersededSandboxId) - .Append(session.SupersededRecord?.SandboxId) - .Where(static id => !string.IsNullOrWhiteSpace(id)) - .Select(static id => id!) - .Where(id => !string.Equals( - id, - session.Record.SandboxId, - StringComparison.Ordinal)) - .Distinct(StringComparer.Ordinal); - foreach (string supersededSandboxId in supersededSandboxIds) - { - if (runtime.GatewayState.ClearForSupersededSession(supersededSandboxId)) - { - log($"Removed the gateway record for superseded session '{supersededSandboxId}'."); - } - } - - Session.SessionRecord record = session.Record; - string helperPath = runtime.StageHelper(record); - progress.Report(new ClawCtlProgress( - "Installing Node.js in the isolated session.")); - SessionRuntimeInstallResult agentRuntime = await runtime.Executor.InstallRuntimeAsync( - record, - helperPath, - GetPackagedNodeArchivePath(options), - applicationDirectory, - cancellationToken).ConfigureAwait(false); - progress.Report(new ClawCtlProgress("Enabling gateway startup at sign-in.")); - Gateway.GatewayPersistenceInstallResult recovery = await lifecycle - .InstallRecoveryAsync(log, cancellationToken).ConfigureAwait(false); - - if (recovery.State != Gateway.GatewayPersistenceState.Ready) - { - return new SetupCommandResult( - 1, - applicationDirectory, - $"{agentRuntime.Version}", - recovery, - false, - warning, - SandboxId: record.SandboxId, - Fresh: fresh, - RuntimeLocation: SetupRuntimeLocation.IsolatedSession, - LocalStateCleared: localStateCleared); - } - - progress.Report(new ClawCtlProgress("Finalizing setup.")); - runtime.CompleteSetup(record, agentRuntime, startupEnabled: true); - return new SetupCommandResult( - 0, - applicationDirectory, - $"{agentRuntime.Version}", - recovery, - true, - warning, - SandboxId: record.SandboxId, - Fresh: fresh, - RuntimeLocation: SetupRuntimeLocation.IsolatedSession, - LocalStateCleared: localStateCleared); - } - - private static FreshDiagnosticReport WriteFreshDiagnosticReport( - Session.SessionRuntime runtime, - Action log) - { - Session.SessionStatus session = runtime.Coordinator.GetRecordedStatus(); - Gateway.GatewayStateResult gateway = runtime.GatewayState.Read(); - string path = runtime.Paths.PreResetReportPath; - string directory = Path.GetDirectoryName(path) - ?? throw new Session.SessionException( - "The pre-reset diagnostic report path has no parent directory."); - Directory.CreateDirectory(directory); - var lines = File.Exists(path) - ? new List(File.ReadAllLines(path)) - : []; - if (lines.Count > 0) - { - lines.Add(string.Empty); - } - - lines.AddRange( - [ - "--- pre-reset snapshot ---", - $"timestampUtc={DateTimeOffset.UtcNow:O}", - $"applicationId={runtime.ApplicationId}", - "report=pre-reset diagnostic metadata; credentials and local file contents are excluded" - ]); - if (session.Record is not null) - { - lines.Add($"sessionSandboxId={session.Record.SandboxId}"); - lines.Add($"sessionAgentUserName={session.Record.AgentUserName ?? string.Empty}"); - lines.Add($"sessionAgentUserSid={session.Record.AgentUserSid ?? string.Empty}"); - } - else - { - lines.Add($"sessionRecordFault={session.Fault?.ToString() ?? "none"}"); - } - - if (gateway.Record is not null) - { - lines.Add($"gatewaySandboxId={gateway.Record.SandboxId}"); - lines.Add($"gatewayProcessId={gateway.Record.ProcessId}"); - lines.Add($"gatewayLaunchPending={gateway.Record.LaunchPending}"); - } - else - { - lines.Add($"gatewayRecordFault={gateway.Fault?.ToString() ?? "none"}"); - } - lines.Add("--- end pre-reset snapshot ---"); - - File.WriteAllLines(path, lines); - log($"Captured redacted pre-reset diagnostic report at {path}."); - return new FreshDiagnosticReport(path, lines); - } - - private static void RestoreFreshDiagnosticReport( - FreshDiagnosticReport report, - Action log) - { - string directory = Path.GetDirectoryName(report.Path) - ?? throw new Session.SessionException( - "The pre-reset diagnostic report path has no parent directory."); - Directory.CreateDirectory(directory); - File.WriteAllLines(report.Path, report.Lines); - log($"Preserved the redacted pre-reset diagnostic report at {report.Path}."); - } - - private sealed record FreshDiagnosticReport( - string Path, - IReadOnlyList Lines); - private static async Task RunPowerShellAsync( HostOptions options, Session.SessionRuntime runtime, @@ -1327,9 +1029,9 @@ private static async Task RunPowerShellAsync( record = await runtime.StartForExecutionAsync(cancellationToken) .ConfigureAwait(false); string helperPath = runtime.RequireStagedHelper(record); - string applicationDirectory = GetPackagedApplicationDirectory(options); + string applicationDirectory = options.RequirePackagedApplicationDirectory(); string agentNodePath = runtime.RequireAgentNodePath( - GetPackagedNodeArchivePath(options)); + options.RequirePackagedNodeArchivePath()); string nodeDirectory = Path.GetDirectoryName(agentNodePath) ?? throw new Session.SessionException( "The agent's Node.js runtime has no parent directory."); @@ -1390,7 +1092,6 @@ record = await runtime.StartForExecutionAsync(cancellationToken) shell.DisplayName, cancellationToken).ConfigureAwait(false); } - private static void DeleteCompletionCache(string cachePath) { if (File.Exists(cachePath)) @@ -1398,39 +1099,4 @@ private static void DeleteCompletionCache(string cachePath) File.Delete(cachePath); } } - - private static string GetPackagedApplicationDirectory(HostOptions options) - { - // Re-check File.Exists here (HostOptions.Parse already checked it) - // so both a never-resolved and a since-removed application directory - // fail through the same FileNotFoundException message. - string? applicationDirectory = options.PackagedApplicationDirectory; - string entryPoint = Path.Combine( - applicationDirectory ?? Path.Combine(AppContext.BaseDirectory, "app"), - "openclaw.mjs"); - if (applicationDirectory is null || !File.Exists(entryPoint)) - { - throw new FileNotFoundException( - "The packaged OpenClaw entry point was not found.", - entryPoint); - } - - return applicationDirectory; - } - - private static string GetPackagedNodeArchivePath(HostOptions options) - { - string? archivePath = options.PackagedNodeArchivePath; - string expectedPath = archivePath ?? Path.Combine( - AppContext.BaseDirectory, - "runtime"); - if (archivePath is null || !File.Exists(archivePath)) - { - throw new FileNotFoundException( - "The packaged Node.js runtime archive was not found.", - expectedPath); - } - - return archivePath; - } } diff --git a/src/OpenClaw.Launcher/Session/SetupOrchestrator.cs b/src/OpenClaw.Launcher/Session/SetupOrchestrator.cs new file mode 100644 index 00000000..8db15e74 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SetupOrchestrator.cs @@ -0,0 +1,484 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Session; + +/// What an call actually did. +internal enum SetupEnsureOutcome +{ + /// + /// Nothing was provisioned. Either this installation is already set up, or + /// its recorded state is degraded in a way that only explicit + /// clawctl setup may resolve. The caller's own validation reports + /// which of those it is. + /// + Skipped, + + /// This call provisioned the installation. + Provisioned +} + +/// +/// The single owner of the setup route. +/// +/// +/// +/// Both entry points reach setup through this type: clawctl setup via +/// , and an openclaw launch on a clean machine via +/// . They share so there is +/// one provisioning sequence rather than two that can drift. +/// +/// +/// The lifecycle lock is not re-entrant: every +/// takes ownership on a fresh dedicated +/// thread, so a second acquire from this process would block against itself +/// until the timeout. That is why takes +/// lockAlreadyHeld, and why releases the lock +/// before returning to a caller that is about to start the session. +/// +/// +internal static class SetupOrchestrator +{ + /// + /// Provisions this installation when, and only when, it has never been set + /// up. + /// + /// + /// Only an absent marker is provisioned automatically. An unreadable, + /// incomplete, foreign, newer-schema, preparing, or tearing-down marker is + /// left exactly as it is so that its explicit recovery message survives: + /// silently reprovisioning would destroy state a user may be mid-recovering. + /// + public static async Task EnsureAsync( + HostOptions options, + SessionRuntime runtime, + IInstallationLifecycle lifecycle, + string applicationDirectory, + Action log, + IProgress progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(lifecycle); + ArgumentNullException.ThrowIfNull(log); + ArgumentNullException.ThrowIfNull(progress); + + if (!IsAbsent(runtime.SetupState.Read(runtime.ApplicationId))) + { + return SetupEnsureOutcome.Skipped; + } + + SetupCommandResult result; + using (runtime.AcquireLifecycleLock()) + { + // Re-read under the lock. Two first launches racing on a clean + // machine must produce one provision, not two. + if (!IsAbsent(runtime.SetupState.Read(runtime.ApplicationId))) + { + log("Setup completed elsewhere while this launch waited for the lifecycle lock."); + return SetupEnsureOutcome.Skipped; + } + + log("No setup record exists; provisioning this installation before launching OpenClaw."); + result = await RunCoreAsync( + runtime, + options, + lifecycle, + applicationDirectory, + log, + lockAlreadyHeld: true, + warning: null, + localStateCleared: false, + fresh: false, + progress, + cancellationToken).ConfigureAwait(false); + } + + if (result.ExitCode != 0) + { + throw new SessionException( + $"Automatic setup did not complete: {DescribeFailure(result)} " + + "Run `clawctl setup` to finish preparing OpenClaw."); + } + + return SetupEnsureOutcome.Provisioned; + } + + /// + /// Whether this installation has never been set up, and so may be + /// provisioned automatically by a launch. + /// + /// + /// Callers use this to decide whether to narrate before calling + /// . It is deliberately not authoritative: + /// re-reads the same state under the lifecycle + /// lock, because another process may provision in between. + /// + public static bool NeedsProvisioning(SessionRuntime runtime) + { + ArgumentNullException.ThrowIfNull(runtime); + return IsAbsent(runtime.SetupState.Read(runtime.ApplicationId)); + } + + private static bool IsAbsent(SetupStateResult state) => + state.Record is null && state.Fault == SetupStateFault.Missing; + + private static string DescribeFailure(SetupCommandResult result) => + result.Error + ?? result.Recovery?.Detail + ?? result.Recovery?.Message + ?? "the reason was not reported."; + + public static async Task RunAsync( + SetupOptions setupOptions, + HostOptions options, + Func getSessionRuntime, + IInstallationLifecycle lifecycle, + Action log, + IProgress progress, + CancellationToken cancellationToken) + { + string applicationDirectory = options.RequirePackagedApplicationDirectory(); + log("Confirmed the packaged OpenClaw application is present."); + // Throws with the reason and its remediation when this machine cannot + // host a session. There is no session-free setup to fall back to, so + // nothing is reported as ready before this succeeds. + await lifecycle.EnsureSessionSupportedAsync(cancellationToken) + .ConfigureAwait(false); + + FreshSetupWarning? warning = null; + bool localStateCleared = false; + try + { + SessionRuntime runtime = getSessionRuntime(); + if (setupOptions.Fresh) + { + progress.Report(new ClawCtlProgress("Inspecting the existing installation.")); + // Resolve every required package input before removing state. + _ = lifecycle.ValidatePackageRuntime(options, runtime); + + using ISessionLockHandle handle = lifecycle.AcquireLifecycleLock(runtime); + FreshDiagnosticReport report = WriteFreshDiagnosticReport(runtime, log); + TeardownResult teardownResult; + try + { + progress.Report(new ClawCtlProgress("Removing existing OpenClaw resources.")); + teardownResult = await lifecycle.TeardownAsync( + options, runtime, log, lockAlreadyHeld: true, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + setupOptions.Force && + exception is SessionException or MxcException) + { + teardownResult = new TeardownResult( + false, + "Teardown failed before external cleanup could be confirmed.", + exception.Message); + } + if (!teardownResult.Succeeded) + { + if (!setupOptions.Force) + { + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + Error: teardownResult.Detail ?? teardownResult.Message, + Fresh: true); + } + + warning = new FreshSetupWarning( + teardownResult.Message, + teardownResult.Detail); + } + + IInstallationStateCleaner cleaner = lifecycle.CreateStateCleaner(runtime); + try + { + progress.Report(new ClawCtlProgress("Clearing package-local state.")); + cleaner.Clear(); + localStateCleared = true; + } + catch (Exception cleanupException) + { + try + { + RestoreFreshDiagnosticReport(report, log); + } + catch (Exception restoreException) + { + throw new SessionException( + "Fresh setup local cleanup failed and the pre-reset " + + $"diagnostic report could not be restored. Cleanup: {cleanupException.Message} " + + $"Report: {restoreException.Message}", + new AggregateException(cleanupException, restoreException)); + } + + throw; + } + + RestoreFreshDiagnosticReport(report, log); + log("Fresh setup cleared package-owned local state."); + if (!teardownResult.Succeeded) + { + const string residualWarning = + "WARNING: Forced fresh setup did not prove a pristine machine because owned external cleanup remains unresolved. " + + "Review the pre-reset report for residual sandbox or gateway identifiers. " + + "A later setup reset cannot remove resources whose ownership record was cleared; " + + "remove them through the backend's administrative cleanup path before treating this machine as pristine."; + log(residualWarning); + } + + return await RunCoreAsync( + runtime, + options, + lifecycle, + applicationDirectory, + log, + lockAlreadyHeld: true, + warning, + localStateCleared, + fresh: true, + progress, + cancellationToken) + .ConfigureAwait(false); + } + + return await RunCoreAsync( + runtime, + options, + lifecycle, + applicationDirectory, + log, + lockAlreadyHeld: false, + warning, + localStateCleared, + fresh: false, + progress, + cancellationToken) + .ConfigureAwait(false); + } + catch (SessionException exception) + { + log($"Isolated session setup is unavailable: {exception.Message}"); + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: exception.Message, + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); + } + catch (IOException exception) + { + log($"Fresh setup local cleanup failed: {exception.Message}"); + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: exception.Message, + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); + } + catch (UnauthorizedAccessException exception) + { + log($"Fresh setup local cleanup was denied: {exception.Message}"); + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: exception.Message, + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); + } + catch (OperationCanceledException) + { + string retryCommand = setupOptions.Fresh + ? "clawctl setup --fresh" + : "clawctl setup"; + log($"Setup was cancelled before it completed. Retry `{retryCommand}`."); + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: + "Setup was cancelled and may be incomplete. " + + $"Run `{retryCommand}` to retry.", + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); + } + } + + internal static async Task RunCoreAsync( + SessionRuntime runtime, + HostOptions options, + IInstallationLifecycle lifecycle, + string applicationDirectory, + Action log, + bool lockAlreadyHeld, + FreshSetupWarning? warning, + bool localStateCleared, + bool fresh, + IProgress progress, + CancellationToken cancellationToken) + { + progress.Report(new ClawCtlProgress("Preparing the isolated session.")); + using ISessionLockHandle? handle = lockAlreadyHeld + ? null + : runtime.AcquireLifecycleLock(); + runtime.SetupState.Write(new SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = SetupPhase.Preparing + }); + SessionStartResult session = await runtime.Coordinator + .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); + IEnumerable supersededSandboxIds = (session.Record.SupersededSandboxIds ?? []) + .Append(session.Record.SupersededSandboxId) + .Append(session.SupersededRecord?.SandboxId) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Select(static id => id!) + .Where(id => !string.Equals( + id, + session.Record.SandboxId, + StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal); + foreach (string supersededSandboxId in supersededSandboxIds) + { + if (runtime.GatewayState.ClearForSupersededSession(supersededSandboxId)) + { + log($"Removed the gateway record for superseded session '{supersededSandboxId}'."); + } + } + + SessionRecord record = session.Record; + string helperPath = runtime.StageHelper(record); + progress.Report(new ClawCtlProgress( + "Installing Node.js in the isolated session.")); + SessionRuntimeInstallResult agentRuntime = await runtime.Executor.InstallRuntimeAsync( + record, + helperPath, + options.RequirePackagedNodeArchivePath(), + applicationDirectory, + cancellationToken).ConfigureAwait(false); + progress.Report(new ClawCtlProgress("Enabling gateway startup at sign-in.")); + GatewayPersistenceInstallResult recovery = await lifecycle + .InstallRecoveryAsync(log, cancellationToken).ConfigureAwait(false); + + if (recovery.State != GatewayPersistenceState.Ready) + { + return new SetupCommandResult( + 1, + applicationDirectory, + $"{agentRuntime.Version}", + recovery, + false, + warning, + SandboxId: record.SandboxId, + Fresh: fresh, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession, + LocalStateCleared: localStateCleared); + } + + progress.Report(new ClawCtlProgress("Finalizing setup.")); + runtime.CompleteSetup(record, agentRuntime, startupEnabled: true); + return new SetupCommandResult( + 0, + applicationDirectory, + $"{agentRuntime.Version}", + recovery, + true, + warning, + SandboxId: record.SandboxId, + Fresh: fresh, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession, + LocalStateCleared: localStateCleared); + } + + private static FreshDiagnosticReport WriteFreshDiagnosticReport( + SessionRuntime runtime, + Action log) + { + SessionStatus session = runtime.Coordinator.GetRecordedStatus(); + GatewayStateResult gateway = runtime.GatewayState.Read(); + string path = runtime.Paths.PreResetReportPath; + string directory = Path.GetDirectoryName(path) + ?? throw new SessionException( + "The pre-reset diagnostic report path has no parent directory."); + Directory.CreateDirectory(directory); + var lines = File.Exists(path) + ? new List(File.ReadAllLines(path)) + : []; + if (lines.Count > 0) + { + lines.Add(string.Empty); + } + + lines.AddRange( + [ + "--- pre-reset snapshot ---", + $"timestampUtc={DateTimeOffset.UtcNow:O}", + $"applicationId={runtime.ApplicationId}", + "report=pre-reset diagnostic metadata; credentials and local file contents are excluded" + ]); + if (session.Record is not null) + { + lines.Add($"sessionSandboxId={session.Record.SandboxId}"); + lines.Add($"sessionAgentUserName={session.Record.AgentUserName ?? string.Empty}"); + lines.Add($"sessionAgentUserSid={session.Record.AgentUserSid ?? string.Empty}"); + } + else + { + lines.Add($"sessionRecordFault={session.Fault?.ToString() ?? "none"}"); + } + + if (gateway.Record is not null) + { + lines.Add($"gatewaySandboxId={gateway.Record.SandboxId}"); + lines.Add($"gatewayProcessId={gateway.Record.ProcessId}"); + lines.Add($"gatewayLaunchPending={gateway.Record.LaunchPending}"); + } + else + { + lines.Add($"gatewayRecordFault={gateway.Fault?.ToString() ?? "none"}"); + } + lines.Add("--- end pre-reset snapshot ---"); + + File.WriteAllLines(path, lines); + log($"Captured redacted pre-reset diagnostic report at {path}."); + return new FreshDiagnosticReport(path, lines); + } + + private static void RestoreFreshDiagnosticReport( + FreshDiagnosticReport report, + Action log) + { + string directory = Path.GetDirectoryName(report.Path) + ?? throw new SessionException( + "The pre-reset diagnostic report path has no parent directory."); + Directory.CreateDirectory(directory); + File.WriteAllLines(report.Path, report.Lines); + log($"Preserved the redacted pre-reset diagnostic report at {report.Path}."); + } + + private sealed record FreshDiagnosticReport( + string Path, + IReadOnlyList Lines); +} diff --git a/tests/OpenClaw.Launcher.Tests/AgentImplicitSetupTests.cs b/tests/OpenClaw.Launcher.Tests/AgentImplicitSetupTests.cs new file mode 100644 index 00000000..6aed2339 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/AgentImplicitSetupTests.cs @@ -0,0 +1,347 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.Launcher.Tests.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests; + +/// +/// What an openclaw launch does about setup on a machine that has never +/// been prepared. +/// +public sealed class AgentImplicitSetupTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + private FakeMxcSessionClient _backend = null!; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public async Task ACleanMachineIsPreparedAndTheCommandStillRuns() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(runtime); + var error = new StringWriter(); + string[] arguments = ["gateway", "run", "--port", "12345", "--", "a b"]; + + int exitCode = await RunAgentAsync( + runtime, lifecycle, arguments, error, childExitCode: 7) + .ConfigureAwait(true); + + Assert.Equal(7, exitCode); + Assert.Equal(arguments, ForwardedArguments); + Assert.Equal(1, lifecycle.RecoveryInstalls); + Assert.Equal( + SetupPhase.Ready, + runtime.SetupState.Read(runtime.ApplicationId).Record!.Phase); + Assert.Contains( + "Setting up OpenClaw", + error.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task APreparedMachineLaunchesWithoutAnySetupNarration() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(runtime); + await RunAgentAsync(runtime, lifecycle, ["status"], new StringWriter(), 0) + .ConfigureAwait(true); + + var error = new StringWriter(); + int exitCode = await RunAgentAsync(runtime, lifecycle, ["status"], error, 0) + .ConfigureAwait(true); + + Assert.Equal(0, exitCode); + // Setup already happened; an ordinary launch must stay quiet and must + // not re-provision the session a gateway may be running in. + Assert.Equal(string.Empty, error.ToString()); + Assert.Equal(1, lifecycle.RecoveryInstalls); + } + + [Fact] + public async Task TheOptOutRestoresTheOriginalNotSetUpFailureAndWritesNoState() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(runtime); + var error = new StringWriter(); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentAsync( + runtime, + lifecycle, + ["status"], + error, + childExitCode: 0, + readEnvironmentVariable: name => + name == OpenClawRuntimeEnvironment.AutoSetupVariable ? "0" : null)) + .ConfigureAwait(true); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + Assert.Equal(string.Empty, error.ToString()); + // The opt-out must be inert, not merely quiet: nothing may be + // provisioned, recorded, or registered for logon recovery. + Assert.Null(runtime.SetupState.Read(runtime.ApplicationId).Record); + Assert.Equal(0, lifecycle.RecoveryInstalls); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task TheOptOutIsInertOnAnAlreadyPreparedMachine() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(runtime); + await RunAgentAsync(runtime, lifecycle, ["status"], new StringWriter(), 0) + .ConfigureAwait(true); + + int exitCode = await RunAgentAsync( + runtime, + lifecycle, + ["status"], + new StringWriter(), + childExitCode: 3, + readEnvironmentVariable: name => + name == OpenClawRuntimeEnvironment.AutoSetupVariable ? "0" : null) + .ConfigureAwait(true); + + Assert.Equal(3, exitCode); + } + + [Fact] + public async Task AnUnsupportedHostFailsBeforeAnythingIsProvisioned() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(runtime); + + await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + CreateOptions(["status"]), + _ => { }, + _ => runtime, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: _ => null, + installationLifecycle: lifecycle)).ConfigureAwait(true); + + Assert.Null(runtime.SetupState.Read(runtime.ApplicationId).Record); + Assert.Equal(0, lifecycle.RecoveryInstalls); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task AnInterruptedSetupIsReportedRatherThanRepairedImplicitly() + { + SessionRuntime runtime = CreateRuntime(); + runtime.SetupState.Write(new SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = SetupPhase.Preparing + }); + var lifecycle = new StubLifecycle(runtime); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentAsync(runtime, lifecycle, ["status"], new StringWriter(), 0)) + .ConfigureAwait(true); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + Assert.Equal(0, lifecycle.RecoveryInstalls); + } + + private string[]? ForwardedArguments { get; set; } + + private Task RunAgentAsync( + SessionRuntime runtime, + StubLifecycle lifecycle, + string[] arguments, + TextWriter error, + int childExitCode, + Func? readEnvironmentVariable = null) + { + _backend.AttachedBehavior = _ => + { + string requestPath = Directory + .GetFiles(_backend.Metadata!.EphemeralWorkspacePath, "launch-*.json") + .Single(); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + ForwardedArguments = [.. request.Arguments!.Skip(1)]; + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = childExitCode + })); + return Task.FromResult(0); + }; + + return Program.RunAgentAsync( + CreateOptions(arguments), + _ => { }, + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: readEnvironmentVariable ?? (_ => null), + isInteractive: () => false, + error: error, + getLogonSessionId: () => "logon-implicit-setup", + errorIsProcessConsoleWriter: () => false, + errorIsInteractive: () => false, + supportsUnicode: () => false, + installationLifecycle: lifecycle); + } + + private static Task SupportedHost(CancellationToken cancellationToken) => + Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.BackendProbe)); + + private HostOptions CreateOptions(IReadOnlyList arguments) + { + string applicationDirectory = Path.Combine(_root, "app"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllText( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + string archivePath = Path.Combine(_root, "node-v24.20.0-win-x64.zip"); + File.WriteAllText(archivePath, "fixture"); + return new HostOptions(applicationDirectory, archivePath, arguments); + } + + private SessionRuntime CreateRuntime() + { + string baseDirectory = Path.Combine(_root, "base"); + Directory.CreateDirectory(baseDirectory); + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(workspace); + string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + File.WriteAllText(helperPath, "fixture"); + + _backend = new FakeMxcSessionClient + { + Metadata = new MxcProvisionMetadata( + "agent_1", + "S-1-5-21-0-0-0-1001", + workspace) + }; + _backend.ExecuteBehavior = _ => + { + string workspacePath = _backend.Metadata!.EphemeralWorkspacePath; + string[] runtimeRequests = Directory.GetFiles(workspacePath, "runtime-*.json"); + if (runtimeRequests.Length > 0) + { + string requestPath = runtimeRequests.Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + ExecutablePath = + @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip" + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + } + + // The postflight's file-only readiness probe. Reporting a config + // that cannot start keeps these tests about setup rather than + // about what the gateway does next. + string readinessPath = Directory + .GetFiles(workspacePath, "config-readiness-*.json").Single(); + SessionConfigReadinessRequest readinessRequest = + SessionConfigReadinessProtocol.ReadRequest(File.ReadAllText(readinessPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(readinessPath), + SessionConfigReadinessProtocol.SerializeResult( + new SessionConfigReadinessResult + { + RequestId = readinessRequest.RequestId, + State = SessionConfigReadinessState.Absent, + Reason = SessionConfigReadinessReason.ConfigFileMissing + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + + return SessionRuntime.Create( + HostPaths.ForRoot(Path.Combine(_root, "state"), "OpenClaw.Gateway_implicit"), + () => throw new InvalidOperationException("The test backend must be supplied."), + baseDirectory, + _ => { }, + _backend); + } + + /// + /// Supplies the runtime under test and a recovery install that never + /// touches a real scheduled task. Everything the launch route must not use + /// throws. + /// + private sealed class StubLifecycle(SessionRuntime runtime) : IInstallationLifecycle + { + public int RecoveryInstalls { get; private set; } + + public SessionRuntime CreateRuntime(Action log) => runtime; + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) + { + RecoveryInstalls++; + return Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true)); + } + + public Task EnsureSessionSupportedAsync(CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs index fa239b8a..2543d4ec 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs @@ -1,5 +1,5 @@ using OpenClaw.Launcher.Gateway; -using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; using OpenClaw.Launcher.Tests.Session; using OpenClaw.SessionProtocol; @@ -20,155 +20,183 @@ private AgentGatewayGuidance Create( SessionConfigReadinessState readinessState, GatewayState gatewayState, out GatewayGuidanceStateStore store, + Action? startCalled = null, + Func, Task>? startGateway = null, + Func? readEnvironmentVariable = null, Action? readinessCalled = null, - Action? statusCalled = null) + Action? statusCalled = null, + ISessionLock? lifecycleLock = null, + Func>? checkReadiness = null, + Func>? getGatewayStatus = null) { store = new GatewayGuidanceStateStore( - Path.Combine(_root, "gateway-guidance.json")); + Path.Combine(_root, $"gateway-guidance-{Guid.NewGuid():N}.json")); return new AgentGatewayGuidance( - new AlwaysFreeLock(), - _ => + lifecycleLock ?? new AlwaysFreeLock(), + checkReadiness ?? (_ => { readinessCalled?.Invoke(); return Task.FromResult(new SessionConfigReadinessResult { RequestId = "r1", State = readinessState, - Reason = readinessState switch - { - SessionConfigReadinessState.Absent => - SessionConfigReadinessReason.ConfigFileMissing, - SessionConfigReadinessState.StartupEligible => - SessionConfigReadinessReason.GatewayModeLocal, - _ => SessionConfigReadinessReason.GatewayModeMissing - } + Reason = readinessState == SessionConfigReadinessState.StartupEligible + ? SessionConfigReadinessReason.GatewayModeLocal + : SessionConfigReadinessReason.GatewayModeMissing }); - }, - _ => + }), + getGatewayStatus ?? (_ => { statusCalled?.Invoke(); return Task.FromResult(new GatewayStatusReport( gatewayState, null, gatewayState.ToString())); - }, + }), store, () => "logon-a", _log.Add, new FixedTimeProvider( - new DateTimeOffset(2026, 9, 18, 18, 0, 0, TimeSpan.Zero))); + new DateTimeOffset(2026, 9, 18, 18, 0, 0, TimeSpan.Zero)), + startGateway: startGateway ?? (_ => + { + startCalled?.Invoke(); + return Task.FromResult(new GatewayStartResult( + GatewayState.Running, + new GatewayRecord(), + AlreadyRunning: false, + "The gateway is running.")); + }), + readEnvironmentVariable: readEnvironmentVariable ?? (_ => null)); } [Theory] - [InlineData(SessionConfigReadinessState.Absent)] - [InlineData(SessionConfigReadinessState.NotReady)] - public async Task ConfigThatIsNotEligibleNeverChecksOrHints( - SessionConfigReadinessState readiness) + [InlineData((int)GatewayState.NotStarted)] + [InlineData((int)GatewayState.Stopped)] + public async Task EligibleGatewayStateStartsAndAcknowledges(int stateValue) { - int statusCalls = 0; + int starts = 0; AgentGatewayGuidance guidance = Create( - readiness, - GatewayState.NotStarted, - out _, - statusCalled: () => statusCalls++); - var error = new StringWriter(); + SessionConfigReadinessState.StartupEligible, + (GatewayState)stateValue, + out GatewayGuidanceStateStore store, + () => starts++); - await guidance.EvaluateAsync(0, interactive: true, error); + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); - Assert.Equal(0, statusCalls); - Assert.Equal(string.Empty, error.ToString()); + Assert.Equal(1, starts); + Assert.True(store.IsAcknowledged("logon-a")); } [Theory] - [InlineData(0)] - [InlineData(2)] - public async Task EligibleStoppedGatewayHintsAfterEverySuccessfulInteractiveCall( - int stateValue) + [InlineData((int)GatewayState.Starting)] + [InlineData((int)GatewayState.Unhealthy)] + [InlineData((int)GatewayState.Unknown)] + public async Task UncertainGatewayNeverStartsOrAcknowledges(int stateValue) { - GatewayState state = (GatewayState)stateValue; + int starts = 0; AgentGatewayGuidance guidance = Create( SessionConfigReadinessState.StartupEligible, - state, - out _); - var error = new StringWriter(); + (GatewayState)stateValue, + out GatewayGuidanceStateStore store, + () => starts++); - await guidance.EvaluateAsync(0, interactive: true, error); - await guidance.EvaluateAsync(0, interactive: true, error); + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); - Assert.Equal( - 2, - error.ToString().Split(AgentGatewayGuidance.Hint).Length - 1); + Assert.Equal(0, starts); + Assert.False(store.IsAcknowledged("logon-a")); } [Theory] [InlineData(false, 0)] [InlineData(true, 1)] [InlineData(true, 130)] - public async Task HintRequiresInteractiveSuccessfulOpenClaw( + public async Task StartRequiresInteractiveSuccessfulOpenClaw( bool interactive, int exitCode) { + int starts = 0; AgentGatewayGuidance guidance = Create( SessionConfigReadinessState.StartupEligible, GatewayState.NotStarted, - out _); - var error = new StringWriter(); + out _, + () => starts++); - await guidance.EvaluateAsync(exitCode, interactive, error); + await guidance.EvaluateAsync(exitCode, interactive, TextWriter.Null); - Assert.Equal(string.Empty, error.ToString()); + Assert.Equal(0, starts); } [Theory] - [InlineData(5)] - [InlineData(3)] - [InlineData(4)] - public async Task UncertainGatewayNeverHintsOrAcknowledges(int stateValue) + [InlineData(SessionConfigReadinessState.Absent)] + [InlineData(SessionConfigReadinessState.NotReady)] + public async Task IneligibleConfigurationNeverStarts( + SessionConfigReadinessState readiness) { - GatewayState state = (GatewayState)stateValue; + int starts = 0; + AgentGatewayGuidance guidance = Create( + readiness, + GatewayState.NotStarted, + out _, + () => starts++); + + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.Equal(0, starts); + } + + [Fact] + public async Task RunningGatewayAcknowledgesWithoutStarting() + { + int starts = 0; AgentGatewayGuidance guidance = Create( SessionConfigReadinessState.StartupEligible, - state, - out GatewayGuidanceStateStore store); - var error = new StringWriter(); + GatewayState.Running, + out GatewayGuidanceStateStore store, + () => starts++); - await guidance.EvaluateAsync(0, interactive: true, error); + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); - Assert.Equal(string.Empty, error.ToString()); - Assert.False(store.IsAcknowledged("logon-a")); + Assert.Equal(0, starts); + Assert.True(store.IsAcknowledged("logon-a")); } [Fact] - public async Task ObservedRunningAcknowledgesAndSkipsEveryLaterProbe() + public async Task ObservedRunningAcknowledgementSkipsLaterProbes() { int readinessCalls = 0; int statusCalls = 0; + int starts = 0; AgentGatewayGuidance guidance = Create( SessionConfigReadinessState.StartupEligible, GatewayState.Running, out GatewayGuidanceStateStore store, - () => readinessCalls++, - () => statusCalls++); + () => starts++, + readinessCalled: () => readinessCalls++, + statusCalled: () => statusCalls++); - await guidance.EvaluateAsync(1, interactive: false, TextWriter.Null); + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); Assert.True(store.IsAcknowledged("logon-a")); Assert.Equal(1, readinessCalls); Assert.Equal(1, statusCalls); + Assert.Equal(0, starts); } [Fact] - public async Task ManualStartAcknowledgementSkipsEveryProbe() + public async Task ManualStartAcknowledgementSkipsEveryProbeAndStart() { int readinessCalls = 0; int statusCalls = 0; + int starts = 0; AgentGatewayGuidance guidance = Create( SessionConfigReadinessState.StartupEligible, GatewayState.NotStarted, out GatewayGuidanceStateStore store, - () => readinessCalls++, - () => statusCalls++); + () => starts++, + readinessCalled: () => readinessCalls++, + statusCalled: () => statusCalls++); guidance.AcknowledgeManualStart(); await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); @@ -176,70 +204,72 @@ public async Task ManualStartAcknowledgementSkipsEveryProbe() Assert.True(store.IsAcknowledged("logon-a")); Assert.Equal(0, readinessCalls); Assert.Equal(0, statusCalls); + Assert.Equal(0, starts); } [Fact] - public void BusyManualAcknowledgementDoesNotAbortGatewayStart() + public void BusyManualAcknowledgementDoesNotThrow() { - var store = new GatewayGuidanceStateStore( - Path.Combine(_root, "gateway-guidance.json")); - var guidance = new AgentGatewayGuidance( - new NeverFreeLock(), - _ => throw new InvalidOperationException("not used"), - _ => throw new InvalidOperationException("not used"), - store, - () => "logon-a", - _log.Add); + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore store, + lifecycleLock: new NeverFreeLock()); guidance.AcknowledgeManualStart(); Assert.False(store.IsAcknowledged("logon-a")); Assert.Contains( _log, - message => message.Contains( - "acknowledgement failed", - StringComparison.Ordinal)); + message => message.Contains("acknowledgement failed", StringComparison.Ordinal)); } [Fact] - public async Task AcknowledgementDuringPostflightSuppressesThePendingHint() + public async Task SuppressedAutomaticStartWritesTheOriginalHint() { - var store = new GatewayGuidanceStateStore( - Path.Combine(_root, "gateway-guidance.json")); - var readinessStarted = - new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var releaseReadiness = - new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var guidance = new AgentGatewayGuidance( - new AlwaysFreeLock(), - async _ => + int starts = 0; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + () => starts++, + readEnvironmentVariable: variable => + variable == OpenClawRuntimeEnvironment.AutoGatewayStartVariable ? "0" : null); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Equal(0, starts); + Assert.Equal( + AgentGatewayGuidance.Hint + Environment.NewLine, + error.ToString()); + } + + [Fact] + public async Task LateAcknowledgementSuppressesThePendingHint() + { + GatewayGuidanceStateStore? state = null; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore created, + readEnvironmentVariable: variable => + variable == OpenClawRuntimeEnvironment.AutoGatewayStartVariable ? "0" : null, + getGatewayStatus: _ => { - readinessStarted.SetResult(); - await releaseReadiness.Task.ConfigureAwait(false); - return new SessionConfigReadinessResult - { - RequestId = "r1", - State = SessionConfigReadinessState.StartupEligible, - Reason = SessionConfigReadinessReason.GatewayModeLocal - }; - }, - _ => Task.FromResult(new GatewayStatusReport( - GatewayState.NotStarted, - null, - "not started")), - store, - () => "logon-a", - _log.Add); + state!.Write( + "logon-a", + GatewayGuidanceAcknowledgement.ManualStartInvoked, + DateTimeOffset.UtcNow); + return Task.FromResult(new GatewayStatusReport( + GatewayState.NotStarted, + null, + "not started")); + }); + state = created; var error = new StringWriter(); - Task evaluation = guidance.EvaluateAsync(0, interactive: true, error); - await readinessStarted.Task.ConfigureAwait(true); - store.Write( - "logon-a", - GatewayGuidanceAcknowledgement.ManualStartInvoked, - DateTimeOffset.UtcNow); - releaseReadiness.SetResult(); - await evaluation.ConfigureAwait(true); + await guidance.EvaluateAsync(0, interactive: true, error); Assert.Equal(string.Empty, error.ToString()); Assert.Contains( @@ -250,25 +280,15 @@ public async Task AcknowledgementDuringPostflightSuppressesThePendingHint() } [Fact] - public async Task BusyFinalAcknowledgementCheckSkipsTheAdvisoryHint() + public async Task BusyFinalHintCheckSkipsTheHint() { - var store = new GatewayGuidanceStateStore( - Path.Combine(_root, "gateway-guidance.json")); - var guidance = new AgentGatewayGuidance( - new NeverFreeLock(), - _ => Task.FromResult(new SessionConfigReadinessResult - { - RequestId = "r1", - State = SessionConfigReadinessState.StartupEligible, - Reason = SessionConfigReadinessReason.GatewayModeLocal - }), - _ => Task.FromResult(new GatewayStatusReport( - GatewayState.NotStarted, - null, - "not started")), - store, - () => "logon-a", - _log.Add); + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + lifecycleLock: new NeverFreeLock(), + readEnvironmentVariable: variable => + variable == OpenClawRuntimeEnvironment.AutoGatewayStartVariable ? "0" : null); var error = new StringWriter(); await guidance.EvaluateAsync(0, interactive: true, error); @@ -276,28 +296,26 @@ public async Task BusyFinalAcknowledgementCheckSkipsTheAdvisoryHint() Assert.Equal(string.Empty, error.ToString()); Assert.Contains( _log, - message => message.Contains( - "lifecycle state is busy", - StringComparison.Ordinal)); + message => message.Contains("lifecycle state is busy", StringComparison.Ordinal)); } [Theory] - [InlineData(false)] [InlineData(true)] - public async Task AdvisoryFailureIsLoggedWithoutOutput(bool backendFailure) + [InlineData(false)] + public async Task AdvisoryProbeFailureIsLoggedWithoutOutput(bool readinessFails) { - var store = new GatewayGuidanceStateStore( - Path.Combine(_root, "gateway-guidance.json")); - Exception failure = backendFailure - ? new MxcException(MxcErrorCode.BackendError, "backend rejected dispatch") - : new SessionLaunchException("bad helper result"); - var guidance = new AgentGatewayGuidance( - new AlwaysFreeLock(), - _ => throw failure, - _ => throw new InvalidOperationException("must not inspect"), - store, - () => "logon-a", - _log.Add); + var failure = new SessionLaunchException( + readinessFails ? "readiness failed" : "status failed"); + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + checkReadiness: readinessFails + ? _ => throw failure + : null, + getGatewayStatus: readinessFails + ? null + : _ => throw failure); var error = new StringWriter(); await guidance.EvaluateAsync(0, interactive: true, error); @@ -305,6 +323,54 @@ public async Task AdvisoryFailureIsLoggedWithoutOutput(bool backendFailure) Assert.Equal(string.Empty, error.ToString()); Assert.Contains( _log, - line => line.Contains(failure.Message, StringComparison.Ordinal)); + message => message.Contains(failure.Message, StringComparison.Ordinal)); + } + + [Fact] + public async Task StartFailureWritesRetryWarningAndLeavesGuidanceUnacknowledged() + { + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore store, + startGateway: _ => throw new SessionBusyException(TimeSpan.Zero)); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.False(store.IsAcknowledged("logon-a")); + Assert.Contains("Gateway start failed", error.ToString(), StringComparison.Ordinal); + Assert.Contains( + "clawctl gateway-service start", + error.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task PostflightPreservesTheOpenClawExitCodeWhenStartFails() + { + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + startGateway: _ => throw new SessionBusyException(TimeSpan.Zero)); + var postflight = new AgentPostflight(guidance); + + int exitCode = await postflight.RunAsync(23, interactive: true, TextWriter.Null); + + Assert.Equal(23, exitCode); + } + + [Fact] + public async Task SuccessfulStartAcknowledgesGuidance() + { + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore store); + + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.True(store.IsAcknowledged("logon-a")); } } diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AutoBehaviorPolicyTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AutoBehaviorPolicyTests.cs new file mode 100644 index 00000000..f44ef3e2 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AutoBehaviorPolicyTests.cs @@ -0,0 +1,181 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class AutoBehaviorPolicyTests +{ + public static IEnumerable GatewayDecisionTruthTable() + { + foreach (GatewayState gatewayState in Enum.GetValues()) + { + foreach (SessionConfigReadinessState readiness in + Enum.GetValues()) + { + foreach (bool interactive in new[] { false, true }) + { + foreach (int exitCode in new[] { 0, 1 }) + { + foreach (string? suppression in new string?[] { null, "off" }) + { + GatewayAutoAction expected = + exitCode == 0 && + interactive && + readiness == SessionConfigReadinessState.StartupEligible && + gatewayState is GatewayState.NotStarted or GatewayState.Stopped + ? suppression is null + ? GatewayAutoAction.Start + : GatewayAutoAction.Hint + : GatewayAutoAction.Silent; + yield return + [ + (int)gatewayState, + (int)readiness, + interactive, + exitCode, + suppression, + (int)expected, + ]; + } + } + } + } + } + } + + [Theory] + [MemberData(nameof(GatewayDecisionTruthTable))] + public void DecideGatewayActionAppliesTheFullEligibilityTruthTable( + int gatewayStateValue, + int readinessValue, + bool interactive, + int exitCode, + string? suppression, + int expectedValue) + { + GatewayState gatewayState = (GatewayState)gatewayStateValue; + SessionConfigReadinessState readiness = + (SessionConfigReadinessState)readinessValue; + GatewayAutoAction result = AutoBehaviorPolicy.DecideGatewayAction( + exitCode, + interactive, + readiness, + gatewayState, + name => name == OpenClawRuntimeEnvironment.AutoGatewayStartVariable + ? suppression + : null); + + Assert.Equal((GatewayAutoAction)expectedValue, result); + } + + [Theory] + [InlineData("0")] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("No")] + [InlineData("off")] + [InlineData("OFF")] + public void SuppressionSpellingsDisableBothAutomaticBehaviors(string value) + { + Assert.False(AutoBehaviorPolicy.IsAutomaticSetupEnabled(_ => value)); + Assert.Equal( + GatewayAutoAction.Hint, + DecideEligibleGatewayAction(_ => value)); + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("yes")] + [InlineData("maybe")] + [InlineData("")] + [InlineData(" ")] + public void UnrecognizedSuppressionValuesLeaveBothAutomaticBehaviorsEnabled( + string value) + { + Assert.True(AutoBehaviorPolicy.IsAutomaticSetupEnabled(_ => value)); + Assert.Equal( + GatewayAutoAction.Start, + DecideEligibleGatewayAction(_ => value)); + } + + [Fact] + public void AbsentSuppressionLeavesBothAutomaticBehaviorsEnabled() + { + Assert.True(AutoBehaviorPolicy.IsAutomaticSetupEnabled(_ => null)); + Assert.Equal( + GatewayAutoAction.Start, + DecideEligibleGatewayAction(_ => null)); + } + + [Fact] + public void AutomaticBehaviorsReadOnlyTheirOwnEnvironmentVariables() + { + List setupQueries = []; + Assert.True(AutoBehaviorPolicy.IsAutomaticSetupEnabled(name => + { + setupQueries.Add(name); + return name == OpenClawRuntimeEnvironment.AutoGatewayStartVariable + ? "off" + : null; + })); + Assert.Equal( + [OpenClawRuntimeEnvironment.AutoSetupVariable], + setupQueries); + + List gatewayQueries = []; + Assert.Equal( + GatewayAutoAction.Start, + DecideEligibleGatewayAction(name => + { + gatewayQueries.Add(name); + return name == OpenClawRuntimeEnvironment.AutoSetupVariable + ? "off" + : null; + })); + Assert.Equal( + [OpenClawRuntimeEnvironment.AutoGatewayStartVariable], + gatewayQueries); + } + + [Fact] + public void BuildExcludesHostSideAutomaticBehaviorPolicyVariables() + { + HashSet queried = new(StringComparer.OrdinalIgnoreCase); + IReadOnlyDictionary environment = + OpenClawRuntimeEnvironment.Build( + false, + name => + { + queried.Add(name); + return name switch + { + OpenClawRuntimeEnvironment.AutoSetupVariable => "off", + OpenClawRuntimeEnvironment.AutoGatewayStartVariable => "off", + _ => null, + }; + }); + + Assert.False( + environment.ContainsKey(OpenClawRuntimeEnvironment.AutoSetupVariable)); + Assert.False( + environment.ContainsKey(OpenClawRuntimeEnvironment.AutoGatewayStartVariable)); + Assert.DoesNotContain( + OpenClawRuntimeEnvironment.AutoSetupVariable, + queried, + StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain( + OpenClawRuntimeEnvironment.AutoGatewayStartVariable, + queried, + StringComparer.OrdinalIgnoreCase); + } + + private static GatewayAutoAction DecideEligibleGatewayAction( + Func readEnvironmentVariable) => + AutoBehaviorPolicy.DecideGatewayAction( + 0, + true, + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + readEnvironmentVariable); +} diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 4cfdb58e..de045044 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -89,7 +89,7 @@ public async Task AgentForwardsEveryArgumentIntoTheSession() } [Fact] - public async Task SuccessfulInteractiveAgentHintsWhenEligibleGatewayWasNeverStarted() + public async Task SuccessfulInteractiveAgentStartsAnEligibleGatewayThatWasNeverStarted() { string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); HostOptions setupOptions = CreateSetupOptions(applicationDirectory); @@ -122,14 +122,22 @@ public async Task SuccessfulInteractiveAgentHintsWhenEligibleGatewayWasNeverStar }; _lastSessionBackend.ExecuteBehavior = _ => { - string requestPath = Directory.GetFiles( + string[] readinessRequests = Directory.GetFiles( _lastSessionBackend.Metadata!.EphemeralWorkspacePath, - "config-readiness-*.json").Single(); + "config-readiness-*.json"); + if (readinessRequests.Length == 0) + { + // Everything after readiness is the gateway launch. Failing it + // here proves the launcher tried to start the gateway and that + // the failure cannot reach the user's exit code. + return Task.FromResult(new MxcExecutionResult(1, string.Empty, "launch refused")); + } + SessionConfigReadinessRequest request = SessionConfigReadinessProtocol.ReadRequest( - File.ReadAllText(requestPath)); + File.ReadAllText(readinessRequests.Single())); File.WriteAllText( - SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.ResultPathFor(readinessRequests.Single()), SessionConfigReadinessProtocol.SerializeResult( new SessionConfigReadinessResult { @@ -158,7 +166,90 @@ public async Task SuccessfulInteractiveAgentHintsWhenEligibleGatewayWasNeverStar errorIsInteractive: () => false, supportsUnicode: () => true); + // The launcher now finishes the job instead of telling the user to run + // the start command themselves. + Assert.NotNull(runtime.GatewayState.Read().Record); + Assert.DoesNotContain( + AgentGatewayGuidance.Hint, + error.ToString(), + StringComparison.Ordinal); + + // A postflight convenience must never turn a successful OpenClaw + // command into a failure. + Assert.Equal(0, exitCode); + // The actionable command must survive on one line: a wrapped command is + // not copyable from a narrow terminal. + Assert.Contains( + "clawctl gateway-service start", + error.ToString(), + StringComparison.Ordinal); + + // Leaving it unacknowledged is what makes a later run retry. + Assert.False( + new GatewayGuidanceStateStore(runtime.Paths.GatewayGuidanceStatePath) + .IsAcknowledged("logon-a")); + } + + [Fact] + public async Task SuppressedAutoStartStillRendersTheHintWithColorAndUnicode() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions setupOptions = CreateSetupOptions(applicationDirectory); + SessionRuntime runtime = CreateSessionRuntime(); + int setupExitCode = await Program.RunControlAsync( + setupOptions, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }); + Assert.Equal(0, setupExitCode); + + _lastSessionBackend!.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "launch-*.json").Single(); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + })); + return Task.FromResult(0); + }; + ConfigureConfigReadiness( + _lastSessionBackend, + SessionConfigReadinessState.StartupEligible, + SessionConfigReadinessReason.GatewayModeLocal); + var error = new StringWriter(); + + int exitCode = await Program.RunAgentAsync( + new HostOptions( + applicationDirectory, + setupOptions.PackagedNodeArchivePath, + ["status"]), + _ => { }, + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: name => + name == OpenClawRuntimeEnvironment.AutoGatewayStartVariable ? "0" : null, + isInteractive: () => true, + error: error, + getLogonSessionId: () => "logon-a", + errorIsProcessConsoleWriter: () => true, + errorIsInteractive: () => false, + supportsUnicode: () => true); + Assert.Equal(0, exitCode); + // Opting out restores the previous experience exactly, including the + // crab identity and the accent palette on an ANSI-capable stderr. + Assert.Null(runtime.GatewayState.Read().Record); Assert.Contains("\u001b[", error.ToString(), StringComparison.Ordinal); Assert.Contains("\U0001f980", error.ToString(), StringComparison.Ordinal); Assert.Contains( diff --git a/tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs new file mode 100644 index 00000000..7601b7ca --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs @@ -0,0 +1,308 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Behavior of the shared setup owner's implicit route, which an +/// openclaw launch uses on a clean machine. +/// +public sealed class SetupOrchestratorTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + private FakeMxcSessionClient _backend = null!; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public async Task AnAbsentMarkerIsProvisionedAndRecordedAsReady() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(); + + SetupEnsureOutcome outcome = await EnsureAsync(runtime, lifecycle).ConfigureAwait(true); + + Assert.Equal(SetupEnsureOutcome.Provisioned, outcome); + SetupRecord? record = runtime.SetupState.Read(runtime.ApplicationId).Record; + Assert.NotNull(record); + Assert.Equal(SetupPhase.Ready, record.Phase); + Assert.Equal(1, lifecycle.RecoveryInstalls); + } + + [Fact] + public async Task ACompletedInstallationIsLeftAloneWithoutDoingAnyWork() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(); + Assert.Equal(SetupEnsureOutcome.Provisioned, await EnsureAsync(runtime, lifecycle).ConfigureAwait(true)); + string sandboxId = runtime.SetupState.Read(runtime.ApplicationId).Record!.SandboxId!; + + SetupEnsureOutcome outcome = await EnsureAsync(runtime, lifecycle).ConfigureAwait(true); + + Assert.Equal(SetupEnsureOutcome.Skipped, outcome); + // Re-provisioning would replace the recorded session out from under a + // gateway that is already running in it. + Assert.Equal(1, lifecycle.RecoveryInstalls); + Assert.Equal(sandboxId, runtime.SetupState.Read(runtime.ApplicationId).Record!.SandboxId); + } + + [Fact] + public Task AnInterruptedPreparingMarkerIsNotRepairedImplicitly() => + AssertMarkerIsPreservedAsync(SetupPhase.Preparing); + + [Fact] + public Task AnInterruptedTeardownMarkerIsNotRepairedImplicitly() => + AssertMarkerIsPreservedAsync(SetupPhase.TearingDown); + + private async Task AssertMarkerIsPreservedAsync(SetupPhase phase) + { + SessionRuntime runtime = CreateRuntime(); + runtime.SetupState.Write(new SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = phase + }); + var lifecycle = new StubLifecycle(); + + SetupEnsureOutcome outcome = await EnsureAsync(runtime, lifecycle).ConfigureAwait(true); + + // Only an absent marker is provisioned automatically. Silently + // reprovisioning here would destroy state the user may be recovering, + // and would hide the explicit message that names the right command. + Assert.Equal(SetupEnsureOutcome.Skipped, outcome); + Assert.Equal(0, lifecycle.RecoveryInstalls); + Assert.Equal(phase, runtime.SetupState.Read(runtime.ApplicationId).Record!.Phase); + } + + [Fact] + public async Task AnUnreadableMarkerIsNotOverwrittenImplicitly() + { + SessionRuntime runtime = CreateRuntime(); + Directory.CreateDirectory(Path.GetDirectoryName(runtime.Paths.SetupStatePath)!); + await File.WriteAllTextAsync(runtime.Paths.SetupStatePath, "{ not json") + .ConfigureAwait(true); + var lifecycle = new StubLifecycle(); + + SetupEnsureOutcome outcome = await EnsureAsync(runtime, lifecycle).ConfigureAwait(true); + + Assert.Equal(SetupEnsureOutcome.Skipped, outcome); + Assert.Equal(0, lifecycle.RecoveryInstalls); + Assert.Equal( + "{ not json", + await File.ReadAllTextAsync(runtime.Paths.SetupStatePath).ConfigureAwait(true)); + } + + [Fact] + public async Task AMarkerFromAnotherInstallationIsNotAdopted() + { + SessionRuntime runtime = CreateRuntime(); + runtime.SetupState.Write(new SetupRecord + { + ApplicationId = "PFN:SomeoneElse_1234567890abc", + Phase = SetupPhase.Ready + }); + var lifecycle = new StubLifecycle(); + + SetupEnsureOutcome outcome = await EnsureAsync(runtime, lifecycle).ConfigureAwait(true); + + Assert.Equal(SetupEnsureOutcome.Skipped, outcome); + Assert.Equal(0, lifecycle.RecoveryInstalls); + } + + [Fact] + public async Task AFailedRecoveryInstallReportsSetupGuidanceAndLeavesNoReadyMarker() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle + { + Recovery = new GatewayPersistenceInstallResult( + GatewayPersistenceState.ActionRequired, + GatewayPersistenceLane.TaskScheduler, + "The logon task could not be registered.", + Changed: false, + Detail: "Access is denied.") + }; + + SessionException failure = await Assert.ThrowsAsync( + () => EnsureAsync(runtime, lifecycle)).ConfigureAwait(true); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + Assert.Contains("Access is denied.", failure.Message, StringComparison.Ordinal); + Assert.NotEqual( + SetupPhase.Ready, + runtime.SetupState.Read(runtime.ApplicationId).Record?.Phase); + } + + [Fact] + public async Task TheLifecycleLockIsReleasedBeforeReturning() + { + SessionRuntime runtime = CreateRuntime(); + + Assert.Equal( + SetupEnsureOutcome.Provisioned, + await EnsureAsync(runtime, new StubLifecycle()).ConfigureAwait(true)); + + // The lifecycle lock is not re-entrant: it takes ownership on a fresh + // dedicated thread per acquire. If ensure returned still holding it, + // the launch that follows would block against this same process until + // the acquire timed out. + using ISessionLockHandle? handle = runtime.LifecycleLock.TryAcquire(TimeSpan.Zero); + Assert.NotNull(handle); + } + + [Fact] + public async Task ConcurrentFirstLaunchesProvisionExactlyOnce() + { + SessionRuntime runtime = CreateRuntime(); + var lifecycle = new StubLifecycle(); + + SetupEnsureOutcome[] outcomes = await Task.WhenAll( + Task.Run(() => EnsureAsync(runtime, lifecycle)), + Task.Run(() => EnsureAsync(runtime, lifecycle))).ConfigureAwait(true); + + // Whichever order they interleave in, the second must observe the + // marker the first wrote rather than provisioning a second session. + Assert.Equal(1, lifecycle.RecoveryInstalls); + Assert.Single(outcomes, SetupEnsureOutcome.Provisioned); + Assert.Single(outcomes, SetupEnsureOutcome.Skipped); + } + + private Task EnsureAsync( + SessionRuntime runtime, + StubLifecycle lifecycle) + { + string applicationDirectory = Path.Combine(_root, "app"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllText(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + string archivePath = Path.Combine(_root, "node-v24.20.0-win-x64.zip"); + File.WriteAllText(archivePath, "fixture"); + + return SetupOrchestrator.EnsureAsync( + new HostOptions(applicationDirectory, archivePath, []), + runtime, + lifecycle, + applicationDirectory, + _ => { }, + new NoProgress(), + CancellationToken.None); + } + + private SessionRuntime CreateRuntime() + { + string baseDirectory = Path.Combine(_root, "base"); + Directory.CreateDirectory(baseDirectory); + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(workspace); + string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + File.WriteAllText(helperPath, "fixture"); + + _backend = new FakeMxcSessionClient + { + Metadata = new MxcProvisionMetadata( + "agent_1", + "S-1-5-21-0-0-0-1001", + workspace) + }; + _backend.ExecuteBehavior = _ => + { + string requestPath = Directory + .GetFiles(_backend.Metadata!.EphemeralWorkspacePath, "runtime-*.json") + .Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + ExecutablePath = + @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip" + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + + return SessionRuntime.Create( + HostPaths.ForRoot( + Path.Combine(_root, "state"), + "OpenClaw.Gateway_ensuretest"), + () => throw new InvalidOperationException("The test backend must be supplied."), + baseDirectory, + _ => { }, + _backend); + } + + private sealed class NoProgress : IProgress + { + public void Report(ClawCtlProgress value) + { + } + } + + /// + /// Only the members the implicit route is allowed to use are implemented. + /// The rest throw, so a change that made ensure tear down, clean, or + /// re-probe support would fail loudly rather than silently touch state. + /// + private sealed class StubLifecycle : IInstallationLifecycle + { + public int RecoveryInstalls { get; private set; } + + public GatewayPersistenceInstallResult Recovery { get; init; } = + new( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) + { + RecoveryInstalls++; + return Task.FromResult(Recovery); + } + + public SessionRuntime CreateRuntime(Action log) => + throw new NotSupportedException(); + + public Task EnsureSessionSupportedAsync(CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime runtime) => + throw new NotSupportedException(); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime) => + throw new NotSupportedException(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) => + throw new NotSupportedException(); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} From fd070b38e0f56e7917975b1354ebfec2d6ad4cd4 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 21 Sep 2026 16:51:08 -0700 Subject: [PATCH 2/5] fix: close automatic lifecycle race windows Record successful automatic gateway starts while the lifecycle lock is still held, and keep advisory acknowledgement failures distinct from startup failures. Drive the new NativeAOT setup scenarios through the production startup route and make concurrent first-launch coverage force real lock contention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e9ba17a2-0bca-4454-96e3-f42ba540a268 --- .../Gateway/AgentGatewayGuidance.cs | 52 ++- .../Gateway/AgentPostflight.cs | 5 +- .../Gateway/GatewayController.cs | 14 +- src/OpenClaw.Launcher/HostStartup.cs | 6 + src/OpenClaw.Launcher/Program.cs | 3 + .../Session/SessionRuntime.cs | 10 +- .../SmokeProgram.cs | 421 +++++++++++++++++- .../Gateway/AgentGatewayGuidanceTests.cs | 62 ++- .../Gateway/GatewayControllerTests.cs | 47 +- .../Session/SetupOrchestratorTests.cs | 101 ++++- 10 files changed, 677 insertions(+), 44 deletions(-) diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs index b370b767..9de89870 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -31,7 +31,10 @@ private readonly Func< TextWriter, Func, Task>, Task> _narrateGatewayStart; - private readonly Func, Task> _startGateway; + private readonly Func< + IProgress, + Action, + Task> _startGateway; private readonly Action _writeGatewayStartFailure; private readonly Func _readEnvironmentVariable; @@ -48,7 +51,10 @@ public AgentGatewayGuidance( TextWriter, Func, Task>, Task>? narrateGatewayStart = null, - Func, Task>? startGateway = null, + Func< + IProgress, + Action, + Task>? startGateway = null, Action? writeGatewayStartFailure = null, Func? readEnvironmentVariable = null) { @@ -63,7 +69,7 @@ public AgentGatewayGuidance( _narrateGatewayStart = narrateGatewayStart ?? ((TextWriter _, Func, Task> start) => start(new Progress())); - _startGateway = startGateway ?? (_ => throw new InvalidOperationException( + _startGateway = startGateway ?? ((_, _) => throw new InvalidOperationException( "Gateway start is not configured.")); _writeGatewayStartFailure = writeGatewayStartFailure ?? ((writer, message) => @@ -188,19 +194,20 @@ private async Task StartGatewayAsync(string logonSessionId, TextWriter error) { try { - GatewayStartResult result = await _narrateGatewayStart(error, _startGateway) + GatewayStartResult result = await _narrateGatewayStart( + error, + progress => _startGateway( + progress, + _ => AcknowledgeUnderLock( + logonSessionId, + GatewayGuidanceAcknowledgement.GatewayObservedRunning))) .ConfigureAwait(false); - if (result.State == GatewayState.Running || result.AlreadyRunning) + if (result.State != GatewayState.Running && !result.AlreadyRunning) { - Acknowledge( - logonSessionId, - GatewayGuidanceAcknowledgement.GatewayObservedRunning); - return; + WriteGatewayStartFailure( + error, + $"Gateway start finished in {result.State} state: {result.Message}"); } - - WriteGatewayStartFailure( - error, - $"Gateway start finished in {result.State} state: {result.Message}"); } catch (Exception exception) when ( exception is MxcException or SessionException or SessionLaunchException or @@ -217,4 +224,23 @@ IOException or UnauthorizedAccessException or InvalidOperationException or private void WriteGatewayStartFailure(TextWriter error, string detail) => _writeGatewayStartFailure(error, detail); + + private void AcknowledgeUnderLock( + string logonSessionId, + GatewayGuidanceAcknowledgement acknowledgement) + { + try + { + _state.Write(logonSessionId, acknowledgement, _clock.GetUtcNow()); + } + catch (Exception exception) when ( + exception is SessionException or + IOException or UnauthorizedAccessException or + InvalidOperationException or Win32Exception) + { + _log( + $"Gateway guidance acknowledgement failed after start: " + + $"{exception.GetType().Name}: {exception.Message}"); + } + } } diff --git a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs index 66aacaef..d84c01f9 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs @@ -86,10 +86,11 @@ public static AgentPostflight Create( interactive, start).ConfigureAwait(false); }, - progress => gateway.StartAsync( + (progress, onRunningUnderLock) => gateway.StartAsync( runtime.HelperPath, CancellationToken.None, - progress), + progress, + onRunningUnderLock), (target, detail) => { using GatewayOutput output = PrepareGatewayOutput( diff --git a/src/OpenClaw.Launcher/Gateway/GatewayController.cs b/src/OpenClaw.Launcher/Gateway/GatewayController.cs index 7ab5c801..cb39aaa3 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayController.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayController.cs @@ -177,11 +177,21 @@ await InspectAsync(session.Record, state.Record, helperPath, cancellationToken) public async Task StartAsync( string helperPath, CancellationToken cancellationToken, - IProgress? progress = null) + IProgress? progress = null, + Action? onRunningUnderLock = null) { using ISessionLockHandle handle = AcquireLock(); - return await StartUnderLockAsync(helperPath, cancellationToken, progress) + GatewayStartResult result = await StartUnderLockAsync( + helperPath, + cancellationToken, + progress) .ConfigureAwait(false); + if (result.State == GatewayState.Running || result.AlreadyRunning) + { + onRunningUnderLock?.Invoke(result); + } + + return result; } private async Task StartUnderLockAsync( diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index f4cee083..30c938f9 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -25,6 +25,12 @@ internal sealed class HostStartup public Func? ReadEnvironmentVariable { get; init; } + public Func>? ProbeReadiness { get; init; } + + public Func? GetPackageFamilyName { get; init; } + + public Func? IsInteractive { get; init; } + public static HostStartup CreateProduction() => new() { Entrypoint = HostEntrypointResolver.Resolve(), diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 5c5b552e..2f9fc5a8 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -150,7 +150,10 @@ PlatformNotSupportedException or startup.InstallationLifecycle is null ? null : startup.InstallationLifecycle.CreateRuntime, + probeReadiness: startup.ProbeReadiness, + getPackageFamilyName: startup.GetPackageFamilyName, readEnvironmentVariable: startup.ReadEnvironmentVariable, + isInteractive: startup.IsInteractive, error: error, errorIsProcessConsoleWriter: startup.UsesProcessConsoleWriters ? () => true : null, diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 4f446605..eaa55288 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -31,7 +31,7 @@ private SessionRuntime( HostPaths paths, SetupStateStore setupState, GatewayStateStore gatewayState, - string lifecycleLockScope) + ISessionLock lifecycleLock) { Coordinator = coordinator; Executor = executor; @@ -41,7 +41,7 @@ private SessionRuntime( Paths = paths; SetupState = setupState; GatewayState = gatewayState; - LifecycleLock = new NamedSessionLock(lifecycleLockScope); + LifecycleLock = lifecycleLock; } public SessionCoordinator Coordinator { get; } @@ -112,7 +112,8 @@ internal static SessionRuntime Create( Func locateRuntime, string baseDirectory, Action log, - IMxcSessionClient? backend = null) + IMxcSessionClient? backend = null, + ISessionLock? lifecycleLock = null) { ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(log); @@ -148,7 +149,8 @@ internal static SessionRuntime Create( paths, new SetupStateStore(paths.SetupStatePath), new GatewayStateStore(paths.GatewayStatePath), - paths.SessionStatePath + "_Installation"); + lifecycleLock ?? + new NamedSessionLock(paths.SessionStatePath + "_Installation")); } diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 5ec9a7eb..19d0dd28 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -1,7 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; using LauncherProgram = OpenClaw.Launcher.Program; namespace OpenClaw.Launcher.AotSmoke; @@ -52,7 +54,11 @@ private static async Task Main() ("gateway narration survives NativeAOT", GatewayNarrationRenders), ("Windows logon identity survives NativeAOT", WindowsLogonIdentityWorks), ("missing application reports diagnostics", MissingApplicationReportsAsync), - ("openclaw never parses its arguments", AgentNeverParsesItsArgumentsAsync) + ("openclaw never parses its arguments", AgentNeverParsesItsArgumentsAsync), + ("first agent launch provisions and forwards arguments", FirstAgentLaunchProvisionsAsync), + ("prepared agent launch has no setup narration", PreparedAgentLaunchIsQuietAsync), + ("automatic setup opt-out preserves missing-setup failure", AutomaticSetupOptOutPreservesFailureAsync), + ("gateway-start opt-out writes the gateway hint to stderr", GatewayStartOptOutWritesHintAsync) ]; int failures = 0; @@ -534,6 +540,93 @@ private static async Task AgentNeverParsesItsArgumentsAsync() fixture.AssertLogRecordsStartupAndExit(); } + private static async Task FirstAgentLaunchProvisionsAsync() + { + using Fixture fixture = await Fixture.CreateAgentFixtureAsync().ConfigureAwait(false); + string[] arguments = ["gateway", "run", "--port", "12345", "--", "a b"]; + + int exitCode = await fixture.RunAsync(arguments).ConfigureAwait(false); + + AssertExitCode(7, exitCode, fixture); + Assert( + arguments.SequenceEqual(fixture.ForwardedArguments), + "The agent launch did not preserve the exact argument vector."); + fixture.AssertSetupCompleted(); + AssertContains(fixture.Error.ToString(), "Setting up OpenClaw", fixture); + Assert( + !fixture.Output.ToString().Contains("Setting up OpenClaw", StringComparison.Ordinal), + "Setup narration leaked to standard output."); + } + + private static async Task PreparedAgentLaunchIsQuietAsync() + { + using Fixture fixture = await Fixture.CreateAgentFixtureAsync().ConfigureAwait(false); + + AssertExitCode(7, await fixture.RunAsync(["status"]).ConfigureAwait(false), fixture); + fixture.ClearOutput(); + + AssertExitCode(7, await fixture.RunAsync(["status"]).ConfigureAwait(false), fixture); + Assert( + !fixture.Error.ToString().Contains("Setting up OpenClaw", StringComparison.Ordinal), + "An already prepared agent launch narrated setup."); + Assert( + string.IsNullOrEmpty(fixture.Output.ToString()), + "Host narration wrote to the child-owned standard output."); + } + + private static async Task AutomaticSetupOptOutPreservesFailureAsync() + { + using Fixture fixture = await Fixture.CreateAgentFixtureAsync().ConfigureAwait(false); + + int exitCode = await fixture.RunAsync( + ["status"], + name => name == OpenClawRuntimeEnvironment.AutoSetupVariable ? "0" : null) + .ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + AssertContains(fixture.Error.ToString(), "clawctl setup", fixture); + fixture.AssertNoSetupWorkOccurred(); + Assert( + string.IsNullOrEmpty(fixture.Output.ToString()), + "The missing-setup failure wrote to standard output."); + } + + private static async Task GatewayStartOptOutWritesHintAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + bool started = false; + var guidance = new AgentGatewayGuidance( + new NamedSessionLock(Path.Combine(fixture.Root, "gateway-guidance-lock")), + _ => Task.FromResult(new SessionConfigReadinessResult + { + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + }), + _ => Task.FromResult(new GatewayStatusReport( + GatewayState.NotStarted, + null, + "The gateway has not been started.")), + new GatewayGuidanceStateStore(Path.Combine(fixture.Root, "gateway-guidance.json")), + () => "fixture-logon", + _ => { }, + writeHint: writer => writer.WriteLine(AgentGatewayGuidance.Hint), + startGateway: (_, _) => + { + started = true; + throw new InvalidOperationException("Gateway start must be suppressed."); + }, + readEnvironmentVariable: name => + name == OpenClawRuntimeEnvironment.AutoGatewayStartVariable ? "0" : null); + + await guidance.EvaluateAsync(0, interactive: true, fixture.Error).ConfigureAwait(false); + + Assert(!started, "Gateway start ran despite the opt-out."); + AssertContains(fixture.Error.ToString(), "clawctl gateway-service start", fixture); + Assert( + string.IsNullOrEmpty(fixture.Output.ToString()), + "Gateway guidance wrote to the child-owned standard output."); + } + private static string LauncherVersion() => typeof(HostStartup).Assembly.GetName().Version?.ToString() ?? "unknown"; @@ -571,16 +664,39 @@ private static void Assert(bool condition, string message) } } + private static async Task CaptureExceptionAsync( + Func action) + where TException : Exception + { + try + { + await action().ConfigureAwait(false); + } + catch (TException exception) + { + return exception; + } + + throw new InvalidOperationException( + $"Expected {typeof(TException).Name} but the operation completed successfully."); + } + private sealed class Fixture : IDisposable { private readonly HostEntrypoint _entrypoint; private readonly WorkTrackingLifecycle? _lifecycle; + private readonly AgentLifecycle? _agentLifecycle; - private Fixture(string root, HostEntrypoint entrypoint, bool allowInstallationWork) + private Fixture( + string root, + HostEntrypoint entrypoint, + bool allowInstallationWork, + AgentLifecycle? agentLifecycle = null) { Root = root; _entrypoint = entrypoint; _lifecycle = allowInstallationWork ? null : new WorkTrackingLifecycle(); + _agentLifecycle = agentLifecycle; LogPath = Path.Combine(root, "diagnostics", "openclaw.log"); ApplicationDirectory = Path.Combine(root, "app"); EntryPoint = Path.Combine(ApplicationDirectory, "openclaw.mjs"); @@ -615,7 +731,16 @@ public static async Task CreateWithApplicationAsync( return fixture; } - public async Task RunAsync(string[] args) + public static async Task CreateAgentFixtureAsync() + { + string root = CreateRoot(); + AgentLifecycle lifecycle = await AgentLifecycle.CreateAsync(root).ConfigureAwait(false); + return new Fixture(root, HostEntrypoint.Agent, allowInstallationWork: true, lifecycle); + } + + public async Task RunAsync( + string[] args, + Func? readEnvironmentVariable = null) { HostStartup startup = new() { @@ -625,12 +750,62 @@ public async Task RunAsync(string[] args) Output = Output, Error = Error, InstallationLifecycle = - _lifecycle ?? (IInstallationLifecycle)InstallationLifecycle.Production + _agentLifecycle ?? + _lifecycle ?? + (IInstallationLifecycle)InstallationLifecycle.Production, + ReadEnvironmentVariable = readEnvironmentVariable ?? (_ => null), + ProbeReadiness = _agentLifecycle is null + ? null + : _ => Task.FromResult(new MxcReadinessReport( + "fixture", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.BackendProbe)), + GetPackageFamilyName = + _agentLifecycle is null + ? null + : () => _agentLifecycle.Runtime.Paths.PackageFamilyName, + IsInteractive = _agentLifecycle is null ? null : () => false }; return await LauncherProgram.RunAsync(args, startup).ConfigureAwait(false); } + public IReadOnlyList ForwardedArguments => + _agentLifecycle?.ForwardedArguments ?? []; + + public void ClearOutput() + { + Output.GetStringBuilder().Clear(); + Error.GetStringBuilder().Clear(); + } + + public void AssertSetupCompleted() + { + AgentLifecycle lifecycle = _agentLifecycle ?? + throw new InvalidOperationException("The fixture has no agent lifecycle."); + Assert( + lifecycle.Runtime.SetupState.Read(lifecycle.Runtime.ApplicationId).Record?.Phase == + SetupPhase.Ready, + "Implicit setup did not write the fixture setup marker."); + Assert( + lifecycle.RecoveryInstalls == 1, + "Implicit setup did not perform exactly one fixture recovery install."); + } + + public void AssertNoSetupWorkOccurred() + { + AgentLifecycle lifecycle = _agentLifecycle ?? + throw new InvalidOperationException("The fixture has no agent lifecycle."); + Assert( + lifecycle.Runtime.SetupState.Read(lifecycle.Runtime.ApplicationId).Record is null, + "Automatic setup opt-out wrote a setup marker."); + Assert(lifecycle.RecoveryInstalls == 0, "Automatic setup opt-out installed recovery."); + Assert(lifecycle.Backend.Calls.Count == 0, "Automatic setup opt-out used the session backend."); + } + public void AssertNoInstallationWorkStarted() => Assert( _lifecycle is not null && !_lifecycle.WorkStarted, @@ -658,6 +833,244 @@ public void Dispose() } } + private sealed class AgentLifecycle : IInstallationLifecycle + { + private AgentLifecycle( + SessionRuntime runtime, + FixtureMxcSessionClient backend, + string nodeArchivePath) + { + Runtime = runtime; + Backend = backend; + NodeArchivePath = nodeArchivePath; + } + + public SessionRuntime Runtime { get; } + + public FixtureMxcSessionClient Backend { get; } + + public string NodeArchivePath { get; } + + public int RecoveryInstalls { get; private set; } + + public IReadOnlyList ForwardedArguments { get; private set; } = []; + + public static async Task CreateAsync(string root) + { + string applicationDirectory = Path.Combine(root, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');").ConfigureAwait(false); + + string runtimeDirectory = Path.Combine(root, "runtime"); + Directory.CreateDirectory(runtimeDirectory); + string nodeArchivePath = Path.Combine( + runtimeDirectory, + "node-v24.20.0-win-x64.zip"); + await File.WriteAllTextAsync(nodeArchivePath, "fixture").ConfigureAwait(false); + + string baseDirectory = Path.Combine(root, "base"); + string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + await File.WriteAllTextAsync(helperPath, "fixture").ConfigureAwait(false); + + string workspace = Path.Combine(root, "workspace"); + Directory.CreateDirectory(workspace); + var backend = new FixtureMxcSessionClient + { + Metadata = new MxcProvisionMetadata( + "agent_1", + "S-1-5-21-0-0-0-1001", + workspace) + }; + SessionRuntime runtime = SessionRuntime.Create( + HostPaths.ForRoot( + Path.Combine(root, "state"), + "OpenClaw.Gateway_aot-smoke"), + () => throw new InvalidOperationException( + "The fixture backend must be supplied."), + baseDirectory, + _ => { }, + backend); + var lifecycle = new AgentLifecycle(runtime, backend, nodeArchivePath); + backend.ExecuteBehavior = lifecycle.HandleExecutionAsync; + return lifecycle; + } + + public SessionRuntime CreateRuntime(Action log) => Runtime; + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) + { + RecoveryInstalls++; + return Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Fixture recovery is configured.", + Changed: true)); + } + + public Task EnsureSessionSupportedAsync(CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + private Task HandleExecutionAsync(MxcExecutionRequest request) + { + string workspace = Backend.Metadata!.EphemeralWorkspacePath; + string[] runtimeRequests = Directory.GetFiles(workspace, "runtime-*.json"); + if (runtimeRequests.Length > 0) + { + string requestPath = runtimeRequests.Single(); + SessionRuntimeInstallRequest runtimeRequest = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = runtimeRequest.RequestId, + ExecutablePath = Path.Combine(workspace, "node.exe"), + Version = "24.20.0", + ArchiveName = Path.GetFileName(NodeArchivePath) + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + } + + string[] launchRequests = Directory.GetFiles(workspace, "launch-*.json"); + if (launchRequests.Length > 0) + { + string requestPath = launchRequests.Single(); + SessionLaunchRequest launchRequest = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + ForwardedArguments = launchRequest.Arguments is null + ? [] + : [.. launchRequest.Arguments.Skip(1)]; + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = launchRequest.RequestId, + Launched = true, + ExitCode = 7 + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + } + + string readinessPath = Directory + .GetFiles(workspace, "config-readiness-*.json") + .Single(); + SessionConfigReadinessRequest readinessRequest = + SessionConfigReadinessProtocol.ReadRequest(File.ReadAllText(readinessPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(readinessPath), + SessionConfigReadinessProtocol.SerializeResult(new SessionConfigReadinessResult + { + RequestId = readinessRequest.RequestId, + State = SessionConfigReadinessState.Absent, + Reason = SessionConfigReadinessReason.ConfigFileMissing + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + } + } + + private sealed class FixtureMxcSessionClient : IMxcSessionClient + { + private int _provisionCount; + + public List Calls { get; } = []; + + public MxcProvisionMetadata? Metadata { get; init; } + + public Func>? ExecuteBehavior { get; set; } + + public Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) + { + Calls.Add("provision"); + _provisionCount++; + return Task.FromResult(new MxcProvisionResult( + MxcSandboxId.Parse($"iso:fixture{_provisionCount}"), + Metadata, + null)); + } + + public Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add("start"); + return Task.CompletedTask; + } + + public Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add("execute"); + return ExecuteBehavior is null + ? Task.FromException( + new InvalidOperationException("Fixture execution is not configured.")) + : ExecuteBehavior(request); + } + + public Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + ExecuteAttachedCoreAsync(request); + + private async Task ExecuteAttachedCoreAsync(MxcExecutionRequest request) + { + Calls.Add("execute-attached"); + if (ExecuteBehavior is null) + { + throw new InvalidOperationException("Fixture execution is not configured."); + } + + MxcExecutionResult result = await ExecuteBehavior(request).ConfigureAwait(false); + return result.ExitCode; + } + + public Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => Task.CompletedTask; + } + // Records whether the command reached real installation work, and // refuses to perform any. Help, version, rejected input, and a missing // application must all fail or finish before touching this. diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs index 2543d4ec..5a37f0b5 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs @@ -21,15 +21,19 @@ private AgentGatewayGuidance Create( GatewayState gatewayState, out GatewayGuidanceStateStore store, Action? startCalled = null, - Func, Task>? startGateway = null, + Func< + IProgress, + Action, + Task>? startGateway = null, Func? readEnvironmentVariable = null, Action? readinessCalled = null, Action? statusCalled = null, ISessionLock? lifecycleLock = null, Func>? checkReadiness = null, - Func>? getGatewayStatus = null) + Func>? getGatewayStatus = null, + GatewayGuidanceStateStore? stateOverride = null) { - store = new GatewayGuidanceStateStore( + store = stateOverride ?? new GatewayGuidanceStateStore( Path.Combine(_root, $"gateway-guidance-{Guid.NewGuid():N}.json")); return new AgentGatewayGuidance( lifecycleLock ?? new AlwaysFreeLock(), @@ -58,14 +62,16 @@ private AgentGatewayGuidance Create( _log.Add, new FixedTimeProvider( new DateTimeOffset(2026, 9, 18, 18, 0, 0, TimeSpan.Zero)), - startGateway: startGateway ?? (_ => + startGateway: startGateway ?? ((_, onRunningUnderLock) => { startCalled?.Invoke(); - return Task.FromResult(new GatewayStartResult( + var result = new GatewayStartResult( GatewayState.Running, new GatewayRecord(), AlreadyRunning: false, - "The gateway is running.")); + "The gateway is running."); + onRunningUnderLock(result); + return Task.FromResult(result); }), readEnvironmentVariable: readEnvironmentVariable ?? (_ => null)); } @@ -88,6 +94,46 @@ public async Task EligibleGatewayStateStartsAndAcknowledges(int stateValue) Assert.True(store.IsAcknowledged("logon-a")); } + [Fact] + public async Task RunningResultWithoutUnderLockCompletionDoesNotAcknowledge() + { + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore store, + startGateway: (_, _) => Task.FromResult(new GatewayStartResult( + GatewayState.Running, + new GatewayRecord(), + AlreadyRunning: false, + "The gateway is running."))); + + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.False(store.IsAcknowledged("logon-a")); + } + + [Fact] + public async Task AcknowledgementFailureDoesNotReportAStartFailure() + { + string statePath = Path.Combine(_root, "state-path-is-a-directory"); + Directory.CreateDirectory(statePath); + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + stateOverride: new GatewayGuidanceStateStore(statePath)); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Equal(string.Empty, error.ToString()); + Assert.Contains( + _log, + message => message.Contains( + "acknowledgement failed after start", + StringComparison.Ordinal)); + } + [Theory] [InlineData((int)GatewayState.Starting)] [InlineData((int)GatewayState.Unhealthy)] @@ -333,7 +379,7 @@ public async Task StartFailureWritesRetryWarningAndLeavesGuidanceUnacknowledged( SessionConfigReadinessState.StartupEligible, GatewayState.NotStarted, out GatewayGuidanceStateStore store, - startGateway: _ => throw new SessionBusyException(TimeSpan.Zero)); + startGateway: (_, _) => throw new SessionBusyException(TimeSpan.Zero)); var error = new StringWriter(); await guidance.EvaluateAsync(0, interactive: true, error); @@ -353,7 +399,7 @@ public async Task PostflightPreservesTheOpenClawExitCodeWhenStartFails() SessionConfigReadinessState.StartupEligible, GatewayState.NotStarted, out _, - startGateway: _ => throw new SessionBusyException(TimeSpan.Zero)); + startGateway: (_, _) => throw new SessionBusyException(TimeSpan.Zero)); var postflight = new AgentPostflight(guidance); int exitCode = await postflight.RunAsync(23, interactive: true, TextWriter.Null); diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs index 429fad5e..d251d030 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs @@ -151,7 +151,8 @@ private SessionCoordinator CreateSessions() => private GatewayController CreateController( GatewayStateStore? store = null, - TimeProvider? clock = null) + TimeProvider? clock = null, + ISessionLock? lifecycleLock = null) { var sessionStore = new SessionStateStore(Path.Combine(_root, "session.json")); if (sessionStore.Read(ApplicationId).Fault == SessionStateFault.Missing) @@ -177,7 +178,7 @@ private GatewayController CreateController( Port: null)), _ => { }, () => sessions.GetRecordedStatus().Record ?? throw new SessionException("Run setup."), - new AlwaysFreeLock(), + lifecycleLock ?? new AlwaysFreeLock(), clock); } @@ -219,6 +220,24 @@ public async Task StartReportsTheObservedPortRatherThanAnAssumedOne() Assert.Contains("port 18789", result.Message, StringComparison.Ordinal); } + [Fact] + public async Task RunningCompletionRunsBeforeTheLifecycleLockIsReleased() + { + _client.Inspection = Healthy(); + var lifecycleLock = new TrackingLock(); + bool callbackObservedHeldLock = false; + + await CreateController(lifecycleLock: lifecycleLock) + .StartAsync( + "helper.exe", + CancellationToken.None, + onRunningUnderLock: _ => + callbackObservedHeldLock = lifecycleLock.IsHeld); + + Assert.True(callbackObservedHeldLock); + Assert.False(lifecycleLock.IsHeld); + } + // A pinned port the gateway is not on means OpenClaw's configuration moved // it. Reporting that as healthy would send the user to the wrong place. [Fact] @@ -585,6 +604,29 @@ public async Task StoppingDoesNotRemoveTheSession() _backend.Calls, call => call.StartsWith("deprovision:", StringComparison.Ordinal)); } + + private sealed class TrackingLock : ISessionLock + { + public bool IsHeld { get; private set; } + + public ISessionLockHandle? TryAcquire(TimeSpan timeout) + { + Assert.False(IsHeld); + IsHeld = true; + return new Handle(this); + } + + private sealed class Handle(TrackingLock owner) : ISessionLockHandle + { + private TrackingLock? _owner = owner; + + public void Dispose() + { + TrackingLock? current = Interlocked.Exchange(ref _owner, null); + current?.IsHeld = false; + } + } + } } public sealed class GatewayStateStoreTests : IDisposable @@ -662,4 +704,5 @@ public void ClearingNothingSucceeds() Assert.Equal(GatewayStateFault.Missing, Store.Read().Fault); } + } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs index 7601b7ca..d4f08a01 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SetupOrchestratorTests.cs @@ -163,15 +163,40 @@ public async Task TheLifecycleLockIsReleasedBeforeReturning() [Fact] public async Task ConcurrentFirstLaunchesProvisionExactlyOnce() { - SessionRuntime runtime = CreateRuntime(); + using var lifecycleLock = new CoordinatedSessionLock(); + SessionRuntime runtime = CreateRuntime(lifecycleLock); var lifecycle = new StubLifecycle(); + (HostOptions options, string applicationDirectory) = CreateHostOptions(); + + ISessionLockHandle held = + lifecycleLock.TryAcquire(TimeSpan.Zero) ?? + throw new InvalidOperationException("The fixture lock was unexpectedly busy."); + lifecycleLock.ObserveNextAttempts(2); + Task[] ensures = + [ + Task.Run(() => EnsureAsync( + options, + applicationDirectory, + runtime, + lifecycle)), + Task.Run(() => EnsureAsync( + options, + applicationDirectory, + runtime, + lifecycle)) + ]; + try + { + await lifecycleLock.WaitForObservedAttemptsAsync().ConfigureAwait(true); + Assert.Equal(0, lifecycle.RecoveryInstalls); + } + finally + { + held.Dispose(); + } - SetupEnsureOutcome[] outcomes = await Task.WhenAll( - Task.Run(() => EnsureAsync(runtime, lifecycle)), - Task.Run(() => EnsureAsync(runtime, lifecycle))).ConfigureAwait(true); + SetupEnsureOutcome[] outcomes = await Task.WhenAll(ensures).ConfigureAwait(true); - // Whichever order they interleave in, the second must observe the - // marker the first wrote rather than provisioning a second session. Assert.Equal(1, lifecycle.RecoveryInstalls); Assert.Single(outcomes, SetupEnsureOutcome.Provisioned); Assert.Single(outcomes, SetupEnsureOutcome.Skipped); @@ -180,6 +205,12 @@ public async Task ConcurrentFirstLaunchesProvisionExactlyOnce() private Task EnsureAsync( SessionRuntime runtime, StubLifecycle lifecycle) + { + (HostOptions options, string applicationDirectory) = CreateHostOptions(); + return EnsureAsync(options, applicationDirectory, runtime, lifecycle); + } + + private (HostOptions Options, string ApplicationDirectory) CreateHostOptions() { string applicationDirectory = Path.Combine(_root, "app"); Directory.CreateDirectory(applicationDirectory); @@ -187,8 +218,19 @@ private Task EnsureAsync( string archivePath = Path.Combine(_root, "node-v24.20.0-win-x64.zip"); File.WriteAllText(archivePath, "fixture"); - return SetupOrchestrator.EnsureAsync( + return ( new HostOptions(applicationDirectory, archivePath, []), + applicationDirectory); + } + + private Task EnsureAsync( + HostOptions options, + string applicationDirectory, + SessionRuntime runtime, + StubLifecycle lifecycle) + { + return SetupOrchestrator.EnsureAsync( + options, runtime, lifecycle, applicationDirectory, @@ -197,7 +239,7 @@ private Task EnsureAsync( CancellationToken.None); } - private SessionRuntime CreateRuntime() + private SessionRuntime CreateRuntime(ISessionLock? lifecycleLock = null) { string baseDirectory = Path.Combine(_root, "base"); Directory.CreateDirectory(baseDirectory); @@ -241,7 +283,48 @@ private SessionRuntime CreateRuntime() () => throw new InvalidOperationException("The test backend must be supplied."), baseDirectory, _ => { }, - _backend); + _backend, + lifecycleLock); + } + + private sealed class CoordinatedSessionLock : ISessionLock, IDisposable + { + private readonly SemaphoreSlim _semaphore = new(1, 1); + private TaskCompletionSource _observedAttempts = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _remainingAttempts; + + public void ObserveNextAttempts(int count) + { + _remainingAttempts = count; + _observedAttempts = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public Task WaitForObservedAttemptsAsync() => _observedAttempts.Task; + + public void Dispose() => _semaphore.Dispose(); + + public ISessionLockHandle? TryAcquire(TimeSpan timeout) + { + if (Volatile.Read(ref _remainingAttempts) > 0 && + Interlocked.Decrement(ref _remainingAttempts) == 0) + { + _observedAttempts.TrySetResult(); + } + + return _semaphore.Wait(timeout) + ? new Handle(() => _semaphore.Release()) + : null; + } + + private sealed class Handle(Action release) : ISessionLockHandle + { + private Action? _release = release; + + public void Dispose() => + Interlocked.Exchange(ref _release, null)?.Invoke(); + } } private sealed class NoProgress : IProgress From 03e6efffc0e03cc0e07ba468fd4f129ac026496c Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 21 Sep 2026 17:31:00 -0700 Subject: [PATCH 3/5] fix: honor late gateway acknowledgement Recheck per-logon gateway acknowledgement under the same lifecycle lock used by automatic startup, then use the controller's no-reacquire path to avoid nesting the non-reentrant lock. Add a deterministic delayed-postflight regression so a later acknowledged start and manual stop cannot be overridden. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e9ba17a2-0bca-4454-96e3-f42ba540a268 --- .../Gateway/AgentGatewayGuidance.cs | 15 +++- .../Gateway/AgentPostflight.cs | 2 +- .../Gateway/GatewayController.cs | 18 ++++ .../Gateway/AgentGatewayGuidanceTests.cs | 82 +++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs index 9de89870..9dc395c7 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -34,7 +34,7 @@ private readonly Func< private readonly Func< IProgress, Action, - Task> _startGateway; + Task> _startGatewayUnderLock; private readonly Action _writeGatewayStartFailure; private readonly Func _readEnvironmentVariable; @@ -69,7 +69,7 @@ public AgentGatewayGuidance( _narrateGatewayStart = narrateGatewayStart ?? ((TextWriter _, Func, Task> start) => start(new Progress())); - _startGateway = startGateway ?? ((_, _) => throw new InvalidOperationException( + _startGatewayUnderLock = startGateway ?? ((_, _) => throw new InvalidOperationException( "Gateway start is not configured.")); _writeGatewayStartFailure = writeGatewayStartFailure ?? ((writer, message) => @@ -194,9 +194,18 @@ private async Task StartGatewayAsync(string logonSessionId, TextWriter error) { try { + using ISessionLockHandle handle = + _lifecycleLock.TryAcquire(AdvisoryTimeout) + ?? throw new SessionBusyException(AdvisoryTimeout); + if (_state.IsAcknowledged(logonSessionId)) + { + _log("Gateway guidance was acknowledged while postflight checks were running."); + return; + } + GatewayStartResult result = await _narrateGatewayStart( error, - progress => _startGateway( + progress => _startGatewayUnderLock( progress, _ => AcknowledgeUnderLock( logonSessionId, diff --git a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs index d84c01f9..e688abfd 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs @@ -86,7 +86,7 @@ public static AgentPostflight Create( interactive, start).ConfigureAwait(false); }, - (progress, onRunningUnderLock) => gateway.StartAsync( + (progress, onRunningUnderLock) => gateway.StartWithLockAlreadyHeldAsync( runtime.HelperPath, CancellationToken.None, progress, diff --git a/src/OpenClaw.Launcher/Gateway/GatewayController.cs b/src/OpenClaw.Launcher/Gateway/GatewayController.cs index cb39aaa3..a4eb296b 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayController.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayController.cs @@ -181,6 +181,24 @@ public async Task StartAsync( Action? onRunningUnderLock = null) { using ISessionLockHandle handle = AcquireLock(); + return await StartWithLockAlreadyHeldAsync( + helperPath, + cancellationToken, + progress, + onRunningUnderLock).ConfigureAwait(false); + } + + /// + /// Starts without reacquiring the lifecycle lock so a caller can make a + /// related state decision and the launch one atomic transition. + /// + /// The caller must hold this controller's lifecycle lock. + internal async Task StartWithLockAlreadyHeldAsync( + string helperPath, + CancellationToken cancellationToken, + IProgress? progress = null, + Action? onRunningUnderLock = null) + { GatewayStartResult result = await StartUnderLockAsync( helperPath, cancellationToken, diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs index 5a37f0b5..54a562e4 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs @@ -94,6 +94,88 @@ public async Task EligibleGatewayStateStartsAndAcknowledges(int stateValue) Assert.True(store.IsAcknowledged("logon-a")); } + [Fact] + public async Task EligibleGatewayStartsInsideTheLifecycleLock() + { + var lifecycleLock = new AlwaysFreeLock(); + bool startObservedHeldLock = false; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + startGateway: (_, onRunningUnderLock) => + { + startObservedHeldLock = lifecycleLock.HeldCount == 1; + var result = new GatewayStartResult( + GatewayState.Running, + new GatewayRecord(), + AlreadyRunning: false, + "The gateway is running."); + onRunningUnderLock(result); + return Task.FromResult(result); + }, + lifecycleLock: lifecycleLock); + + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.True(startObservedHeldLock); + Assert.Equal(1, lifecycleLock.TotalAcquisitions); + Assert.Equal(0, lifecycleLock.HeldCount); + } + + [Fact] + public async Task LateAcknowledgementAfterStopSuppressesPendingAutomaticStart() + { + var lifecycleLock = new AlwaysFreeLock(); + var statusStarted = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowStoppedStatus = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int starts = 0; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore store, + () => starts++, + lifecycleLock: lifecycleLock, + getGatewayStatus: async _ => + { + statusStarted.TrySetResult(); + await allowStoppedStatus.Task.ConfigureAwait(false); + return new GatewayStatusReport( + GatewayState.NotStarted, + null, + "The gateway was stopped."); + }); + + Task pending = guidance.EvaluateAsync( + 0, + interactive: true, + TextWriter.Null); + await statusStarted.Task.ConfigureAwait(true); + + using (ISessionLockHandle handle = + lifecycleLock.TryAcquire(TimeSpan.Zero) ?? + throw new InvalidOperationException("The fixture lock was unexpectedly busy.")) + { + store.Write( + "logon-a", + GatewayGuidanceAcknowledgement.GatewayObservedRunning, + DateTimeOffset.UtcNow); + } + + allowStoppedStatus.TrySetResult(); + await pending.ConfigureAwait(true); + + Assert.Equal(0, starts); + Assert.True(store.IsAcknowledged("logon-a")); + Assert.Contains( + _log, + message => message.Contains( + "acknowledged while postflight", + StringComparison.Ordinal)); + } + [Fact] public async Task RunningResultWithoutUnderLockCompletionDoesNotAcknowledge() { From f3b9873af065b6ff42416b78cd2856e6bcd8577e Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 21 Sep 2026 17:52:33 -0700 Subject: [PATCH 4/5] fix: improve automatic gateway start feedback Preserve the process-console capability established before postflight so automatic gateway startup uses Spectre live status instead of redirected progress lines. Allow 90 seconds for a cold gateway listener to appear before reporting that startup is still in progress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e9ba17a2-0bca-4454-96e3-f42ba540a268 --- src/OpenClaw.Launcher/ClawCtlConsole.cs | 19 +++++++++++- .../Gateway/AgentPostflight.cs | 8 +++-- .../Gateway/GatewayController.cs | 2 +- .../ClawCtlConsoleTests.cs | 30 +++++++++++++++++++ .../Gateway/GatewayStartWaitTests.cs | 1 + 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 05fe0332..b5bf5305 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -120,11 +120,13 @@ internal static Task NarrateGatewayStartAsync( TextWriter output, bool useColor, bool narrate, + bool outputIsInteractive, Func, Task> start) => NarrateAsync( output, useColor, narrate, + outputIsInteractive, GatewayStartProgress.Initial, start); @@ -264,6 +266,21 @@ internal static async Task NarrateAsync( bool useColor, bool narrate, ClawCtlProgress initial, + Func, Task> operation) => + await NarrateAsync( + output, + useColor, + narrate, + IsInteractiveConsole(output), + initial, + operation).ConfigureAwait(false); + + private static async Task NarrateAsync( + TextWriter output, + bool useColor, + bool narrate, + bool outputIsInteractive, + ClawCtlProgress initial, Func, Task> operation) { ArgumentNullException.ThrowIfNull(output); @@ -276,7 +293,7 @@ internal static async Task NarrateAsync( return await operation(NullProgress.Instance).ConfigureAwait(false); } - if (!IsInteractiveConsole(output)) + if (!outputIsInteractive) { await output.WriteLineAsync($"{new string(' ', Indent)}{initial.Message}") .ConfigureAwait(false); diff --git a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs index e688abfd..61b50bdb 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs @@ -84,6 +84,7 @@ public static AgentPostflight Create( target, output.UseColor, interactive, + output.UseLiveRendering, start).ConfigureAwait(false); }, (progress, onRunningUnderLock) => gateway.StartWithLockAlreadyHeldAsync( @@ -139,21 +140,24 @@ private static GatewayOutput PrepareGatewayOutput( out restore)); bool useUnicode = supportsUnicode?.Invoke() ?? (interactive && Console.OutputEncoding.CodePage == 65001); + bool useLiveRendering = interactive && processConsoleWriter; log( $"Gateway output capabilities: interactive={interactive}, " + $"processStderr={processConsoleWriter}, " + $"stderrConsole={selectedStreamIsInteractive}, " + - $"color={useColor}, unicode={useUnicode}."); - return new GatewayOutput(useColor, useUnicode, restore); + $"live={useLiveRendering}, color={useColor}, unicode={useUnicode}."); + return new GatewayOutput(useColor, useUnicode, useLiveRendering, restore); } private sealed class GatewayOutput( bool useColor, bool useUnicode, + bool useLiveRendering, IDisposable? restore) : IDisposable { public bool UseColor { get; } = useColor; public bool UseUnicode { get; } = useUnicode; + public bool UseLiveRendering { get; } = useLiveRendering; public void Dispose() => restore?.Dispose(); } diff --git a/src/OpenClaw.Launcher/Gateway/GatewayController.cs b/src/OpenClaw.Launcher/Gateway/GatewayController.cs index a4eb296b..b3143b31 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayController.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayController.cs @@ -67,7 +67,7 @@ internal sealed class GatewayController /// is still coming up. Long enough for a cold Node.js start inside the /// sandbox, short enough that a wedged launch does not hold the terminal. /// - internal static readonly TimeSpan ListenerWaitBudget = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan ListenerWaitBudget = TimeSpan.FromSeconds(90); /// /// Gap between listener checks. Each check is an IPC round trip into the diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 166577ab..a1512ea8 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -230,6 +230,36 @@ await Assert.ThrowsAsync(() => _ => Task.FromResult(0))); } + [Fact] + public async Task GatewayNarrationUsesKnownInteractiveProcessOutput() + { + using var output = new StringWriter(); + + GatewayStartResult result = await ClawCtlConsole.NarrateGatewayStartAsync( + output, + useColor: false, + narrate: true, + outputIsInteractive: true, + progress => + { + progress.Report(new GatewayStartProgress( + GatewayStartStage.Launching, + "Launching the gateway.")); + return Task.FromResult(new GatewayStartResult( + GatewayState.Running, + new GatewayRecord(), + AlreadyRunning: false, + "The gateway is running.")); + }); + + Assert.Equal(GatewayState.Running, result.State); + Assert.Contains("Launching the gateway.", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain( + $" {GatewayStartProgress.Initial.Message}{Environment.NewLine}", + output.ToString(), + StringComparison.Ordinal); + } + [Fact] public void DiagnosticsBundlePathRemainsAnExactStandaloneLine() { diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs index b0278fb1..429c72b5 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayStartWaitTests.cs @@ -168,6 +168,7 @@ public async Task StartStopsWaitingOnceTheBudgetIsSpent() GatewayStartResult result = await harness.StartAsync(); + Assert.Equal(TimeSpan.FromSeconds(90), GatewayController.ListenerWaitBudget); Assert.Equal(GatewayState.Starting, result.State); Assert.Contains("not listening yet", result.Message, StringComparison.Ordinal); From 661906f3f6d586504512a7f1dc3625663047a052 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 22 Sep 2026 10:28:49 -0700 Subject: [PATCH 5/5] docs: align skipped setup guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e9ba17a2-0bca-4454-96e3-f42ba540a268 --- docs/local-development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/local-development.md b/docs/local-development.md index af329b7f..5391c300 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -49,7 +49,7 @@ Use options for a concrete reason: | `-PayloadRunId ` | You need a specific successful workflow payload instead of the latest one. Use the current branch's successful workflow run when that branch changes packaged application content. | | `-RefreshPayload` | A cached payload must be downloaded again; the previous payload remains available until the replacement registers successfully. | | `-Force` | You intentionally need to re-register although the recorded inputs have not changed. | -| `-SkipSetup` | You only need the registration. Run `clawctl setup` before attempting to run OpenClaw, because the bundled Node.js runtime has not yet been extracted. | +| `-SkipSetup` | You only need the registration and intentionally want to defer setup. If setup state is absent, the next `openclaw` invocation performs setup automatically unless `CLAWCTL_AUTO_SETUP` suppresses it; run `clawctl setup` when you want to provision or recover explicitly. | | `-ReplaceExistingInstall` | You explicitly accept removal of an installed MSIX or a registration owned by another checkout. This is destructive because app data cannot survive the loose/installed transition. | The command is idempotent, not timestamp-driven. `LocalPackage.psm1` records a