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
102 changes: 102 additions & 0 deletions src/lib/inventory/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,108 @@ describe("inventory commands", () => {
]);
});

it("hides a route-only reservation (never-created sandbox) from the list (#7609)", async () => {
const inventory = await getSandboxInventory({
recoverRegistryEntries: async () => ({
sandboxes: [
// A failed onboard (e.g. untrusted base image rejected) leaves this:
// pendingRouteReservation with no createdAt — never a live sandbox.
{
name: "base-img-reject",
provider: "nvidia-prod",
model: "m",
pendingRouteReservation: true,
},
{ name: "real", provider: "nvidia-prod", model: "m", createdAt: "2026-01-01T00:00:00Z" },
],
defaultSandbox: null,
}),
getLiveInference: () => null,
loadLastSession: () => null,
});

expect(inventory.sandboxes.map((sandbox) => sandbox.name)).toEqual(["real"]);
});

it("keeps a created sandbox with a lingering pending reservation flag (#7609)", async () => {
const inventory = await getSandboxInventory({
recoverRegistryEntries: async () => ({
// createdAt is set, so this is a real sandbox — the filter must NOT hide
// it just because the reservation flag was not cleared.
sandboxes: [
{
name: "created",
provider: "nvidia-prod",
model: "m",
pendingRouteReservation: true,
createdAt: "2026-01-01T00:00:00Z",
},
],
defaultSandbox: null,
}),
getLiveInference: () => null,
loadLastSession: () => null,
});

expect(inventory.sandboxes.map((sandbox) => sandbox.name)).toEqual(["created"]);
});

it("hides route-only reservations from status output too (#7609)", () => {
const report = getStatusReport({
listSandboxes: () => ({
sandboxes: [
{
name: "base-img-reject",
provider: "nvidia-prod",
model: "m",
pendingRouteReservation: true,
},
{ name: "real", provider: "nvidia-prod", model: "m", createdAt: "2026-01-01T00:00:00Z" },
],
defaultSandbox: "real",
}),
getLiveInference: () => null,
showServiceStatus: vi.fn(),
});

expect(report.sandboxes.map((sandbox) => sandbox.name)).toEqual(["real"]);

const lines: string[] = [];
showStatusCommand({
listSandboxes: () => ({
sandboxes: [
{
name: "base-img-reject",
provider: "nvidia-prod",
model: "m",
pendingRouteReservation: true,
},
],
defaultSandbox: null,
}),
getLiveInference: () => null,
showServiceStatus: vi.fn(),
log: (message = "") => lines.push(message),
});
expect(lines.some((line) => line.includes("base-img-reject"))).toBe(false);
});

it("shows the empty-state hint when only route-only reservations exist (#7609)", async () => {
const lines: string[] = [];
await listSandboxesCommand({
recoverRegistryEntries: async () => ({
sandboxes: [{ name: "base-img-reject", pendingRouteReservation: true }],
defaultSandbox: null,
}),
getLiveInference: () => null,
loadLastSession: () => null,
log: (message = "") => lines.push(message),
});

expect(lines.some((line) => line.includes("No sandboxes registered"))).toBe(true);
expect(lines.some((line) => line.includes("base-img-reject"))).toBe(false);
});

