diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts index 4d74e5ce841..81b02550676 100644 --- a/src/lib/onboard/station-express-resume.test.ts +++ b/src/lib/onboard/station-express-resume.test.ts @@ -6,7 +6,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { createSession } from "../state/onboard-session"; +import { createSession, normalizeSession } from "../state/onboard-session"; import { assertStationExpressInstallerResumeMatches, cleanupStationExpressReceiptRetirementClaims, @@ -14,6 +14,7 @@ import { getStationExpressResumeIntent, INSTALLER_AUTO_FRESH_RECEIPT_GENERATION_ENV, parseStationExpressResumeIntent, + requireStationExpressResumeIntent, retireStationExpressInstallerResume, STATION_EXPRESS_ENV, STATION_EXPRESS_RECEIPT_GENERATION_ENV, @@ -918,3 +919,222 @@ describe("DGX Station Express resume (#7048)", () => { expect(deps.error).toHaveBeenCalledWith(expect.stringContaining("NEMOCLAW_VLLM_MODEL")); }); }); + +const sparkModelEnv = "qwen3.6-35b-a3b-nvfp4"; +const sparkServedModel = "nvidia/Qwen3.6-35B-A3B-NVFP4"; +const sparkIntent = { + version: 1 as const, + kind: "spark" as const, + sandboxName: "my-assistant", +}; +const sparkCaptureDeps = { detectNvidiaPlatform: () => "spark" as const }; + +function sparkExpressEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { NEMOCLAW_PROVIDER: "install-vllm", NEMOCLAW_NON_INTERACTIVE: "1", ...overrides }; +} + +describe("DGX Spark managed-vLLM Express resume (#7231)", () => { + it("captures a marker-free spark intent from a non-interactive install-vllm run", () => { + expect( + getStationExpressResumeIntent(sparkExpressEnv(), "my-assistant", sparkCaptureDeps), + ).toEqual({ + ok: true, + intent: sparkIntent, + }); + }); + + it("pins the model only when the user set a spark NEMOCLAW_VLLM_MODEL", () => { + expect( + getStationExpressResumeIntent( + sparkExpressEnv({ NEMOCLAW_VLLM_MODEL: sparkModelEnv }), + "my-assistant", + sparkCaptureDeps, + ), + ).toEqual({ ok: true, intent: { ...sparkIntent, model: sparkModelEnv } }); + }); + + it("never smuggles a Station-only model into a spark intent", () => { + expect( + getStationExpressResumeIntent( + sparkExpressEnv({ NEMOCLAW_VLLM_MODEL: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4" }), + "my-assistant", + sparkCaptureDeps, + ), + ).toEqual({ ok: true, intent: sparkIntent }); + }); + + it("ignores non-express, interactive, or nameless runs", () => { + expect( + getStationExpressResumeIntent( + sparkExpressEnv({ NEMOCLAW_PROVIDER: "build" }), + "my-assistant", + sparkCaptureDeps, + ), + ).toEqual({ ok: true, intent: null }); + expect( + getStationExpressResumeIntent( + { NEMOCLAW_PROVIDER: "install-vllm" }, + "my-assistant", + sparkCaptureDeps, + ), + ).toEqual({ ok: true, intent: null }); + expect(getStationExpressResumeIntent(sparkExpressEnv(), null, sparkCaptureDeps)).toEqual({ + ok: true, + intent: null, + }); + }); + + it("does not capture a generic Linux install-vllm run as Spark (#7231)", () => { + expect( + getStationExpressResumeIntent( + sparkExpressEnv({ NEMOCLAW_VLLM_MODEL: "nemotron-3-nano-4b" }), + "my-assistant", + { detectNvidiaPlatform: () => "linux" }, + ), + ).toEqual({ ok: true, intent: null }); + }); + + it("round-trips and strictly validates a persisted spark intent", () => { + expect(parseStationExpressResumeIntent(sparkIntent)).toEqual(sparkIntent); + expect(parseStationExpressResumeIntent({ ...sparkIntent, model: sparkModelEnv })).toEqual({ + ...sparkIntent, + model: sparkModelEnv, + }); + expect( + parseStationExpressResumeIntent({ ...sparkIntent, token: "must-not-persist" }), + ).toBeNull(); + expect( + parseStationExpressResumeIntent({ ...sparkIntent, model: "nemotron-3-ultra-550b-a55b" }), + ).toBeNull(); + expect(parseStationExpressResumeIntent({ ...sparkIntent, kind: "station" })).toBeNull(); + }); + + it("restores install-vllm without Station state or inherited model overrides", async () => { + const originalEnv: NodeJS.ProcessEnv = { + NEMOCLAW_STATION_EXPRESS: "1", + NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_PROVIDER: "", + NEMOCLAW_VLLM_MODEL: sparkModelEnv, + NEMOCLAW_MODEL: sparkServedModel, + }; + const env = { ...originalEnv }; + const failedSession = createSession({ + mode: "non-interactive", + stationExpressIntent: sparkIntent, + }); + failedSession.status = "failed"; + const deps = resumeDeps(failedSession); + const run = vi.fn(async () => { + expect(env).toMatchObject({ + NEMOCLAW_PROVIDER: "install-vllm", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_YES: "1", + NEMOCLAW_SANDBOX_NAME: "my-assistant", + }); + expect(env.NEMOCLAW_STATION_EXPRESS).toBeUndefined(); + expect(env.NEMOCLAW_POLICY_MODE).toBeUndefined(); + expect(env.NEMOCLAW_VLLM_MODEL).toBeUndefined(); + expect(env.NEMOCLAW_MODEL).toBeUndefined(); + }); + + await withStationExpressResumeEnvironment(run, deps, env)({ resume: true }); + + expect(run).toHaveBeenCalledTimes(1); + expect(env).toEqual(originalEnv); + }); + + it("restores a pinned spark model on resume", async () => { + const env: NodeJS.ProcessEnv = {}; + const failedSession = createSession({ + mode: "non-interactive", + stationExpressIntent: { ...sparkIntent, model: sparkModelEnv }, + }); + failedSession.status = "failed"; + const deps = resumeDeps(failedSession); + const run = vi.fn(async () => { + expect(env).toMatchObject({ + NEMOCLAW_PROVIDER: "install-vllm", + NEMOCLAW_VLLM_MODEL: sparkModelEnv, + NEMOCLAW_MODEL: sparkServedModel, + }); + expect(env.NEMOCLAW_STATION_EXPRESS).toBeUndefined(); + }); + + await withStationExpressResumeEnvironment(run, deps, env)({ resume: true }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it("normalizes an incomplete spark session and rejects tampered states", () => { + const base = createSession({ + mode: "non-interactive", + stationExpressIntent: sparkIntent, + }) as unknown as Record; + base.resumable = true; + base.status = "failed"; + + const reloaded = normalizeSession(base as never); + expect(reloaded).not.toBeNull(); + expect(reloaded?.stationExpressIntent).toEqual(sparkIntent); + // A spark Express run is inherently non-interactive. + expect(normalizeSession({ ...base, mode: "interactive" } as never)).toBeNull(); + // provider/model must stay null until provider_selection completes. + expect(normalizeSession({ ...base, provider: "vllm-local" } as never)).toBeNull(); + }); + + it("carries the intent through capture, reload normalization, and resume restore", async () => { + // Capture exactly as the onboard entry path does, from the installer env. + const captured = requireStationExpressResumeIntent( + { NEMOCLAW_PROVIDER: "install-vllm", NEMOCLAW_NON_INTERACTIVE: "1" }, + "my-assistant", + false, + { + detectNvidiaPlatform: () => "spark", + error: vi.fn(), + exitProcess: vi.fn((code: number): never => { + throw new Error(`exit ${String(code)}`); + }), + }, + ); + expect(captured).toEqual(sparkIntent); + + // createSession -> JSON round-trip -> normalizeSession is exactly the + // saveSession/loadSession sequence across a process boundary. + const created = createSession({ mode: "non-interactive", stationExpressIntent: captured }); + created.status = "failed"; + const reloaded = normalizeSession(JSON.parse(JSON.stringify(created))); + expect(reloaded?.stationExpressIntent).toEqual(sparkIntent); + + const env: NodeJS.ProcessEnv = { NEMOCLAW_PROVIDER: "" }; + const reconcileReceiptRetirement = vi.fn(() => { + throw new Error("spark resume must not touch Station receipt retirement"); + }); + const run = vi.fn(async () => { + expect(env.NEMOCLAW_PROVIDER).toBe("install-vllm"); + expect(env.NEMOCLAW_NON_INTERACTIVE).toBe("1"); + expect(env.NEMOCLAW_SANDBOX_NAME).toBe("my-assistant"); + expect(env.NEMOCLAW_STATION_EXPRESS).toBeUndefined(); + expect(env.NEMOCLAW_POLICY_MODE).toBeUndefined(); + }); + + await withStationExpressResumeEnvironment( + run, + { + loadSession: () => reloaded, + clearInstallerResume: vi.fn(), + cleanupReceiptRetirementClaims: vi.fn(), + reconcileReceiptRetirement, + error: vi.fn(), + exitProcess: vi.fn((code: number): never => { + throw new Error(`exit ${String(code)}`); + }), + }, + env, + )({ resume: true }); + + expect(run).toHaveBeenCalledTimes(1); + expect(reconcileReceiptRetirement).not.toHaveBeenCalled(); + // The wrapper restores the prior environment afterwards. + expect(env).toEqual({ NEMOCLAW_PROVIDER: "" }); + }); +}); diff --git a/src/lib/onboard/station-express-resume.ts b/src/lib/onboard/station-express-resume.ts index 85cacd3dcf7..2a888a8626f 100644 --- a/src/lib/onboard/station-express-resume.ts +++ b/src/lib/onboard/station-express-resume.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { isErrnoException } from "../core/errno"; +import { detectNvidiaPlatform, type NvidiaPlatform } from "../inference/nim"; import { selectVllmModelFromEnv, type VllmModelDef } from "../inference/vllm-models"; import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../name-validation"; import { getNemoclawStateRoot, resolveHome, STATE_DIR_NAME } from "../state/state-root"; @@ -16,8 +17,9 @@ export const INSTALLER_AUTO_FRESH_RECEIPT_GENERATION_ENV = "NEMOCLAW_INSTALLER_AUTO_FRESH_RECEIPT_GENERATION"; export const STATION_EXPRESS_INTENT_VERSION = 1; -export interface StationExpressResumeIntent { +interface StationResumeIntent { version: typeof STATION_EXPRESS_INTENT_VERSION; + kind?: undefined; model: string; sandboxName: string; receiptGeneration?: string; @@ -25,6 +27,16 @@ export interface StationExpressResumeIntent { checkpointModel?: string; } +interface SparkResumeIntent { + version: typeof STATION_EXPRESS_INTENT_VERSION; + kind: "spark"; + // The default model is resolved after host detection, after intent capture. + model?: string; + sandboxName: string; +} + +export type StationExpressResumeIntent = StationResumeIntent | SparkResumeIntent; + export interface StationExpressSessionLike { resumable?: boolean; status?: string; @@ -51,7 +63,12 @@ interface StationExpressResumeDeps { exitProcess(code: number): never; } -type StationExpressFailureDeps = Pick; +interface StationExpressCaptureDeps { + detectNvidiaPlatform(): NvidiaPlatform; +} + +type StationExpressFailureDeps = Pick & + Partial; type IntentResult = | { ok: true; intent: StationExpressResumeIntent | null } @@ -68,12 +85,20 @@ const RESUME_ENV = [ "NEMOCLAW_MODEL", STATION_EXPRESS_RECEIPT_GENERATION_ENV, ] as const; +type ExpectedResumeEnvironment = Partial>; const MAX_SERVED_MODEL_LENGTH = 512; const UNBOUND_INTENT_KEYS = "model,sandboxName,version"; const UNBOUND_RECEIPT_INTENT_KEYS = "model,receiptGeneration,sandboxName,version"; const BOUND_INTENT_KEYS = "checkpointModel,model,sandboxName,servedModel,version"; const BOUND_RECEIPT_INTENT_KEYS = "checkpointModel,model,receiptGeneration,sandboxName,servedModel,version"; +// DGX Spark managed-vLLM Express intents (#7231). They only ever exist while +// provider selection is incomplete (they are cleared, never bound, on +// completion), so they never carry the Station-only served/checkpoint/receipt +// fields. The model key is present only when the user pinned NEMOCLAW_VLLM_MODEL. +const SPARK_INTENT_KEYS = "kind,sandboxName,version"; +const SPARK_INTENT_KEYS_WITH_MODEL = "kind,model,sandboxName,version"; +const SPARK_EXPRESS_PROVIDER = "install-vllm"; const STATION_EXPRESS_INSTALLER_RESUME_FILE = "station-express-resume"; const STATION_EXPRESS_RETIREMENT_CLAIM_PREFIX = `${STATION_EXPRESS_INSTALLER_RESUME_FILE}.retiring-`; const STATION_EXPRESS_RETIREMENT_CLAIM_RECEIPT = "receipt"; @@ -100,6 +125,16 @@ function stationModel(value: unknown): VllmModelDef | null { } } +function sparkModel(value: unknown): VllmModelDef | null { + if (typeof value !== "string") return null; + try { + const model = selectVllmModelFromEnv({ NEMOCLAW_VLLM_MODEL: value }); + return model?.platforms.includes("spark") ? model : null; + } catch { + return null; + } +} + function canonicalStationModelValue(value: string): string | null { if (value.trim() !== value) return null; return stationModel(value)?.envValue ?? null; @@ -634,9 +669,33 @@ export function cleanupStationExpressReceiptRetirementClaims( } } +function parseSparkResumeIntent( + value: Record, + hasModel: boolean, +): StationExpressResumeIntent | null { + if (value.version !== STATION_EXPRESS_INTENT_VERSION || value.kind !== "spark") return null; + const sandboxName = value.sandboxName; + if (!validSandboxName(sandboxName)) return null; + let model: string | undefined; + if (hasModel) { + const resolved = sparkModel(value.model); + if (!resolved || value.model !== resolved.envValue) return null; + model = resolved.envValue; + } + return { + version: STATION_EXPRESS_INTENT_VERSION, + kind: "spark", + sandboxName, + ...(model !== undefined ? { model } : {}), + }; +} + export function parseStationExpressResumeIntent(value: unknown): StationExpressResumeIntent | null { if (!isObject(value)) return null; const keys = Object.keys(value).sort().join(","); + if (keys === SPARK_INTENT_KEYS || keys === SPARK_INTENT_KEYS_WITH_MODEL) { + return parseSparkResumeIntent(value, keys === SPARK_INTENT_KEYS_WITH_MODEL); + } if ( keys !== UNBOUND_INTENT_KEYS && keys !== UNBOUND_RECEIPT_INTENT_KEYS && @@ -686,10 +745,12 @@ export function bindStationExpressProviderSelection( checkpointModel: unknown, ): StationExpressResumeIntent { const intent = parseStationExpressResumeIntent(intentValue); - const selectedModel = intent ? stationModel(intent.model) : null; - if (!intent || !selectedModel) { + if (!intent || intent.kind === "spark") { throw new Error("Cannot record an invalid DGX Station Express provider selection."); } + const selectedModel = stationModel(intent.model); + if (!selectedModel) + throw new Error("Cannot record an invalid DGX Station Express provider selection."); if (intent.servedModel !== undefined) { if (provider === "vllm-local" && model === intent.servedModel) return intent; throw new Error("Cannot record an invalid DGX Station Express provider selection."); @@ -712,10 +773,33 @@ export function bindStationExpressProviderSelection( function expectedEnvironment( intent: StationExpressResumeIntent, includeProviderSelection = true, -): Partial> | null { +): ExpectedResumeEnvironment | null { + if (intent.kind === "spark") { + const expected: ExpectedResumeEnvironment = { + [STATION_EXPRESS_ENV]: null, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_YES: "1", + NEMOCLAW_POLICY_MODE: null, + NEMOCLAW_SANDBOX_NAME: intent.sandboxName, + }; + if (includeProviderSelection) { + expected.NEMOCLAW_PROVIDER = SPARK_EXPRESS_PROVIDER; + // Restore a pinned model only; when none was pinned the install re-runs + // and re-resolves the same default model deterministically for this host. + const pinned = intent.model ? sparkModel(intent.model) : null; + if (pinned) { + expected.NEMOCLAW_VLLM_MODEL = pinned.envValue; + expected.NEMOCLAW_MODEL = servedModel(pinned); + } else { + expected.NEMOCLAW_VLLM_MODEL = null; + expected.NEMOCLAW_MODEL = null; + } + } + return expected; + } const model = stationModel(intent.model); if (!model) return null; - const expected: Partial> = { + const expected: ExpectedResumeEnvironment = { [STATION_EXPRESS_ENV]: "1", NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_YES: "1", @@ -739,7 +823,7 @@ function equivalentEnvironmentValue( expected: string, ): boolean { if (name === "NEMOCLAW_VLLM_MODEL") { - return stationModel(actual)?.envValue === expected; + return (stationModel(actual)?.envValue ?? sparkModel(actual)?.envValue) === expected; } if (name === "NEMOCLAW_SANDBOX_NAME") { return actual.trim().toLowerCase() === expected; @@ -749,11 +833,11 @@ function equivalentEnvironmentValue( function validateExpectedEnvironment( env: NodeJS.ProcessEnv, - expected: Partial>, + expected: ExpectedResumeEnvironment, ): string | null { for (const name of RESUME_ENV) { const expectedValue = expected[name]; - if (expectedValue === undefined) continue; + if (expectedValue === undefined || expectedValue === null) continue; const actual = env[name]; if (typeof actual !== "string" || actual.trim().length === 0) continue; if (!equivalentEnvironmentValue(name, actual, expectedValue)) return name; @@ -761,12 +845,45 @@ function validateExpectedEnvironment( return null; } +/** + * Capture a DGX Spark managed-vLLM Express resume intent (#7231) when the + * non-interactive installer selected `install-vllm` without the Station marker. + * Unlike Station this needs no receipt/served/checkpoint state: restoring + * `NEMOCLAW_PROVIDER=install-vllm` (plus the sandbox name and, when pinned, the + * model) is enough for resume to re-run the identical managed install instead + * of dropping into the interactive provider menu defaulting to NVIDIA Endpoints. + */ +function getSparkExpressResumeIntent( + env: NodeJS.ProcessEnv, + sandboxName: string | null, + deps: StationExpressCaptureDeps, +): IntentResult { + const provider = String(env.NEMOCLAW_PROVIDER ?? "") + .trim() + .toLowerCase(); + const nonInteractive = String(env.NEMOCLAW_NON_INTERACTIVE ?? "").trim() === "1"; + if (provider !== SPARK_EXPRESS_PROVIDER || !nonInteractive) return { ok: true, intent: null }; + if (!sandboxName || !validSandboxName(sandboxName)) return { ok: true, intent: null }; + if (deps.detectNvidiaPlatform() !== "spark") return { ok: true, intent: null }; + const pinned = sparkModel(env.NEMOCLAW_VLLM_MODEL); + return { + ok: true, + intent: { + version: STATION_EXPRESS_INTENT_VERSION, + kind: "spark", + sandboxName, + ...(pinned ? { model: pinned.envValue } : {}), + }, + }; +} + export function getStationExpressResumeIntent( env: NodeJS.ProcessEnv, sandboxName: string | null, + deps: StationExpressCaptureDeps = { detectNvidiaPlatform }, ): IntentResult { const marker = String(env[STATION_EXPRESS_ENV] ?? "").trim(); - if (!marker) return { ok: true, intent: null }; + if (!marker) return getSparkExpressResumeIntent(env, sandboxName, deps); if (marker !== "1") { return { ok: false, message: `${STATION_EXPRESS_ENV} must be 1 when set.` }; } @@ -827,10 +944,13 @@ export function requireStationExpressResumeIntent( deps: StationExpressFailureDeps = { error: (message) => console.error(message), exitProcess: (code) => process.exit(code), + detectNvidiaPlatform, }, ): StationExpressResumeIntent | null { if (resume) return null; - const result = getStationExpressResumeIntent(env, sandboxName); + const result = getStationExpressResumeIntent(env, sandboxName, { + detectNvidiaPlatform: deps.detectNvidiaPlatform ?? detectNvidiaPlatform, + }); if (!result.ok) { deps.error(` ${result.message}`); deps.exitProcess(1); @@ -867,6 +987,9 @@ function matchesRecordedStationExpressSelection( if (session.sandboxName != null && session.sandboxName !== intent.sandboxName) return false; const providerComplete = session.steps?.provider_selection?.status === "complete"; + if (intent.kind === "spark") { + return !providerComplete && session.provider == null && session.model == null; + } const providerBound = Boolean(intent.servedModel && intent.checkpointModel); if (providerComplete !== providerBound) return false; if (!providerComplete) return session.provider == null && session.model == null; @@ -1001,7 +1124,8 @@ export function withStationExpressResumeEnvironment { expect(summary.endpointUrl).toBe(loaded.endpointUrl); }); + it("clears a spark Express intent once provider selection completes (#7231)", () => { + session.saveSession( + session.createSession({ + mode: "non-interactive", + stationExpressIntent: { version: 1, kind: "spark", sandboxName: "my-assistant" }, + }), + ); + markStepCompleteLegacy(session, stepMutation, "provider_selection", { + provider: "vllm-local", + model: "nvidia/Qwen3.6-35B-A3B-NVFP4", + sandboxName: "my-assistant", + }); + + const loaded = requireLoadedSession(session.loadSession()); + expect(loaded.stationExpressIntent).toBeNull(); + expect(loaded.provider).toBe("vllm-local"); + }); + it("marks steps started, completed, and failed", () => { session.saveSession(session.createSession()); markStepStartedLegacy(session, stepMutation, "gateway"); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index f182f4a206b..cd2df12d645 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -790,16 +790,17 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): } if (normalized.stationExpressIntent) { + const intent = normalized.stationExpressIntent; const providerComplete = normalized.steps.provider_selection?.status === "complete"; const providerBound = Boolean( - normalized.stationExpressIntent.servedModel && - normalized.stationExpressIntent.checkpointModel, + intent.kind !== "spark" && intent.servedModel && intent.checkpointModel, ); if ( providerComplete !== providerBound || (providerComplete && - (normalized.provider !== "vllm-local" || - normalized.model !== normalized.stationExpressIntent.servedModel)) || + (intent.kind === "spark" || + normalized.provider !== "vllm-local" || + normalized.model !== intent.servedModel)) || (!providerComplete && (normalized.provider !== null || normalized.model !== null)) ) { return null; @@ -1384,8 +1385,13 @@ function markStepCompleteWithOptions( const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; + // Spark managed-vLLM Express intents (#7231) carry no receipt/served state + // and exist only to re-arm the install on resume, so clear them once + // provider selection completes instead of binding a Station selection. + const sparkExpressComplete = + stepName === "provider_selection" && session.stationExpressIntent?.kind === "spark"; const stationExpressIntent = - stepName === "provider_selection" && session.stationExpressIntent + stepName === "provider_selection" && session.stationExpressIntent && !sparkExpressComplete ? bindStationExpressProviderSelection( session.stationExpressIntent, safeUpdates.provider, @@ -1401,6 +1407,7 @@ function markStepCompleteWithOptions( session.failure = null; Object.assign(session, safeUpdates); if (stationExpressIntent) session.stationExpressIntent = stationExpressIntent; + else if (sparkExpressComplete) session.stationExpressIntent = null; const nextState = nextMachineStateAfterCompletedStep(stepName, session); shouldEmit = Boolean(nextState && shouldUpdateMachine(options)); if (nextState && shouldEmit) transitionMachineSnapshot(session, nextState, now); @@ -1602,7 +1609,10 @@ export function completeSession( let wasComplete = false; let receiptGeneration: string | null = null; let updatedSession = updateSession((session) => { - const intentReceiptGeneration = session.stationExpressIntent?.receiptGeneration ?? null; + const intentReceiptGeneration = + session.stationExpressIntent?.kind === "spark" + ? null + : (session.stationExpressIntent?.receiptGeneration ?? null); receiptGeneration = session.stationExpressReceiptRetirement ?? intentReceiptGeneration; if (intentReceiptGeneration) { assertStationExpressInstallerResumeMatches(intentReceiptGeneration);