Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,11 @@ For the version-pinned plugin workflow, refer to [Install OpenClaw Plugins](../m

If the sandbox is unreachable or the managed runtime paths are present, NemoClaw retains the existing generic gateway-log and host OpenShell-log guidance because the base-only failure is not proven.

A custom image without the managed runtime can also fail earlier, while NemoClaw is still bringing the sandbox container up.
The container exits immediately on every restart because its startup command does not exist in the image, the OpenShell supervisor never reconnects, and onboarding stops before deployment verification runs.
In that case the failure block reports that the container exited with code 127 and names the same remedy; the `Pre-rollback diagnostics saved:` directory holds the container log that shows the missing startup command.
Onboarding restores the pre-patch sandbox before it exits, so the failed container is no longer available for `docker logs` afterwards.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

</AgentOnly>

<AgentOnly variant="openclaw,hermes">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ describe("Docker GPU patch diagnostics", () => {
expect(flat).toContain("patched_create_option=--gpus all");
});

it("explains exit code 127 as a sandbox image without the managed startup command (#7996)", () => {
const result = classify(
failureSnapshot("Error", { Status: "restarting", ExitCode: 127 }, "alpha Error 1m ago"),
);

expect(result.kind).toBe("patched_container_failed");
const hints = (result.hints ?? []).join("\n");
expect(hints).toContain("does not provide the NemoClaw-managed startup command");
expect(hints).toContain("uses the supplied Dockerfile as the complete sandbox image");
expect(hints).toContain("intermediate dependency image");
// The prose stays out of the machine-readable on-disk summary.
expect(result.summaryLines.join("\n")).not.toContain("intermediate dependency image");
});

it("does not attach the missing-startup-command hints to other non-zero exits (#7996)", () => {
const result = classify(
failureSnapshot("Error", { Status: "exited", ExitCode: 125 }, "alpha Error 1m ago"),
);

expect(result.kind).toBe("patched_container_failed");
expect(result.hints ?? []).toEqual([]);
});

it("classifies an Error-phase sandbox with unknown container state as sandbox_error_phase", () => {
const result = classify(failureSnapshot("Error", null, null));

Expand Down
101 changes: 101 additions & 0 deletions src/lib/onboard/docker-gpu-patch-failure-print.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";

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

import {
buildDockerGpuMode,
type DockerGpuPatchFailureClassification,
printDockerGpuPatchFailureAndExit,
} from "./docker-gpu-patch";

const PRE_ROLLBACK: DockerGpuPatchFailureClassification = {
kind: "patched_container_failed",
headline: "Patched GPU container exited with code 127 (--gpus all).",
summaryLines: ["patched_container_exit_code=127"],
hints: ["Exit code 127 means the sandbox image does not provide the managed startup command."],
};

/**
* Drive the printer and return everything it wrote to stderr. Diagnostics
* persistence is disabled so the assertions only observe console output.
*/
function printAndCapture(deps: Parameters<typeof printDockerGpuPatchFailureAndExit>[2]): string {
const output: string[] = [];
const errorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
output.push(args.map(String).join(" "));
});
const mkdirSpy = vi.spyOn(fs, "mkdirSync").mockImplementation(() => {
throw new Error("diagnostics disabled for test");
});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => {
throw new Error("__test_exit__");
}) as never);
try {
expect(() =>
printDockerGpuPatchFailureAndExit("alpha", new Error("supervisor did not reconnect"), deps),
).toThrow(/__test_exit__/);
return output.join("\n");
} finally {
exitSpy.mockRestore();
mkdirSpy.mockRestore();
errorSpy.mockRestore();
}
}

