Skip to content

Commit 544da17

Browse files
committed
fix(readiness): measure the reuse window from collection completion
A readiness collection stamped its start time, so a probe slower than the 30-second window aged out the facts it had just gathered and left no retry that could succeed. Rebuild then had no working path at all: a running gateway failed the readiness gate, and a stopped one failed the route check, because an unanswerable gateway was treated as a route mismatch. The window now starts when collection finishes, the preflight checkpoints collect gateway facts again instead of rescoring an old snapshot, and the rebuild route check separates a gateway that cannot answer from one that answers with another provider and model. Stale evidence carries the measured age and the applied window. This reverses the slow-collection rejection introduced by #8738. Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
1 parent 8cdc3c4 commit 544da17

16 files changed

Lines changed: 285 additions & 130 deletions

docs/manage-sandboxes/recover-rebuild-sandboxes.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ $$nemoclaw <sandbox-name> rebuild
262262

263263
### Resolve Rebuild Preflight Stops
264264

265-
Before it backs up or deletes the existing sandbox, `rebuild` validates the recorded sandbox, gateway, policy, MCP, agent, and operation-lock state.
265+
Before it backs up or deletes the existing sandbox, `rebuild` validates the recorded sandbox, gateway, inference route, policy, MCP, agent, and operation-lock state.
266266
When one of these checks fails, NemoClaw prints `Rebuild preflight failed`, explains how to recover, and ends with `Aborting rebuild`.
267267
At this boundary, the existing sandbox is unchanged and no sandbox data has been removed.
268268

@@ -274,6 +274,10 @@ Use the recovery guidance that matches the reported check:
274274
- Resolve an incomplete MCP destroy transaction before retrying.
275275
- Back up the sandbox state and recreate it with `$$nemoclaw onboard` when the record contains multiple agents. Transactional multi-agent rebuild is not supported.
276276
- Wait for another onboarding or rebuild operation to finish before retrying. If verified stale-lock cleanup is still in progress, wait briefly and rerun the command. Do not delete the lock manually.
277+
- Set the live OpenShell inference route to the sandbox's recorded provider and model when rebuild reports route drift.
278+
279+
A gateway that reports no live inference route does not stop the rebuild.
280+
Replacement onboarding configures and verifies the recorded route before it recreates the sandbox.
277281

278282
<AgentOnly variant="openclaw">
279283
The rebuild command preserves the mounted workspace and registered policies while recreating the container.

docs/reference/system-readiness.mdx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,13 @@ Fresh onboarding and authoritative rebuilds use this order:
289289
8. Revalidate gateway authority immediately before gateway selection, recovery, reconciliation, or other lifecycle effects.
290290

291291
The readiness gate runs before model-router cleanup, provider selection, credential registration, policy changes, image builds, or sandbox lifecycle effects.
292-
Host and gateway observations have a 30-second reuse window.
293-
If collection itself takes too long, onboarding rejects the stale composite instead of assigning a fresh timestamp to old facts.
292+
Host and gateway observations have a 30-second reuse window that starts when collection finishes.
293+
The readiness gate does not reject a collection for the time its own probes take.
294+
The `gateway.owner` evidence records the gateway collection duration as `collectionMs`.
295+
The readiness gate rejects an observation set that waits past the window for another collection.
296+
Onboarding then collects that set again instead of assigning a fresh timestamp to old facts.
297+
Bounded evidence for a rejected set appears under `host.probe.stale` or `gateway.probe.stale` with the applied `windowMs` and the measured `ageMs`.
298+
`ageMs` is `null` when the recorded time cannot be parsed or is later than the current time.
294299

295300
The policy permits only these narrow exceptions:
296301

src/lib/onboard.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,7 @@ const providerExistsInGateway = (name: string, gatewayName: string = GATEWAY_NAM
929929
const {
930930
verifyInferenceRoute,
931931
isInferenceRouteReady,
932+
readInferenceRouteState,
932933
checkGatewayRouteCompatibility,
933934
preflightGatewayRouteDiscovery,
934935
} = inferenceRouteHelpers.createInferenceRouteHelpers(runCaptureOpenshell);
@@ -3024,7 +3025,7 @@ async function preflightAuthoritativeRebuildTarget(
30243025
fail(`OpenShell component preflight exited with code ${String(code)}`),
30253026
),
30263027
assertGatewayReadiness: onboardPreflightGatewayAuthority.collectGatewayReadiness,
3027-
inferenceRouteReady: (p, m) => isInferenceRouteReady(authoritativeGateway.name, p, m),
3028+
inferenceRouteState: (p, m) => readInferenceRouteState(authoritativeGateway.name, p, m),
30283029
captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }),
30293030
checkPort: (port) => checkPortAvailable(port),
30303031
},

