diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index fc6b53c1f9b..d4e9a3e3dfa 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -267,6 +267,8 @@ Use these details when your first-run path needs more control. ``` The onboard flow starts both port forwards automatically. + If OpenShell reports `sandbox is not ready`, NemoClaw waits 5 seconds and retries the affected forward up to three times. + These retries preserve the existing sandbox and selected host port. The Hermes dashboard URL does not include an OpenClaw `#token=` fragment. `nemohermes my-hermes dashboard-url --quiet` returns `http://127.0.0.1:18789/` when the default local forward is active. Check the API health endpoint from the host. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 6c3ae76d7a9..b2dfacc2f9b 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -398,6 +398,8 @@ Use these details when your first-run path needs more control. The wizard starts a background dashboard port forward and prints its URL in the ready summary. The default host port is `18789`. When that port is occupied, NemoClaw uses the next free dashboard port, such as `18790`, and includes the port in the URL. + If OpenShell reports `sandbox is not ready`, NemoClaw waits 5 seconds and retries the dashboard forward up to three times. + These retries preserve the existing sandbox and selected port. If the selected port becomes occupied after the sandbox build begins, onboarding rolls back the new sandbox and asks you to retry rather than print an unreachable URL. The installation transcript does not print the gateway token. Use `nemoclaw my-gpt-claw dashboard-url --quiet` to print the complete authenticated URL explicitly. diff --git a/src/lib/onboard/forward-start.test.ts b/src/lib/onboard/forward-start.test.ts index 5b9aaa4789b..287a8ce6f02 100644 --- a/src/lib/onboard/forward-start.test.ts +++ b/src/lib/onboard/forward-start.test.ts @@ -937,6 +937,41 @@ describe("runDetachedForwardStartWithRetries", () => { expect(spawn).toHaveBeenCalledTimes(2); }); + it("retries an OpenShell sandbox readiness rejection after a bounded settle delay", () => { + const fetchList = vi + .fn() + .mockReturnValueOnce(forwardListWith([])) + .mockReturnValue(forwardListWith([{ sandbox: "my-sandbox", port: 18789 }])); + const spawn = vi + .fn() + .mockImplementationOnce(({ stderr }: { stderr: number }) => { + fs.writeSync( + stderr, + "Error: code: 'The system is not in a state required for the operation's execution', message: \"sandbox is not ready\"\n", + ); + return { pid: 784 }; + }) + .mockReturnValueOnce({ pid: 785 }); + const beforeRetry = vi.fn(); + const sleep = vi.fn(); + + const result = runDetachedForwardStartWithRetries( + spawn, + fetchList, + { port: 18789, sandboxName: "my-sandbox" }, + beforeRetry, + { + sleepMs: sleep, + isPortListening: vi.fn().mockReturnValue(false), + }, + ); + + expect(result.ok).toBe(true); + expect(beforeRetry).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(5_000); + }); + it("preserves a ControlMaster listener created by the current attempt (#6099)", () => { const fetchList = vi.fn().mockReturnValue(forwardListWith([])); const spawn = vi.fn().mockImplementation(({ stderr }: { stderr: number }) => { diff --git a/src/lib/onboard/forward-start.ts b/src/lib/onboard/forward-start.ts index 1b3a38922bd..ed894b17a0c 100644 --- a/src/lib/onboard/forward-start.ts +++ b/src/lib/onboard/forward-start.ts @@ -120,8 +120,12 @@ export function looksLikeUntrackedForward(diagnostic: string): boolean { * once OpenShell either keeps the attempt alive until the listener is ready or * exposes a structured retryable outcome. Keep the fragments narrow so an * unrelated SSH or gateway failure cannot enter the listener-retry path. + * OpenShell 0.0.101 can also reject a forward during the sandbox readiness + * handoff. That command has already exited, so list polling cannot recover it; + * the retry wrapper below gives the OpenShell gateway a bounded settle interval. */ export function looksLikeForwardListenerStartFailure(diagnostic: string): boolean { + if (/\bsandbox is not ready\b/i.test(diagnostic)) return true; return /ssh exited before local forward listener opened|local forward listener did not open\b/i.test( diagnostic, ); @@ -178,6 +182,7 @@ function blockingSleepMs(ms: number): void { // supported OpenShell version either stops retaining persistent dead rows or // exposes an atomic recovery operation. const DEAD_FORWARD_GRACE_MS = 2_000; +const SANDBOX_READY_RETRY_SETTLE_MS = 5_000; /** * Build a `DetachedForwardSpawnRunner` that spawns the given argv as a @@ -502,6 +507,7 @@ export function runDetachedForwardStartWithRetries( options: DetachedForwardStartOptions = {}, ): DetachedForwardStartOutcome { const maxRetries = options.maxRetries ?? 3; + const sleepImpl = options.sleepMs ?? blockingSleepMs; let deadForwardRecoveryAvailable = true; const isPortListening = options.isPortListening ?? probeLocalPortListening; const runAttempt = (): DetachedForwardStartOutcome => @@ -528,6 +534,11 @@ export function runDetachedForwardStartWithRetries( if (looksLikeForwardPortConflict(attempt.diagnostic)) { beforeRetryCleanup(); } + if (/\bsandbox is not ready\b/i.test(attempt.diagnostic)) { + // Keep the existing sandbox and port ownership intact while the + // OpenShell gateway finishes the readiness handoff. + sleepImpl(SANDBOX_READY_RETRY_SETTLE_MS); + } standardRetries++; } attempt = runAttempt();