describe("Docker GPU patch failure reporting (#7996)", () => {
it("prefers the pre-rollback verdict once rollback has erased the container", () => {
// Rollback already removed the replacement container, so a fresh inspect
// returns nothing and the sandbox only shows a generic Error phase.
const stderr = printAndCapture({
runCaptureOpenshell: vi.fn(() => "alpha Error 1m ago\n"),
dockerCapture: vi.fn(() => ""),
context: {
sandboxName: "alpha",
newContainerId: "new-container-id",
selectedMode: buildDockerGpuMode("gpus"),
},
preRollbackClassification: PRE_ROLLBACK,
});

expect(stderr).toContain("Patched GPU container exited with code 127");
expect(stderr).toContain("patched_container_exit_code=127");
expect(stderr).toContain("does not provide the managed startup command");
expect(stderr).not.toContain("entered Error phase");
});

it("keeps the freshly observed verdict when the container is still inspectable", () => {
// The live snapshot has first-hand evidence, so a stale pre-rollback
// verdict must not overwrite it.
const stderr = printAndCapture({
runCaptureOpenshell: vi.fn(() => "alpha Error 1m ago\n"),
dockerCapture: vi.fn(() => JSON.stringify({ Status: "exited", ExitCode: 125 })),
context: {
sandboxName: "alpha",
newContainerId: "new-container-id",
selectedMode: buildDockerGpuMode("gpus"),
},
preRollbackClassification: PRE_ROLLBACK,
});

expect(stderr).toContain("Patched GPU container exited with code 125");
expect(stderr).not.toContain("code 127");
});

it("falls back to the observed verdict when no pre-rollback verdict was captured", () => {
const stderr = printAndCapture({
runCaptureOpenshell: vi.fn(() => "alpha Error 1m ago\n"),
dockerCapture: vi.fn(() => ""),
context: {
sandboxName: "alpha",
newContainerId: "new-container-id",
selectedMode: buildDockerGpuMode("gpus"),
},
preRollbackClassification: null,
});

expect(stderr).toContain("entered Error phase");
});
});
6 changes: 6 additions & 0 deletions src/lib/onboard/docker-gpu-patch-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ export type DockerGpuPatchFailureClassification = {
kind: DockerGpuPatchFailureKind;
headline: string;
summaryLines: string[];
/**
* Prose guidance for failure signatures whose cause is unambiguous from the
* container state alone. Kept separate from `summaryLines` so the on-disk
* summary stays machine-readable `key=value` (#7996).
*/
hints?: string[];
};

