Skip to content
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"src/lib/cli/nemoclaw-oclif-command.ts": 104,
"src/lib/cli/terminal-style.ts": 45,
"src/lib/core/json-types.ts": 37,
"src/lib/core/ports.ts": 89,
"src/lib/core/ports.ts": 88,
"src/lib/core/shell-quote.ts": 26,
"src/lib/core/url-utils.ts": 28,
"src/lib/core/wait.ts": 35,
Expand Down
4 changes: 1 addition & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ const { getGatewayReuseSnapshot, selectNamedGatewayForReuseIfNeeded } =
});

// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const { getSandboxReuseState, getSandboxRecreateObservation, repairRecordedSandbox } = sandboxReuse.createSandboxReuseHelpers({ runCaptureOpenshell, runOpenshell, getSandboxStateFromOutputs, note, getGatewayName: () => GATEWAY_NAME });
const { getSandboxReuseState, getSandboxRecreateObservation } = sandboxReuse.createSandboxReuseHelpers({ runCaptureOpenshell, getSandboxStateFromOutputs, getGatewayName: () => GATEWAY_NAME });

const {
executeSandboxCommandForVerification,
Expand Down Expand Up @@ -4372,7 +4372,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
removeSandboxFromRegistry: registry.removeSandboxWithReceipt.bind(registry),
restoreSandboxRegistryEntryIfMissing:
registry.restoreSandboxEntryIfMissing.bind(registry),
repairRecordedSandbox,
ensureValidatedWebSearchCredential,
isBackToSelection,
configureWebSearch,
Expand Down Expand Up @@ -4643,7 +4642,6 @@ module.exports = {
parsePolicyPresetEnv,
parseSandboxStatus,
preflightAuthoritativeRebuildTarget,
repairRecordedSandbox,
recoverGatewayRuntime,
buildChain,
buildControlUiUrls,
Expand Down
1 change: 0 additions & 1 deletion src/lib/onboard/machine/core-flow-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,6 @@ function createPhases(
left.length === right.length && left.every((item) => right.includes(item)),
removeSandboxFromRegistry: vi.fn(() => null),
restoreSandboxRegistryEntryIfMissing: vi.fn(() => false),
repairRecordedSandbox: vi.fn(),
ensureValidatedWebSearchCredential: vi.fn(async () => null),
isBackToSelection: () => false,
configureWebSearch: vi.fn(async () => null),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,6 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => {
expect(calls.createSandbox).not.toHaveBeenCalled();
expect(calls.removeSandbox).not.toHaveBeenCalled();
expect(calls.retireReplacedSandboxWorkload).not.toHaveBeenCalled();
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.error.mock.calls.flat().join("\n")).toContain("nemoclaw onboard --fresh");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ describe("sandbox create intent machine boundary", () => {
expect(resolveSandboxCreateIntent).toHaveBeenCalledTimes(1);
expect(calls.startStep).not.toHaveBeenCalled();
expect(calls.removeSandbox).not.toHaveBeenCalled();
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.createSandbox).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ describe("handleSandboxState live DCode selection", () => {
requestedDcodeAutoApprovalMode: "thread-opt-in",
});

expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.started", {
state: "sandbox",
metadata: { repair: "recorded-sandbox-cleanup", sandboxName: "saved" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ it("journals not-ready repair on the selected non-default gateway (#6492)", asyn
gatewayName: "nemoclaw-31818",
});

expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(createSandbox).toHaveBeenCalledOnce();
const createIntent = createSandbox.mock.calls[0]?.at(-1);
expect(createIntent).toMatchObject({
Expand Down Expand Up @@ -388,7 +387,6 @@ it("rejects an active recreate journal on a different gateway authority (#6492)"
).rejects.toThrow(/journaled gateway.*does not match the selected gateway authority/i);
expect(getSandboxRecreateObservation).not.toHaveBeenCalled();
expect(calls.createSandbox).not.toHaveBeenCalled();
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.removeSandbox).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -430,7 +428,6 @@ it("refuses an unjournaled same-name replacement when the bound gateway authorit
gatewayName: "nemoclaw",
}),
).rejects.toThrow(/no recreate transaction proves ownership/i);
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.removeSandbox).not.toHaveBeenCalled();
expect(calls.createSandbox).not.toHaveBeenCalled();
expect(session.checkpoint?.sandboxRecreate).toBeNull();
Expand Down Expand Up @@ -460,7 +457,6 @@ it("refuses an unjournaled legacy same-name repair without gateway authority (#7
gatewayName: "nemoclaw",
}),
).rejects.toThrow(/no recreate transaction proves ownership/i);
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.removeSandbox).not.toHaveBeenCalled();
expect(calls.createSandbox).not.toHaveBeenCalled();
expect(session.checkpoint?.sandboxRecreate).toBeNull();
Expand Down Expand Up @@ -517,7 +513,6 @@ it("creates a missing sandbox from a preserved registry row without removing the

expect(createSandbox).toHaveBeenCalledOnce();
expect(calls.removeSandbox).not.toHaveBeenCalled();
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(session.checkpoint?.sandboxRecreate ?? null).toBeNull();
});

