Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a29cebe
fix(onboard): preserve truthful review recovery
yimoj Aug 10, 2026
819c065
refactor(onboard): extract review recovery wiring
yimoj Aug 11, 2026
838f0e7
merge: update onboarding recovery branch
yimoj Aug 11, 2026
d4584db
test(onboard): satisfy review recovery budgets
yimoj Aug 11, 2026
52c7607
refactor(onboard): keep entrypoint within budget
yimoj Aug 11, 2026
343a0a8
test(onboard): cover review dependency ordering
yimoj Aug 11, 2026
bfadff9
refactor(onboard): reduce provider recovery complexity
yimoj Aug 11, 2026
4ead75b
chore(onboard): apply repository formatting
yimoj Aug 11, 2026
62adee2
merge: refresh onboarding recovery base
yimoj Aug 11, 2026
dbd9c2f
test(onboard): keep review fixture branchless
yimoj Aug 11, 2026
592139d
Merge branch 'main' into fix/8686-onboard-cancellation-status
cv Aug 11, 2026
fe75ba2
Merge branch 'main' into fix/8686-onboard-cancellation-status
cv Aug 11, 2026
ddcef29
Merge branch 'main' into fix/8686-onboard-cancellation-status
cv Aug 11, 2026
5c856c5
test(onboard): cover proxy preparation failures
apurvvkumaria Aug 11, 2026
35ac5d3
merge: refresh onboarding recovery base
apurvvkumaria Aug 11, 2026
68dd02a
merge: incorporate onboarding recovery branch updates
apurvvkumaria Aug 11, 2026
ed7271d
fix(onboard): defer Ollama proxy preparation
apurvvkumaria Aug 11, 2026
acfc89b
Merge branch 'main' into fix/8686-onboard-cancellation-status
cv Aug 11, 2026
9fd77cd
docs(onboard): explain configuration review recovery
apurvvkumaria Aug 11, 2026
85b6ac5
merge(main): refresh PR #8724
apurvvkumaria Aug 11, 2026
41dda5c
test(onboard): align Ollama autostart sentinel
apurvvkumaria Aug 11, 2026
4ca80d2
Merge branch 'main' into fix/8686-onboard-cancellation-status
cv Aug 11, 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
3 changes: 2 additions & 1 deletion src/lib/core/non-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ afterEach(() => {
});

