From d0d4e19b89a407f85805fe01e8579f0ccd08797d Mon Sep 17 00:00:00 2001 From: Rui Luo Date: Wed, 12 Aug 2026 20:43:46 +0800 Subject: [PATCH] fix(onboard): print portable recovery instead of --resume Signed-off-by: Rui Luo --- src/lib/build-context.test.ts | 16 ++++++++ src/lib/build-context.ts | 27 +++++++------ src/lib/onboard/exit-step-failure.test.ts | 10 ++++- src/lib/onboard/exit-step-failure.ts | 12 +++--- .../gateway-start-failure-integration.test.ts | 12 ++++++ src/lib/onboard/gateway-start-failure.ts | 12 +++++- src/lib/onboard/resume-hint.test.ts | 38 +++++++++++++++++-- src/lib/onboard/resume-hint.ts | 29 ++++++++++---- 8 files changed, 125 insertions(+), 31 deletions(-) diff --git a/src/lib/build-context.test.ts b/src/lib/build-context.test.ts index 86d906f0984..abfe0adf1fd 100644 --- a/src/lib/build-context.test.ts +++ b/src/lib/build-context.test.ts @@ -256,6 +256,22 @@ describe("printSandboxCreateRecoveryHints", () => { expect(out).toContain("NEMOCLAW_WEB_SEARCH_ENABLED=0"); expect(out).toContain("onboard --resume"); }); + + it("prints the portable-profile recovery command when the portable env is set", () => { + const prev = process.env.NEMOCLAW_EXPERIMENTAL_PROFILE; + process.env.NEMOCLAW_EXPERIMENTAL_PROFILE = "portable"; + try { + printSandboxCreateRecoveryHints(""); + const out = stderr(); + expect(out).toContain("onboard --experimental-profile portable"); + expect(out).not.toContain("--resume"); + expect(out).not.toContain("Or: nemoclaw onboard"); + } finally { + prev === undefined + ? delete process.env.NEMOCLAW_EXPERIMENTAL_PROFILE + : (process.env.NEMOCLAW_EXPERIMENTAL_PROFILE = prev); + } + }); }); describe("reconstructImageRefCreateCommand", () => { diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts index 2af7c78adcc..e5ee546600e 100644 --- a/src/lib/build-context.ts +++ b/src/lib/build-context.ts @@ -9,7 +9,8 @@ import fs from "node:fs"; import path from "node:path"; import { CLI_NAME } from "./cli/branding"; -import { noteOnboardResumeHintShown } from "./onboard/resume-hint"; +import { isPortableExperimentalProfile } from "./onboard/experimental/portable-profile"; +import { noteOnboardResumeHintShown, onboardRecoveryCommand } from "./onboard/resume-hint"; import { classifySandboxCreateFailure, planSandboxCreateRecovery } from "./validation"; @@ -107,6 +108,8 @@ export function printSandboxCreateRecoveryHints( // Every branch below prints tailored `--resume` recovery guidance, so suppress // the generic incomplete-exit backstop (#6003). noteOnboardResumeHintShown(); + const portable = isPortableExperimentalProfile(); + const recoveryCommand = onboardRecoveryCommand(portable); const failure = classifySandboxCreateFailure(output); if (failure.kind === "image_upload_container_missing") { const { arm64ImageRefWorkaround } = planSandboxCreateRecovery(failure, { platform, arch }); @@ -173,14 +176,14 @@ export function printSandboxCreateRecoveryHints( ); } console.error( - ` If you would rather let NemoClaw rebuild and retry from scratch: ${CLI_NAME} onboard --resume`, + ` If you would rather let NemoClaw rebuild and retry from scratch: ${recoveryCommand}`, ); return; } if (failure.kind === "image_transfer_timeout") { console.error(" Hint: image upload into the OpenShell gateway timed out."); - console.error(` Recovery: ${CLI_NAME} onboard --resume`); - if (failure.uploadedToGateway) { + console.error(` Recovery: ${recoveryCommand}`); + if (failure.uploadedToGateway && !portable) { console.error( " Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state.", ); @@ -190,7 +193,7 @@ export function printSandboxCreateRecoveryHints( } if (failure.kind === "image_transfer_reset") { console.error(" Hint: the image push/import stream was interrupted."); - console.error(` Recovery: ${CLI_NAME} onboard --resume`); + console.error(` Recovery: ${recoveryCommand}`); if (failure.uploadedToGateway) { console.error(" The image appears to have reached the gateway before the stream failed."); } @@ -199,7 +202,7 @@ export function printSandboxCreateRecoveryHints( } if (failure.kind === "sandbox_create_incomplete") { console.error(" Hint: sandbox creation started but the create stream did not finish cleanly."); - console.error(` Recovery: ${CLI_NAME} onboard --resume`); + console.error(` Recovery: ${recoveryCommand}`); console.error( " Check: openshell sandbox list # verify whether the sandbox became ready", ); @@ -210,7 +213,7 @@ export function printSandboxCreateRecoveryHints( " Hint: TLS certificate mismatch — the gateway certificate changed since the CLI last trusted it.", ); console.error(" Fix: openshell gateway trust -g nemoclaw"); - console.error(` Then: ${CLI_NAME} onboard --resume`); + console.error(` Then: ${recoveryCommand}`); return; } if (failure.kind === "gpu_cdi_injection_failed") { @@ -220,9 +223,9 @@ export function printSandboxCreateRecoveryHints( ); console.error(" NEMOCLAW_DOCKER_GPU_PATCH=0 does not bypass this path."); console.error(" Skip GPU passthrough entirely with either:"); - console.error(` ${CLI_NAME} onboard --no-gpu`); + console.error(` ${portable ? recoveryCommand : `${CLI_NAME} onboard`} --no-gpu`); console.error(" NEMOCLAW_SANDBOX_GPU=0 (env var, applies to subsequent runs)"); - console.error(` Recovery: ${CLI_NAME} onboard --resume --no-gpu`); + if (!portable) console.error(` Recovery: ${recoveryCommand} --no-gpu`); return; } if (failure.kind === "plugin_install_network_denied") { @@ -237,9 +240,9 @@ export function printSandboxCreateRecoveryHints( console.error( " feature that requires this plugin (e.g. NEMOCLAW_WEB_SEARCH_ENABLED=0).", ); - console.error(` Recovery: ${CLI_NAME} onboard --resume`); + console.error(` Recovery: ${recoveryCommand}`); return; } - console.error(` Recovery: ${CLI_NAME} onboard --resume`); - console.error(` Or: ${CLI_NAME} onboard`); + console.error(` Recovery: ${recoveryCommand}`); + if (!portable) console.error(` Or: ${CLI_NAME} onboard`); } diff --git a/src/lib/onboard/exit-step-failure.test.ts b/src/lib/onboard/exit-step-failure.test.ts index 74db2223986..37362b1536a 100644 --- a/src/lib/onboard/exit-step-failure.test.ts +++ b/src/lib/onboard/exit-step-failure.test.ts @@ -71,8 +71,9 @@ describe("terminal step failure helper", () => { expect(loaded.machine.state).toBe("failed"); }); - it("simulates the onboard exit listener and ignores successful or complete exits", () => { + it("keeps the portable profile captured by the onboard exit listener (#8873)", () => { const listeners: Array<(code: number) => void> = []; + const errors: string[] = []; let complete = false; const processLike = { once: (event: "exit", listener: (code: number) => void) => { @@ -82,13 +83,16 @@ describe("terminal step failure helper", () => { }; session.saveSession(session.createSession({ lastStepStarted: "inference" })); // The incomplete exit also prints the #6003 resume hint; capture it. - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation((message = "") => errors.push(String(message))); registerIncompleteOnboardExitFailureHandler( session, () => complete, "Onboarding exited before the step completed.", processLike, + true, ); listeners[0](0); expect(requireLoadedSession().status).toBe("in_progress"); @@ -100,6 +104,8 @@ describe("terminal step failure helper", () => { complete = false; listeners[0](1); errorSpy.mockRestore(); + expect(errors.join("\n")).toContain("onboard --experimental-profile portable"); + expect(errors.join("\n")).not.toContain("onboard --resume"); const loaded = requireLoadedSession(); expect(loaded.steps.inference.status).toBe("failed"); diff --git a/src/lib/onboard/exit-step-failure.ts b/src/lib/onboard/exit-step-failure.ts index 49701e8ce85..c58bf063ddb 100644 --- a/src/lib/onboard/exit-step-failure.ts +++ b/src/lib/onboard/exit-step-failure.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { Session } from "../state/onboard-session"; +import { isPortableExperimentalProfile } from "./experimental/portable-profile"; import { printOnboardResumeHint } from "./resume-hint"; export interface ExitStepFailureSessionDeps { @@ -44,17 +45,18 @@ export function registerIncompleteOnboardExitFailureHandler( isComplete: () => boolean, message: string, processLike: OnboardExitFailureProcessLike = process, + portable = isPortableExperimentalProfile(), ): void { const failIncompleteStep = (): void => { if (isComplete()) return; - // A non-null return means a step was in progress, so the session records a - // resumable point — surface `--resume` for exit paths that don't print - // their own recovery guidance (#6003). When an explicit cancel has already - // cleared the session (or no step started), this is null and stays silent; + // A non-null return means a step was in progress, so surface the + // profile-appropriate recovery command for exit paths that don't print + // their own guidance (#6003, #8873). When an explicit cancel has already + // cleared the session (or no step started), this is null and stays silent. // printOnboardResumeHint also self-dedupes against tailored hints. const interrupted = markLastStartedStepFailed(deps, message, true); if (!interrupted) return; - printOnboardResumeHint(); + printOnboardResumeHint(portable); }; processLike.once("exit", (code) => { diff --git a/src/lib/onboard/gateway-start-failure-integration.test.ts b/src/lib/onboard/gateway-start-failure-integration.test.ts index d1f357cf09e..ed070e780e6 100644 --- a/src/lib/onboard/gateway-start-failure-integration.test.ts +++ b/src/lib/onboard/gateway-start-failure-integration.test.ts @@ -84,6 +84,18 @@ describe("startGatewayWithOptions docker-unreachable abort (#2347)", () => { expect(joined).not.toContain("colima start"); expect(joined).not.toContain("systemctl"); }); + + it("prints the rootless-Podman recovery hint when portable=true (#8873)", () => { + const printed: string[] = []; + printDockerDaemonRecovery((message = "") => printed.push(message), "linux", true); + const joined = printed.join("\n"); + expect(joined).toContain("rootless Podman API service is not reachable"); + expect(joined).toContain("Start Podman"); + expect(joined).toContain("nemoclaw onboard --experimental-profile portable"); + expect(joined).not.toContain("sudo systemctl start docker"); + expect(joined).not.toContain("colima start"); + expect(joined).not.toContain("--resume"); + }); }); // ── Layer 1: handleFinalGatewayStartFailure dockerUnreachable branch ───── diff --git a/src/lib/onboard/gateway-start-failure.ts b/src/lib/onboard/gateway-start-failure.ts index 0269f7ba91a..f5af8057f0c 100644 --- a/src/lib/onboard/gateway-start-failure.ts +++ b/src/lib/onboard/gateway-start-failure.ts @@ -4,6 +4,8 @@ import { compactText } from "../core/url-utils"; import { redact } from "../security/redact"; import { classifyGatewayStartFailure } from "../validation"; +import { isPortableExperimentalProfile } from "./experimental/portable-profile"; +import { onboardRecoveryCommand } from "./resume-hint"; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; @@ -45,7 +47,15 @@ export function reportLegacyGatewayStartResultFailure( export function printDockerDaemonRecovery( printError: (message?: string) => void, platform: NodeJS.Platform = process.platform, + portable = isPortableExperimentalProfile(), ): void { + if (portable) { + printError(" The rootless Podman API service is not reachable."); + printError(""); + printError(` Start Podman, then rerun: ${onboardRecoveryCommand(portable)}`); + return; + } + printError(" Docker daemon is not running — cannot start the gateway."); printError(""); printError(" Start Docker, then rerun `nemoclaw onboard`:"); @@ -116,7 +126,7 @@ export function createFinalGatewayStartFailureHandler(deps: FinalGatewayStartFai printError( ` docker volume ls -q --filter "name=openshell-cluster-${gatewayName}" | xargs -r docker volume rm`, ); - printError(" nemoclaw onboard --resume"); + printError(` ${onboardRecoveryCommand()}`); return exitProcess(1); }; } diff --git a/src/lib/onboard/resume-hint.test.ts b/src/lib/onboard/resume-hint.test.ts index 0cb94f01fb3..b8593699f12 100644 --- a/src/lib/onboard/resume-hint.test.ts +++ b/src/lib/onboard/resume-hint.test.ts @@ -13,25 +13,55 @@ beforeEach(() => resetOnboardResumeHintForTests()); afterEach(() => resetOnboardResumeHintForTests()); describe("onboard resume hint", () => { + const portableEnv = "NEMOCLAW_EXPERIMENTAL_PROFILE"; + it("prints the --resume recovery guidance through the injected logger", () => { const lines: string[] = []; - printOnboardResumeHint((message) => lines.push(message)); + printOnboardResumeHint(false, (message) => lines.push(message)); const text = lines.join("\n"); expect(text).toContain("onboard --resume"); expect(text).toContain("--fresh"); }); + it("prints the portable-profile recovery guidance when the portable env is set (#8873)", () => { + const prev = process.env[portableEnv]; + process.env[portableEnv] = "portable"; + try { + const lines: string[] = []; + printOnboardResumeHint(undefined, (message) => lines.push(message)); + const text = lines.join("\n"); + expect(text).toContain("onboard --experimental-profile portable"); + expect(text).not.toContain("--resume"); + } finally { + prev === undefined ? delete process.env[portableEnv] : (process.env[portableEnv] = prev); + } + }); + + it("uses an explicit portable profile after the environment is restored (#8873)", () => { + const prev = process.env[portableEnv]; + delete process.env[portableEnv]; + try { + const lines: string[] = []; + printOnboardResumeHint(true, (message) => lines.push(message)); + const text = lines.join("\n"); + expect(text).toContain("onboard --experimental-profile portable"); + expect(text).not.toContain("--resume"); + } finally { + prev === undefined ? delete process.env[portableEnv] : (process.env[portableEnv] = prev); + } + }); + it("prints at most once per process", () => { const lines: string[] = []; - printOnboardResumeHint((message) => lines.push(message)); - printOnboardResumeHint((message) => lines.push(message)); + printOnboardResumeHint(false, (message) => lines.push(message)); + printOnboardResumeHint(false, (message) => lines.push(message)); expect(lines.filter((line) => line.includes("onboard --resume"))).toHaveLength(1); }); it("stays silent once a tailored hint was noted", () => { const lines: string[] = []; noteOnboardResumeHintShown(); - printOnboardResumeHint((message) => lines.push(message)); + printOnboardResumeHint(false, (message) => lines.push(message)); expect(lines).toHaveLength(0); }); }); diff --git a/src/lib/onboard/resume-hint.ts b/src/lib/onboard/resume-hint.ts index 4c2a410429d..242838f5a1a 100644 --- a/src/lib/onboard/resume-hint.ts +++ b/src/lib/onboard/resume-hint.ts @@ -2,6 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { CLI_NAME } from "../cli/branding"; +import { isPortableExperimentalProfile } from "./experimental/portable-profile"; + +export function onboardRecoveryCommand(portable = isPortableExperimentalProfile()): string { + return portable + ? `${CLI_NAME} onboard --experimental-profile portable` + : `${CLI_NAME} onboard --resume`; +} // Whether an onboard `--resume` recovery hint has already been emitted this run. // Context-specific failure explainers (e.g. the sandbox build-context hints) @@ -11,27 +18,35 @@ import { CLI_NAME } from "../cli/branding"; let resumeHintShown = false; /** - * Print the generic onboard `--resume` recovery hint, once per process. + * Print the generic onboard recovery hint, once per process. * * Onboarding exits through dozens of scattered `process.exit(1)` paths; most - * never mention `--resume`, so users assume a failed run requires a full + * never mention how to resume, so users assume a failed run requires a full * reinstall (#6003). The incomplete-exit handler calls this as a catch-all when * a resumable step was in progress, covering every exit that does not already - * print its own recovery guidance. + * print its own recovery guidance. The recovery command adapts to whether the + * run selected the portable experimental profile (which forces `--fresh` and + * rejects `--resume`) (#8873). */ export function printOnboardResumeHint( + portable = isPortableExperimentalProfile(), log: (message: string) => void = (message) => console.error(message), ): void { if (resumeHintShown) return; resumeHintShown = true; log(""); - log(" Onboarding did not finish. Resume from the step that failed with:"); - log(` ${CLI_NAME} onboard --resume`); - log(" Completed steps are skipped; pass --fresh instead to start over."); + if (portable) { + log(" Onboarding did not finish. Portable onboarding always starts fresh; rerun:"); + log(` ${onboardRecoveryCommand(portable)}`); + } else { + log(" Onboarding did not finish. Resume from the step that failed with:"); + log(` ${onboardRecoveryCommand(portable)}`); + log(" Completed steps are skipped; pass --fresh instead to start over."); + } } /** - * Record that a context-specific `--resume` hint was already printed this run so + * Record that a context-specific hint was already printed this run so * the catch-all in {@link printOnboardResumeHint} stays silent. */ export function noteOnboardResumeHintShown(): void {