Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/get-started/quickstart-hermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/get-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions src/lib/onboard/forward-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 26 additions & 11 deletions src/lib/onboard/forward-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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();
}
Expand Down
Loading