it("normalizes invalid configured inference fields out of status rows", () => {
const report = getStatusReport({
listSandboxes: () => ({
Expand Down
40 changes: 34 additions & 6 deletions src/lib/inventory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type { GatewayInference } from "../inference/config";
import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation";
import type { GatewayOwnerDescription } from "../onboard/gateway-ownership";
import { redactFull } from "../security/redact";
import { getSandboxEntryDisplayInference, type SandboxMessagingState } from "../state/registry";
import {
getSandboxEntryDisplayInference,
isRouteOnlySandboxReservation,
type SandboxMessagingState,
} from "../state/registry";
import { resolveDefaultSandboxName } from "../tunnel/service-command";

export interface SandboxEntry {
Expand All @@ -24,6 +28,12 @@ export interface SandboxEntry {
messaging?: SandboxMessagingState | null;
agent?: string | null;
dashboardPort?: number | null;
// Passthrough of the durable registry reservation markers so the list can
// recognize (and hide) a route-only reservation left by a failed onboard
// (#7609). A real sandbox carries createdAt; a never-created reservation does
// not. Not rendered — read only by isRouteOnlySandboxReservation.
pendingRouteReservation?: true;
createdAt?: string;
// #5714: display-only markers for a sandbox recovered directly from the live
// gateway. `recoveredFromGateway` flags that agent/GPU are genuinely unknown
// (the gateway sandbox list does not expose them) so the renderer shows
Expand Down Expand Up @@ -252,9 +262,18 @@ export async function getSandboxInventory(
recoveredFromGateway: recovery.recoveredFromGateway || 0,
},
lastOnboardedSandbox,
sandboxes: recovery.sandboxes.map((sandbox) =>
buildSandboxInventoryRow(sandbox, resolvedDefault, deps.getActiveSessionCount),
),
// A route-only reservation (pendingRouteReservation with no createdAt) is an
// internal artifact of an onboard that reserved the gateway route but never
// finished creating the sandbox — e.g. an untrusted base image was rejected
// (#7609), or the image build failed. The reservation is intentionally kept
// for `--resume` (#6572/#6626), but it must not render as a real sandbox in
// `nemoclaw list`. Filter it here so the display matches every other
// consumer that already excludes it (maintenance, upgrade-sandboxes).
sandboxes: recovery.sandboxes
.filter((sandbox) => !isRouteOnlySandboxReservation(sandbox))
.map((sandbox) =>
buildSandboxInventoryRow(sandbox, resolvedDefault, deps.getActiveSessionCount),
),
};
}

Expand Down Expand Up @@ -447,7 +466,12 @@ function normalizeGatewayAuthority(

export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport {
const sandboxList = deps.listSandboxes();
const { sandboxes } = sandboxList;
// Hide route-only reservations from a failed onboard (#7609) — same as
// `nemoclaw list`. Pending reservations cannot become the registry default;
// explicit environment overrides still control host-service selection.
const sandboxes = sandboxList.sandboxes.filter(
(sandbox) => !isRouteOnlySandboxReservation(sandbox),
);
const resolvedDefault = resolveDefaultSandboxName(() => sandboxList) ?? null;
const liveInference = sandboxes.length > 0 ? deps.getLiveInference() : null;
const gatewayHealth =
Expand Down Expand Up @@ -486,7 +510,11 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport {
export function showStatusCommand(deps: ShowStatusCommandDeps): void {
const log = deps.log ?? console.log;
const sandboxList = deps.listSandboxes();
const { sandboxes } = sandboxList;
// Hide route-only reservations from a failed onboard (#7609) — same as
// `nemoclaw list`.
const sandboxes = sandboxList.sandboxes.filter(
(sandbox) => !isRouteOnlySandboxReservation(sandbox),
);
const resolvedDefault = resolveDefaultSandboxName(() => sandboxList) ?? null;
log("");
log(" Global status (registered sandboxes and host services):");
Expand Down
13 changes: 11 additions & 2 deletions src/lib/state/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,8 +674,17 @@ export function reserveSandboxInferenceRoute(
});
}

/** True only for an inference route reserved before sandbox registration. */
export function isRouteOnlySandboxReservation(entry: SandboxEntry): boolean {
/**
* True only for an inference route reserved before sandbox registration.
*
* Structural parameter (only the two fields it reads) so display-layer entry
* types that omit the rest of the durable registry shape can reuse this single
* source of truth instead of re-deriving the predicate (#7609).
*/
export function isRouteOnlySandboxReservation(entry: {
pendingRouteReservation?: true;
createdAt?: string;
}): boolean {
return entry.pendingRouteReservation === true && entry.createdAt === undefined;
}

Expand Down
Loading