diff --git a/src/lib/gateway-state.ts b/src/lib/gateway-state.ts index 1b78a02d29..25c05d3d1b 100644 --- a/src/lib/gateway-state.ts +++ b/src/lib/gateway-state.ts @@ -59,7 +59,10 @@ export function getReportedGatewayName(output = ""): string | null { } export function isGatewayConnected(statusOutput = ""): boolean { - return typeof statusOutput === "string" && statusOutput.includes("Connected"); + return ( + typeof statusOutput === "string" && + (statusOutput.includes("Connected") || statusOutput.includes("Server Status")) + ); } export function hasActiveGatewayInfo(activeGatewayInfoOutput = ""): boolean { @@ -80,11 +83,20 @@ export function isGatewayHealthy( activeGatewayInfoOutput = "", ): boolean { const namedGatewayKnown = hasStaleGateway(gwInfoOutput); - if (!namedGatewayKnown || !isGatewayConnected(statusOutput)) return false; - const activeGatewayName = getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); - return activeGatewayName === GATEWAY_NAME; + const connected = isGatewayConnected(statusOutput); + const activeInfo = hasActiveGatewayInfo(activeGatewayInfoOutput); + + // Primary path: status reports connected and gateway name matches + if (connected && activeGatewayName === GATEWAY_NAME) return true; + + // Fallback: status is empty (ARM64/non-TTY) but gateway info confirms + // the named gateway exists and has an active endpoint + const statusEmpty = typeof statusOutput === 'string' && stripAnsi(statusOutput).trim().length === 0; + if (statusEmpty && namedGatewayKnown && activeInfo && activeGatewayName === GATEWAY_NAME) return true; + + return false; } export function getGatewayReuseState( diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 53a2f0ec77..56400137d1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2486,9 +2486,14 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { } console.log(" Waiting for gateway health..."); - const healthPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", 5); - const healthPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2); + // ARM64 (e.g. Raspberry Pi) needs more time: k3s takes 90-180s to init + const isArm64 = process.arch === "arm64"; + const healthPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 5); + const healthPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 2); for (let i = 0; i < healthPollCount; i++) { + // Ensure the gateway is selected before each probe (non-TTY environments + // like ARM64 may not have it selected automatically) + runCaptureOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); const status = runCaptureOpenshell(["status"], { ignoreError: true }); const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true, diff --git a/test/gateway-state.test.ts b/test/gateway-state.test.ts new file mode 100644 index 0000000000..5b980f0c7b --- /dev/null +++ b/test/gateway-state.test.ts @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Unit tests for gateway-state.ts classifiers. +// Covers ARM64/non-TTY fallback paths where `openshell status` returns empty output. +// See: https://github.com/NVIDIA/NemoClaw/issues/1711 + +import { describe, it, expect } from "vitest"; +import { + isGatewayConnected, + isGatewayHealthy, + getGatewayReuseState, + hasStaleGateway, + hasActiveGatewayInfo, + getReportedGatewayName, +} from "../src/lib/gateway-state.js"; + +// Realistic CLI outputs +const STATUS_CONNECTED = ` +Server Status + +Gateway: nemoclaw +Server: https://127.0.0.1:8080/ +Connected +`; + +const STATUS_SERVER_STATUS_ONLY = ` +Server Status + +Gateway: nemoclaw +Server: https://127.0.0.1:8080/ +`; + +const GW_INFO_BASE = ` +Gateway Info + +Gateway: nemoclaw +Gateway endpoint: https://127.0.0.1:8080/ +`; + +// Both aliases reference the same fixture — previously duplicated as +// GW_INFO_NAMED / GW_INFO_ACTIVE. +const GW_INFO_NAMED = GW_INFO_BASE; +const GW_INFO_ACTIVE = GW_INFO_BASE; + +const GW_INFO_MISSING = "No gateway metadata found"; + +// Active endpoint without a "Gateway: " line — unnamed gateway +const GW_INFO_UNNAMED_ENDPOINT = ` +Gateway Info + +Gateway endpoint: https://127.0.0.1:8080/ +`; + +// Status output with a foreign (non-nemoclaw) gateway name +const STATUS_FOREIGN = ` +Server Status + +Gateway: other-gw +Server: https://127.0.0.1:9090/ +Connected +`; + +describe("hasStaleGateway", () => { + it("returns true when output contains the named gateway", () => { + expect(hasStaleGateway(GW_INFO_NAMED)).toBe(true); + }); + + it("returns false for empty string", () => { + expect(hasStaleGateway("")).toBe(false); + }); + + it("returns false when output says no gateway metadata found", () => { + expect(hasStaleGateway(GW_INFO_MISSING)).toBe(false); + }); + + it("returns false when gateway name does not match", () => { + const other = GW_INFO_NAMED.replace("nemoclaw", "other-gw"); + expect(hasStaleGateway(other)).toBe(false); + }); +}); + +describe("hasActiveGatewayInfo", () => { + it("returns true when output contains Gateway endpoint", () => { + expect(hasActiveGatewayInfo(GW_INFO_ACTIVE)).toBe(true); + }); + + it("returns true for unnamed endpoint output", () => { + expect(hasActiveGatewayInfo(GW_INFO_UNNAMED_ENDPOINT)).toBe(true); + }); + + it("returns false for empty string", () => { + expect(hasActiveGatewayInfo("")).toBe(false); + }); + + it("returns false when output says no gateway metadata found", () => { + expect(hasActiveGatewayInfo(GW_INFO_MISSING)).toBe(false); + }); +}); + +describe("getReportedGatewayName", () => { + it("extracts gateway name from status output", () => { + expect(getReportedGatewayName(STATUS_CONNECTED)).toBe("nemoclaw"); + }); + + it("extracts gateway name from gateway info output", () => { + expect(getReportedGatewayName(GW_INFO_NAMED)).toBe("nemoclaw"); + }); + + it("returns null for empty string", () => { + expect(getReportedGatewayName("")).toBeNull(); + }); + + it("returns null when no Gateway: line is present", () => { + expect(getReportedGatewayName(GW_INFO_UNNAMED_ENDPOINT)).toBeNull(); + }); + + it("returns null for undefined", () => { + expect(getReportedGatewayName()).toBeNull(); + }); +}); + +describe("isGatewayConnected", () => { + it("matches 'Connected' keyword", () => { + expect(isGatewayConnected(STATUS_CONNECTED)).toBe(true); + }); + + it("matches 'Server Status' keyword (OpenShell 0.0.25+)", () => { + expect(isGatewayConnected(STATUS_SERVER_STATUS_ONLY)).toBe(true); + }); + + it("returns false for empty string", () => { + expect(isGatewayConnected("")).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isGatewayConnected()).toBe(false); + }); +}); + +describe("isGatewayHealthy", () => { + it("returns true when status shows Connected and gateway name matches", () => { + expect(isGatewayHealthy(STATUS_CONNECTED, GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe(true); + }); + + it("returns true when status shows Server Status and gateway name matches", () => { + expect(isGatewayHealthy(STATUS_SERVER_STATUS_ONLY, GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe(true); + }); + + it("returns true via fallback when status is empty but gateway info confirms health (#1711)", () => { + // ARM64 / non-TTY: openshell status returns "" + expect(isGatewayHealthy("", GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe(true); + }); + + it("returns false when nothing is available", () => { + expect(isGatewayHealthy("", "", "")).toBe(false); + }); + + it("returns false when gateway info is missing", () => { + expect(isGatewayHealthy("", GW_INFO_MISSING, "")).toBe(false); + }); + + it("returns false when gateway name does not match", () => { + const wrongName = GW_INFO_ACTIVE.replace("nemoclaw", "other-gw"); + expect(isGatewayHealthy("", GW_INFO_NAMED, wrongName)).toBe(false); + }); + + it("does not trigger fallback when status is non-empty", () => { + // Non-empty status that lacks Connected/Server Status should not fall through to fallback + const nonEmptyStatus = "some unexpected output"; + expect(isGatewayHealthy(nonEmptyStatus, GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe(false); + }); + + it("returns false for Disconnected status (regression)", () => { + // Disconnected is non-empty, so fallback must not trigger + expect(isGatewayHealthy("Disconnected", GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe(false); + }); + + it("returns true via fallback when status contains only ANSI escapes", () => { + // Some terminals emit bare ANSI codes with no readable text — should + // be treated as empty after stripping, triggering the ARM64 fallback. + const ansiOnly = "\x1b[0m\x1b[32m"; + expect(isGatewayHealthy(ansiOnly, GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe(true); + }); +}); + +describe("getGatewayReuseState", () => { + it("returns 'healthy' for normal connected state", () => { + expect(getGatewayReuseState(STATUS_CONNECTED, GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe("healthy"); + }); + + it("returns 'healthy' via ARM64 fallback path (#1711)", () => { + expect(getGatewayReuseState("", GW_INFO_NAMED, GW_INFO_ACTIVE)).toBe("healthy"); + }); + + it("returns 'foreign-active' when connected to a different gateway", () => { + expect(getGatewayReuseState(STATUS_FOREIGN, "", "")).toBe("foreign-active"); + }); + + it("returns 'stale' when named gateway exists but no active endpoint", () => { + // gwInfo has "Gateway: nemoclaw" but activeGatewayInfo is empty — no live endpoint + expect(getGatewayReuseState("", GW_INFO_NAMED, "")).toBe("stale"); + }); + + it("returns 'active-unnamed' when endpoint exists without gateway name", () => { + // No status, no gwInfo, but activeGatewayInfo has an endpoint without a Gateway: line + expect(getGatewayReuseState("", "", GW_INFO_UNNAMED_ENDPOINT)).toBe("active-unnamed"); + }); + + it("returns 'missing' when all outputs are empty", () => { + expect(getGatewayReuseState("", "", "")).toBe("missing"); + }); +}); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index b4bc918c2d..a1b9366aa6 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -963,7 +963,7 @@ describe("onboard helpers", () => { "", "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", ), - ).toBe("active-unnamed"); + ).toBe("healthy"); expect( getGatewayReuseState( "Gateway status: Connected",