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
14 changes: 7 additions & 7 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2810,8 +2810,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
collector: null,
span: null,
};
let completed = false,
returnedNormally = false;
let completed = false, preserveDeferredExitSession = false, preserveIncompleteSession = false;
try {
await portableRetirementEntry.run(async () => {
const lockedRuntime = await resumeRuntime.prepare(
Expand Down Expand Up @@ -2920,10 +2919,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
process.exit(1);
}

registerIncompleteOnboardExitHandlerForSession(
onboardSession,
() => completed || returnedNormally,
);
registerIncompleteOnboardExitHandlerForSession(onboardSession, () => completed || preserveIncompleteSession);
const agent = await selectOnboardAgent({
agentFlag: opts.agent,
session,
Expand Down Expand Up @@ -3493,6 +3489,9 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
}
process.exitCode = completed ? 0 : 1;
});
} catch (error) {
preserveDeferredExitSession = onboardSessionBootstrap.shouldPreserveIncompleteOnboardSession(error);
throw error;
} finally {
try {
await hermesApiPortReservationScope.release();
Expand All @@ -3510,8 +3509,9 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
restorePortableEnvScope();
hostMountScope.restore();
}
if (preserveDeferredExitSession) preserveIncompleteSession = true;
}
returnedNormally = true;
preserveIncompleteSession = true;
}

module.exports = {
Expand Down
31 changes: 15 additions & 16 deletions src/lib/onboard/inference-selection-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { OnboardInferenceCapabilityCache } from "./inference-capability-cache";
import { createInferenceSelectionValidationHelpers } from "./inference-selection-validation";

const listen = useOpenAiValidationTestServers();
const resumableValidationExit = {
code: 1,
name: "OnboardDeferredExitError",
preserveIncompleteSession: true,
};

describe("inference selection validation", () => {
it.each([
Expand Down Expand Up @@ -235,8 +240,8 @@ describe("inference selection validation", () => {
"meta/llama-3.3-70b-instruct",
"NVIDIA_INFERENCE_API_KEY",
),
).rejects.toThrow("Non-interactive endpoint validation failed.");
expect(exit).toHaveBeenCalledWith(1);
).rejects.toMatchObject(resumableValidationExit);
expect(exit).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(promptValidationRecovery).not.toHaveBeenCalled();
expect(teardownOrphanManagedGatewayOnAbort).toHaveBeenCalledOnce();
Expand Down Expand Up @@ -588,9 +593,9 @@ describe("inference selection validation", () => {
"model-a",
"COMPATIBLE_ANTHROPIC_API_KEY",
),
).rejects.toThrow("Non-interactive endpoint validation failed.");
).rejects.toMatchObject(resumableValidationExit);
expect(probeAnthropicEndpoint).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
expect(exit).not.toHaveBeenCalled();
} finally {
process.exitCode = originalExitCode;
exit.mockRestore();
Expand Down Expand Up @@ -621,12 +626,9 @@ describe("inference selection validation", () => {
"model-a",
"COMPATIBLE_ANTHROPIC_API_KEY",
),
).rejects.toThrow("Non-interactive endpoint validation failed.");
).rejects.toMatchObject(resumableValidationExit);
expect(teardownOrphanManagedGatewayOnAbort).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(1);
expect(teardownOrphanManagedGatewayOnAbort.mock.invocationCallOrder[0]).toBeLessThan(
exit.mock.invocationCallOrder[0] ?? 0,
);
expect(exit).not.toHaveBeenCalled();
} finally {
process.exitCode = originalExitCode;
exit.mockRestore();
Expand Down Expand Up @@ -659,13 +661,10 @@ describe("inference selection validation", () => {
"model-a",
"COMPATIBLE_ANTHROPIC_API_KEY",
),
).rejects.toThrow("Non-interactive endpoint validation failed.");
).rejects.toMatchObject(resumableValidationExit);
expect(teardownOrphanManagedGatewayOnAbort).toHaveBeenCalledTimes(1);
expect(error.mock.calls.map((call) => String(call[0])).join("\n")).toContain("teardown boom");
expect(exit).toHaveBeenCalledWith(1);
expect(teardownOrphanManagedGatewayOnAbort.mock.invocationCallOrder[0]).toBeLessThan(
exit.mock.invocationCallOrder[0] ?? 0,
);
expect(exit).not.toHaveBeenCalled();
} finally {
process.exitCode = originalExitCode;
exit.mockRestore();
Expand Down Expand Up @@ -1017,8 +1016,8 @@ exit 0
null,
{ intendedApi: "openai-completions" },
),
).rejects.toThrow("Non-interactive endpoint validation failed.");
expect(exit).toHaveBeenCalledWith(1);
).rejects.toMatchObject(resumableValidationExit);
expect(exit).not.toHaveBeenCalled();
expect(promptValidationRecovery).not.toHaveBeenCalled();
const errorOutput = error.mock.calls.map((args) => args.join(" ")).join("\n");
expect(errorOutput).toContain(
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard/inference-selection-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { shouldForceCompletionsApi } from "../validation";
import { getProbeRecovery } from "../validation-recovery";
import { summarizeProbeForDisplay } from "./probe-diagnostics";
import { normalizeReasoningFlag } from "./reasoning-mode";
import { OnboardDeferredExitError } from "./session-bootstrap";

export type EndpointValidationResult =
| {
Expand Down Expand Up @@ -174,8 +175,7 @@ export function createInferenceSelectionValidationHelpers(
);
}
process.exitCode = 1;
(process.exit as (code?: number) => void)(1);
throw new Error("Non-interactive endpoint validation failed.");
throw new OnboardDeferredExitError(1, { preserveIncompleteSession: true });
}