describe("non-interactive environment detection", () => {
it("treats only the canonical value as non-interactive", () => {
it("treats only the canonical explicit value as non-interactive", () => {
expect(isNonInteractiveEnv({ NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv)).toBe(true);
expect(isNonInteractiveEnv({ NEMOCLAW_NON_INTERACTIVE: "true" } as NodeJS.ProcessEnv)).toBe(
false,
);
expect(isNonInteractiveEnv({ NEMOCLAW_NON_INTERACTIVE: "" } as NodeJS.ProcessEnv)).toBe(false);
expect(isNonInteractiveEnv({ NEMOCLAW_YES: "1" } as NodeJS.ProcessEnv)).toBe(false);
expect(isNonInteractiveEnv({} as NodeJS.ProcessEnv)).toBe(false);
});

Expand Down
31 changes: 10 additions & 21 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3671,6 +3671,7 @@ const {

const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary);
const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary);
const recordStepRejected = onboardRuntimeBoundary.recordStepRejected.bind(onboardRuntimeBoundary);
const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary);
const recordStepFailed = onboardRuntimeBoundary.recordStepFailed.bind(onboardRuntimeBoundary);
const recordStateSkipped = onboardRuntimeBoundary.recordStateSkipped.bind(onboardRuntimeBoundary);
Expand Down Expand Up @@ -3752,33 +3753,16 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
authoritativeGateway?.name ?? GATEWAY_NAME,
);
setOnboardBrandingAgent(opts.agent || process.env.NEMOCLAW_AGENT || null);
NON_INTERACTIVE = opts.nonInteractive || isNonInteractiveEnv();
RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1";
AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1";
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const { fresh, nonInteractive, requestedFromDockerfile, requestedSandboxName, cannotPrompt, resume } = onboardEntryOptions.resolveOnboardRunEntryOptions(opts, process.env, onboardSession.loadSession()?.status ?? null, isNonInteractiveEnv, { validateName, reservedSandboxNames: RESERVED_SANDBOX_NAMES, cliDisplayName, getNameValidationGuidance, error: (message) => console.error(message), exitProcess: (code) => process.exit(code) });
NON_INTERACTIVE = nonInteractive;
RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1";
_preflightDashboardPort =
opts.controlUiPort ?? (process.env.NEMOCLAW_DASHBOARD_PORT != null ? DASHBOARD_PORT : null);
onboardRuntimeBoundary.reset();
if (!authoritativeGateway) delete process.env.OPENSHELL_GATEWAY;
preparedDcodeRuntime.applyGatewayEnv(process.env);
const { resume, fresh, requestedFromDockerfile, requestedSandboxName, cannotPrompt } =
onboardEntryOptions.resolveOnboardEntryOptions(
{
opts,
env: process.env,
stdinIsTty: Boolean(process.stdin && process.stdin.isTTY),
stdoutIsTty: Boolean(process.stdout && process.stdout.isTTY),
persistedSessionStatus: onboardSession.loadSession()?.status ?? null,
},
{
isNonInteractive,
validateName,
reservedSandboxNames: RESERVED_SANDBOX_NAMES,
cliDisplayName,
getNameValidationGuidance,
error: (message) => console.error(message),
exitProcess: (code) => process.exit(code),
},
);
const baseImageResolutionContext = baseImageResolutionFlow.createBaseImageResolutionContext({
fresh,
initialHint: opts.baseImageResolutionHint,
Expand Down Expand Up @@ -4111,6 +4095,9 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const endpointProvenance = { endpointSource: opts.endpointSource, endpointSourceProvider: opts.rebuildRegistryInferenceRoute?.route.provider ?? null, endpointSourceEndpointUrl: opts.rebuildRegistryInferenceRoute?.route.endpointUrl ?? null, getSandboxRegistryEntry: registry.getSandbox };
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const providerReviewDeps = setupInferenceFactory.createProviderReviewDeps(onboardSession.updateSession, onboardSessionBootstrap.checkpointSandboxName, { shouldFrontOllamaWithProxy, startOllamaAuthProxy, getOllamaProxyToken, persistAndProbeOllamaProxy }, process.exit, console.error);
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const coreFlowPhases = createCoreOnboardFlowPhases<InitialOnboardFlowContext, unknown, MessagingChannelConfig, import("./resources-cmd").ResourceProfile>({
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
resumeProvider: { isNonInteractive, isRoutedInferenceProvider, providerExistsInGateway, replaceNamedCredential, resumeManagedLlamaCppRuntime: (sandboxName) => setupNimFlow.resumeManagedLlamaCppRuntime(sandboxName, { gatewayPort: GATEWAY_PORT, runtimeProvider: setupNimFlow.resolveCurrentRuntimeProviderBundle() }) },
Expand All @@ -4136,6 +4123,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
setupInference,
startRecordedStep,
recordStepComplete,
recordStepRejected,
toSessionUpdates: (updates) =>
toSessionUpdates(updates as Parameters<typeof toSessionUpdates>[0]),
skippedStepMessage,
Expand Down Expand Up @@ -4167,6 +4155,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
},
reserveSandboxInferenceRoute: registry.reserveSandboxInferenceRoute,
registryUpdateSandbox: (name, updates) => registry.updateSandbox(name, updates),
...providerReviewDeps,
promptValidatedSandboxName,
assessHost,
formatSandboxBuildEstimateNote,
Expand Down
53 changes: 53 additions & 0 deletions src/lib/onboard/entry-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest";
import {
type OnboardEntryOptionsDeps,
resolveOnboardEntryOptions,
resolveOnboardRunOptions,
withNonInteractiveEnvironment,
} from "./entry-options";

Expand All @@ -30,6 +31,41 @@ function createDeps(overrides: Partial<OnboardEntryOptionsDeps> = {}): OnboardEn
};
}

describe("resolveOnboardRunOptions", () => {
it.each([
[false, true],
[false, false],
])("treats auto-yes resume as non-interactive when stdin=%s and stdout=%s", (stdinIsTty, stdoutIsTty) => {
expect(
resolveOnboardRunOptions({ autoYes: true, resume: true }, {}, null, () => false, {
stdinIsTty,
stdoutIsTty,
}).nonInteractive,
).toBe(true);
});

it.each([
[true, true],
[true, false],
])("keeps auto-yes resume interactive when stdin=%s and stdout=%s", (stdinIsTty, stdoutIsTty) => {
expect(
resolveOnboardRunOptions({ autoYes: true, resume: true }, {}, null, () => false, {
stdinIsTty,
stdoutIsTty,
}).nonInteractive,
).toBe(false);
});

it("keeps fresh no-TTY auto-yes interactive", () => {
expect(
resolveOnboardRunOptions({ autoYes: true }, {}, null, () => false, {
stdinIsTty: false,
stdoutIsTty: false,
}).nonInteractive,
).toBe(false);
});
});

describe("resolveOnboardEntryOptions", () => {
it("rejects mutually exclusive resume and fresh flags", () => {
const deps = createDeps();
Expand Down Expand Up @@ -157,6 +193,23 @@ describe("resolveOnboardEntryOptions", () => {
expect(deps.error).not.toHaveBeenCalled();
});

it("does not auto-resume a rejected non-resumable session", () => {
const deps = createDeps();

const result = resolveOnboardEntryOptions(
{
opts: {},
env: {},
stdinIsTty: true,
stdoutIsTty: true,
persistedSessionStatus: "failed",
},
deps,
);

expect(result.resume).toBe(false);
});

it("does not auto-resume when --fresh is set even with an in_progress session (#5470)", () => {
const deps = createDeps();

Expand Down
45 changes: 45 additions & 0 deletions src/lib/onboard/entry-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,51 @@ export interface ResolvedOnboardEntryOptions {

type NonInteractiveEntryOptions = { nonInteractive?: boolean };
type ResumableEntryOptions = NonInteractiveEntryOptions & { resume?: boolean; fresh?: boolean };

export function resolveOnboardRunOptions(
options: OnboardEntryOptionsInput["opts"] & { autoYes?: boolean; nonInteractive?: boolean },
env: NodeJS.ProcessEnv,
persistedSessionStatus: string | null,
isNonInteractiveEnv: () => boolean,
terminal: { stdinIsTty: boolean; stdoutIsTty: boolean } = {
stdinIsTty: Boolean(process.stdin?.isTTY),
stdoutIsTty: Boolean(process.stdout?.isTTY),
},
) {
const resume =
options.resume === true || (options.fresh !== true && persistedSessionStatus === "in_progress");
const nonInteractive =
options.nonInteractive === true ||
((options.autoYes === true || env.NEMOCLAW_YES === "1") && resume && !terminal.stdinIsTty) ||
isNonInteractiveEnv();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return {
resume,
nonInteractive,
entryOptionsInput: { opts: options, env, ...terminal, persistedSessionStatus },
};
}

export function resolveOnboardRunEntryOptions(
options: OnboardEntryOptionsInput["opts"] & { autoYes?: boolean; nonInteractive?: boolean },
env: NodeJS.ProcessEnv,
persistedSessionStatus: string | null,
isNonInteractiveEnv: () => boolean,
deps: Omit<OnboardEntryOptionsDeps, "isNonInteractive">,
) {
const context = resolveOnboardRunOptions(
options,
env,
persistedSessionStatus,
isNonInteractiveEnv,
);
return {
...context,
...resolveOnboardEntryOptions(context.entryOptionsInput, {
...deps,
isNonInteractive: () => context.nonInteractive,
}),
};
}
interface StationExpressSessionLifecycle {
loadSession(): StationExpressSessionLike | null;
reconcileStationExpressReceiptRetirement(generation: string): void;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/onboard/machine/core-flow-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ function createPhases(
recordStepComplete: vi.fn(async (_stepName: string, updates: SessionUpdates = {}) =>
sessionWithUpdates(updates),
),
recordStepRejected: vi.fn(async () => createSession()),
toSessionUpdates: (updates) => updates as SessionUpdates,
skippedStepMessage: vi.fn(),
ensureManagedLlamaCppResumeReady: vi.fn(async () => false),
Expand Down Expand Up @@ -183,6 +184,8 @@ function createPhases(
}),
reserveSandboxInferenceRoute: vi.fn(() => true),
registryUpdateSandbox: vi.fn(),
checkpointSandboxIdentity: vi.fn(async () => undefined),
prepareLocalProviderForInference: vi.fn(async () => undefined),
promptValidatedSandboxName: vi.fn(async () => "my-sandbox"),
assessHost: () => ({ memoryGb: 64 }),
formatSandboxBuildEstimateNote: () => null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function createDeps() {
setupInference: calls.setupInference,
startRecordedStep: vi.fn(async () => undefined),
recordStepComplete: calls.recordStepComplete,
recordStepRejected: vi.fn(async () => createSession()),
toSessionUpdates: (updates: Record<string, unknown>) => updates as SessionUpdates,
skippedStepMessage: vi.fn(),
ensureManagedLlamaCppResumeReady: vi.fn(async () => false),
Expand All @@ -118,6 +119,8 @@ function createDeps() {
reupsertRoutedProvider: calls.reupsertRoutedProvider,
reserveSandboxInferenceRoute: calls.reserveRoute,
registryUpdateSandbox: calls.updateSandbox,
checkpointSandboxIdentity: vi.fn(async () => undefined),
prepareLocalProviderForInference: vi.fn(async () => undefined),
promptValidatedSandboxName: vi.fn(async () => "target-sandbox"),
assessHost: () => ({ cpus: 8 }),
formatSandboxBuildEstimateNote: () => "estimate",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function createDeps(
setupInference: vi.fn(async () => ({ ok: true as const })),
startStep: vi.fn(async () => undefined),
complete: vi.fn(async () => createSession()),
rejected: vi.fn(async () => createSession()),
skipped: vi.fn(),
recoverProvider: vi.fn(
async (
Expand Down Expand Up @@ -133,6 +134,8 @@ export function createDeps(
),
reserveRoute: vi.fn(() => true),
updateSandbox: vi.fn(),
checkpointSandboxIdentity: vi.fn(async () => undefined),
prepareLocalProviderForInference: vi.fn(async () => undefined),
promptName: vi.fn(async () => "my-assistant"),
promptYesNo: vi.fn(async () => true),
log: vi.fn(),
Expand All @@ -158,6 +161,7 @@ export function createDeps(
setupInference: calls.setupInference,
startRecordedStep: calls.startStep,
recordStepComplete: calls.complete,
recordStepRejected: calls.rejected,
toSessionUpdates: (updates: Record<string, unknown>) => updates as SessionUpdates,
skippedStepMessage: calls.skipped,
ensureManagedLlamaCppResumeReady: calls.recoverManagedLlamaCpp,
Expand Down Expand Up @@ -195,6 +199,8 @@ export function createDeps(
reupsertRoutedProvider: calls.reupsertRoutedProvider,
reserveSandboxInferenceRoute: calls.reserveRoute,
registryUpdateSandbox: calls.updateSandbox,
checkpointSandboxIdentity: calls.checkpointSandboxIdentity,
prepareLocalProviderForInference: calls.prepareLocalProviderForInference,
promptValidatedSandboxName: calls.promptName,
assessHost: () => ({ cpus: 8 }),
formatSandboxBuildEstimateNote: () => "estimate",
Expand Down
27 changes: 12 additions & 15 deletions src/lib/onboard/machine/handlers/provider-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from "vitest";
import { decisionSelected, decisionUnset } from "../../../state/onboard-checkpoint-decision";
import { deriveCheckpointFromSession } from "../../../state/onboard-checkpoint-migrate";
import type { CheckpointSandboxIdentity } from "../../../state/onboard-checkpoint-types";
import { createSession } from "../../../state/onboard-session";
import { createSession, type SessionUpdates } from "../../../state/onboard-session";
import {
handleProviderInferenceState,
type ProviderInferenceStateOptions,
Expand Down Expand Up @@ -38,7 +38,12 @@ describe("handleProviderInferenceState", () => {
it("runs provider selection and inference setup on a fresh flow", async () => {
const { deps, calls } = createDeps();
const session = createSession();
calls.complete.mockResolvedValue(session);
calls.complete.mockImplementation(async (...args: unknown[]) => {
if (args[0] === "provider_selection") {
session.steps.provider_selection.status = "complete";
}
return session;
});

const result = await handleProviderInferenceState(baseOptions(deps, session));

Expand All @@ -60,7 +65,11 @@ describe("handleProviderInferenceState", () => {
expect(selectionUpdates).not.toHaveProperty("onboardEndpointUrl");
expect(calls.promptName).toHaveBeenCalledWith(null);
expect(calls.log).toHaveBeenCalledWith("summary:nvidia-prod/nvidia/test/my-assistant");
expect(calls.startStep).toHaveBeenNthCalledWith(2, "inference", {
expect(calls.startStep).toHaveBeenNthCalledWith(2, "provider_selection", {
provider: "nvidia-prod",
model: "nvidia/test",
});
expect(calls.startStep).toHaveBeenNthCalledWith(3, "inference", {
provider: "nvidia-prod",
model: "nvidia/test",
});
Expand Down Expand Up @@ -1420,18 +1429,6 @@ describe("handleProviderInferenceState", () => {
]);
});

it("aborts before inference setup when the configuration summary is rejected", async () => {
const { deps, calls } = createDeps({
isNonInteractive: () => false,
promptYesNoOrDefault: vi.fn(async () => false),
});

await expect(handleProviderInferenceState(baseOptions(deps))).rejects.toThrow("exit 0");

expect(calls.exit).toHaveBeenCalledWith(0);
expect(calls.setupInference).not.toHaveBeenCalled();
});

// Regression: #4241. When the provider selection step accepted a no-tools
// Ollama model (the user answered "yes" to the override prompt or
// NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 was set), the same flag must reach
Expand Down
Loading
Loading