Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ab4a337
fix(status): wait for inference after gateway recovery
jyaunches Aug 7, 2026
643dc41
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 7, 2026
f863593
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 7, 2026
78f681e
test(shields): allow policy transition setup time
jyaunches Aug 7, 2026
8220e81
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 7, 2026
43a1234
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 7, 2026
8905424
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 8, 2026
1590e31
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 8, 2026
4579f29
test(docs): follow split gateway auth guide
jyaunches Aug 8, 2026
77c7293
test(onboard): allow DGX Spark setup time
jyaunches Aug 8, 2026
920a6f6
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 8, 2026
6d4ded2
test(shields): allow unlock flow setup time
jyaunches Aug 8, 2026
a8e1fa8
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 8, 2026
a66989d
test(mcp): allow containment commit time
jyaunches Aug 8, 2026
e9d8755
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 8, 2026
bfdbd95
Merge branch 'main' into codex/e2e-sandbox-recovery
cv Aug 9, 2026
18f6365
Merge branch 'main' into codex/e2e-sandbox-recovery
cv Aug 9, 2026
13ee280
Merge remote-tracking branch 'origin/main' into codex/e2e-sandbox-rec…
jyaunches Aug 9, 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
99 changes: 98 additions & 1 deletion src/lib/actions/sandbox/status-snapshot-inference-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,113 @@ describe("collectSandboxStatusSnapshot inference route health", () => {
expect(order).toEqual(["reconcile", "recover-agent-and-forward", "probe-inference"]);
});

it("waits for the inference route after recovering the agent gateway", async () => {
const unreachable: SandboxInferenceRouteHealth = {
ok: false,
endpoint: "https://inference.local/v1/models",
httpStatus: 0,
detail: "unreachable",
};
const healthy: SandboxInferenceRouteHealth = {
ok: true,
endpoint: "https://inference.local/v1/models",
httpStatus: 200,
detail: "reachable",
};
const options = snapshotDeps(unreachable);
options.deps.reconcile = async () => ({
state: "present",
output: "Phase: Ready",
recoveredSandbox: true,
recoverySandboxVia: "started-stopped-original",
});
const probeSandboxInferenceGatewayHealthImpl = vi
.fn()
.mockResolvedValueOnce(unreachable)
.mockResolvedValueOnce(healthy);
const delayInferenceRecoveryProbe = vi.fn(async () => undefined);
const recoverSandboxProcesses = vi.fn(() => ({
checked: true,
wasRunning: false,
recovered: true,
forwardRecovered: true,
}));

const snapshot = await collectSandboxStatusSnapshot("alpha", {
...options,
deps: {
...options.deps,
delayInferenceRecoveryProbe,
probeSandboxInferenceGatewayHealthImpl,
recoverSandboxProcesses,
},
});

expect(probeSandboxInferenceGatewayHealthImpl).toHaveBeenCalledTimes(2);
expect(delayInferenceRecoveryProbe).toHaveBeenCalledOnce();
expect(delayInferenceRecoveryProbe).toHaveBeenCalledWith(2_000);
expect(snapshot.inferenceHealth).toMatchObject({ ok: true, okLabel: "reachable" });
});

it("reports the inference route as unreachable after all post-recovery probes", async () => {
const unreachable: SandboxInferenceRouteHealth = {
ok: false,
endpoint: "https://inference.local/v1/models",
httpStatus: 0,
detail: "unreachable",
};
const options = snapshotDeps(unreachable);
options.deps.reconcile = async () => ({
state: "present",
output: "Phase: Ready",
recoveredSandbox: true,
recoverySandboxVia: "started-stopped-original",
});
const probeSandboxInferenceGatewayHealthImpl = vi.fn(async () => unreachable);
const delayInferenceRecoveryProbe = vi.fn(async () => undefined);
const recoverSandboxProcesses = vi.fn(() => ({
checked: true,
wasRunning: false,
recovered: true,
forwardRecovered: true,
}));

const snapshot = await collectSandboxStatusSnapshot("alpha", {
...options,
deps: {
...options.deps,
delayInferenceRecoveryProbe,
probeSandboxInferenceGatewayHealthImpl,
recoverSandboxProcesses,
},
});

expect(probeSandboxInferenceGatewayHealthImpl).toHaveBeenCalledTimes(3);
expect(delayInferenceRecoveryProbe).toHaveBeenCalledTimes(2);
expect(snapshot.inferenceHealth).toMatchObject({ ok: false, failureLabel: "unreachable" });
});