Expand Down Expand Up @@ -566,7 +561,6 @@ it("journals not-ready resumed sandboxes before recreation (#7736)", async () =>
state: "sandbox",
metadata: { repair: "recorded-sandbox-cleanup", sandboxName: "saved" },
});
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.completed", {
state: "sandbox",
metadata: { repair: "recorded-sandbox-cleanup", sandboxName: "saved" },
Expand Down Expand Up @@ -601,5 +595,4 @@ it("records failed repair events when journaled replacement creation fails (#773
metadata: { repair: "recorded-sandbox-cleanup", sandboxName: "saved" },
});
expect(calls.repairEvent).not.toHaveBeenCalledWith("state.repair.completed", expect.anything());
expect(calls.repairSandbox).not.toHaveBeenCalled();
});
15 changes: 15 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ describe("decideSandboxResume", () => {
).toEqual({ kind: "repair-and-recreate" });
});

it.each([
["live DCode inference selection", { inferenceSelectionChanged: true }],
["agent selection", { resumeAgentChanged: true }],
["Hermes inference route", { inferenceRouteConfigChanged: true }],
] as const)("repairs a not-ready sandbox before recreating for %s drift", (_label, drift) => {
expect(
decideSandboxResume(
resumeSignals({
sandboxReuseState: "not_ready",
...drift,
}),
),
).toEqual({ kind: "repair-and-recreate" });
});

