Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
61 changes: 61 additions & 0 deletions src/lib/actions/sandbox/gateway-state-hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const requireDist = createRequire(import.meta.url);
describe("printGatewayLifecycleHint multi-instance hints", () => {
let gatewayState: GatewayStateModule;
let captureOpenshellSpy: MockInstance;
let getSandboxDockerRuntimeSpy: MockInstance;
let getNamedGatewayLifecycleStateSpy: MockInstance;
let getSandboxSpy: MockInstance;
let recoverNamedGatewayRuntimeSpy: MockInstance;
Expand All @@ -23,6 +24,7 @@ describe("printGatewayLifecycleHint multi-instance hints", () => {
const openshellRuntime = requireDist("../../adapters/openshell/runtime.js");
const gatewayRuntime = requireDist("../../gateway-runtime-action.js");
const registry = requireDist("../../state/registry.js");
const dockerHealth = requireDist("./docker-health.js");
const gatewaySelect = requireDist("./gateway-select.js");
vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").mockReturnValue(null);
vi.spyOn(gatewayDrift, "detectOpenShellStateRpcResultIssue").mockReturnValue(null);
Expand All @@ -41,6 +43,11 @@ describe("printGatewayLifecycleHint multi-instance hints", () => {
gatewayName: "nemoclaw",
gatewayPort: 8080,
});
getSandboxDockerRuntimeSpy = vi.spyOn(dockerHealth, "getSandboxDockerRuntime").mockReturnValue({
health: "none",
paused: false,
containerName: "openshell-instance-a-abc",
});
vi.spyOn(gatewaySelect, "selectSandboxOwningGateway").mockReturnValue({
outcome: "selected",
gatewayName: "nemoclaw",
Expand Down Expand Up @@ -137,6 +144,60 @@ describe("printGatewayLifecycleHint multi-instance hints", () => {
);
});

it("steers a non-paused Error sandbox to the workspace-preserving start path (#7222)", async () => {
captureOpenshellSpy.mockReturnValue({
status: 0,
output: "Sandbox:\n Name: instance-a\n Phase: Error",
});
const lines: string[] = [];
vi.spyOn(console, "error").mockImplementation((line = "") => {
lines.push(String(line));
});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as never);

await expect(gatewayState.ensureLiveSandboxOrExit("instance-a")).rejects.toThrow(
"process.exit(1)",
);

const output = lines.join("\n");
expect(output).toContain("nemoclaw instance-a start");
expect(output).toContain("workspace state preserved");
expect(output).not.toContain("docker unpause");
expect(getSandboxDockerRuntimeSpy).toHaveBeenCalledWith("instance-a");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("preserves docker-unpause recovery for a paused Error sandbox (#4495)", async () => {
captureOpenshellSpy.mockReturnValue({
status: 0,
output: "Sandbox:\n Name: instance-a\n Phase: Error",
});
getSandboxDockerRuntimeSpy.mockReturnValue({
health: "none",
paused: true,
containerName: "openshell-instance-a-abc",
});
const lines: string[] = [];
vi.spyOn(console, "error").mockImplementation((line = "") => {
lines.push(String(line));
});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as never);

await expect(gatewayState.ensureLiveSandboxOrExit("instance-a")).rejects.toThrow(
"process.exit(1)",
);

const output = lines.join("\n");
expect(output).toContain("docker unpause openshell-instance-a-abc");
expect(output).not.toContain("nemoclaw instance-a start");
expect(output).not.toContain("rebuild --yes");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it.each([
{
lifecycle: {
Expand Down
21 changes: 20 additions & 1 deletion src/lib/actions/sandbox/gateway-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ import {
OPENSHELL_OPERATION_TIMEOUT_MS,
OPENSHELL_PROBE_TIMEOUT_MS,
} from "../../adapters/openshell/timeouts";
import { D, R } from "../../cli/terminal-style";
import {
type DockerDriverRecoveryResult,
recoverDockerDriverSandbox,
} from "../../onboard/docker-driver-sandbox-recovery";
import { getSandboxDockerRuntime } from "./docker-health";
import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier";

export type SandboxGatewayState = {
Expand Down Expand Up @@ -583,13 +585,30 @@ export async function ensureLiveSandboxOrExit(
printDockerRuntimeDownGuidance(sandboxName);
process.exit(1);
}
const dockerRuntime = phase === "Error" ? getSandboxDockerRuntime(sandboxName) : null;
if (dockerRuntime?.paused && dockerRuntime.containerName) {
console.error(` Sandbox '${sandboxName}' is stuck in '${phase}' phase.`);
console.error("");
console.error(
` The Docker-driver container for '${sandboxName}' is paused: ${dockerRuntime.containerName}`,
);
console.error(
" A paused container can report 'Phase: Error' even though the sandbox is intact.",
);
console.error(" Resume it to restore the running phase:");
console.error(` ${D}docker unpause ${dockerRuntime.containerName}${R}`);
process.exit(1);
}
console.error(` Sandbox '${sandboxName}' is stuck in '${phase}' phase.`);
console.error(
" This usually happens when a process crash inside the sandbox prevented clean startup.",
);
console.error("");
console.error(
` Run \`${CLI_NAME} ${sandboxName} rebuild --yes\` to recreate the sandbox (--yes skips the confirmation prompt; workspace state will be preserved).`,
` Run \`${CLI_NAME} ${sandboxName} start\` to restart the crashed container and recover the sandbox with workspace state preserved.`,
);
console.error(
` (\`${CLI_NAME} ${sandboxName} rebuild --yes\` recreates the sandbox instead, but its pre-rebuild backup cannot snapshot a stopped container, so start it first.)`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
);
process.exit(1);
}
Expand Down
72 changes: 72 additions & 0 deletions src/lib/actions/sandbox/status-lookup-rendering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it, vi } from "vitest";

import { printNonReadySandboxPhaseGuidance } from "./status-lookup-rendering";

function captureConsoleLog(): { lines: () => string; restore: () => void } {
const out: string[] = [];
const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
out.push(args.map((a) => String(a)).join(" "));
});
return { lines: () => out.join("\n"), restore: () => spy.mockRestore() };
}

describe("printNonReadySandboxPhaseGuidance (#7222)", () => {
afterEach(() => vi.restoreAllMocks());

it("steers a crashed sandbox to `start` (workspace-preserving), not `rebuild --yes`", () => {
const cap = captureConsoleLog();
// Error phase, container present but not paused → the crashed-container path.
printNonReadySandboxPhaseGuidance({
sandboxName: "beta",
phase: "Error",
dockerRuntime: {
health: "none",
paused: false,
containerName: "openshell-beta-abc",
},
});
const text = cap.lines();
cap.restore();

// The recovery hint now leads with `start`, which recovers without data loss.
expect(text).toContain("nemoclaw beta start");
expect(text).toContain("workspace state preserved");
// `rebuild --yes` is only mentioned as the recreate alternative, and must no
// longer be the promised recovery command (its pre-rebuild backup aborts on a
// stopped container — the reported bug).
expect(text).not.toContain("Run `nemoclaw beta rebuild --yes` to recreate");
expect(text).not.toContain("workspace state will be preserved");
// The `rebuild --yes` mention explains why `start` must come first.
expect(text).toContain("cannot snapshot a stopped container");
});

it("keeps the unpause hint for a paused container and never suggests start/rebuild (#4495)", () => {
const cap = captureConsoleLog();
printNonReadySandboxPhaseGuidance({
sandboxName: "beta",
phase: "Error",
dockerRuntime: {
health: "none",
paused: true,
containerName: "openshell-beta-abc",
},
});
const text = cap.lines();
cap.restore();

expect(text).toContain("docker unpause openshell-beta-abc");
expect(text).not.toContain("rebuild --yes");
expect(text).not.toContain("beta start");
});

it("prints no guidance for a Ready sandbox", () => {
const cap = captureConsoleLog();
printNonReadySandboxPhaseGuidance({ sandboxName: "beta", phase: "Ready", dockerRuntime: null });
const text = cap.lines();
cap.restore();
expect(text).toBe("");
});
});
7 changes: 5 additions & 2 deletions src/lib/actions/sandbox/status-lookup-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ async function printUnknownGatewayLookupStatus({
process.exit(1);
}

function printNonReadySandboxPhaseGuidance({
export function printNonReadySandboxPhaseGuidance({
sandboxName,
phase,
dockerRuntime,
Expand Down Expand Up @@ -244,6 +244,9 @@ function printNonReadySandboxPhaseGuidance({
);
console.log("");
console.log(
` Run \`${CLI_NAME} ${sandboxName} rebuild --yes\` to recreate the sandbox (--yes skips the confirmation prompt; workspace state will be preserved).`,
` Run \`${CLI_NAME} ${sandboxName} start\` to restart the crashed container and recover the sandbox with workspace state preserved.`,
);
console.log(
` (\`${CLI_NAME} ${sandboxName} rebuild --yes\` recreates the sandbox instead, but its pre-rebuild backup cannot snapshot a stopped container, so start it first.)`,
);
}