export type DockerContainerInspect = {
Expand Down
40 changes: 38 additions & 2 deletions src/lib/onboard/docker-gpu-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ function printDockerGpuPatchClassificationLines(
if (!classification) return;
if (classification.headline) console.error(` ${classification.headline}`);
for (const line of classification.summaryLines) console.error(` ${line}`);
for (const hint of classification.hints ?? []) console.error(` ${hint}`);
}

function patchedContainerIdFromContext(
Expand Down Expand Up @@ -177,6 +178,12 @@ export function printDockerGpuPatchFailureAndExit(
context?: DockerGpuPatchFailureContext | null;
selectedMode?: DockerGpuPatchMode | null;
additionalSummaryLines?: readonly string[];
/**
* Classification captured while the replacement container still existed.
* Rollback removes that container before this printer runs, so a fresh
* snapshot can no longer read its exit state (#7996).
*/
preRollbackClassification?: DockerGpuPatchFailureClassification | null;
},
): never {
const context = deps.context || getDockerGpuPatchFailureContext(error) || null;
Expand All @@ -187,7 +194,17 @@ export function printDockerGpuPatchFailureAndExit(
{ patchedContainerId: patchedContainerIdFromContext(context) },
inspectDeps,
);
const classification = classifyDockerGpuPatchFailure(snapshot, selectedMode);
// Rollback deletes the replacement container before we get here, so this
// snapshot degrades to the weaker `sandbox_error_phase` story ("entered Error
// phase") even when the pre-rollback capture already proved *why* the
// container died. Prefer the earlier, strictly better-evidenced verdict; it
// is the only one that can carry the exit code and its hints (#7996).
const observedClassification = classifyDockerGpuPatchFailure(snapshot, selectedMode);
const classification =
deps.preRollbackClassification?.kind === "patched_container_failed" &&
observedClassification.kind !== "patched_container_failed"
? deps.preRollbackClassification
: observedClassification;
const diagnostics = collectDockerGpuPatchDiagnostics(
sandboxName,
{
Expand Down Expand Up @@ -419,6 +436,19 @@ export function captureDockerGpuPatchSandboxSnapshot(
return { sandboxPhase, sandboxListLine, patchedContainerState };
}

// The sandbox entrypoint launches the managed startup command through `env`,
// which exits 127 when that command does not exist in the image. For a sandbox
// container the signature is therefore unambiguous: the image does not carry
// the NemoClaw-managed runtime, so every restart dies before the supervisor can
// reconnect. Nothing about the Docker GPU/startup-command patch itself is
// broken, so the generic patch escape hatches do not apply here (#7996).
const SANDBOX_STARTUP_COMMAND_NOT_FOUND_EXIT_CODE = 127;
const SANDBOX_STARTUP_COMMAND_NOT_FOUND_HINTS: readonly string[] = [
"Exit code 127 means the sandbox image does not provide the NemoClaw-managed startup command, so the container exits on every restart.",
"`nemoclaw onboard --from` uses the supplied Dockerfile as the complete sandbox image; it does not layer it over the managed runtime.",
"Rebuild the custom image from the full NemoClaw Dockerfile and source context for the same release. `ghcr.io/nvidia/nemoclaw/sandbox-base` is an intermediate dependency image and is not a usable final image on its own.",
];

function describePatchedContainerState(state: DockerContainerState | null): string[] {
if (!state) return [];
const lines: string[] = [];
Expand Down Expand Up @@ -474,6 +504,7 @@ export function classifyDockerGpuPatchFailure(
const sandboxNotLive =
!!snapshot.sandboxPhase && !SANDBOX_LIVE_PHASE_TOKENS.has(snapshot.sandboxPhase);

const hints: string[] = [];
let kind: DockerGpuPatchFailureKind = "unknown";
let headline: string;
if (containerFailed) {
Expand All @@ -484,6 +515,9 @@ export function classifyDockerGpuPatchFailure(
typeof exit === "number" && exit !== 0
? `Patched GPU container exited with code ${exit}${opt}.`
: `Patched GPU container is not running${opt}.`;
if (exit === SANDBOX_STARTUP_COMMAND_NOT_FOUND_EXIT_CODE) {
hints.push(...SANDBOX_STARTUP_COMMAND_NOT_FOUND_HINTS);
}
} else if (sandboxInErrorPhase) {
kind = "sandbox_error_phase";
headline = `OpenShell sandbox entered ${snapshot.sandboxPhase} phase before the GPU proof could run.`;
Expand Down Expand Up @@ -514,5 +548,7 @@ export function classifyDockerGpuPatchFailure(
options.proofError instanceof Error ? options.proofError.message : String(options.proofError);
if (proofText) lines.push(`proof_error=${proofText}`);
}
return { kind, headline, summaryLines: lines };
return hints.length > 0
? { kind, headline, summaryLines: lines, hints }
: { kind, headline, summaryLines: lines };
}
15 changes: 13 additions & 2 deletions src/lib/onboard/docker-gpu-pre-rollback-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
DockerContainerInspect,
DockerGpuPatchDeps,
DockerGpuPatchDiagnostics,
DockerGpuPatchFailureClassification,
DockerGpuPatchFailureContext,
DockerGpuPatchResult,
} from "./docker-gpu-patch-types";
Expand Down Expand Up @@ -118,11 +119,21 @@ function primeSensitiveDiagnosticValues(
return [...sensitiveValues];
}

/**
* Diagnostics bundle plus the verdict computed while the replacement container
* was still inspectable. Rollback removes that container immediately after this
* returns, so `classification` is the only place the exit-state evidence
* survives for the caller's user-facing failure message (#7996).
*/
export type DockerGpuPreRollbackDiagnostics = DockerGpuPatchDiagnostics & {
classification: DockerGpuPatchFailureClassification;
};

export function captureDockerGpuPreRollbackDiagnostics(
sandboxName: string,
result: DockerGpuPatchResult,
deps: PreRollbackDiagnosticsDeps = {},
): DockerGpuPatchDiagnostics | null {
): DockerGpuPreRollbackDiagnostics | null {
const context: DockerGpuPatchFailureContext = {
sandboxName,
oldContainerId: result.oldContainerId,
Expand Down Expand Up @@ -167,5 +178,5 @@ export function captureDockerGpuPreRollbackDiagnostics(
if (!diagnostics) return null;

console.error(` Pre-rollback diagnostics saved: ${diagnostics.dir}`);
return diagnostics;
return { ...diagnostics, classification };
}
79 changes: 79 additions & 0 deletions src/lib/onboard/docker-gpu-sandbox-create-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,83 @@ describe("Docker GPU create diagnostics fail-safety (#6110)", () => {
expect(finalizeBackup).toHaveBeenCalledWith({ result: RESULT, supervisorReady: false }, deps);
expect(onPatchFailureExit).toHaveBeenCalledTimes(1);
});

it("forwards the pre-rollback classification to the failure printer (#7996)", () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const deps = {
runOpenshell: vi.fn(() => ({ status: 0 })),
runCaptureOpenshell: vi.fn(() => ""),
sleep: vi.fn(),
dockerCapture: vi.fn(() => ""),
};
const classification = {
kind: "patched_container_failed" as const,
headline: "Patched GPU container exited with code 127 (--gpus all).",
summaryLines: ["patched_container_exit_code=127"],
hints: ["Exit code 127 means the sandbox image does not provide ..."],
};
const onPatchFailureExit = vi.fn();
const patch = createDockerGpuSandboxCreatePatch({
route: "compatibility",
sandboxName: "alpha",
timeoutSecs: 60,
deps,
overrides: {
recreatePatch: vi.fn(() => RESULT),
waitForSupervisor: vi.fn(() => false),
capturePreRollbackDiagnostics: vi.fn(() => ({
dir: "/tmp/pre-rollback",
cleanupCommands: [],
summaryLines: [],
classification,
})),
finalizeBackup: vi.fn(() => ({ backupRemoved: false, rolledBack: true })),
onPatchFailureExit,
},
});

patch.ensureApplied();
patch.waitForSupervisorReconnectIfNeeded();

// Rollback has already removed the container by the time the printer runs,
// so this hand-off is the only surviving path for the exit-code evidence.
expect(onPatchFailureExit).toHaveBeenCalledWith(
"alpha",
expect.any(Error),
expect.objectContaining({ preRollbackClassification: classification }),
);
});

it("passes a null pre-rollback classification when capture returns nothing (#7996)", () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const deps = {
runOpenshell: vi.fn(() => ({ status: 0 })),
runCaptureOpenshell: vi.fn(() => ""),
sleep: vi.fn(),
dockerCapture: vi.fn(() => ""),
};
const onPatchFailureExit = vi.fn();
const patch = createDockerGpuSandboxCreatePatch({
route: "compatibility",
sandboxName: "alpha",
timeoutSecs: 60,
deps,
overrides: {
recreatePatch: vi.fn(() => RESULT),
waitForSupervisor: vi.fn(() => false),
capturePreRollbackDiagnostics: vi.fn(() => null),
finalizeBackup: vi.fn(() => ({ backupRemoved: false, rolledBack: true })),
onPatchFailureExit,
},
});

patch.ensureApplied();
patch.waitForSupervisorReconnectIfNeeded();

expect(onPatchFailureExit).toHaveBeenCalledWith(
"alpha",
expect.any(Error),
expect.objectContaining({ preRollbackClassification: null }),
);
});
});
11 changes: 10 additions & 1 deletion src/lib/onboard/docker-gpu-sandbox-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { finalizeDockerGpuPatchBackup } from "./docker-gpu-patch-finalize";
import type {
DockerGpuPatchBackend,
DockerGpuPatchDeps,
DockerGpuPatchFailureClassification,
DockerGpuPatchFailureContext,
DockerGpuPatchMode,
DockerGpuPatchResult,
Expand Down Expand Up @@ -229,9 +230,16 @@ export function createDockerGpuSandboxCreatePatch(
sleep: options.deps.sleep,
},
);
// Keep the pre-rollback verdict: it is computed while the replacement
// container is still inspectable, so it is the only classification that
// can name the exit code. Rollback removes that container a few lines
// below, after which the failure printer can only observe the restored
// pre-patch sandbox (#7996).
let preRollbackClassification: DockerGpuPatchFailureClassification | null = null;
if (!supervisorReady && result) {
try {
captureFailedClone(options.sandboxName, result, options.deps);
preRollbackClassification =
captureFailedClone(options.sandboxName, result, options.deps)?.classification ?? null;
} catch (error) {
console.warn(
` ⚠ Could not capture the failed GPU container before rollback: ${error instanceof Error ? error.message : String(error)}`,
Expand Down Expand Up @@ -277,6 +285,7 @@ export function createDockerGpuSandboxCreatePatch(
runCaptureOpenshell: options.deps.runCaptureOpenshell,
dockerCapture: options.deps.dockerCapture,
additionalSummaryLines: routeAdapter.additionalSummaryLines,
preRollbackClassification,
context: {
sandboxName: options.sandboxName,
oldContainerId: result?.oldContainerId,
Expand Down
Loading