Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
252b244
fix(onboard): heartbeat + per-phase timing so no onboarding phase is …
yimoj Jul 2, 2026
2fb2015
fix(onboard): emit phase-timings summary from reporter seam; address …
yimoj Jul 2, 2026
a86c0f0
fix(onboard): harden phase-progress telemetry + emit summary at termi…
yimoj Jul 2, 2026
f2bcdfb
docs(onboard): document resume-compat phase-progress narrowing (#6002…
yimoj Jul 2, 2026
60031e8
perf(onboard): build sandbox image with BuildKit to cut create time ~…
yimoj Jul 2, 2026
00f5432
fix(onboard): keep BuildKit prebuild inert under the Vitest runner
yimoj Jul 2, 2026
e98fc08
fix(onboard): sanitize prebuild env + pass actual create args to fail…
yimoj Jul 2, 2026
8a3bdbd
fix(onboard): heartbeat inference/policy work in non-interactive runs…
yimoj Jul 2, 2026
cc3c469
fix(onboard): make --non-interactive flag set NEMOCLAW_NON_INTERACTIV…
yimoj Jul 2, 2026
236a2ba
fix(onboard): drop KUBECONFIG/SSH_AUTH_SOCK from BuildKit prebuild env
yimoj Jul 2, 2026
43b7db7
test(e2e): live acceptance test for onboard heartbeats + timing + bud…
yimoj Jul 2, 2026
53d055b
fix(e2e): linear test body (no if) + enforce 3-min budget by default …
yimoj Jul 2, 2026
149e362
refactor(onboard): single shared phase-progress reporter across all s…
yimoj Jul 2, 2026
e7c2056
test(e2e): measure [1/8]-to-first-response with a real agent turn (#6…
yimoj Jul 2, 2026
e2ff3a6
test(e2e): prove max-silence ≤60s in the onboard acceptance test (#6002)
yimoj Jul 3, 2026
90f11a9
merge(onboard): merge main into fix/6002; resolve onboard.ts create-l…
yimoj Jul 3, 2026
091039b
test(e2e): assert parsed agent reply + fix *_MS→*_SECS env names (Cod…
yimoj Jul 3, 2026
8ea226c
merge(onboard): merge latest main; pick up OpenShell v0.0.72 installe…
yimoj Jul 3, 2026
0d47599
chore: drop monitor/triage artifacts accidentally committed in the merge
yimoj Jul 3, 2026
c38666d
test(onboard): split phase-progress reporter and seam suites
yimoj Jul 3, 2026
ceae436
fix(onboard): narrow BuildKit prebuild env to the Docker-build boundary
yimoj Jul 3, 2026
5ac0014
test(onboard): add real-reporter phase-progress pipeline integration
yimoj Jul 3, 2026
6a08c5c
test(onboard): drop circular registry-reset assertion from pipeline test
yimoj Jul 3, 2026
e0d75f0
docs(onboard): document prebuild removal condition and force-on caveat
yimoj Jul 3, 2026
7e2af13
test(onboard): cover recovery hints for post-prebuild image-ref args
yimoj Jul 3, 2026
490b202
merge: sync main and simplify onboarding progress
apurvvkumaria Jul 3, 2026
6f592c3
merge: sync latest main
apurvvkumaria Jul 3, 2026
f2d4aa3
merge: sync terminal-agent onboarding changes
apurvvkumaria Jul 3, 2026
233610d
merge: sync gateway port-release changes
apurvvkumaria Jul 3, 2026
d85f1d1
refactor(onboard): extract create orchestration
apurvvkumaria Jul 3, 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
44 changes: 22 additions & 22 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const {
const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup");
const extraPlaceholderKeysModule: typeof import("./onboard/extra-placeholder-keys") = require("./onboard/extra-placeholder-keys");
const buildContextStage: typeof import("./onboard/build-context-stage") = require("./onboard/build-context-stage");
const sandboxPrebuild: typeof import("./onboard/sandbox-prebuild") = require("./onboard/sandbox-prebuild");
const sandboxBuildPatchConfig: typeof import("./onboard/sandbox-build-patch-config") = require("./onboard/sandbox-build-patch-config");
const sandboxDockerfilePatchFlow: typeof import("./onboard/sandbox-dockerfile-patch-flow") = require("./onboard/sandbox-dockerfile-patch-flow");
const sandboxMessagingPreflight: typeof import("./onboard/sandbox-messaging-preflight") = require("./onboard/sandbox-messaging-preflight");
Expand Down Expand Up @@ -3020,11 +3021,19 @@ async function createSandbox(
warn: console.warn,
});
const sandboxReadyTimeoutSecs = getSandboxReadyTimeoutSecs(effectiveSandboxGpuConfig);
// Pre-build the image locally with BuildKit so openshell skips its slower
// classic build; falls back to the openshell build if ineligible/fails (#6002).
const { createArgs: launchCreateArgs } = await sandboxPrebuild.prebuildSandboxImageIfEligible({
buildCtx,
createArgs,
sandboxName,
dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(),
});
const { createCommand, effectiveDashboardPort, sandboxEnv, sandboxStartupCommand } =
sandboxCreateLaunch.prepareSandboxCreateLaunch({
agent,
chatUiUrl,
createArgs,
createArgs: launchCreateArgs,
env: process.env,
extraPlaceholderKeys,
getDashboardForwardPort,
Expand Down Expand Up @@ -3068,27 +3077,14 @@ async function createSandbox(
dockerGpuCreatePatch.exitOnPatchError();

if (createResult.status !== 0) {
const failure = classifySandboxCreateFailure(createResult.output);
if (failure.kind === "sandbox_create_incomplete") {
// The sandbox was created in the gateway but the create stream exited
// with a non-zero code (e.g. SSH 255). Fall through to the ready-wait
// loop — the sandbox may still reach Ready on its own.
console.warn("");
console.warn(
` Create stream exited with code ${createResult.status} after sandbox was created.`,
);
console.warn(" Checking whether the sandbox reaches Ready state...");
} else {
console.error("");
console.error(` Sandbox creation failed (exit ${createResult.status}).`);
if (createResult.output) {
console.error("");
console.error(createResult.output);
}
console.error(" Try: openshell sandbox list # check gateway state");
printSandboxCreateRecoveryHints(createResult.output, { createArgs });
process.exit(createResult.status || 1);
}
sandboxCreateFailureDiagnostics.handleSandboxCreateResultFailure(createResult, {
classifyFailure: classifySandboxCreateFailure,
printRecoveryHints: printSandboxCreateRecoveryHints,
// Recovery hints must reflect the args actually used to create (which the
// BuildKit prebuild may have rewritten to `--from <image-ref>`).
createArgs: launchCreateArgs,
exit: (code) => process.exit(code),
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

dockerGpuCreatePatch.ensureApplied();
Expand Down Expand Up @@ -4655,6 +4651,10 @@ function skippedStepMessage(
async function onboard(opts: OnboardOptions = {}): Promise<void> {
setOnboardBrandingAgent(opts.agent || process.env.NEMOCLAW_AGENT || null);
NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1";
// Propagate the --non-interactive flag to the env so env-based consumers
// (phase-progress heartbeats, sandbox-create-plan, policy presets, …) honor
// the flag, not just the env var (#6002 review).
if (NON_INTERACTIVE) process.env.NEMOCLAW_NON_INTERACTIVE = "1";
RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1";
AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1";
_preflightDashboardPort =
Expand Down
74 changes: 73 additions & 1 deletion src/lib/onboard/machine/flow-slices.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";

import {
createSession,
Expand All @@ -11,6 +11,7 @@ import {
type Session,
type SessionUpdates,
} from "../../state/onboard-session";
import { getPhaseTimings, recordPhaseTiming, resetPhaseTimings } from "../phase-timings";
import type { OnboardFlowContext } from "./flow-context";
import {
coreOnboardFlowPhases,
Expand Down Expand Up @@ -269,3 +270,74 @@ describe("onboard flow slices", () => {
});
});
});

describe("onboard flow slices phase-timing summary (#6002)", () => {
beforeEach(() => {
resetPhaseTimings();
});

it("emits the accumulated timing summary and clears the registry after the final slice", async () => {
// Timings recorded by earlier slices are still in the shared registry.
recordPhaseTiming({
phase: "sandbox",
label: "Sandbox creation",
durationMs: 250_000,
status: "completed",
});
const summaries: string[] = [];

await runFinalOnboardFlowSequence({
context: context(),
runtime: runtime(
createSession({
machine: {
version: MACHINE_SNAPSHOT_VERSION,
state: "finalizing",
stateEnteredAt: "2026-05-29T00:00:00.000Z",
revision: 0,
},
}),
),
phases: [
phase("finalizing", "post_verify"),
{
state: "post_verify",
run: (ctx) => ({ context: ctx, result: completeOnboardMachine({}) }),
},
],
emitSummary: (summary) => summaries.push(summary),
});

// Summary emitted once at the terminal seam (after finalizing AND
// post_verify complete), then the registry is cleared so nothing leaks into
// a later run in the same process.
expect(summaries).toHaveLength(1);
expect(summaries[0]).toContain("Sandbox creation");
expect(getPhaseTimings()).toHaveLength(0);
});

it("resets stale timings at the start of a new onboard run", async () => {
// A prior run that failed before finalization can leave timings behind.
recordPhaseTiming({
phase: "gateway",
label: "Gateway startup",
durationMs: 4_000,
status: "completed",
});
expect(getPhaseTimings()).toHaveLength(1);

await runInitialOnboardFlowSequence({
context: context(),
runtime: runtime(),
phases: [
phase("preflight", "gateway"),
phase("gateway", "provider_selection"),
phase("provider_selection", "inference"),
],
});

// The start-of-run reset cleared the stale entry; the fresh run records
// nothing here (the reporter is inert under Vitest), so the registry is empty.
expect(getPhaseTimings()).toHaveLength(0);
});
});
22 changes: 20 additions & 2 deletions src/lib/onboard/machine/flow-slices.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 { formatPhaseTimingsSummary, resetPhaseTimings } from "../phase-timings";
import type { OnboardFlowContext } from "./flow-context";
import { onboardFlowPhaseResult } from "./flow-context";
import { advanceTo } from "./result";
Expand Down Expand Up @@ -41,6 +42,10 @@ export async function runInitialOnboardFlowSequence<Context extends OnboardFlowC
runtime: OnboardMachineRunnerRuntime;
phases: readonly OnboardSequencePhase<Context>[];
}) {
// Clear any per-phase timings left over from an earlier onboard run in the
// same process (e.g. a prior run that failed before finalization reset the
// registry) so this run's timing summary only reflects this run (#6002).
resetPhaseTimings();
return runOnboardSequenceWithRunner({
...options,
phases: initialOnboardFlowPhases(options.phases),
Expand All @@ -64,9 +69,22 @@ export async function runFinalOnboardFlowSequence<Context extends OnboardFlowCon
context: Context;
runtime: OnboardMachineRunnerRuntime;
phases: readonly OnboardSequencePhase<Context>[];
emitSummary?: (summary: string) => void;
}) {
return runOnboardSequenceWithRunner({
...options,
const { emitSummary = (line: string) => console.log(line), ...runnerOptions } = options;
const result = await runOnboardSequenceWithRunner({
...runnerOptions,
phases: finalOnboardFlowPhases(options.phases),
});
// Emit the accumulated per-phase timing summary from the terminal seam —
// after every wrapped phase in the final slice (finalization, and any
// post_verify) has recorded — then clear the registry. This is the true
// "all phases complete" boundary, so the summary can't miss the last phase
// or leave a stray entry behind, regardless of which phase runs last (#6002).
const summary = formatPhaseTimingsSummary();
if (summary) {
emitSummary(summary);
resetPhaseTimings();
}
return result;
}
7 changes: 7 additions & 0 deletions src/lib/onboard/machine/live-flow-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export async function runLiveOnboardFlowSlice<Context>({
assertUniquePhases(phases);
let nextContext = context;
for (const phase of phases) {
// This resume-repair compatibility branch runs the raw phases directly and
// is intentionally NOT wrapped by the phase-progress reporter: heartbeats
// and per-phase timing are scoped to fresh onboarding (the reporter runs on
// the strict runOnboardSequenceWithRunner path). Because this branch never
// records timings, and the registry is per-process and cleared at the start
// of each fresh run, a resume can neither surface a partial "Phase timings"
// summary nor leak stale timing state into a later run (#6002 review PRA-7).
const phaseResult = await phase.run(nextContext);
for (const result of asResultArray(phaseResult.result, phase.state)) {
await applyCompatibleResult(result);
Expand Down
Loading
Loading