Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dd83418
fix(sandbox): report intentional stops cleanly
HOYALIM Sep 8, 2026
7c14529
merge: refresh issue 11025 fix with latest main
rsliter Sep 8, 2026
8df8a9f
merge: refresh issue 11025 fix with latest main
rsliter Sep 8, 2026
c620ed0
fix: preserve start compatibility without stop marker
rsliter Sep 8, 2026
3ec865b
fix: suppress recovery after intentional stop
rsliter Sep 8, 2026
eef0dec
fix: reconcile intentional stop recovery
rsliter Sep 8, 2026
a9acc26
fix: report stopped missing sandboxes
rsliter Sep 8, 2026
421aafa
fix: preserve rebuild architecture budgets
rsliter Sep 8, 2026
f06e9df
fix: retry recovered stop intent cleanup
rsliter Sep 8, 2026
978a8f5
docs: distinguish intentional stopped status
rsliter Sep 8, 2026
dabf106
fix(sandbox): clear stop intent after provider transition
rsliter Sep 8, 2026
e850cee
merge: refresh PR 11211 with latest main
rsliter Sep 8, 2026
ea08720
fix(sandbox): close intentional stop review gaps
rsliter Sep 8, 2026
9cd54d5
fix(sandbox): revoke stale stop intent
rsliter Sep 8, 2026
84d8d37
docs(status): qualify stopped gateway exit rule
rsliter Sep 8, 2026
cf66f5e
fix(status): revoke stop intent while running
rsliter Sep 8, 2026
8b7f188
docs(status): qualify stopped recovery guidance
rsliter Sep 9, 2026
12713ee
merge: integrate patched dependencies for intentional stop status
rsliter Sep 9, 2026
f2e81ff
merge: integrate validation rules and cover stopped provisioning status
rsliter Sep 9, 2026
6539cd7
docs: distinguish intentional stops from recovery failures
rsliter Sep 9, 2026
595d0f9
merge: integrate current architecture validation
rsliter Sep 9, 2026
25840ee
merge: consume shared CLI typecheck memory fix
rsliter Sep 10, 2026
e344e30
merge: integrate canonical validation toolchain for PR 11211
rsliter Sep 10, 2026
393fac7
merge: align required canonical validation inputs
rsliter Sep 10, 2026
e307b0d
fix(status): classify stop intent state failures
prekshivyas Sep 10, 2026
4f64e5c
merge: refresh intentional stop fix with latest main
prekshivyas Sep 10, 2026
91e2a26
docs(status): document stop intent update failures
prekshivyas Sep 10, 2026
4082dfd
merge: consume MCP fixture lifetime fix
rsliter Sep 10, 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 docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,12 @@ For Portable Hermes, `status` reports `Portable lifecycle phase: pending`, `conf

</AgentOnly>

For a non-Portable sandbox, status reports a clean `phase: "Stopped"` only when the persisted
intentional-stop record agrees with a provider-confirmed `sandbox_container_stopped` result. In that
case it keeps `failureLayer` null, suppresses inference probes, and exits `0`; other preflight
failures remain visible. A stopped runtime without recorded stop intent remains a
`sandbox_container_stopped` failure.

For a `compatible-endpoint` route that uses `openai-completions`, the text output prints `Reasoning effort` as `low`, `medium`, `high`, or `endpoint-default`. The line is omitted for another provider or API family.

Pass `--json` to emit a structured per-sandbox report instead of the text renderer. The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `recordedRoute`, `liveRoute`, `routeDrift`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `policiesAvailable`, `llamaCpp`, `failureLayer`, `terminalRuntimeHealth`, `servingProcessHealth`, and `dockerPaused`. `policies` is derived from the current OpenShell policy; NemoClaw does not persist a second preset list or baseline-exclusion ledger. `policiesAvailable` is `false` when that live policy cannot be read or parsed, distinguishing an unavailable result from a verified empty `policies` array; text status prints `Policies: unavailable` for the same state. When the live gateway route matches the sandbox's recorded llama.cpp route, `llamaCpp.kind` is `attached`, `managed`, or `unavailable`. An attached route also includes its fixed loopback `llamaCpp.endpointUrl`. An unavailable result means NemoClaw could not safely verify the managed ownership receipt; it does not include an endpoint and provides a secret-free diagnostic and recovery action. During route drift, `status` omits llama.cpp ownership attribution; inspect `routeDrift` before acting on ownership or endpoint information. The schema-version `1` `model` and `provider` fields keep their established live-route meaning when the gateway route is readable. Use `recordedRoute` for the sandbox's durable provider and model and `liveRoute` for the gateway-global route. When the live shared route differs, text output prints both routes and JSON output sets `routeDrift.live`, `routeDrift.recorded`, and `routeDrift.canConnect`. When `routeDrift.canConnect` is `false`, `connect` cannot safely restore the recorded route because provider-global identity differs or required route or gateway metadata is incomplete. Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for the route-sharing workflow. `openshellDriver` and `openshellVersion` are always strings (falling back to `"unknown"` when the registry has no value), so consumers can rely on `typeof` checks. `agent` is always a string and reports `openclaw` when the registry records no agent for the sandbox. `failureLayer` is `null` when no preflight failure was detected and otherwise one of `docker_unreachable`, `sandbox_container_stopped`, or `sandbox_dashboard_port_conflict`; when set, `inferenceHealth` is suppressed to `null` so automation does not see a stale remote-provider healthy status during a local outage. `inferenceHealth.ok` reports whether the inference route returned a structurally valid result for one request sent from inside the sandbox. The result must match Chat Completions, Responses, or Anthropic Messages for the selected route. An empty body, malformed JSON, provider-error envelope, or wrong response shape reports `unhealthy`, even with a 2xx status. The probe captures at most 64 KiB and does not include the response body in diagnostics. The route probe treats any final HTTP status from `200` through `499` as reachable, so a route with an invalidated provider credential answers HTTP `401` while the route is up. The request uses the live gateway route's provider and model, and falls back to the recorded values when the live route is unreadable. When the live provider matches the recorded provider, the request uses the sandbox's recorded API family, even when only the model differs. This includes `openai-responses`. When the live provider differs, NemoClaw does not carry the recorded API family to the live provider. An ordinary run sends one 16-token request per attempt through the stored provider credential, with a 30-second timeout for each request, and consumes provider tokens on a hosted route. When the inference request returns HTTP `429`, `502`, `503`, or `504`, `status` retries the route and inference request together up to three total attempts, with a two-second delay between failed attempts, because those statuses are transient gateway and availability answers rather than evidence that the route is broken. Before each retry, it writes the failed probe boundary, an HTTP status when one is available, the next attempt, total attempts, and delay to stderr; `--json` keeps stdout machine-readable. On an ordinary run, every other failure is final on the first attempt with no delay: HTTP `401`, `403`, `404`, and `500`, an invalid 2xx response body, a request that returned no HTTP status, and a failing `/v1/models` route probe. When the same run recovers a managed gateway, `status` retries any failed route or inference probe on that schedule instead, while the restarted delivery chain settles. Each inference request can consume another 16 tokens on a hosted route. Each route probe has a 10-second timeout and each inference request has a 30-second timeout, so three complete probe pairs and two delays can take about 124 seconds. When NemoClaw sends an inference request, `inferenceHealth.subprobes` reports the route probe result as the `route reachability` hop, so a failing verdict still shows that the route itself answered. `inferenceHealth.failureLabel` reports why the inference request failed:
Expand Down
20 changes: 20 additions & 0 deletions src/lib/actions/sandbox/connect-probe-observe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ describe("connectSandbox probe-only observe mode", () => {

it("waits through the initial Error after starting a stopped container (#10466)", async () => {
const harness = createConnectHarness({
registryEntry: { stopped: true },
dockerRuntime: { containerName: "openshell-alpha", running: false, paused: false },
listOutputs: ["alpha Error", "alpha Provisioning", "alpha Ready"],
});
Expand Down Expand Up @@ -183,9 +184,28 @@ describe("connectSandbox probe-only observe mode", () => {
listInvocations[0]!.order,
);
expect(listInvocations).toHaveLength(4);
expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { stopped: false });
expect(harness.registryEntries[0]?.stopped).toBe(false);
expect(exitSpy).not.toHaveBeenCalled();
});

it("retains stop intent when a recovered container cannot publish the registry update", async () => {
const harness = createConnectHarness({
registryEntry: { stopped: true },
dockerRuntime: { containerName: "openshell-alpha", running: false, paused: false },
listOutputs: ["alpha Error", "alpha Provisioning", "alpha Ready"],
});
harness.registryUpdateSpy.mockReturnValue(false);

await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow(
"could not clear its intentional-stop record",
);

expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { stopped: false });
expect(harness.registryEntries[0]?.stopped).toBe(true);
expect(harness.publishLaunchReadinessSpy).not.toHaveBeenCalled();
});

it.each([
{
condition: "Docker reports a failed start",
Expand Down
8 changes: 8 additions & 0 deletions src/lib/actions/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2696,6 +2696,14 @@ async function prepareConnectSandboxWithinLifecycleFence(
);
}
requalify();
if (
startedStoppedContainer &&
!registry.recordSandboxStopIntent(sandboxName, false, registry.updateSandbox)
) {
throw new Error(
`Sandbox '${sandboxName}' recovered, but NemoClaw could not clear its intentional-stop record. Run '${CLI_NAME} ${sandboxName} status' before another lifecycle command.`,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
});
const retainedCommand = hermesReadinessAuthority?.command;
Expand Down
14 changes: 14 additions & 0 deletions src/lib/actions/sandbox/gateway-state-observe-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import * as gatewayRuntime from "../../gateway-runtime-action";
import * as openshellRuntime from "../../adapters/openshell/runtime";
import * as dockerDriverRecovery from "../../onboard/docker-driver-sandbox-recovery";
import * as portableAgentLifecycle from "../../onboard/experimental/portable-agent-lifecycle";
import * as registry from "../../state/registry";
import * as gatewaySelect from "./gateway-select";
Expand Down Expand Up @@ -104,6 +105,19 @@ describe("getReconciledSandboxGatewayState observe mode", () => {
expect(result).toMatchObject({ state: "present" });
});

it("does not restore a missing sandbox in observe mode (#11025)", async () => {
const recover = vi.spyOn(dockerDriverRecovery, "recoverDockerDriverSandbox");
const getState = vi.fn().mockResolvedValue({ state: "missing", output: "not found" });

const result = await getReconciledSandboxGatewayState("beta", {
getState,
gatewayRecovery: "observe",
});

expect(recover).not.toHaveBeenCalled();
expect(result).toMatchObject({ state: "missing", output: "not found" });
});

it("keeps receipt-owned observation scoped without changing global gateway selection (#9203)", async () => {
const getState = vi.fn().mockResolvedValue({ state: "present", output: "Phase: Ready" });

Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/gateway-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,7 @@ export async function getReconciledSandboxGatewayState(
return lookup;
}
if (lookup.state === "missing") {
if (gatewayRecovery === "observe") return lookup;
return reconcileMissingAgainstNamedGateway(sandboxName, lookup, targetGatewayName);
}

Expand Down
17 changes: 17 additions & 0 deletions src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ describe("rebuildSandbox flow: lifecycle", () => {
hermesAuthMethod: null,
}),
);
expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { stopped: false });
const deleteCall = harness.runOpenshellSpy.mock.calls.findIndex(
(call) =>
Array.isArray(call[0]) &&
Expand Down Expand Up @@ -283,6 +284,22 @@ describe("rebuildSandbox flow: lifecycle", () => {
).toContain("rebuild completed");
});

it("reports failure and retains stop intent when the rebuilt sandbox cannot clear it", async () => {
const harness = createRebuildFlowHarness({ sandboxEntry: { stopped: true } });
const updateSandbox = harness.registryUpdateSpy.getMockImplementation();
harness.registryUpdateSpy.mockImplementation((name, updates) => {
if (updates?.stopped === false) return false;
return updateSandbox?.(name, updates) ?? true;
});

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
).rejects.toThrow("could not clear its intentional-stop record");

expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { stopped: false });
expect(harness.getSandboxEntry().stopped).toBe(true);
});

it("retains removed immutability state when mutable config verification fails", async () => {
const harness = createRebuildFlowHarness({
sandboxEntry: {},
Expand Down
10 changes: 9 additions & 1 deletion src/lib/actions/sandbox/rebuild-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ import {
recordRebuildRecoveryBackup,
} from "./rebuild-recreate-journal";
import { runRebuildRecreatePhase } from "./rebuild-recreate-phase";
import { createRebuildRegistryRollback } from "./rebuild-registry-rollback";
import {
createRebuildRegistryRollback,
persistSandboxStopIntent,
} from "./rebuild-registry-rollback";
import { runRebuildRestorePhase } from "./rebuild-restore-phase";

export { buildRefreshMutableOpenClawConfigHashCommand, stageMessagingManifestPlanForRebuild };
Expand Down Expand Up @@ -837,6 +840,11 @@ async function rebuildSandboxUnlocked(
}
retireRemovedImmutabilityStateRecord(sandboxName, "mutable-rebuild");
}
if (!persistSandboxStopIntent(sandboxName, false)) {
return bail(
`Sandbox '${sandboxName}' was rebuilt, but NemoClaw could not clear its intentional-stop record. Run 'nemoclaw ${sandboxName} status' before another lifecycle command.`,
);
}
if (backup.backupManifest) {
if (!completePolicyHandoffCleanup(recreateJournal.id, backup.backupManifest)) return;
if (!clearRecoveryMarker(recreateJournal.id, backup.backupManifest)) return;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/actions/sandbox/rebuild-registry-rollback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export interface RebuildRegistryRollback {
restoreForRetry(): void;
}

export function persistSandboxStopIntent(name: string, stopped: boolean): boolean {
return registry.recordSandboxStopIntent(name, stopped, registry.updateSandbox);
}

interface RebuildRegistryRollbackDeps {
restoreSandboxEntry?: typeof registry.restoreSandboxEntry;
restoreSandboxEntryIfMissing?: typeof registry.restoreSandboxEntryIfMissing;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ describe("sandbox recovery with a Hermes cron restore gate", () => {
await expect(recoverSandboxWithHermesCronRestore("alpha")).rejects.toThrow(
"recovery authority is unsafe",
);

expect(mocks.connectSandbox).not.toHaveBeenCalled();
expect(mocks.recoverHermesCronRestore).not.toHaveBeenCalled();
});
Expand Down
39 changes: 37 additions & 2 deletions src/lib/actions/sandbox/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const FAILED_RECOVERY = { ...SUCCESSFUL_RECOVERY, wasRunning: false } as const;
const REDACTED_TOKEN = "opaque-token-8662";

function harness(overrides: Partial<SandboxStartDeps> = {}) {
const getSandbox = vi.fn<NonNullable<SandboxStartDeps["getSandbox"]>>(() => sandbox());
let storedSandbox = sandbox({ stopped: true });
const getSandbox = vi.fn<NonNullable<SandboxStartDeps["getSandbox"]>>(() => storedSandbox);
const isDockerRuntimeDown = vi.fn<DockerRuntimeProviderDependencies["isRuntimeDown"]>(
() => false,
);
Expand Down Expand Up @@ -73,6 +74,10 @@ function harness(overrides: Partial<SandboxStartDeps> = {}) {
NonNullable<SandboxStartDeps["waitForManagedGatewaySupervisor"]>
>(() => false);
const log = vi.fn<(message: string) => void>();
const updateSandbox = vi.fn<NonNullable<SandboxStartDeps["updateSandbox"]>>((_name, updates) => {
storedSandbox = { ...storedSandbox, ...updates };
return true;
});
const runtimeProviders = createRuntimeProviderBundleRegistry([
[
"docker",
Expand All @@ -94,6 +99,7 @@ function harness(overrides: Partial<SandboxStartDeps> = {}) {
restoreStartupState,
waitForManagedGatewaySupervisor,
verifyGateway,
updateSandbox,
log,
withLifecycleLock: async (_sandboxName, operation) => operation(),
...overrides,
Expand All @@ -110,6 +116,7 @@ function harness(overrides: Partial<SandboxStartDeps> = {}) {
recoverDockerDriverSandbox,
recoverPortableSandbox,
restoreStartupState,
updateSandbox,
waitForManagedGatewaySupervisor,
verifyGateway,
};
Expand Down Expand Up @@ -244,6 +251,33 @@ describe("startSandbox", () => {
);
});

it("records stopped: false in the sandbox registry on successful start (#11025)", async () => {
const h = harness();

const result = await startSandbox("my-sandbox", h.deps);

expect(result.exitCode).toBe(0);
expect(h.updateSandbox).toHaveBeenCalledWith("my-sandbox", { stopped: false });
expect(h.getSandbox("my-sandbox")?.stopped).toBe(false);
});

it("does not require a registry write when no intentional stop is recorded (#11025)", async () => {
const h = harness();
h.getSandbox.mockReturnValue(sandbox());

await expect(startSandbox("my-sandbox", h.deps)).resolves.toEqual({ exitCode: 0 });

expect(h.updateSandbox).not.toHaveBeenCalled();
});

it("reports a partial success when the running state cannot be recorded (#11025)", async () => {
const h = harness({ updateSandbox: vi.fn(() => false) });

await expect(startSandbox("my-sandbox", h.deps)).rejects.toThrow(
"started, but NemoClaw could not clear its intentional-stop record",
);
});

it(
"retries startup after a structured recovery failure (#8662)",
testTimeoutOptions(30_000),
Expand Down Expand Up @@ -764,12 +798,13 @@ describe("startSandbox", () => {
);
const h = harness({ probeInferenceInvocation });
h.getSandbox.mockReturnValue(
sandbox({ provider: "ollama-local", model: "nemotron-3-nano:30b" }),
sandbox({ stopped: true, provider: "ollama-local", model: "nemotron-3-nano:30b" }),
);

const result = await startSandbox("my-sandbox", h.deps);

expect(result.exitCode).toBe(1);
expect(h.updateSandbox).toHaveBeenCalledWith("my-sandbox", { stopped: false });
const output = h.log.mock.calls.map(([line]) => line).join("\n");
expect(output).toContain("HTTP 401");
expect(output).toContain("doctor");
Expand Down
13 changes: 13 additions & 0 deletions src/lib/actions/sandbox/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface SandboxStartDeps {
observer?: OpenShellSandboxObserver;
environment?: NodeJS.ProcessEnv;
getSandbox?: typeof registry.getSandbox;
updateSandbox?: typeof registry.updateSandbox;
restoreProcessState?: (sandboxName: string) => SandboxStartupRecoveryResult;
runtimeProviders?: RuntimeProviderBundleRegistry;
restoreStartupState?: (
Expand Down Expand Up @@ -238,6 +239,18 @@ async function startSandboxWithinLifecycleFence(
await (deps.verifyGateway ?? verifyGateway)(name);
readiness.inference = checkStartedSandboxInference(name, resolved.sandbox, deps, log);
});
if (
resolved.sandbox.stopped === true &&
!registry.recordSandboxStopIntent(
sandboxName,
false,
deps.updateSandbox ?? registry.updateSandbox,
)
) {
throw new Error(
`Sandbox '${sandboxName}' started, but NemoClaw could not clear its intentional-stop record. Run '${cliName()} ${sandboxName} status' before another lifecycle command.`,
);
}
if (readiness.inference && !readiness.inference.ok) {
log(` The sandbox started but inference is not usable: ${readiness.inference.detail}.`);
log(` Run the sandbox doctor command for '${sandboxName}' to identify the failing hop.`);
Expand Down
Loading
Loading