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
20 changes: 16 additions & 4 deletions src/lib/gateway-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return false;
}

export function getGatewayReuseState(
Expand Down
9 changes: 7 additions & 2 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
213 changes: 213 additions & 0 deletions test/gateway-state.test.ts
Original file line number Diff line number Diff line change
@@ -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: <name>" 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");
});
});
2 changes: 1 addition & 1 deletion test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading