diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index c1dcac4bff3..3ff0ff8a3e2 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -286,8 +286,8 @@ Use these details when your first-run path needs more control. The onboard flow starts both port forwards automatically. For a new sandbox, NemoClaw reserves the selected dashboard loopback port through sandbox preparation and the image build. If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources. - 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. + If OpenShell returns the exact `sandbox is not ready` response, NemoClaw waits 5 seconds and retries the affected forward up to 12 times. + The readiness-specific delays total at most 1 minute and preserve the existing sandbox and selected host port. NemoClaw releases the reservation immediately before OpenShell starts the dashboard forward. If forwarding then fails, onboarding removes the new sandbox and tells you to resolve the reported error before retrying. The Hermes dashboard URL does not include an OpenClaw `#token=` fragment. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 66cf7ea7eef..c91c8ab8ab3 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -419,8 +419,8 @@ Use these details when your first-run path needs more control. When that port is occupied, NemoClaw uses the next free dashboard port, such as `18790`, and includes the port in the URL. For a new sandbox, NemoClaw reserves the selected loopback port through sandbox preparation and the image build. If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources. - 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 OpenShell returns the exact `sandbox is not ready` response, NemoClaw waits 5 seconds and retries the dashboard forward up to 12 times. + The readiness-specific delays total at most 1 minute and preserve the existing sandbox and selected port. NemoClaw releases the reservation immediately before OpenShell starts the dashboard forward. If forwarding then fails, onboarding removes the new sandbox and tells you to resolve the reported error before retrying. The installation transcript does not print the gateway token. diff --git a/src/lib/onboard/forward-start.test.ts b/src/lib/onboard/forward-start.test.ts index 549e9eb9949..4c23b066388 100644 --- a/src/lib/onboard/forward-start.test.ts +++ b/src/lib/onboard/forward-start.test.ts @@ -27,6 +27,21 @@ function forwardListWith( return [header, ...rows].join("\n"); } +const SANDBOX_NOT_READY_FORWARD_DIAGNOSTIC = `Error: × code: 'The system is not in a state required for the operation's + │ execution', message: "sandbox is not ready" +`; + +function readinessHandoffSpawn(rejections: number) { + const diagnostics = [ + ...Array(rejections).fill(SANDBOX_NOT_READY_FORWARD_DIAGNOSTIC), + "", + ]; + return vi.fn(({ stderr }: { stderr: number }) => { + fs.writeSync(stderr, diagnostics.shift() ?? ""); + return {}; + }); +} + describe("runDetachedForwardStartWithDiagnostics", () => { it("returns ok as soon as the forward appears in the list", () => { const fetchList = vi @@ -983,6 +998,59 @@ describe("runDetachedForwardStartWithRetries", () => { expect(events).toEqual(["spawn-1", "sleep-5000", "spawn-2"]); }); + it("keeps retrying when four consecutive readiness handoffs are still settling", () => { + const spawn = readinessHandoffSpawn(4); + const fetchList = vi.fn(() => + spawn.mock.calls.length >= 5 + ? forwardListWith([{ sandbox: "my-sandbox", port: 18789 }]) + : forwardListWith([]), + ); + 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(result.reason).toBe("ok"); + expect(beforeRetry).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledTimes(5); + expect(sleep).toHaveBeenCalledTimes(4); + expect(sleep).toHaveBeenCalledWith(5_000); + }); + + it("stops after the independent sandbox readiness retry bound", () => { + const spawn = readinessHandoffSpawn(13); + const beforeRetry = vi.fn(); + const sleep = vi.fn(); + + const result = runDetachedForwardStartWithRetries( + spawn, + vi.fn().mockReturnValue(forwardListWith([])), + { port: 18789, sandboxName: "my-sandbox" }, + beforeRetry, + { + sleepMs: sleep, + isPortListening: vi.fn().mockReturnValue(false), + }, + ); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("listener-start-failure"); + expect(beforeRetry).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledTimes(13); + expect(sleep).toHaveBeenCalledTimes(12); + expect(sleep).toHaveBeenCalledWith(5_000); + }); + it("does not retry a composite authentication diagnostic that mentions readiness", () => { let now = 0; vi.spyOn(Date, "now").mockImplementation(() => now); diff --git a/src/lib/onboard/forward-start.ts b/src/lib/onboard/forward-start.ts index 219c8a8d9ea..4dfbf2597b3 100644 --- a/src/lib/onboard/forward-start.ts +++ b/src/lib/onboard/forward-start.ts @@ -61,7 +61,9 @@ export interface DetachedForwardStartOptions { onProgress?: (info: { elapsedMs: number; listSnapshot: string }) => void; progressIntervalMs?: number; // Number of retryable startup attempts after the initial attempt. Honoured - // only by `runDetachedForwardStartWithRetries`. Defaults to 3. + // only by `runDetachedForwardStartWithRetries`. An explicit value applies + // to every retryable outcome. Ordinary failures default to 3 retries; exact + // sandbox readiness handoffs use their own longer default below. maxRetries?: number; // Loopback port-liveness probe. Defaults to `probeLocalPortListening` (a // synchronous Node TCP connect to 127.0.0.1:port). The retry wrapper uses it @@ -191,6 +193,11 @@ function blockingSleepMs(ms: number): void { // exposes an atomic recovery operation. const DEAD_FORWARD_GRACE_MS = 2_000; const SANDBOX_READY_RETRY_SETTLE_MS = 5_000; +// A newly created sandbox can remain between OpenShell's create-ready and +// forward-ready states longer than the ordinary listener retry budget. Keep +// this exact-diagnostic path bounded to one additional minute without +// widening retries for authentication, ownership, or listener failures. +const SANDBOX_READY_MAX_RETRIES = 12; /** * Build a `DetachedForwardSpawnRunner` that spawns the given argv as a @@ -515,8 +522,10 @@ export function runDetachedForwardStartWithRetries( options: DetachedForwardStartOptions = {}, ): DetachedForwardStartOutcome { const maxRetries = options.maxRetries ?? 3; + const maxSandboxReadyRetries = options.maxRetries ?? SANDBOX_READY_MAX_RETRIES; const sleepImpl = options.sleepMs ?? blockingSleepMs; let deadForwardRecoveryAvailable = true; + let sandboxReadyRetries = 0; const isPortListening = options.isPortListening ?? probeLocalPortListening; const runAttempt = (): DetachedForwardStartOutcome => isPortListening(expect.port) @@ -534,20 +543,26 @@ export function runDetachedForwardStartWithRetries( deadForwardRecoveryAvailable = false; beforeRetryCleanup(); } else { - const isRetryableStandardFailure = - (attempt.reason !== "listener-ownership-conflict" && - looksLikeForwardPortConflict(attempt.diagnostic)) || - attempt.reason === "listener-start-failure"; - if (!isRetryableStandardFailure || standardRetries >= maxRetries) break; - if (looksLikeForwardPortConflict(attempt.diagnostic)) { - beforeRetryCleanup(); - } - if (looksLikeSandboxNotReadyForwardStart(attempt.diagnostic)) { + const isSandboxReadinessHandoff = + attempt.reason === "listener-start-failure" && + looksLikeSandboxNotReadyForwardStart(attempt.diagnostic); + if (isSandboxReadinessHandoff) { + if (sandboxReadyRetries >= maxSandboxReadyRetries) break; // Keep the existing sandbox and port ownership intact while the // OpenShell gateway finishes the readiness handoff. sleepImpl(SANDBOX_READY_RETRY_SETTLE_MS); + sandboxReadyRetries++; + } else { + const isRetryableStandardFailure = + (attempt.reason !== "listener-ownership-conflict" && + looksLikeForwardPortConflict(attempt.diagnostic)) || + attempt.reason === "listener-start-failure"; + if (!isRetryableStandardFailure || standardRetries >= maxRetries) break; + if (looksLikeForwardPortConflict(attempt.diagnostic)) { + beforeRetryCleanup(); + } + standardRetries++; } - standardRetries++; } attempt = runAttempt(); }