src/lib/onboard/authoritative-rebuild-target.test.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
rebuildProviderFlowOptions,
1010
resolveAuthoritativeOnboardGatewayBinding,
1111
} from "./authoritative-rebuild-target";
12+
import type { InferenceRouteState } from "./inference-route";
1213
import {
1314
mintProviderRecoveryReceipt,
1415
type ProviderRecoveryReceiptTarget,
@@ -30,7 +31,7 @@ function deps(overrides: Partial<AuthoritativeRebuildTargetDeps> = {}) {
3031
runFatalRuntimePreflight: vi.fn(),
3132
ensureOpenshell: vi.fn(),
3233
assertGatewayReadiness: vi.fn(),
33-
inferenceRouteReady: vi.fn(() => true),
34+
inferenceRouteState: vi.fn((): InferenceRouteState => "matched"),
3435
captureForwardList: vi.fn(() => "alpha 127.0.0.1 18789 42 active"),
3536
checkPort: vi.fn(async () => ({ ok: true })),
3637
...overrides,
@@ -228,7 +229,7 @@ describe("authoritative rebuild target preflight", () => {
228229
expect(targetDeps.bindGatewayAuthority).not.toHaveBeenCalled();
229230
expect(targetDeps.ensureOpenshell).not.toHaveBeenCalled();
230231
expect(targetDeps.assertGatewayReadiness).not.toHaveBeenCalled();
231-
expect(targetDeps.inferenceRouteReady).not.toHaveBeenCalled();
232+
expect(targetDeps.inferenceRouteState).not.toHaveBeenCalled();
232233
});
233234

234235
it("pins the requested gateway for route and forward checks, then restores it", async () => {
@@ -238,9 +239,9 @@ describe("authoritative rebuild target preflight", () => {
238239
await preflightAuthoritativeRebuildTarget(
239240
target,
240241
deps({
241-
inferenceRouteReady: vi.fn(() => {
242+
inferenceRouteState: vi.fn((): InferenceRouteState => {
242243
seen.push(`route:${process.env.OPENSHELL_GATEWAY}`);
243-
return true;
244+
return "matched";
244245
}),
245246
captureForwardList: vi.fn(() => {
246247
seen.push(`forward:${process.env.OPENSHELL_GATEWAY}`);
@@ -259,13 +260,25 @@ describe("authoritative rebuild target preflight", () => {
259260
await expect(
260261
preflightAuthoritativeRebuildTarget(
261262
target,
262-
deps({ inferenceRouteReady: vi.fn(() => false) }),
263+
deps({ inferenceRouteState: vi.fn((): InferenceRouteState => "mismatched") }),
263264
),
264265
).rejects.toThrow("inference route does not match");
265266
});
266267

268+
it("proceeds when the gateway cannot answer the route query (#9310)", async () => {
269+
const targetDeps = deps({
270+
inferenceRouteState: vi.fn((): InferenceRouteState => "unanswered"),
271+
});
272+
273+
await expect(preflightAuthoritativeRebuildTarget(target, targetDeps)).resolves.toBeUndefined();
274+
275+
expect(targetDeps.inferenceRouteState).toHaveBeenCalledOnce();
276+
});
277+
267278
it("defers route validation for prepared recovery until authoritative onboard (#6114)", async () => {
268-
const targetDeps = deps({ inferenceRouteReady: vi.fn(() => false) });
279+
const targetDeps = deps({
280+
inferenceRouteState: vi.fn((): InferenceRouteState => "mismatched"),
281+
});
269282

270283
await expect(
271284
preflightAuthoritativeRebuildTarget(
@@ -274,7 +287,7 @@ describe("authoritative rebuild target preflight", () => {
274287
),
275288
).resolves.toBeUndefined();
276289

277-
expect(targetDeps.inferenceRouteReady).not.toHaveBeenCalled();
290+
expect(targetDeps.inferenceRouteState).not.toHaveBeenCalled();
278291
expect(targetDeps.runFatalRuntimePreflight).toHaveBeenCalledOnce();
279292
expect(targetDeps.ensureOpenshell).toHaveBeenCalledOnce();
280293
});
@@ -329,9 +342,9 @@ describe("authoritative rebuild target preflight", () => {
329342
}),
330343
ensureOpenshell: vi.fn(() => calls.push("openshell")),
331344
assertGatewayReadiness: vi.fn(() => calls.push("gateway")),
332-
inferenceRouteReady: vi.fn(() => {
345+
inferenceRouteState: vi.fn((): InferenceRouteState => {
333346
calls.push("route");
334-
return true;
347+
return "matched";
335348
}),
336349
});
337350

@@ -356,6 +369,6 @@ describe("authoritative rebuild target preflight", () => {
356369
);
357370
expect(targetDeps.ensureOpenshell).not.toHaveBeenCalled();
358371
expect(targetDeps.assertGatewayReadiness).not.toHaveBeenCalled();
359-
expect(targetDeps.inferenceRouteReady).not.toHaveBeenCalled();
372+
expect(targetDeps.inferenceRouteState).not.toHaveBeenCalled();
360373
});
361374
});

src/lib/onboard/authoritative-rebuild-target.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { findDashboardForwardOwner } from "./dashboard-port";
55
import { resolveGatewayName } from "./gateway-binding";
6+
import type { InferenceRouteState } from "./inference-route";
67
import type { PortProbeResult } from "./preflight";
78
import { assertDashboardPortNotReserved } from "./preflight-ports";
89
import {
@@ -181,7 +182,7 @@ export type AuthoritativeRebuildTargetDeps = {
181182
runFatalRuntimePreflight(): unknown | Promise<unknown>;
182183
ensureOpenshell(): unknown;
183184
assertGatewayReadiness(): unknown | Promise<unknown>;
184-
inferenceRouteReady(provider: string, model: string): boolean;
185+
inferenceRouteState(provider: string, model: string): InferenceRouteState;
185186
captureForwardList(): string | null;
186187
checkPort(port: number): Promise<PortProbeResult>;
187188
env?: NodeJS.ProcessEnv;
@@ -209,10 +210,12 @@ export async function preflightAuthoritativeRebuildTarget(
209210
// Prepared-backup recovery can run after the installer has replaced a
210211
// legacy gateway. That fresh gateway has no inference route to validate
211212
// yet; authoritative onboarding configures and verifies the pinned route
212-
// before recreating the sandbox. Normal rebuilds must still match here.
213+
// before recreating the sandbox. A gateway that cannot answer at all leaves
214+
// the route unknown, which onboarding resolves the same way. Only a gateway
215+
// that answers with a different route contradicts the rebuild target.
213216
if (
214217
target.deferInferenceRouteUntilOnboard !== true &&
215-
!deps.inferenceRouteReady(target.provider, target.model)
218+
deps.inferenceRouteState(target.provider, target.model) === "mismatched"
216219
) {
217220
fail(
218221
`OpenShell inference route does not match provider '${target.provider}' and model '${target.model}'.`,

src/lib/onboard/fatal-runtime-preflight.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,9 +553,11 @@ describe("readiness-gated runtime preflight", () => {
553553
"gateway-admission",
554554
"host-observation",
555555
"gpu-observation",
556+
"gateway-admission",
556557
"gpu-runtime-proof",
557558
"gateway-admission",
558559
"host-observation",
560+
"gateway-admission",
559561
"gpu-validation",
560562
"bridge-dns",
561563
]);
@@ -638,7 +640,16 @@ describe("readiness-gated runtime preflight", () => {
638640
},
639641
);
640642

641-
expect(calls).toEqual(["gateway", "host", "gateway", "host", "gpu", "bridge"]);
643+
expect(calls).toEqual([
644+
"gateway",
645+
"host",
646+
"gateway",
647+
"host",
648+
"gateway",
649+
"gateway",
650+
"gpu",
651+
"bridge",
652+
]);
642653
});
643654

644655
it("uses the already-qualified portable host facts for runtime probe effects", async () => {
@@ -664,7 +675,16 @@ describe("readiness-gated runtime preflight", () => {
664675
},
665676
);
666677

667-
expect(calls).toEqual(["gateway", "host", "gateway", "host", "gpu", "bridge"]);
678+
expect(calls).toEqual([
679+
"gateway",
680+
"host",
681+
"gateway",
682+
"host",
683+
"gateway",
684+
"gateway",
685+
"gpu",
686+
"bridge",
687+
]);
668688
expect(mocks.preparePortableExperimentalHost).not.toHaveBeenCalled();
669689
});
670690

src/lib/onboard/fatal-runtime-preflight.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { detectGpu, type GpuDetection } from "../inference/nim";
66
import {
77
createGatewayReadinessProjection,
88
type GatewayReadinessProjection,
9-
refreshGatewayReadinessProjection,
109
} from "../readiness/gateway";
1110
import {
1211
createProductionGatewayReadinessDependencies,
@@ -360,10 +359,11 @@ export async function runReadinessGatedRuntimePreflight(
360359
let gatewayReadiness = await context.collectGatewayReadiness();
361360
assertOnboardGatewayReadiness(gatewayReadiness, exitProcess);
362361
let managedGatewayReadiness = isManagedGatewayReadiness(gatewayReadiness);
363-
// Gateway collection can be slow. Replace the earlier host observation so
364-
// the composite gate never stamps an old assessment with a fresh timestamp.
362+
// Gateway collection can be slow. Replace both observations so the composite
363+
// gate never stamps an old assessment with a fresh timestamp, and never
364+
// condemns the run over facts it could have observed again.
365365
let refreshedResult = refreshOnboardHostReadiness(options, context, managedGatewayReadiness);
366-
gatewayReadiness = refreshGatewayReadinessProjection(gatewayReadiness);
366+
gatewayReadiness = await context.collectGatewayReadiness();
367367
assertOnboardGatewayReadiness(gatewayReadiness, exitProcess);
368368
managedGatewayReadiness = isManagedGatewayReadiness(gatewayReadiness);
369369
let readinessReport = composeSystemReadinessReport(
@@ -413,7 +413,7 @@ export async function runReadinessGatedRuntimePreflight(
413413
: {}),
414414
};
415415
}
416-
gatewayReadiness = refreshGatewayReadinessProjection(gatewayReadiness);
416+
gatewayReadiness = await context.collectGatewayReadiness();
417417
assertOnboardGatewayReadiness(gatewayReadiness, exitProcess);
418418
managedGatewayReadiness = isManagedGatewayReadiness(gatewayReadiness);
419419
readinessReport = composeSystemReadinessReport(refreshedResult.readinessReport, gatewayReadiness);

src/lib/onboard/inference-route.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,37 @@ describe("verifyInferenceRoute", () => {
5050
);
5151
});
5252
});
53+
54+
describe("readInferenceRouteState", () => {
55+
it("reports a matched route", () => {
56+
const helpers = createInferenceRouteHelpers(() =>
57+
gatewayRoute("compatible-endpoint", "test-model"),
58+
);
59+
60+
expect(helpers.readInferenceRouteState("nemoclaw", "compatible-endpoint", "test-model")).toBe(
61+
"matched",
62+
);
63+
});
64+
65+
it.each([
66+
["openai-api", "test-model"],
67+
["compatible-endpoint", "other-model"],
68+
])("reports a route answered as %s/%s as mismatched", (provider, model) => {
69+
const helpers = createInferenceRouteHelpers(() => gatewayRoute(provider, model));
70+
71+
expect(helpers.readInferenceRouteState("nemoclaw", "compatible-endpoint", "test-model")).toBe(
72+
"mismatched",
73+
);
74+
});
75+
76+
it("separates a gateway that cannot answer from a mismatched route (#9310)", () => {
77+
const helpers = createInferenceRouteHelpers(() => null);
78+
79+
expect(helpers.readInferenceRouteState("nemoclaw", "compatible-endpoint", "test-model")).toBe(
80+
"unanswered",
81+
);
82+
expect(helpers.isInferenceRouteReady("nemoclaw", "compatible-endpoint", "test-model")).toBe(
83+
false,
84+
);
85+
});
86+
});

src/lib/onboard/inference-route.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { listSandboxes } from "../state/registry";
1616

1717
type RunCaptureOpenshell = (args: string[], options?: { ignoreError?: boolean }) => string | null;
1818

19+
/** A gateway that cannot answer is distinct from one that answers with another route. */
20+
export type InferenceRouteState = "matched" | "mismatched" | "unanswered";
21+
1922
/** Resolve the exact portable inference route used by managed clone preparation. */
2023
export function resolveManagedStartupInferenceRoute(
2124
agentName: string,
@@ -50,11 +53,20 @@ export function createInferenceRouteHelpers(
5053
}
5154
}
5255

53-
function isInferenceRouteReady(gatewayName: string, provider: string, model: string): boolean {
56+
function readInferenceRouteState(
57+
gatewayName: string,
58+
provider: string,
59+
model: string,
60+
): InferenceRouteState {
5461
const live = parseGatewayInference(
5562
runCaptureOpenshell(["inference", "get", "-g", gatewayName], { ignoreError: true }),
5663
);
57-
return Boolean(live && live.provider === provider && live.model === model);
64+
if (!live) return "unanswered";
65+
return live.provider === provider && live.model === model ? "matched" : "mismatched";
66+
}
67+
68+
function isInferenceRouteReady(gatewayName: string, provider: string, model: string): boolean {
69+
return readInferenceRouteState(gatewayName, provider, model) === "matched";
5870
}
5971

6072
const checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck = (request) =>
@@ -72,6 +84,7 @@ export function createInferenceRouteHelpers(
7284
return {
7385
verifyInferenceRoute,
7486
isInferenceRouteReady,
87+
readInferenceRouteState,
7588
checkGatewayRouteCompatibility,
7689
preflightGatewayRouteDiscovery,
7790
};

0 commit comments

Comments
 (0)