function printValidationFailure(
Expand Down
8 changes: 7 additions & 1 deletion src/lib/onboard/session-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,13 @@ const ONBOARD_DEFERRED_EXIT_ERROR = Symbol.for("nemoclaw.onboard.deferred-exit-e
export class OnboardDeferredExitError extends Error {
readonly [ONBOARD_DEFERRED_EXIT_ERROR] = true;
readonly code: number;
readonly preserveIncompleteSession: boolean;

constructor(code: number) {
constructor(code: number, options: { preserveIncompleteSession?: boolean } = {}) {
super(`Onboarding requested exit ${String(code)}.`);
this.name = "OnboardDeferredExitError";
this.code = code;
this.preserveIncompleteSession = options.preserveIncompleteSession === true;
}
}

Expand All @@ -181,6 +183,10 @@ export function isOnboardDeferredExitError(error: unknown): error is OnboardDefe
);
}

export function shouldPreserveIncompleteOnboardSession(error: unknown): boolean {
return isOnboardDeferredExitError(error) && error.preserveIncompleteSession;
}

interface DeferredExitOptions {
readonly deferProcessExit?: boolean;
}
Expand Down
80 changes: 66 additions & 14 deletions test/onboard-exit-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("onboard exit handler registration", () => {
expect(loaded.machine.state).toBe("init");
});

it("onboard() registers incomplete nonzero exit handling after bootstrap", () => {
it("preserves an incomplete onboarding session after validation fails and records an unexpected exit as failed (#9732)", () => {
const repoRoot = path.join(import.meta.dirname, "..");
const scriptPath = path.join(tmpDir, "onboard-exit-registration.cjs");
const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts"));
Expand All @@ -111,12 +111,16 @@ describe("onboard exit handler registration", () => {
const sessionPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"),
);
const validationPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "onboard", "inference-selection-validation.ts"),
);

fs.writeFileSync(
scriptPath,
`
const flowSlices = require(${flowSlicesPath});
const onboardSession = require(${sessionPath});
const validation = require(${validationPath});
const sentinel = new Error("stop-after-exit-registration");
const exitListeners = [];
const originalOnce = process.once;
Expand All @@ -133,8 +137,30 @@ process.exit = function exit(code) {
throw new Error("process.exit:" + String(code));
};

const validationHelpers = validation.createInferenceSelectionValidationHelpers({
isNonInteractive: () => true,
agentProductName: () => "OpenClaw",
getCredential: () => "test-key",
probeOpenAiLikeEndpoint: async () => ({
ok: false,
failures: [{ name: "Chat Completions API", httpStatus: 503 }],
}),
resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }],
teardownOrphanManagedGatewayOnAbort: () => {},
promptValidationRecovery: async () => "selection",
});

flowSlices.runInitialOnboardFlowSequence = async ({ runtime }) => {
await runtime.markStepStarted("preflight");
if (process.env.NEMOCLAW_TEST_EXIT_KIND === "validation") {
await validationHelpers.validateCustomOpenAiLikeSelection(
"Custom endpoint",
"https://endpoint.test/v1",
"model-a",
"COMPATIBLE_API_KEY",
);
throw new Error("expected validation exit");
}
throw sentinel;
};

Expand All @@ -151,7 +177,11 @@ const { onboard } = require(${onboardPath});
});
throw new Error("expected sentinel");
} catch (error) {
if (error !== sentinel && error?.message !== sentinel.message) {
const validationExit = process.env.NEMOCLAW_TEST_EXIT_KIND === "validation";
if (
(!validationExit && error !== sentinel && error?.message !== sentinel.message) ||
(validationExit && error?.message !== "process.exit:1")
) {
throw error;
}
const exitHandler = exitListeners.at(-1);
Expand All @@ -170,18 +200,22 @@ const { onboard } = require(${onboardPath});
`,
);

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
HOME: tmpDir,
PATH: ONBOARD_FIXTURE_PATH,
TMPDIR: tmpDir,
NEMOCLAW_TEST_NO_SLEEP: "1",
},
timeout: 60_000,
});
const runOnboard = (home: string, exitKind: "unexpected" | "validation") =>
spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
HOME: home,
PATH: ONBOARD_FIXTURE_PATH,
TMPDIR: tmpDir,
NEMOCLAW_TEST_EXIT_KIND: exitKind,
NEMOCLAW_TEST_NO_SLEEP: "1",
},
timeout: 60_000,
});