it("creates without resume-specific cleanup when the step is incomplete", () => {
expect(
decideSandboxResume(
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard/machine/handlers/sandbox-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,14 @@ function runtimeConfigurationResumeDecision(

export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision {
if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" };
if (signals.sandboxReuseState === "not_ready") return { kind: "repair-and-recreate" };
const compatibilityDecision = compatibilityResumeDecision(signals);
if (compatibilityDecision) return compatibilityDecision;
if (canReuseSandbox(signals)) return { kind: "reuse" };
const configurationDecision = runtimeConfigurationResumeDecision(signals);
if (configurationDecision) return configurationDecision;
const toolDisclosureDecision = toolDisclosureResumeDecision(signals);
if (toolDisclosureDecision) return toolDisclosureDecision;
if (signals.sandboxReuseState === "not_ready") return { kind: "repair-and-recreate" };
return {
kind: "recreate",
note: " [resume] Recorded sandbox state is unavailable; recreating it.",
Expand Down
2 changes: 0 additions & 2 deletions src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ export function createDeps(
clearPlanEnv: vi.fn(),
removeSandbox: vi.fn((): SandboxRemovalReceipt | null => null),
restoreSandboxRegistryEntryIfMissing: vi.fn(() => false),
repairSandbox: vi.fn(),
validateBrave: vi.fn(async () => "brave-key"),
isBackToSelection: vi.fn(() => false),
configureWebSearch: vi.fn(async () => null as WebSearchConfig | null),
Expand Down Expand Up @@ -297,7 +296,6 @@ export function createDeps(
left.length === right.length && left.every((value) => right.includes(value)),
removeSandboxFromRegistry: calls.removeSandbox,
restoreSandboxRegistryEntryIfMissing: calls.restoreSandboxRegistryEntryIfMissing,
repairRecordedSandbox: calls.repairSandbox,
ensureValidatedWebSearchCredential: calls.validateBrave,
isBackToSelection: calls.isBackToSelection,
configureWebSearch: calls.configureWebSearch,
Expand Down
1 change: 0 additions & 1 deletion src/lib/onboard/machine/handlers/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1017,7 +1017,6 @@ describe("handleSandboxState", () => {
).rejects.toThrow("Tavily credential rejected");

expect(calls.removeSandbox).not.toHaveBeenCalled();
expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(calls.createSandbox).not.toHaveBeenCalled();
});

Expand Down
1 change: 0 additions & 1 deletion src/lib/onboard/machine/handlers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ export interface SandboxStateOptions<
stringSetsEqual(left: string[], right: string[]): boolean;
removeSandboxFromRegistry(sandboxName: string): SandboxRemovalReceipt | null;
restoreSandboxRegistryEntryIfMissing(receipt: SandboxRemovalReceipt): boolean;
repairRecordedSandbox(sandboxName: string | null): void;
ensureValidatedWebSearchCredential(config: WebSearchConfig): Promise<unknown>;
isBackToSelection(value: unknown): boolean;
configureWebSearch(
Expand Down
1 change: 0 additions & 1 deletion src/lib/onboard/machine/transition-traces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,6 @@ describe("onboard machine lifecycle traces (#6225)", () => {
stopStates: ["openclaw"],
});

expect(calls.repairSandbox).not.toHaveBeenCalled();
expect(journal.completeCreate).toHaveBeenCalledOnce();
expect(traceOf(events)).toEqual([
"onboard.resumed:sandbox",
Expand Down
6 changes: 0 additions & 6 deletions src/lib/onboard/sandbox-reuse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,7 @@ describe("createSandboxReuseHelpers", () => {
const getSandboxStateFromOutputs = vi.fn(() => "ready");
const helpers = createSandboxReuseHelpers({
runCaptureOpenshell,
runOpenshell: vi.fn(),
getSandboxStateFromOutputs,
note: vi.fn(),
});

expect(helpers.getSandboxRecreateObservation("alpha")).toEqual({
Expand All @@ -231,9 +229,7 @@ describe("createSandboxReuseHelpers", () => {
);
const helpers = createSandboxReuseHelpers({
runCaptureOpenshell,
runOpenshell: vi.fn(),
getSandboxStateFromOutputs: vi.fn(() => "ready"),
note: vi.fn(),
getGatewayName: () => "nemoclaw",
});

Expand All @@ -254,9 +250,7 @@ describe("createSandboxReuseHelpers", () => {
it("preserves an unknown reuse state but rejects it for recreate recovery", () => {
const helpers = createSandboxReuseHelpers({
runCaptureOpenshell: vi.fn(() => ""),
runOpenshell: vi.fn(),
getSandboxStateFromOutputs: vi.fn(() => "unknown"),
note: vi.fn(),
});

expect(helpers.getSandboxReuseState("alpha")).toBe("unknown");
Expand Down
15 changes: 1 addition & 14 deletions src/lib/onboard/sandbox-reuse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import type { AgentDefinition } from "../agent/defs";
import { DASHBOARD_PORT } from "../core/ports";
import type { SandboxEntry } from "../state/registry";
import * as registry from "../state/registry";
import { bestEffortForwardStop } from "./forward-cleanup";
import {
getHermesDashboardRegistryFields,
type HermesDashboardOnboardState,
Expand All @@ -18,9 +16,7 @@ import {

export interface SandboxReuseDeps {
runCaptureOpenshell(args: string[], opts?: Record<string, unknown>): string;
runOpenshell(args: string[], opts?: Record<string, unknown>): unknown;
getSandboxStateFromOutputs(sandboxName: string, getOutput: string, listOutput: string): string;
note(message: string): void;
// Read at call time: onboarding rebinds the gateway after preflight resolves it.
getGatewayName?(): string;
}
Expand All @@ -31,7 +27,6 @@ export interface SandboxReuseHelpers {
sandboxName: string | null,
gatewayName?: string,
): SandboxRecreateObservation;
repairRecordedSandbox(sandboxName: string | null): void;
}

export interface ReusedSandboxDashboardForwarding {
Expand Down Expand Up @@ -161,13 +156,5 @@ export function createSandboxReuseHelpers(deps: SandboxReuseDeps): SandboxReuseH
return readSandboxState(sandboxName).state;
}

function repairRecordedSandbox(sandboxName: string | null): void {
if (!sandboxName) return;
deps.note(` [resume] Cleaning up recorded sandbox '${sandboxName}' before recreating it.`);
bestEffortForwardStop(deps.runOpenshell, DASHBOARD_PORT);
deps.runOpenshell(["sandbox", "delete", ...gatewayArgs(), sandboxName], { ignoreError: true });
registry.removeSandbox(sandboxName);
}

return { getSandboxReuseState, getSandboxRecreateObservation, repairRecordedSandbox };
return { getSandboxReuseState, getSandboxRecreateObservation };
}
Loading