it("does not mutate the agent or host forward during an ordinary present status lookup", async () => {
const options = snapshotDeps(null);
const recoverSandboxProcesses = vi.fn();
const delayInferenceRecoveryProbe = vi.fn(async () => undefined);
const probeSandboxInferenceGatewayHealthImpl = vi.fn(
options.deps.probeSandboxInferenceGatewayHealthImpl,
);

await collectSandboxStatusSnapshot("alpha", {
...options,
deps: { ...options.deps, recoverSandboxProcesses },
deps: {
...options.deps,
delayInferenceRecoveryProbe,
probeSandboxInferenceGatewayHealthImpl,
recoverSandboxProcesses,
},
});

expect(recoverSandboxProcesses).not.toHaveBeenCalled();
expect(probeSandboxInferenceGatewayHealthImpl).toHaveBeenCalledOnce();
expect(delayInferenceRecoveryProbe).not.toHaveBeenCalled();
});

it("labels a reachable route okLabel: reachable, not a bare healthy claim (#6846)", async () => {
Expand Down
20 changes: 17 additions & 3 deletions src/lib/actions/sandbox/status-snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { setTimeout as sleep } from "node:timers/promises";
import {
detectOpenShellStateRpcResultIssue,
type OpenShellStateRpcIssue,
Expand Down Expand Up @@ -55,6 +56,10 @@ type ProbeProviderHealth = (
options?: ProviderHealthProbeOptions,
) => ProviderHealthStatus | null;
type ProbeSandboxInferenceGatewayHealth = typeof probeSandboxInferenceGatewayHealth;
type DelayInferenceRecoveryProbe = (delayMs: number) => Promise<void>;

const RECOVERED_INFERENCE_PROBE_ATTEMPTS = 3;
const RECOVERED_INFERENCE_PROBE_DELAY_MS = 2_000;

/**
* Honest serving-process state while the self-report response and probe
Expand Down Expand Up @@ -289,6 +294,7 @@ interface CollectSandboxStatusSnapshotDeps {
captureOpenshellForStatusImpl?: typeof captureOpenshellForStatus;
probeProviderHealthImpl?: ProbeProviderHealth;
probeSandboxInferenceGatewayHealthImpl?: ProbeSandboxInferenceGatewayHealth;
delayInferenceRecoveryProbe?: DelayInferenceRecoveryProbe;
reportInferenceProbeError?: (message: string) => void;
probeTerminalRuntimeHealth?: ProbeTerminalRuntimeHealth;
recoverSandboxProcesses?: RecoverSandboxProcesses;
Expand Down Expand Up @@ -422,6 +428,7 @@ export async function collectSandboxStatusSnapshot(
(sb.agent ?? "openclaw") === "openclaw" &&
parseSandboxPhase(lookup.output || "") === "Ready" &&
!opts.preflight?.failure;
let recoveredManagedGateway = false;
if (
lookup.state === "present" &&
(lookup.recoveredSandbox || managedOpenClawDeliveryMustBeProven)
Expand All @@ -438,6 +445,8 @@ export async function collectSandboxStatusSnapshot(
},
);
failure = processRecoveryFailure(recovery);
recoveredManagedGateway =
failure === null && recovery.wasRunning === false && recovery.recovered === true;
} catch (error) {
failure = {
layer: "recovery-error",
Expand Down Expand Up @@ -559,9 +568,14 @@ export async function collectSandboxStatusSnapshot(
if (!suppressInferenceProbe && lookup.state === "present") {
let gatewayChain: Awaited<ReturnType<ProbeSandboxInferenceGatewayHealth>> = null;
try {
gatewayChain = await (
opts.deps?.probeSandboxInferenceGatewayHealthImpl ?? probeSandboxInferenceGatewayHealth
)(sandboxName);
const probe =
opts.deps?.probeSandboxInferenceGatewayHealthImpl ?? probeSandboxInferenceGatewayHealth;
const attempts = recoveredManagedGateway ? RECOVERED_INFERENCE_PROBE_ATTEMPTS : 1;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
gatewayChain = await probe(sandboxName);
if (gatewayChain?.ok || attempt === attempts) break;
await (opts.deps?.delayInferenceRecoveryProbe ?? sleep)(RECOVERED_INFERENCE_PROBE_DELAY_MS);
}
} catch (error) {
// This is a permanent fail-closed runtime boundary, but unexpected
// OpenShell/transport exceptions must remain observable for diagnosis.
Expand Down
Loading