const result = runOnboard(tmpDir, "unexpected");

expect(result.status, result.stderr).toBe(0);
const lastLine = result.stdout.trim().split(/\n/).at(-1) ?? "";
Expand All @@ -195,6 +229,24 @@ const { onboard } = require(${onboardPath});
expect(payload.loaded.failure?.step).toBe("preflight");
expect(payload.loaded.failure?.message).toBe("Onboarding exited before the step completed.");
expect(payload.loaded.machine.state).toBe("failed");

const validationHome = path.join(tmpDir, "validation-home");
fs.mkdirSync(validationHome);
const validationResult = runOnboard(validationHome, "validation");

expect(validationResult.status, validationResult.stderr).toBe(1);
const validationLastLine = validationResult.stdout.trim().split(/\n/).at(-1) ?? "";
const validationPayload = JSON.parse(validationLastLine) as {
loaded: ReturnType<typeof onboardSession.createSession>;
exitListeners: number;
};
expect(validationPayload.exitListeners).toBeGreaterThanOrEqual(2);
expect(validationPayload.loaded.steps.preflight.status).toBe("in_progress");
expect(validationPayload.loaded.status).toBe("in_progress");
expect(validationPayload.loaded.failure).toBeNull();
expect(validationPayload.loaded.machine.state).toBe("init");
expect(validationPayload.loaded.checkpoint).not.toBeNull();
expect(validationPayload.loaded.checkpoint?.machineState).toBe("init");
Comment thread
apurvvkumaria marked this conversation as resolved.
});

it("onboard() preserves a resumable session after a normal incomplete result (#9048)", () => {
Expand Down
Loading