Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3c62e66
fix(onboard): detect bound dashboard ports more reliably; rollback on…
laitingsheng May 9, 2026
024a24a
fix(onboard): preserve real forward-start error and surface host-boun…
laitingsheng May 9, 2026
b4639ff
refactor(onboard): extract dashboard-port helpers to src/lib/onboard/…
laitingsheng May 12, 2026
dfdae32
merge: branch 'main' into fix/3260-retry-dashboard-forward-on-port-fa…
laitingsheng May 12, 2026
30f301c
fix(onboard): roll back the sandbox when create-path dashboard port b…
laitingsheng May 12, 2026
78cc57c
Merge branch 'main' into fix/3260-retry-dashboard-forward-on-port-fai…
laitingsheng May 12, 2026
50bd4ff
fix(onboard): clean up stale openclaw-gateway listeners and surface g…
laitingsheng May 12, 2026
3f40e23
fix(onboard,status): preserve live forwards on --fresh sweep and skip…
laitingsheng May 12, 2026
8fcfeb6
Merge branch 'main' into fix/3260-retry-dashboard-forward-on-port-fai…
jyaunches May 12, 2026
0bc7741
chore: extract status exit code and stale-gateway cleanup into separa…
laitingsheng May 12, 2026
2d6d070
Merge remote-tracking branch 'origin/fix/3260-retry-dashboard-forward…
laitingsheng May 12, 2026
44a4983
Merge branch 'main' into fix/3260-retry-dashboard-forward-on-port-fai…
laitingsheng May 12, 2026
c42ba7c
merge: current main into dashboard port fix
ericksoa May 13, 2026
cb7fd5a
Merge remote-tracking branch 'origin/main' into pr-3313-review
ericksoa May 13, 2026
099e133
fix(onboard): avoid pipe hang starting dashboard forward
ericksoa May 13, 2026
3f97b4b
refactor(onboard): extract dashboard forward diagnostics
ericksoa May 13, 2026
2071006
fix(onboard): avoid diagnostic file race
ericksoa May 13, 2026
0c876eb
Merge remote-tracking branch 'origin/main' into pr-3313-review
ericksoa May 13, 2026
da4a439
fix(onboard): remove duplicate web search import
ericksoa May 13, 2026
521e77d
Apply suggestions from code review
cv May 13, 2026
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
6 changes: 6 additions & 0 deletions src/lib/actions/sandbox/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as onboardSession from "../../state/onboard-session";
import type { Session } from "../../state/onboard-session";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts";
import { DASHBOARD_PORT } from "../../core/ports";
import { stopStaleDashboardListeners } from "../../onboard/stale-gateway-cleanup";
import * as registry from "../../state/registry";
import { resolveOpenshell } from "../../adapters/openshell/resolve";
import { parseLiveSandboxNames } from "../../runtime-recovery";
Expand Down Expand Up @@ -112,6 +113,11 @@ function cleanupGatewayAfterLastSandbox(): void {
ignoreError: true,
stdio: ["ignore", "ignore", "ignore"],
});
// After the cooperative forward-stop, sweep the dashboard port range for
// stale host-side gateway-forward processes (#3397, #3398). The forward-stop
// above releases ports the live openshell tracks; this catches orphans whose
// openshell record was lost across upgrades or failed onboards.
stopStaleDashboardListeners();
if (process.platform === "linux") {
stopDockerDriverGatewayProcess();
const removeResult = runOpenshell(["gateway", "remove", NEMOCLAW_GATEWAY_NAME], {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getAgentBranding, type AgentBranding } from "../../cli/branding";
import { sleepMs } from "../../core/wait";
import { defaultUninstallPaths, NEMOCLAW_OLLAMA_MODELS, NEMOCLAW_PROVIDERS, type UninstallPaths } from "../../domain/uninstall/paths";
import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/plan";
import { stopStaleDashboardListeners } from "../../onboard/stale-gateway-cleanup";
import { classifyShimPath, type FileSystemDeps } from "./plan";

export interface RunResult {
Expand Down Expand Up @@ -556,6 +557,14 @@ function executePlan(plan: UninstallPlan, paths: UninstallPaths, options: Uninst
stopHelperServices(paths, runtime);
removeGlob(paths.helperServiceGlob, runtime);
stopMatchingPids(`openshell.*forward.*${runtime.env.NEMOCLAW_DASHBOARD_PORT || "18789"}`, runtime, "local OpenShell forward processes");
stopStaleDashboardListeners({
run: runtime.run,
kill: runtime.kill,
env: runtime.env,
log: runtime.log,
warn: runtime.warn,
commandExists: runtime.commandExists,
});
stopOrphanedOpenShell(runtime);
stopOllamaAuthProxy(paths, runtime);
} else if (step.name === "OpenShell resources") {
Expand Down
72 changes: 72 additions & 0 deletions src/lib/inventory/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,4 +585,76 @@ describe("inventory commands", () => {
expect(lines).toContain(" alpha * (minimaxai/minimax-m2.7)");
expect(lines).toContain(" (onboarded: unknown)");
});

it("emits a gateway-down diagnostic and sets process.exitCode when the gateway is unhealthy (#3386)", () => {
const previousExitCode = process.exitCode;
process.exitCode = 0;
const lines: string[] = [];
try {
showStatusCommand({
listSandboxes: () => ({
sandboxes: [{ name: "alpha", model: "m" }],
defaultSandbox: "alpha",
}),
getLiveInference: () => null,
showServiceStatus: vi.fn(),
getGatewayHealth: () => ({
healthy: false,
state: "named_unreachable",
reason: "host port held or container not running",
}),
log: (message = "") => lines.push(message),
});

expect(
lines.some((l) =>
l.includes("gateway: down [named_unreachable] (host port held or container not running)"),
),
).toBe(true);
expect(process.exitCode).toBe(1);
} finally {
process.exitCode = previousExitCode;
}
});

it("keeps process.exitCode at 0 when getGatewayHealth reports healthy", () => {
const previousExitCode = process.exitCode;
process.exitCode = 0;
const lines: string[] = [];
try {
showStatusCommand({
listSandboxes: () => ({
sandboxes: [{ name: "alpha", model: "m" }],
defaultSandbox: "alpha",
}),
getLiveInference: () => null,
showServiceStatus: vi.fn(),
getGatewayHealth: () => ({ healthy: true, state: "healthy_named" }),
log: (message = "") => lines.push(message),
});

expect(lines.some((l) => l.includes("gateway: down"))).toBe(false);
expect(process.exitCode).toBe(0);
} finally {
process.exitCode = previousExitCode;
}
});

it("preserves legacy 0-exit behaviour when getGatewayHealth dep is omitted", () => {
const previousExitCode = process.exitCode;
process.exitCode = 0;
try {
showStatusCommand({
listSandboxes: () => ({
sandboxes: [{ name: "alpha", model: "m" }],
defaultSandbox: "alpha",
}),
getLiveInference: () => null,
showServiceStatus: vi.fn(),
});
expect(process.exitCode).toBe(0);
} finally {
process.exitCode = previousExitCode;
}
});
});
33 changes: 33 additions & 0 deletions src/lib/inventory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,25 @@ export interface MessagingOverlap {
reason?: "matching-token" | "unknown-token";
}

export interface GatewayHealth {
healthy: boolean;
state: string;
reason?: string;
}

export interface ShowStatusCommandDeps {
listSandboxes: () => { sandboxes: SandboxEntry[]; defaultSandbox?: string | null };
getLiveInference: () => GatewayInference | null;
showServiceStatus: (options: { sandboxName?: string }) => void;
getServiceStatuses?: (options: { sandboxName?: string }) => StatusServiceRow[];
/**
* Report whether the named NemoClaw gateway is reachable. When omitted,
* `showStatusCommand` keeps its legacy 0-exit behaviour; when provided and
* the gateway is unhealthy, `showStatusCommand` emits a `gateway: down`
* diagnostic and sets `process.exitCode = 1` so shell and CI callers can
* detect the degraded state from `$?` (#3386).
*/
getGatewayHealth?: () => GatewayHealth;
checkMessagingBridgeHealth?: (
sandboxName: string,
channels: string[],
Expand Down Expand Up @@ -385,6 +399,25 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void {
log("");
}

// Surface gateway health between the sandbox list and the host-service
// list, since the gateway sits logically between the two. When the named
// gateway is unhealthy we also set process.exitCode = 1 so shell scripts
// and CI can detect the degraded state from `$?` (#3386). The exit code is
// set, not thrown — JSON callers (`status --json`) remain unaffected and
// the rest of the report keeps printing.
if (deps.getGatewayHealth) {
const health = deps.getGatewayHealth();
if (!health.healthy) {
log("");
const detail = health.reason ? ` (${health.reason})` : "";
log(` gateway: down [${health.state}]${detail}`);
log(
` Run 'openshell gateway start --name nemoclaw' or 'nemoclaw onboard --resume' to recover.`,
);
process.exitCode = 1;
}
}

deps.showServiceStatus({ sandboxName: defaultSandbox || undefined });

if (deps.backfillAndFindOverlaps) {
Expand Down
Loading
Loading