diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index 71cac0768a6..c7d80896bae 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -371,6 +371,22 @@ Accepting the preview prompt sets explicit managed-vLLM intent for the narrow pe Declining the prompt or setting only `NEMOCLAW_NO_EXPRESS=1` stops the installer before onboarding; neither path opens interactive provider selection. Set `NEMOCLAW_PROVIDER=install-vllm` when you need to bypass the prompt with the same explicit preview intent. +On a host that still meets the N1x identity checks, `rebuild` reuses the recorded route as explicit preview intent only when all of these conditions hold: + +- The sandbox registry records provider `vllm-local`. +- The registry records model `nvidia/Qwen3.6-35B-A3B-NVFP4`. +- If the registry records `endpointUrl` as `null`, NemoClaw derives `http://host.openshell.internal:8000/v1`; otherwise, `endpointUrl` must equal that URL. +- The registry records `endpointSource` as `onboard` and `openshellDriver` as `docker`. +- The replacement keeps the recorded provider and model, then re-derives the canonical local endpoint. +- `hostLocalInferenceReceipt` is absent or `null`, or contains a canonical vLLM receipt for `host.openshell.internal:8000` and the same model. + +NemoClaw v0.0.109 can omit `hostLocalInferenceReceipt` or record it as `null`. +Matching sandboxes from that release remain eligible only when every condition above passes. +You do not need to set `NEMOCLAW_PROVIDER` again for a matching rebuild. +Any unmet condition stops readiness preflight before NemoClaw deletes the existing sandbox. +A malformed, non-vLLM, or conflicting receipt also stops readiness preflight before deletion. +This reuse waives only the pending physical-validation finding and does not change the Deferred status. + Physical CUDA and CDI checks passed on one N1x host. The full NemoClaw Express E2E test is still pending, so the N1x platform status remains Deferred. diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts index d770d0a6ab3..b75d7407373 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts @@ -127,6 +127,8 @@ export type RebuildRecreateOnboardOpts = { rebuildRegistryInferenceRoute?: RebuildRouteHandoff; rebuildProviderReconfigure?: RebuildProviderReconfigureHandoff; providerRecoveryReceipt?: ProviderRecoveryReceipt; + /** Recorded managed-vLLM intent admitted only by the N1x readiness exception. */ + allowDeferredN1xManagedVllm?: true; /** Target-scoped authority admitted by the authoritative rebuild preflight. */ rebuildGatewayAuthority?: CheckpointGatewayAuthority; preparedImageRebuild?: PreparedImageRebuildHandoff; diff --git a/src/lib/actions/sandbox/rebuild-managed-workload-context-window.test.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase-orchestration.test.ts similarity index 64% rename from src/lib/actions/sandbox/rebuild-managed-workload-context-window.test.ts rename to src/lib/actions/sandbox/rebuild-preflight-target-phase-orchestration.test.ts index 20d454d82ed..ab4fcdeb885 100644 --- a/src/lib/actions/sandbox/rebuild-managed-workload-context-window.test.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase-orchestration.test.ts @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ bail: vi.fn(), + preflightAuthoritativeOnboardRuntime: vi.fn(async (..._args: unknown[]) => false), prepareManagedWorkloadRebuildHandoff: vi.fn(), prepareSandboxWorkloadSourceFromRebuildHandoff: vi.fn(), prepareRebuildTargetConfig: vi.fn(), @@ -33,7 +34,7 @@ vi.mock("../../onboard/workload/runtime", () => ({ vi.mock("./rebuild-target-preflight", async (importOriginal) => ({ ...(await importOriginal()), hydrateMessagingConfigForRebuild: vi.fn(), - preflightAuthoritativeOnboardRuntime: vi.fn(async () => false), + preflightAuthoritativeOnboardRuntime: mocks.preflightAuthoritativeOnboardRuntime, prepareRebuildRecreateOptions: mocks.prepareRebuildRecreateOptions, prepareRebuildTargetConfig: mocks.prepareRebuildTargetConfig, stageRebuildHermesDashboardConfig: vi.fn(() => true), @@ -50,7 +51,64 @@ vi.mock("./rebuild-messaging-conflict-preflight", () => ({ import { managedRebuildProfileDependencies } from "./agents/managed-workload-rebuild-profile"; import { prepareRebuildTargetPreflights } from "./rebuild-preflight-target-phase"; -describe("managed workload rebuild context-window preflight", () => { +describe("prepareRebuildTargetPreflights", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.prepareManagedWorkloadRebuildHandoff.mockResolvedValue(null); + mocks.preflightAuthoritativeOnboardRuntime.mockResolvedValue(false); + }); + + async function prepareN1xTarget(endpointSource: "onboard" | "inference-set") { + const resumeConfig = { + provider: "vllm-local", + model: "nvidia/Qwen3.6-35B-A3B-NVFP4", + preferredInferenceApi: "openai-completions", + pinEndpoint: true, + endpointUrl: null, + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + registryInferenceRoute: null, + }; + mocks.prepareRebuildTargetConfig.mockReturnValue({ + agentDefinition: {}, + resumeConfig, + durableConfig: { + toolDisclosure: "progressive", + dcodeAutoApprovalMode: "disabled", + webSearchConfig: null, + }, + credentialEnv: null, + fromDockerfile: false, + hermesToolGateways: [], + }); + mocks.prepareRebuildRecreateOptions.mockReturnValue({ + controlUiPort: 18_789, + targetGatewayName: "nemoclaw", + toolDisclosure: "progressive", + dcodeAutoApprovalMode: "disabled", + observabilityEnabled: false, + }); + + await prepareRebuildTargetPreflights({ + sandboxName: "my-assistant", + sandboxEntry: { + name: "my-assistant", + agent: "openclaw", + gatewayName: "nemoclaw", + openshellDriver: "docker", + provider: resumeConfig.provider, + model: resumeConfig.model, + endpointUrl: "http://host.openshell.internal:8000/v1", + endpointSource, + } as never, + rebuildAgent: "openclaw", + autoYes: true, + log: vi.fn(), + bail: mocks.bail as never, + }); + return mocks.preflightAuthoritativeOnboardRuntime.mock.calls[0]?.[2]; + } + it("resolves the Ollama context window through target preparation", async () => { const catalogHandoff = { agent: "openclaw", @@ -123,4 +181,18 @@ describe("managed workload rebuild context-window preflight", () => { ).resolves.toBeNull(); expect(mocks.resolveContextWindowForModel).toHaveBeenCalledWith("ollama-local", "qwen3.5:9b"); }); + + it("passes exact legacy N1x intent into authoritative readiness (#9292)", async () => { + const readinessOptions = await prepareN1xTarget("onboard"); + + expect(readinessOptions).toEqual( + expect.objectContaining({ allowDeferredN1xManagedVllm: true }), + ); + }); + + it("withholds N1x intent for a mismatched endpoint source (#9292)", async () => { + const readinessOptions = await prepareN1xTarget("inference-set"); + + expect(readinessOptions).not.toHaveProperty("allowDeferredN1xManagedVllm"); + }); }); diff --git a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts index 9ccdf93d231..0c415187ce8 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts @@ -56,6 +56,7 @@ import { prepareRebuildTargetConfig, type RebuildTargetConfig, stageRebuildHermesDashboardConfig, + stageRecordedManagedVllmIntent, } from "./rebuild-target-preflight"; /** Upper bound on how long a minted provider-recovery receipt stays valid. */ @@ -233,6 +234,7 @@ export async function prepareRebuildTargetPreflights(args: { recreateOptions.observabilityEnabled = requestedObservabilityEnabled ?? recreateOptions.observabilityEnabled; recreateOptions.observabilityRequestedExplicitly = requestedObservabilityEnabled !== undefined; + stageRecordedManagedVllmIntent(recreateOptions, sandboxEntry, resumeConfig); if ( !stageRebuildHermesDashboardConfig( rebuildAgent, diff --git a/src/lib/actions/sandbox/rebuild-target-preflight.ts b/src/lib/actions/sandbox/rebuild-target-preflight.ts index f46289ef311..c0368f626bd 100644 --- a/src/lib/actions/sandbox/rebuild-target-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-target-preflight.ts @@ -19,4 +19,5 @@ export { hydrateMessagingConfigForRebuild, prepareRebuildRecreateOptions, stageRebuildHermesDashboardConfig, + stageRecordedManagedVllmIntent, } from "./rebuild-target-staging"; diff --git a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts index 928f0abcec2..e03cc004781 100644 --- a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts +++ b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ ensureValidatedWebSearchCredential: vi.fn(), isDockerDesktopWslRuntime: vi.fn(), isLinuxDockerDriverGatewayEnabled: vi.fn(), + preflightAuthoritativeRebuildTarget: vi.fn(), preflightRebuildCredentials: vi.fn(), readGatewayProviderMetadata: vi.fn(), runOpenshell: vi.fn(), @@ -30,6 +31,7 @@ vi.mock("../../onboard/gateway-provider-metadata", async (importOriginal) => { vi.mock("./rebuild-onboard-dependencies", () => ({ rebuildOnboardDependencies: { ensureValidatedWebSearchCredential: mocks.ensureValidatedWebSearchCredential, + preflightAuthoritativeRebuildTarget: mocks.preflightAuthoritativeRebuildTarget, }, })); @@ -52,8 +54,12 @@ vi.mock("./rebuild-credential-preflight", () => ({ import * as rebuildImagePreflight from "./rebuild-custom-image-preflight"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; +import type { RebuildResumeConfig } from "./rebuild-resume-config"; import type { RebuildTargetConfig } from "./rebuild-target-config"; -import { preflightRebuildTargetRuntime } from "./rebuild-target-runtime"; +import { + preflightAuthoritativeOnboardRuntime, + preflightRebuildTargetRuntime, +} from "./rebuild-target-runtime"; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; const TARGET = { @@ -189,6 +195,40 @@ describe("preflightRebuildTargetRuntime GPU route", () => { }); }); +describe("authoritative rebuild readiness", () => { + it("passes recorded managed-vLLM intent to the pre-delete readiness gate (#9292)", async () => { + const authority = { checkpoint: "gateway-authority" }; + mocks.preflightAuthoritativeRebuildTarget.mockResolvedValue(authority); + const recreateOptions = { + ...RECREATE_OPTIONS, + allowDeferredN1xManagedVllm: true, + } as RebuildRecreateOnboardOpts; + const bail = vi.fn((message: string): never => { + throw new Error(message); + }); + + await expect( + preflightAuthoritativeOnboardRuntime( + "alpha", + { provider: "vllm-local", model: "test-model" } as RebuildResumeConfig, + recreateOptions, + bail, + ), + ).resolves.toBe(true); + + expect(mocks.preflightAuthoritativeRebuildTarget).toHaveBeenCalledWith( + expect.objectContaining({ + allowDeferredN1xManagedVllm: true, + provider: "vllm-local", + model: "test-model", + sandboxName: "alpha", + }), + ); + expect(recreateOptions.rebuildGatewayAuthority).toBe(authority); + expect(bail).not.toHaveBeenCalled(); + }); +}); + describe("preflightRebuildTargetRuntime web search credential", () => { const WEB_SEARCH_TARGET = { ...TARGET, diff --git a/src/lib/actions/sandbox/rebuild-target-staging.ts b/src/lib/actions/sandbox/rebuild-target-staging.ts index 9c8b7d70a47..e703e3ec364 100644 --- a/src/lib/actions/sandbox/rebuild-target-staging.ts +++ b/src/lib/actions/sandbox/rebuild-target-staging.ts @@ -2,11 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; +import { isRecordedN1xManagedVllmRebuildEligible } from "../../domain/sandbox/n1x-managed-vllm-rebuild"; import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config"; import { createRebuildRouteHandoff, type RegistryInferenceRoute, } from "../../onboard/rebuild-route-handoff"; +import { parseHostLocalInferenceReceipt } from "../../onboard/runtime-provider/host-local-inference"; import type { SandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; import * as onboardSession from "../../state/onboard-session"; import type { RebuildBail } from "./rebuild-credential-preflight"; @@ -84,6 +86,36 @@ export function stageRebuildHermesDashboardConfig( return true; } +/** Stage the recorded N1x decision without moving action state into the domain layer. */ +export function stageRecordedManagedVllmIntent( + recreateOptions: Pick, + sandboxEntry: Pick< + RebuildSandboxEntry, + | "provider" + | "model" + | "endpointUrl" + | "endpointSource" + | "openshellDriver" + | "hostLocalInferenceReceipt" + >, + rebuildSelection: { + provider: string; + model: string; + pinEndpoint: boolean; + endpointUrl: string | null; + }, +): void { + if ( + isRecordedN1xManagedVllmRebuildEligible( + sandboxEntry, + rebuildSelection, + parseHostLocalInferenceReceipt, + ) + ) { + recreateOptions.allowDeferredN1xManagedVllm = true; + } +} + export function hydrateMessagingConfigForRebuild( sandboxName: string, log: (msg: string) => void, diff --git a/src/lib/domain/sandbox/n1x-managed-vllm-rebuild.test.ts b/src/lib/domain/sandbox/n1x-managed-vllm-rebuild.test.ts new file mode 100644 index 00000000000..c28e1c7de63 --- /dev/null +++ b/src/lib/domain/sandbox/n1x-managed-vllm-rebuild.test.ts @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + hostLocalInferenceReceipt, + serializedHostLocalInferenceReceipt, + serializedLlamaCppHostLocalInferenceReceipt, +} from "../../../../test/helpers/host-local-inference-receipt"; +import { + parseHostLocalInferenceReceipt, + serializeHostLocalInferenceReceipt, +} from "../../onboard/runtime-provider/host-local-inference"; +import { isRecordedN1xManagedVllmRebuildEligible } from "./n1x-managed-vllm-rebuild"; + +describe("recorded N1x managed-vLLM rebuild eligibility", () => { + const n1xExpressEntry = { + provider: "vllm-local", + model: "nvidia/Qwen3.6-35B-A3B-NVFP4", + endpointUrl: null, + endpointSource: "onboard", + openshellDriver: "docker", + }; + const n1xExpressSelection = { + provider: n1xExpressEntry.provider, + model: n1xExpressEntry.model, + pinEndpoint: true, + endpointUrl: null, + }; + const eligible = ( + sandboxEntry: Parameters[0], + rebuildSelection: Parameters< + typeof isRecordedN1xManagedVllmRebuildEligible + >[1] = n1xExpressSelection, + ) => + isRecordedN1xManagedVllmRebuildEligible( + sandboxEntry, + rebuildSelection, + parseHostLocalInferenceReceipt, + ); + + it.each([ + { state: "derived endpoint and absent receipt", sandboxEntry: n1xExpressEntry }, + { + state: "derived endpoint and null receipt", + sandboxEntry: { ...n1xExpressEntry, hostLocalInferenceReceipt: null }, + }, + { + state: "recorded canonical endpoint", + sandboxEntry: { + ...n1xExpressEntry, + endpointUrl: "http://host.openshell.internal:8000/v1", + }, + }, + ])("accepts the v0.0.109 N1x Express selection with a $state (#9292)", ({ sandboxEntry }) => { + expect(eligible(sandboxEntry)).toBe(true); + }); + + it("accepts a canonical vLLM receipt on the exact N1x Express selection (#9292)", () => { + const genericReceipt = hostLocalInferenceReceipt("docker"); + const n1xReceipt = serializeHostLocalInferenceReceipt({ + ...genericReceipt, + endpoint: { + ...genericReceipt.endpoint, + host: "host.openshell.internal", + }, + inference: { + protocol: "openai-chat-completions", + model: n1xExpressEntry.model, + toolCallingRequired: true, + }, + }); + + expect(eligible({ ...n1xExpressEntry, hostLocalInferenceReceipt: n1xReceipt })).toBe(true); + }); + + it.each([ + { + caseName: "different recorded provider", + sandboxEntry: { ...n1xExpressEntry, provider: "compatible-endpoint" }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "different recorded model", + sandboxEntry: { ...n1xExpressEntry, model: "meta-llama/Llama-3.1-8B-Instruct" }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "different recorded endpoint", + sandboxEntry: { ...n1xExpressEntry, endpointUrl: "http://host.openshell.internal:8001/v1" }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "different endpoint source", + sandboxEntry: { ...n1xExpressEntry, endpointSource: "inference-set" }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "different OpenShell driver", + sandboxEntry: { ...n1xExpressEntry, openshellDriver: "podman" }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "malformed receipt", + sandboxEntry: { ...n1xExpressEntry, hostLocalInferenceReceipt: "not-json" }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "different host-local service", + sandboxEntry: { + ...n1xExpressEntry, + hostLocalInferenceReceipt: serializedLlamaCppHostLocalInferenceReceipt(), + }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "conflicting vLLM receipt", + sandboxEntry: { + ...n1xExpressEntry, + hostLocalInferenceReceipt: serializedHostLocalInferenceReceipt(), + }, + rebuildSelection: n1xExpressSelection, + }, + { + caseName: "different rebuild provider", + sandboxEntry: n1xExpressEntry, + rebuildSelection: { ...n1xExpressSelection, provider: "compatible-endpoint" }, + }, + { + caseName: "different rebuild model", + sandboxEntry: n1xExpressEntry, + rebuildSelection: { ...n1xExpressSelection, model: "meta-llama/Llama-3.1-8B-Instruct" }, + }, + { + caseName: "unresolved rebuild endpoint", + sandboxEntry: n1xExpressEntry, + rebuildSelection: { ...n1xExpressSelection, pinEndpoint: false }, + }, + { + caseName: "noncanonical rebuild endpoint", + sandboxEntry: n1xExpressEntry, + rebuildSelection: { + ...n1xExpressSelection, + endpointUrl: "http://host.openshell.internal:8001/v1", + }, + }, + ])("rejects a $caseName (#9292)", ({ sandboxEntry, rebuildSelection }) => { + expect(eligible(sandboxEntry, rebuildSelection)).toBe(false); + }); +}); diff --git a/src/lib/domain/sandbox/n1x-managed-vllm-rebuild.ts b/src/lib/domain/sandbox/n1x-managed-vllm-rebuild.ts new file mode 100644 index 00000000000..7509c79c88d --- /dev/null +++ b/src/lib/domain/sandbox/n1x-managed-vllm-rebuild.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const N1X_EXPRESS_PROVIDER = "vllm-local"; +const N1X_EXPRESS_MODEL = "nvidia/Qwen3.6-35B-A3B-NVFP4"; +const N1X_EXPRESS_ENDPOINT_URL = "http://host.openshell.internal:8000/v1"; + +export interface RecordedN1xManagedVllmRoute { + provider?: string | null; + model?: string | null; + endpointUrl?: string | null; + endpointSource?: string | null; + openshellDriver?: string | null; + hostLocalInferenceReceipt?: string | null; +} + +export interface N1xManagedVllmRebuildSelection { + provider: string; + model: string; + pinEndpoint: boolean; + endpointUrl: string | null; +} + +export interface N1xManagedVllmReceipt { + service: string; + endpoint: { host: string; port: number }; + inference?: { model: string }; +} + +export type ParseN1xManagedVllmReceipt = (serialized: string) => N1xManagedVllmReceipt; + +/** Decide whether a recorded route proves the exact Deferred N1x managed-vLLM selection. */ +export function isRecordedN1xManagedVllmRebuildEligible( + sandboxEntry: RecordedN1xManagedVllmRoute, + rebuildSelection: N1xManagedVllmRebuildSelection, + parseReceipt: ParseN1xManagedVllmReceipt, +): boolean { + const recordedEndpointUsesCanonicalLocalRoute = + sandboxEntry.endpointUrl === null || sandboxEntry.endpointUrl === N1X_EXPRESS_ENDPOINT_URL; + if ( + sandboxEntry.provider !== N1X_EXPRESS_PROVIDER || + sandboxEntry.model !== N1X_EXPRESS_MODEL || + !recordedEndpointUsesCanonicalLocalRoute || + sandboxEntry.endpointSource !== "onboard" || + sandboxEntry.openshellDriver !== "docker" || + rebuildSelection.provider !== sandboxEntry.provider || + rebuildSelection.model !== sandboxEntry.model || + rebuildSelection.pinEndpoint !== true || + rebuildSelection.endpointUrl !== null + ) { + return false; + } + const serialized = sandboxEntry.hostLocalInferenceReceipt; + if (serialized === undefined || serialized === null) return true; + try { + const receipt = parseReceipt(serialized); + return ( + receipt.service === "vllm" && + receipt.endpoint.host === "host.openshell.internal" && + receipt.endpoint.port === 8000 && + receipt.inference?.model === N1X_EXPRESS_MODEL + ); + } catch { + return false; + } +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 136b7401381..1cae38016e3 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3012,11 +3012,7 @@ async function preflightAuthoritativeRebuildTarget( bindGatewayAuthority: () => bindGatewayOwner(getGatewayOwner()), runFatalRuntimePreflight: async () => onboardPreflightGatewayAuthority.runRuntimePreflight( - { - sandboxGpu: opts.sandboxGpu, - sandboxGpuDevice: opts.sandboxGpuDevice, - noGpu: opts.noGpu, - }, + authoritativeRebuildTarget.authoritativeRebuildRuntimePreflightOptions(opts), (code) => fail(`onboard runtime preflight exited with code ${String(code)}`), ), ensureOpenshell: () => @@ -3297,6 +3293,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { sandboxGpuDevice: opts.sandboxGpuDevice ?? null, gpuRequested: opts.gpu === true, noGpu: opts.noGpu === true, + allowDeferredN1xManagedVllm: opts.allowDeferredN1xManagedVllm, env: process.env, recordedGpuPassthroughBeforePreflight, commitSelectedAgentTransition: selectedAgentTransition.commit, diff --git a/src/lib/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index 52893b9fc96..07a3b83bf33 100644 --- a/src/lib/onboard/authoritative-rebuild-target.test.ts +++ b/src/lib/onboard/authoritative-rebuild-target.test.ts @@ -4,7 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + authoritativeRebuildRuntimePreflightOptions, type AuthoritativeRebuildTargetDeps, + type AuthoritativeRebuildPreflightOptions, preflightAuthoritativeRebuildTarget, rebuildProviderFlowOptions, resolveAuthoritativeOnboardGatewayBinding, @@ -23,6 +25,39 @@ const target = { }; const originalGateway = process.env.OPENSHELL_GATEWAY; +describe("authoritative rebuild runtime preflight options", () => { + it("carries only target GPU state and recorded N1x preview intent (#9292)", () => { + const options = { + authoritativeResumeConfig: true, + sandboxName: "alpha", + provider: "vllm-local", + model: "nvidia/Qwen3.6-35B-A3B-NVFP4", + targetGatewayName: "nemoclaw-12345", + targetGatewayPort: 12345, + controlUiPort: null, + sandboxGpu: "enable", + sandboxGpuDevice: "nvidia.com/gpu=all", + noGpu: false, + allowDeferredN1xManagedVllm: true, + } satisfies AuthoritativeRebuildPreflightOptions; + + expect(authoritativeRebuildRuntimePreflightOptions(options)).toEqual({ + sandboxGpu: "enable", + sandboxGpuDevice: "nvidia.com/gpu=all", + noGpu: false, + allowDeferredN1xManagedVllm: true, + }); + + const { allowDeferredN1xManagedVllm: _recordedIntent, ...withoutRecordedIntent } = options; + expect(authoritativeRebuildRuntimePreflightOptions(withoutRecordedIntent)).toEqual({ + sandboxGpu: "enable", + sandboxGpuDevice: "nvidia.com/gpu=all", + noGpu: false, + allowDeferredN1xManagedVllm: false, + }); + }); +}); + function deps(overrides: Partial = {}) { return { resolveBaselinePolicy: vi.fn(() => ({})), diff --git a/src/lib/onboard/authoritative-rebuild-target.ts b/src/lib/onboard/authoritative-rebuild-target.ts index ad527349f99..796545bc9f6 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -21,7 +21,7 @@ export type AuthoritativeGatewayOptions = Pick< export type AuthoritativeRebuildPreflightOptions = Pick< OnboardOptions, - "sandboxGpu" | "sandboxGpuDevice" | "noGpu" | "controlUiPort" + "sandboxGpu" | "sandboxGpuDevice" | "noGpu" | "controlUiPort" | "allowDeferredN1xManagedVllm" > & { authoritativeResumeConfig: true; /** Internal prepared-backup recovery defers route repair to authoritative onboard. */ @@ -33,6 +33,20 @@ export type AuthoritativeRebuildPreflightOptions = Pick< targetGatewayPort: number; }; +/** Project only the target-bound GPU and N1x intent into runtime preflight. */ +export function authoritativeRebuildRuntimePreflightOptions( + opts: AuthoritativeRebuildPreflightOptions, +): Pick & { + allowDeferredN1xManagedVllm: boolean; +} { + return { + sandboxGpu: opts.sandboxGpu, + sandboxGpuDevice: opts.sandboxGpuDevice, + noGpu: opts.noGpu, + allowDeferredN1xManagedVllm: opts.allowDeferredN1xManagedVllm === true, + }; +} + export function resolveAuthoritativeOnboardGatewayBinding( opts: AuthoritativeGatewayOptions, ): AuthoritativeOnboardGatewayBinding | null { diff --git a/src/lib/onboard/fatal-runtime-preflight.test.ts b/src/lib/onboard/fatal-runtime-preflight.test.ts index 8b9620c2cb0..04cc1dac2e8 100644 --- a/src/lib/onboard/fatal-runtime-preflight.test.ts +++ b/src/lib/onboard/fatal-runtime-preflight.test.ts @@ -91,7 +91,7 @@ afterEach(() => { }); describe("report-backed runtime readiness (#7411)", () => { - it("requires explicit managed-vLLM intent for the Deferred N1x readiness exception (#8574)", () => { + it("requires explicit managed-vLLM intent and lets rebuild reject ambient intent (#9292)", () => { const readiness: SystemReadinessReport = { schemaVersion: "1.1.0", status: "incompatible", @@ -137,6 +137,14 @@ describe("report-backed runtime readiness (#7411)", () => { }), ).toThrow("exit"); + expect( + assertOnboardSystemReadiness(readiness, hostWithRuntime("docker"), { + explicitlyOptedOutGpuPassthrough: false, + allowDeferredN1xManagedVllm: true, + exitProcess: exit as never, + }), + ).toBe(readiness); + vi.stubEnv("NEMOCLAW_PROVIDER", "install-vllm"); expect( assertOnboardSystemReadiness(readiness, hostWithRuntime("docker"), { @@ -144,6 +152,14 @@ describe("report-backed runtime readiness (#7411)", () => { exitProcess: exit as never, }), ).toBe(readiness); + + expect(() => + assertOnboardSystemReadiness(readiness, hostWithRuntime("docker"), { + explicitlyOptedOutGpuPassthrough: false, + allowDeferredN1xManagedVllm: false, + exitProcess: exit as never, + }), + ).toThrow("exit"); }); it("rejects ambiguous gateway ownership before the caller can run effects", () => { diff --git a/src/lib/onboard/fatal-runtime-preflight.ts b/src/lib/onboard/fatal-runtime-preflight.ts index eef29cd7ecb..237229ce26e 100644 --- a/src/lib/onboard/fatal-runtime-preflight.ts +++ b/src/lib/onboard/fatal-runtime-preflight.ts @@ -46,6 +46,8 @@ export type FatalRuntimePreflightOptions = Pick< OnboardOptions, "sandboxGpu" | "sandboxGpuDevice" | "gpu" | "noGpu" > & { + /** Explicit false prevents ambient provider intent from crossing a rebuild boundary. */ + allowDeferredN1xManagedVllm?: boolean; optedOutGpuPassthrough?: boolean; }; @@ -106,6 +108,7 @@ export interface OnboardHostReadinessOptions { resuming?: boolean; allowStorageRemediation?: boolean; allowPortableHostPreparation?: boolean; + allowDeferredN1xManagedVllm?: boolean; exitProcess?: (code: number) => never; observedAt?: string; now?: () => Date; @@ -153,7 +156,9 @@ export function assertOnboardSystemReadiness( isPortableExperimentalProfile() || !isLinuxDockerDriverGatewayEnabled(), allowStorageRemediation: options.allowStorageRemediation === true, allowPortableHostPreparation: options.allowPortableHostPreparation, - allowDeferredN1xManagedVllm: process.env.NEMOCLAW_PROVIDER === MANAGED_VLLM_PROVIDER_KEY, + allowDeferredN1xManagedVllm: + options.allowDeferredN1xManagedVllm ?? + process.env.NEMOCLAW_PROVIDER === MANAGED_VLLM_PROVIDER_KEY, }); if (admission.admitted) return readinessReport; const jetsonRuntimeMissing = admission.findingIds.includes("host.gpu.nvidia_runtime_missing"); @@ -262,6 +267,7 @@ function refreshOnboardHostReadiness( wslDockerDesktopGpuProofPassed: runtimeGpu?.wslDockerDesktopGpuProofPassed, resuming: context.resuming, allowStorageRemediation, + allowDeferredN1xManagedVllm: options.allowDeferredN1xManagedVllm, exitProcess: context.exitProcess, observedAt, now, @@ -375,6 +381,7 @@ export async function runReadinessGatedRuntimePreflight( refreshedResult.sandboxGpuConfig.mode === "0" || options.optedOutGpuPassthrough === true, resuming: context.resuming, allowStorageRemediation: managedGatewayReadiness, + allowDeferredN1xManagedVllm: options.allowDeferredN1xManagedVllm, exitProcess, }); // The only GPU detection path that may pull or start a container is delayed @@ -422,6 +429,7 @@ export async function runReadinessGatedRuntimePreflight( refreshedResult.sandboxGpuConfig.mode === "0" || options.optedOutGpuPassthrough === true, resuming: context.resuming, allowStorageRemediation: managedGatewayReadiness, + allowDeferredN1xManagedVllm: options.allowDeferredN1xManagedVllm, exitProcess, }); const gatedResult = { @@ -456,6 +464,7 @@ export function runFatalOnboardRuntimePreflight( explicitlyOptedOutGpuPassthrough, resuming: context.resuming, allowStorageRemediation: context.allowStorageRemediation, + allowDeferredN1xManagedVllm: options.allowDeferredN1xManagedVllm, exitProcess, observedAt, now, diff --git a/src/lib/onboard/machine/handlers/preflight.test.ts b/src/lib/onboard/machine/handlers/preflight.test.ts index 2d7f487cf5a..fec340b4401 100644 --- a/src/lib/onboard/machine/handlers/preflight.test.ts +++ b/src/lib/onboard/machine/handlers/preflight.test.ts @@ -195,6 +195,33 @@ describe("handlePreflightState", () => { expect(result.resumePreflight).toBe(true); }); + it("carries verified N1x intent through cached resume readiness (#9292)", async () => { + const session = createSession(); + session.steps.preflight.status = "complete"; + const assertOnboardHostReadiness = vi.fn(); + const harness = createDeps({ assertOnboardHostReadiness }); + + await handlePreflightState({ + ...baseOptions(harness.deps, session), + resume: true, + allowDeferredN1xManagedVllm: true, + }); + + expect(assertOnboardHostReadiness).toHaveBeenCalledTimes(2); + expect(assertOnboardHostReadiness).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({ allowDeferredN1xManagedVllm: true }), + ); + expect(assertOnboardHostReadiness).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + expect.objectContaining({ allowDeferredN1xManagedVllm: true }), + ); + }); + it("rejects changed gateway ownership before cached resume probe effects (#7411)", async () => { const session = createSession(); session.steps.preflight.status = "complete"; diff --git a/src/lib/onboard/machine/handlers/preflight.ts b/src/lib/onboard/machine/handlers/preflight.ts index 72c031c9c09..eb3c859e2b4 100644 --- a/src/lib/onboard/machine/handlers/preflight.ts +++ b/src/lib/onboard/machine/handlers/preflight.ts @@ -34,6 +34,7 @@ export interface PreflightStateOptions< sandboxGpuDevice?: string | null; gpuRequested: boolean; noGpu: boolean; + allowDeferredN1xManagedVllm?: boolean; env: NodeJS.ProcessEnv; deps: { getSandbox(name: string): SandboxEntry | null; @@ -55,6 +56,7 @@ export interface PreflightStateOptions< observedAt?: string; now?: () => Date; wslDockerDesktopGpuProofPassed?: boolean; + allowDeferredN1xManagedVllm?: boolean; resuming: true; }, ): void; @@ -129,6 +131,7 @@ export async function handlePreflightState< sandboxGpuDevice, gpuRequested, noGpu, + allowDeferredN1xManagedVllm, env, deps, }: PreflightStateOptions): Promise< @@ -175,6 +178,7 @@ export async function handlePreflightState< explicitlyOptedOutGpuPassthrough: resumeSandboxGpuConfig.mode === "0", observedAt: hostObservedAt, now, + allowDeferredN1xManagedVllm, resuming: true, }); // A full detector can run the bounded ARM64 WSL Docker GPU proof. Keep it @@ -197,6 +201,7 @@ export async function handlePreflightState< observedAt: hostObservedAt, now, ...(wslDockerDesktopGpuProofPassed === undefined ? {} : { wslDockerDesktopGpuProofPassed }), + allowDeferredN1xManagedVllm, resuming: true, }); } diff --git a/src/lib/onboard/machine/initial-flow-phases.ts b/src/lib/onboard/machine/initial-flow-phases.ts index c53061ef1f6..3fef64a6338 100644 --- a/src/lib/onboard/machine/initial-flow-phases.ts +++ b/src/lib/onboard/machine/initial-flow-phases.ts @@ -53,6 +53,7 @@ export interface InitialOnboardFlowPhaseOptions< sandboxGpuDevice?: string | null; gpuRequested: boolean; noGpu: boolean; + allowDeferredN1xManagedVllm?: boolean; env: NodeJS.ProcessEnv; platform?: NodeJS.Platform; recordedGpuPassthroughBeforePreflight: boolean; @@ -144,6 +145,7 @@ export function createInitialOnboardFlowPhases< sandboxGpuDevice: options.sandboxGpuDevice ?? null, gpuRequested: options.gpuRequested, noGpu: options.noGpu, + allowDeferredN1xManagedVllm: options.allowDeferredN1xManagedVllm, env: options.env, deps: { ...options.preflightDeps, diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index c6254404187..3783929be02 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -117,6 +117,8 @@ export type OnboardOptions = { rebuildProviderReconfigure?: import("./rebuild-route-handoff").RebuildProviderReconfigureHandoff; /** Internal one-shot authority to recover the recorded provider during a locked rebuild resume. */ providerRecoveryReceipt?: import("./rebuild-route-handoff").ProviderRecoveryReceipt; + /** Internal rebuild handoff for a recorded managed-vLLM N1x preview selection. */ + allowDeferredN1xManagedVllm?: true; /** Internal one-shot handoff for the exact image context validated before rebuild deletion. */ preparedImageRebuild?: import("./prepared-dcode-rebuild").PreparedImageRebuildHandoff; /** Internal immutable managed-image/profile handoff validated before rebuild deletion. */