From 9bf48f11100fcfeacaa7251ab695a2b00f25c3de Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Mon, 27 Jul 2026 15:07:40 +0800 Subject: [PATCH 1/2] fix(list): hide route-only reservations from list and status (#7609) A failed onboard that reserved the gateway inference route but never created the sandbox (e.g. an untrusted base-image override is rejected, or the agent image build fails) leaves a `pendingRouteReservation` registry entry with no `createdAt`. `nemoclaw list` and `nemoclaw status` rendered that route-only reservation as a real sandbox, so it appeared as a ghost that the user had to `destroy` even though it was never in the live gateway. This reproduced for OpenClaw, Hermes and LangChain Deep Agents Code because the reservation is written before, and independently of, the agent-specific create path. The reservation is intentionally preserved for `--resume` (#6572/#6626), so the fix filters it at the display boundary rather than releasing it: `buildSandboxInventory`, `getStatusReport` and `showStatusCommand` now exclude `isRouteOnlySandboxReservation` entries, matching what `maintenance` and `upgrade-sandboxes` already do. `isRouteOnlySandboxReservation` takes a structural parameter so the display entry type can reuse the single source of truth. Fixes #7609 Signed-off-by: Yanyun Liao --- src/lib/inventory/index.test.ts | 102 ++++++++++++++++++++++++++++++++ src/lib/inventory/index.ts | 40 +++++++++++-- src/lib/state/registry.ts | 13 +++- 3 files changed, 147 insertions(+), 8 deletions(-) diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index f31bc065391..6ccd0988518 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -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: () => ({ diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 96f8f8e20d1..ecb35b14fc2 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -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 { @@ -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 @@ -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), + ), }; } @@ -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`. resolveDefaultSandboxName already excludes them, so + // filtering here keeps `status` consistent with `list`. + const sandboxes = sandboxList.sandboxes.filter( + (sandbox) => !isRouteOnlySandboxReservation(sandbox), + ); const resolvedDefault = resolveDefaultSandboxName(() => sandboxList) ?? null; const liveInference = sandboxes.length > 0 ? deps.getLiveInference() : null; const gatewayHealth = @@ -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):"); diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 9ee071585d6..f2dfa6e4b9c 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -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; } From 6a348e3355cd4597d6ae95f66f9de1cf9aa3ae0e Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 27 Jul 2026 01:30:26 -0700 Subject: [PATCH 2/2] docs(inventory): clarify reservation default handling Signed-off-by: Prekshi Vyas --- src/lib/inventory/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index ecb35b14fc2..1f753cb3d68 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -467,8 +467,8 @@ function normalizeGatewayAuthority( export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { const sandboxList = deps.listSandboxes(); // Hide route-only reservations from a failed onboard (#7609) — same as - // `nemoclaw list`. resolveDefaultSandboxName already excludes them, so - // filtering here keeps `status` consistent with `list`. + // `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), );