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
2 changes: 1 addition & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2692,7 +2692,7 @@ async function createSandboxWithBaseImageResolution(
console.warn(` Warning: failed to remove old sandbox image '${previousEntry.imageTag}'.`);
}
}
registry.removeSandbox(sandboxName);
sandboxLifecycle.removeSandboxUnlessSessionReservation(previousEntry, sandboxName);
}

// Stage build context — use the custom Dockerfile path when provided,
Expand Down
6 changes: 5 additions & 1 deletion src/lib/onboard/machine/core-flow-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,11 @@ describe("core onboard flow phases", () => {
"HERMES_API_KEY",
"api_key",
["nous-web"],
{ gatewayName: "nemoclaw", allowToolsIncompatible: false },
{
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
reservationSessionId: session.sessionId,
},
);
expect(result.context.hermesToolGateways).toEqual(["nous-web"]);

Expand Down
55 changes: 48 additions & 7 deletions src/lib/onboard/machine/handlers/provider-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import {
describe("handleProviderInferenceState", () => {
it("runs provider selection and inference setup on a fresh flow", async () => {
const { deps, calls } = createDeps();
const session = createSession();
calls.complete.mockResolvedValue(session);

const result = await handleProviderInferenceState(baseOptions(deps));
const result = await handleProviderInferenceState(baseOptions(deps, session));

expect(calls.startStep).toHaveBeenNthCalledWith(1, "provider_selection");
expect(calls.setupNim).toHaveBeenCalledWith(
Expand Down Expand Up @@ -57,6 +59,7 @@ describe("handleProviderInferenceState", () => {
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
preferredInferenceApi: "openai-responses",
reservationSessionId: session.sessionId,
},
);
expect(calls.deleteEnv).toHaveBeenCalledWith("NVIDIA_INFERENCE_API_KEY");
Expand Down Expand Up @@ -97,9 +100,11 @@ describe("handleProviderInferenceState", () => {
preferredInferenceApi: "anthropic-messages",
}));
const { deps, calls } = createDeps({ setupNim });
const session = createSession();
calls.complete.mockResolvedValue(session);

const result = await handleProviderInferenceState({
...baseOptions(deps),
...baseOptions(deps, session),
agent: { name: "hermes" },
sandboxName: "hermes-custom",
});
Expand All @@ -125,6 +130,7 @@ describe("handleProviderInferenceState", () => {
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
preferredInferenceApi: "openai-completions",
reservationSessionId: session.sessionId,
},
);
expect(result.preferredInferenceApi).toBe("openai-completions");
Expand All @@ -141,6 +147,7 @@ describe("handleProviderInferenceState", () => {
preferredInferenceApi: "anthropic-messages",
});
const { deps, calls } = createDeps({ isInferenceRouteReady: vi.fn(() => true) });
calls.complete.mockResolvedValue(session);

const result = await handleProviderInferenceState({
...baseOptions(deps, session),
Expand All @@ -167,6 +174,7 @@ describe("handleProviderInferenceState", () => {
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
preferredInferenceApi: "openai-completions",
reservationSessionId: session.sessionId,
},
);
expect(calls.complete).toHaveBeenCalledWith(
Expand All @@ -191,6 +199,7 @@ describe("handleProviderInferenceState", () => {
isInferenceRouteReady: vi.fn(() => true),
isResumeProviderSurfaceReady: surfaceReady,
});
calls.complete.mockResolvedValue(session);

await handleProviderInferenceState({
...baseOptions(deps, session),
Expand Down Expand Up @@ -223,6 +232,7 @@ describe("handleProviderInferenceState", () => {
gatewayName: "nemoclaw-9090",
allowToolsIncompatible: false,
preferredInferenceApi: "openai-completions",
reservationSessionId: session.sessionId,
},
);
});
Expand Down Expand Up @@ -348,6 +358,8 @@ describe("handleProviderInferenceState", () => {
preferredInferenceApi: "openai-completions",
});
const { deps, calls } = createDeps({ isInferenceRouteReady: vi.fn(() => true) });
const rebuiltSession = createSession({ sessionId: "rebuild-session-id" });
calls.complete.mockResolvedValueOnce(rebuiltSession);

const result = await handleProviderInferenceState({
...baseOptions(deps, session),
Expand Down Expand Up @@ -383,6 +395,7 @@ describe("handleProviderInferenceState", () => {
credentialEnv: "COMPATIBLE_API_KEY",
preferredInferenceApi: "openai-completions",
gatewayName: "nemoclaw",
reservationSessionId: rebuiltSession.sessionId,
});
});

Expand Down Expand Up @@ -662,6 +675,7 @@ describe("handleProviderInferenceState", () => {
credentialEnv: "COMPATIBLE_API_KEY",
})),
});
calls.complete.mockResolvedValue(session);

await handleProviderInferenceState({
...baseOptions(deps, session),
Expand All @@ -679,7 +693,11 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
{ gatewayName: "nemoclaw", allowToolsIncompatible: false },
{
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
reservationSessionId: session.sessionId,
},
);
});

Expand All @@ -698,6 +716,7 @@ describe("handleProviderInferenceState", () => {
credentialEnv: "COMPATIBLE_API_KEY",
})),
});
calls.complete.mockResolvedValue(session);

await handleProviderInferenceState({
...baseOptions(deps, session),
Expand All @@ -715,7 +734,11 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
{ gatewayName: "nemoclaw", allowToolsIncompatible: false },
{
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
reservationSessionId: session.sessionId,
},
);
});

Expand All @@ -731,6 +754,7 @@ describe("handleProviderInferenceState", () => {
hydrateCredentialEnv: vi.fn(() => "host-key"),
isInferenceRouteReady: vi.fn(() => true),
});
calls.complete.mockResolvedValue(session);

await handleProviderInferenceState({
...baseOptions(deps, session),
Expand All @@ -752,7 +776,11 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
{ gatewayName: "nemoclaw", allowToolsIncompatible: false },
{
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
reservationSessionId: session.sessionId,
},
);
expect(calls.log).toHaveBeenCalledWith(
" [resume] Refreshing compatible-endpoint inference route for messaging.",
Expand Down Expand Up @@ -808,6 +836,7 @@ describe("handleProviderInferenceState", () => {
hydrateCredentialEnv: vi.fn(() => null),
isInferenceRouteReady: vi.fn(() => true),
});
calls.complete.mockResolvedValue(session);

await handleProviderInferenceState({
...baseOptions(deps, session),
Expand All @@ -830,6 +859,7 @@ describe("handleProviderInferenceState", () => {
skipHostInferenceSmoke: true,
reuseGatewayCredentialWithoutLocalKey: true,
preferredInferenceApi: "openai-completions",
reservationSessionId: session.sessionId,
},
);
expect(calls.log).toHaveBeenCalledWith(
Expand Down Expand Up @@ -903,6 +933,7 @@ describe("handleProviderInferenceState", () => {
hydrateCredentialEnv: vi.fn(() => "nvapi-test"),
isInferenceRouteReady: vi.fn(() => true),
});
calls.complete.mockResolvedValue(session);

await handleProviderInferenceState({
...baseOptions(deps, session),
Expand All @@ -919,7 +950,11 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
{ gatewayName: "nemoclaw", allowToolsIncompatible: false },
{
gatewayName: "nemoclaw",
allowToolsIncompatible: false,
reservationSessionId: session.sessionId,
},
);
expect(calls.log).toHaveBeenCalledWith(
" [resume] Refreshing compatible-endpoint inference route for messaging.",
Expand Down Expand Up @@ -1028,6 +1063,8 @@ describe("handleProviderInferenceState", () => {
isInferenceRouteReady: vi.fn(() => true),
withGatewayRouteMutationLock,
});
const rebuiltSession = createSession({ sessionId: "router-rebuild-session-id" });
calls.complete.mockResolvedValueOnce(rebuiltSession);
calls.reconcileRouter.mockImplementation(async () => {
expect(insideGatewayLock).toBe(true);
});
Expand Down Expand Up @@ -1055,6 +1092,7 @@ describe("handleProviderInferenceState", () => {
credentialEnv: "NVIDIA_INFERENCE_API_KEY",
preferredInferenceApi: null,
gatewayName: "nemoclaw",
reservationSessionId: rebuiltSession.sessionId,
});
});

Expand Down Expand Up @@ -1156,8 +1194,10 @@ describe("handleProviderInferenceState", () => {
allowToolsIncompatible: true,
}));
const { deps, calls } = createDeps({ setupNim });
const session = createSession();
calls.complete.mockResolvedValue(session);

await handleProviderInferenceState(baseOptions(deps));
await handleProviderInferenceState(baseOptions(deps, session));

expect(calls.setupInference).toHaveBeenCalledWith(
"my-assistant",
Expand All @@ -1171,6 +1211,7 @@ describe("handleProviderInferenceState", () => {
gatewayName: "nemoclaw",
allowToolsIncompatible: true,
preferredInferenceApi: "openai-responses",
reservationSessionId: session.sessionId,
},
);
});
Expand Down
7 changes: 7 additions & 0 deletions src/lib/onboard/machine/handlers/provider-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ProviderInferenceSetupOptions {
preferredInferenceApi?: string | null;
/** Public addresses approved for custom endpoint host probes. */
endpointPinnedAddresses?: readonly string[];
/** Onboard session that owns the route reservation this setup creates. */
reservationSessionId?: string;
}

export interface ProviderSelectionResult {
Expand Down Expand Up @@ -172,6 +174,7 @@ export interface ProviderInferenceStateOptions<Gpu, Agent, Host> {
credentialEnv: string | null;
preferredInferenceApi: string | null;
gatewayName: string;
reservationSessionId?: string;
},
): boolean;
registryUpdateSandbox(sandboxName: string, updates: { nimContainer?: string | null }): void;
Expand Down Expand Up @@ -553,6 +556,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
: {}),
...(preferredInferenceApi ? { preferredInferenceApi } : {}),
...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}),
reservationSessionId: session?.sessionId,
};
await deps.startRecordedStep("inference", { provider, model });
inferenceResult = await withInferenceTrace(
Expand Down Expand Up @@ -636,6 +640,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
credentialEnv,
preferredInferenceApi,
gatewayName,
reservationSessionId: session?.sessionId,
})
: null;
return { reupserted, reserved };
Expand Down Expand Up @@ -670,6 +675,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
credentialEnv,
preferredInferenceApi,
gatewayName,
reservationSessionId: session?.sessionId,
});
});
if (!reserved) {
Expand Down Expand Up @@ -738,6 +744,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
...(reuseGatewayCredentialWithoutLocalKey ? { reuseGatewayCredentialWithoutLocalKey } : {}),
...(preferredInferenceApi ? { preferredInferenceApi } : {}),
...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}),
reservationSessionId: session?.sessionId,
};
await deps.startRecordedStep("inference", { provider, model });
inferenceResult = await withInferenceTrace(
Expand Down
60 changes: 56 additions & 4 deletions src/lib/onboard/sandbox-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,65 @@ const registryState = vi.hoisted(() => ({
removeSandbox: vi.fn(),
sandbox: null as SandboxEntry | null,
}));
const onboardSessionState = vi.hoisted(() => ({ sessionId: "session-owner" as string | null }));

vi.mock("../state/registry", () => ({
getSandbox: () => registryState.sandbox,
removeSandbox: registryState.removeSandbox,
vi.mock("../state/registry", async (importOriginal) => {
const actual = await importOriginal<typeof import("../state/registry")>();
return {
...actual,
getSandbox: () => registryState.sandbox,
removeSandbox: registryState.removeSandbox,
};
});
vi.mock("../state/onboard-session", () => ({
loadSession: () =>
onboardSessionState.sessionId === null ? null : { sessionId: onboardSessionState.sessionId },
}));

import { createSandboxLifecycleHelpers } from "./sandbox-lifecycle";
import {
createSandboxLifecycleHelpers,
removeSandboxUnlessSessionReservation,
} from "./sandbox-lifecycle";

describe("sandbox recreate reservation ownership", () => {
beforeEach(() => {
registryState.removeSandbox.mockReset();
onboardSessionState.sessionId = "session-owner";
});

it("preserves a pending reservation owned by the active session (#6562)", () => {
removeSandboxUnlessSessionReservation(
{
name: "alpha",
pendingRouteReservation: true,
reservationSessionId: "session-owner",
},
"alpha",
);

expect(registryState.removeSandbox).not.toHaveBeenCalled();
});

it.each([
{
label: "foreign-session",
entry: {
name: "alpha",
pendingRouteReservation: true,
reservationSessionId: "session-other",
},
},
{
label: "unstamped",
entry: { name: "alpha", pendingRouteReservation: true },
},
] as const)("removes a $label pending reservation before recreation (#6562)", ({ entry }) => {
removeSandboxUnlessSessionReservation(entry, "alpha");

expect(registryState.removeSandbox).toHaveBeenCalledOnce();
expect(registryState.removeSandbox).toHaveBeenCalledWith("alpha");
});
});

describe("sandbox lifecycle MCP destroy boundaries", () => {
beforeEach(() => {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/onboard/sandbox-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import * as onboardSession from "../state/onboard-session";
import type { SandboxEntry, SandboxMcpState } from "../state/registry";
import * as registry from "../state/registry";
import type { SelectionDrift } from "./selection-drift";

export function removeSandboxUnlessSessionReservation(
entry: SandboxEntry | null,
sandboxName: string,
): void {
if (!registry.isPendingReservationForSession(entry, onboardSession.loadSession()?.sessionId)) {
registry.removeSandbox(sandboxName);
}
}

export interface SandboxLifecycleDeps {
runCaptureOpenshell(args: string[], opts?: Record<string, unknown>): string | null;
fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null;
Expand Down
Loading
Loading