import { AsyncLock, delay } from "../../core/async"; import { primaryLogPathFor, readFileTailBytes } from "../../core/log-files"; import { Event, type EventManager } from "../system/event-manager"; import { CONTROLLER_EVENTS } from "../../contracts/controller-events"; import { pidExists } from "./process/process-utilities"; import { isRecipeRunning } from "../models/recipes/recipe-matching"; import type { ProcessInfo, Recipe } from "../models/types"; import type { Config } from "../../config/env"; import type { Logger } from "../../core/logger"; import type { ProcessManager } from "./process/process-manager"; import type { RecipeStore } from "../models/recipes/recipe-store"; import { LIFECYCLE_READY_TIMEOUT_MS } from "./configs"; import type { EngineService, RuntimeType, UpgradeResult, RuntimeInfo, DownloadRequest, HfModel, SetActiveRecipeResult, SetActiveRecipeOptions } from "./engine-service"; import type { ModelDownload } from "../shared/recipe-types"; import type { DownloadManager } from "./downloads/download-manager"; import { getVllmRuntimeInfo, upgradeVllmRuntime, getVllmConfigHelp } from "./runtimes/vllm-runtime"; import { getLlamacppConfigHelp } from "./runtimes/llamacpp-runtime"; import { getLlamacppRuntimeInfo, getSglangRuntimeInfo, getExllamav3RuntimeInfo } from "./runtimes/runtime-info"; import { upgradeSglangRuntime, upgradeLlamacppRuntime, runPlatformUpgrade } from "./runtimes/runtime-upgrade"; import { fetchHuggingFaceModelInfo } from "./downloads/huggingface-api"; interface CoordinatorDeps { config: Config; logger: Logger; eventManager: EventManager; processManager: ProcessManager; recipeStore: RecipeStore; downloadManager: DownloadManager; abortRunsForModel?: (modelName: string) => number; } /** * */ export class EngineCoordinator implements EngineService { private readonly switchLock = new AsyncLock(); private currentRecipe: Recipe | null = null; private activeLifecycleAbort: AbortController | null = null; private activeLaunchPid: number | null = null; private lifecycleIntentSerial = 0; private autoActivationBlocked = false; /** * * @param deps * @param deps */ constructor(private readonly deps: CoordinatorDeps) {} /** * Set the authoritative active recipe. * @param recipe - Recipe to activate, or null to evict the active process. * @param options - Optional cancellation controls. * @param options * @returns Operation result. */ async setActiveRecipe(recipe: Recipe | null, options: SetActiveRecipeOptions = {}): Promise { const intentSerial = ++this.lifecycleIntentSerial; if (!recipe) { this.autoActivationBlocked = true; this.activeLifecycleAbort?.abort(); if (this.activeLaunchPid) { await this.deps.processManager.killProcess(this.activeLaunchPid, true); } } else { this.autoActivationBlocked = false; } const release = await this.switchLock.acquire(); let spawnedPid: number | null = null; let cancelled = false; const lifecycleAbort = recipe ? new AbortController() : null; const abortLifecycle = (): void => lifecycleAbort?.abort(); if (lifecycleAbort) { if (options.signal?.aborted) lifecycleAbort.abort(); options.signal?.addEventListener("abort", abortLifecycle, { once: true }); this.activeLifecycleAbort = lifecycleAbort; } const isAborted = (): boolean => Boolean(lifecycleAbort?.signal.aborted || intentSerial !== this.lifecycleIntentSerial); const publishCancelled = async (targetRecipe: Recipe): Promise => { if (cancelled) return { ok: false, error: "Launch cancelled" }; cancelled = true; if (spawnedPid) { await this.deps.processManager.killProcess(spawnedPid, true); } await this.deps.eventManager.publishLaunchProgress(targetRecipe.id, "cancelled", "Launch cancelled", 0); return { ok: false, error: "Launch cancelled" }; }; const abortIfNeeded = async (targetRecipe: Recipe | null): Promise => { if (!isAborted()) return null; if (!targetRecipe) return null; return publishCancelled(targetRecipe); }; try { if (recipe && intentSerial !== this.lifecycleIntentSerial) { return { ok: false, error: "Launch cancelled" }; } const current = await this.deps.processManager.findInferenceProcess(this.deps.config.inference_port); const initialAbort = await abortIfNeeded(recipe); if (initialAbort) return initialAbort; if (!recipe && !current) { this.currentRecipe = null; return { ok: true }; } if (recipe && current && isRecipeRunning(recipe, current)) { this.currentRecipe = recipe; return { ok: true }; } const killCurrent = async (process: ProcessInfo): Promise => { const evictedRecipe = this.findRecipeForProcess(process); if (evictedRecipe) { await this.deps.eventManager.publishLaunchProgress(evictedRecipe.id, "stopping", `Stopping ${evictedRecipe.name}...`, 0.1); } const stopped = await this.deps.processManager.killProcess(process.pid, true); if (evictedRecipe) { this.abortRunsForRecipe(evictedRecipe); await this.deps.eventManager.publishLaunchProgress(evictedRecipe.id, stopped ? "stopped" : "error", stopped ? "Model stopped" : "Model did not stop cleanly", stopped ? 1 : 0); } return stopped; }; if (current && (!recipe || !isRecipeRunning(recipe, current))) { const stopped = await killCurrent(current); if (!stopped) { return { ok: false, error: `Failed to stop process ${current.pid}` }; } await delay(500); } const postEvictAbort = await abortIfNeeded(recipe); if (postEvictAbort) return postEvictAbort; if (!recipe) { this.currentRecipe = null; return { ok: true }; } await this.deps.eventManager.publishLaunchProgress(recipe.id, "launching", `Starting ${recipe.name}...`, 0.25); const launch = await this.deps.processManager.launchModel(recipe); spawnedPid = launch.pid; this.activeLaunchPid = launch.pid; if (!launch.success) { await this.deps.eventManager.publishLaunchProgress(recipe.id, "error", launch.message, 0); return { ok: false, error: launch.message }; } const postLaunchAbort = await abortIfNeeded(recipe); if (postLaunchAbort) return postLaunchAbort; await this.deps.eventManager.publishLaunchProgress(recipe.id, "waiting", "Loading model... (0s)", 0.5); const waitOptions: Parameters[0] = { recipe, pid: launch.pid, logFilePath: launch.log_file ?? primaryLogPathFor(this.deps.config.data_dir, recipe.id), timeoutMs: LIFECYCLE_READY_TIMEOUT_MS, }; if (lifecycleAbort) { waitOptions.cancel = lifecycleAbort.signal; } const ready = await this.waitForReady(waitOptions); if (isAborted()) { return publishCancelled(recipe); } if (ready.ready) { this.currentRecipe = recipe; await this.deps.eventManager.publishLaunchProgress(recipe.id, "ready", "Model is ready!", 1); return { ok: true }; } if (launch.pid) { await this.deps.processManager.killProcess(launch.pid, true); } await this.deps.eventManager.publishLaunchProgress(recipe.id, "error", ready.message, 0); return { ok: false, error: ready.message }; } finally { if (this.activeLifecycleAbort === lifecycleAbort) { this.activeLifecycleAbort = null; } if (this.activeLaunchPid === spawnedPid) { this.activeLaunchPid = null; } options.signal?.removeEventListener("abort", abortLifecycle); release(); } } /** * * @param options * @param options.recipe * @param options.pid * @param options.logFilePath * @param options.cancel * @param options.timeoutMs * @param options.fatalPatterns * @param options.onProgress * @param options.recipe * @param options.logFilePath * @param options.timeoutMs * @param options.onProgress */ private async waitForReady(options: { recipe: Recipe; pid: number | null; logFilePath: string | null; cancel?: AbortSignal; timeoutMs?: number; fatalPatterns?: string[]; onProgress?: (elapsedSeconds: number) => Promise }): Promise<{ ready: true } | { ready: false; message: string }> { const timeout = options.timeoutMs ?? LIFECYCLE_READY_TIMEOUT_MS; const start = Date.now(); while (Date.now() - start < timeout) { if (options.cancel?.aborted) { return { ready: false, message: "Launch cancelled" }; } if (options.pid && !pidExists(options.pid)) { const errorTail = options.logFilePath ? readFileTailBytes(options.logFilePath, 500) : ""; return { ready: false, message: `Model ${options.recipe.id} crashed during startup: ${errorTail.slice(-200)}`, }; } if (options.logFilePath && options.fatalPatterns && options.fatalPatterns.length > 0) { const logTail = readFileTailBytes(options.logFilePath, 3000); for (const pattern of options.fatalPatterns) { if (!logTail.includes(pattern)) continue; const lines = logTail.split("\n"); const index = lines.findIndex((line) => line.includes(pattern)); const snippet = index >= 0 ? lines.slice(Math.max(0, index - 1), index + 3).join("\n") : pattern; return { ready: false, message: `Fatal error: ${snippet.slice(0, 300)}` }; } } try { const { fetchLocal } = await import("../../http/local-fetch"); const response = await fetchLocal(this.deps.config.inference_port, "/health", { timeoutMs: 5000, }); if (response.status === 200) { return { ready: true }; } } catch { } const elapsedSeconds = Math.floor((Date.now() - start) / 1000); if (options.onProgress) { await options.onProgress(elapsedSeconds); } await delay(2000); } return { ready: false, message: `Model ${options.recipe.id} failed to become ready (timeout)`, }; } /** * * @param current * @param current */ private findRecipeForProcess(current: ProcessInfo): Recipe | null { for (const candidate of this.deps.recipeStore.list()) { if (isRecipeRunning(candidate, current, { allowEitherPathContains: true })) { return candidate; } } return null; } /** * * @param recipe */ private abortRunsForRecipe(recipe: Recipe): void { if (!this.deps.abortRunsForModel) return; const modelCandidates = [recipe.served_model_name, recipe.id].filter((value): value is string => Boolean(value && value.trim())); let totalAborted = 0; const abortedCandidates = new Set(); for (const candidate of modelCandidates) { const normalized = candidate.trim(); const canonical = normalized.toLowerCase(); if (abortedCandidates.has(canonical)) continue; abortedCandidates.add(canonical); totalAborted += this.deps.abortRunsForModel(normalized); } if (totalAborted > 0) { this.deps.logger.info("Aborted active chat runs for evicted model", { recipe_id: recipe.id, aborted_runs: totalAborted, }); } } /** * * @param recipe * @param options * @param options.force_evict * @param options.publish_events */ async ensureActive(recipe: Recipe, options: { force_evict?: boolean; publish_events?: boolean } = {}): Promise<{ switched: boolean; error: string | null }> { const existing = await this.deps.processManager.findInferenceProcess(this.deps.config.inference_port); if (existing && isRecipeRunning(recipe, existing)) { return { switched: false, error: null }; } if (this.autoActivationBlocked) { return { switched: false, error: "Model auto-loading is disabled because the model was manually stopped. Start a model from vLLM Studio before sending local inference requests.", }; } const intentSerial = ++this.lifecycleIntentSerial; const lifecycleAbort = new AbortController(); this.activeLifecycleAbort = lifecycleAbort; let launchPid: number | null = null; const release = await this.switchLock.acquire(); try { if (lifecycleAbort.signal.aborted || intentSerial !== this.lifecycleIntentSerial) { return { switched: false, error: "Model switch cancelled" }; } const latest = await this.deps.processManager.findInferenceProcess(this.deps.config.inference_port); if (latest && isRecipeRunning(recipe, latest)) { return { switched: false, error: null }; } if (this.autoActivationBlocked) { return { switched: false, error: "Model auto-loading is disabled because the model was manually stopped. Start a model from vLLM Studio before sending local inference requests.", }; } const publishEvents = options.publish_events !== false; const observedProcess = latest ?? existing; const fromRecipe = observedProcess ? this.findRecipeForProcess(observedProcess) : null; const fromModel = fromRecipe ? (fromRecipe.served_model_name ?? fromRecipe.id) : observedProcess ? observedProcess.model_path : null; const fromBackend = observedProcess?.backend ?? fromRecipe?.backend ?? "unknown"; if (publishEvents) { await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.MODEL_SWITCH, { status: "started", from_model: fromModel, from_backend: fromBackend, to_recipe_id: recipe.id, to_model: recipe.served_model_name ?? recipe.id, to_backend: recipe.backend, }) ); } const evictedRecipe = observedProcess ? this.findRecipeForProcess(observedProcess) : null; await this.deps.processManager.evictModel(true); if (evictedRecipe) { this.abortRunsForRecipe(evictedRecipe); } await delay(2000); if (lifecycleAbort.signal.aborted || intentSerial !== this.lifecycleIntentSerial) { return { switched: true, error: "Model switch cancelled" }; } const launch = await this.deps.processManager.launchModel(recipe); launchPid = launch.pid; this.activeLaunchPid = launch.pid; if (!launch.success) { const message = `Failed to launch model ${recipe.id}: ${launch.message}`; if (publishEvents) { await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.MODEL_SWITCH, { status: "error", to_recipe_id: recipe.id, to_model: recipe.served_model_name ?? recipe.id, to_backend: recipe.backend, reason: message, }) ); } return { switched: true, error: message }; } const logFilePath = primaryLogPathFor(this.deps.config.data_dir, recipe.id); const ready = await this.waitForReady({ recipe, pid: launch.pid, logFilePath, timeoutMs: LIFECYCLE_READY_TIMEOUT_MS, cancel: lifecycleAbort.signal, }); if (lifecycleAbort.signal.aborted || intentSerial !== this.lifecycleIntentSerial) { if (launch.pid) { await this.deps.processManager.killProcess(launch.pid, true); } return { switched: true, error: "Model switch cancelled" }; } if (ready.ready) { if (publishEvents) { await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.MODEL_SWITCH, { status: "ready", to_recipe_id: recipe.id, to_model: recipe.served_model_name ?? recipe.id, to_backend: recipe.backend, from_model: fromModel, from_backend: fromBackend, }) ); } this.currentRecipe = recipe; return { switched: true, error: null }; } if (launch.pid) { await this.deps.processManager.killProcess(launch.pid, true); } if (publishEvents) { await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.MODEL_SWITCH, { status: "error", to_recipe_id: recipe.id, to_model: recipe.served_model_name ?? recipe.id, to_backend: recipe.backend, reason: ready.message, }) ); } return { switched: true, error: ready.message }; } finally { if (this.activeLifecycleAbort === lifecycleAbort) { this.activeLifecycleAbort = null; } if (this.activeLaunchPid === launchPid) { this.activeLaunchPid = null; } release(); } } /** * */ getCurrentRecipe(): Recipe | null { return this.currentRecipe; } /** * */ async getCurrentProcess(): Promise { return this.deps.processManager.findInferenceProcess(this.deps.config.inference_port); } /** * * @param request * @param request */ async startDownload(request: DownloadRequest): Promise { return await this.deps.downloadManager.start(request); } /** * * @param downloadId * @param downloadId */ pauseDownload(downloadId: string): ModelDownload { return this.deps.downloadManager.pause(downloadId); } /** * * @param downloadId * @param downloadId * @param hfToken */ resumeDownload(downloadId: string, hfToken?: string | null): ModelDownload { return this.deps.downloadManager.resume(downloadId, hfToken ?? null); } /** * * @param downloadId */ cancelDownload(downloadId: string): ModelDownload { return this.deps.downloadManager.cancel(downloadId); } /** * */ listDownloads(): ModelDownload[] { return this.deps.downloadManager.list(); } /** * * @param downloadId * @param downloadId */ getDownload(downloadId: string): ModelDownload | null { return this.deps.downloadManager.get(downloadId); } /** * * @param query * @param hfToken * @param hfToken */ async searchHuggingFace(query: string, hfToken?: string | null): Promise { const info = await fetchHuggingFaceModelInfo(query, undefined, hfToken ?? undefined); return [ { id: info.modelId ?? query, name: info.modelId ?? query, }, ]; } /** * */ listRuntimes(): Record { const llamacppInfo = getLlamacppRuntimeInfo(this.deps.config); const exllamav3Info = getExllamav3RuntimeInfo(this.deps.config); return { vllm: { installed: false, version: null, python_path: null, upgrade_command_available: true, }, sglang: { installed: false, version: null, python_path: this.deps.config.sglang_python ?? null, upgrade_command_available: true, }, llamacpp: { installed: llamacppInfo.installed, version: llamacppInfo.version, binary_path: llamacppInfo.binary_path ?? null, upgrade_command_available: llamacppInfo.upgrade_command_available ?? false, }, exllamav3: { installed: exllamav3Info.installed, version: exllamav3Info.version, binary_path: exllamav3Info.binary_path ?? null, upgrade_command_available: exllamav3Info.upgrade_command_available ?? false, }, }; } /** * */ async getVllmRuntimeInfoAsync(): Promise { const info = await getVllmRuntimeInfo(); return { installed: info.installed, version: info.version, python_path: info.python_path, binary_path: info.vllm_bin, upgrade_command_available: info.upgrade_command_available ?? false, }; } /** * */ async getSglangRuntimeInfoAsync(): Promise { const current = await this.deps.processManager.findInferenceProcess(this.deps.config.inference_port); const info = await getSglangRuntimeInfo(this.deps.config, current); return { installed: info.installed, version: info.version, python_path: info.python_path, upgrade_command_available: info.upgrade_command_available ?? false, }; } /** * * @param runtime * @param options * @param options.version * @param options.args */ async upgradeRuntime(runtime: RuntimeType, options?: { version?: string; args?: string[] }): Promise { switch (runtime) { case "vllm": { const result = await upgradeVllmRuntime({ preferBundled: true, ...(options?.version ? { version: options.version } : {}), ...(options?.args ? { args: options.args as string[] } : {}), }); await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.RUNTIME_VLLM_UPGRADED, { success: result.success, version: result.version, used_wheel: result.used_wheel, }) ); return { success: result.success, version: result.version, output: result.output, error: result.error, used_command: null, }; } case "sglang": { const result = await upgradeSglangRuntime(this.deps.config, { ...(options?.args ? { args: options.args as string[] } : {}), }); await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.RUNTIME_SGLANG_UPGRADED, { success: result.success, version: result.version, used_command: result.used_command, }) ); return result; } case "llamacpp": { const result = await upgradeLlamacppRuntime(this.deps.config, { ...(options?.args ? { args: options.args as string[] } : {}), }); await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.RUNTIME_LLAMACPP_UPGRADED, { success: result.success, version: result.version, used_command: result.used_command, }) ); return result; } case "cuda": { const result = runPlatformUpgrade("cuda", { ...(options?.args ? { args: options.args as string[] } : {}), }); await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.RUNTIME_CUDA_UPGRADED, { success: result.success, version: result.version, used_command: result.used_command, }) ); return result; } case "rocm": { const result = runPlatformUpgrade("rocm", { ...(options?.args ? { args: options.args as string[] } : {}), }); await this.deps.eventManager.publish( new Event(CONTROLLER_EVENTS.RUNTIME_ROCM_UPGRADED, { success: result.success, version: result.version, used_command: result.used_command, }) ); return result; } case "exllamav3": return { success: false, version: null, output: null, error: "Runtime upgrades are not supported for exllamav3", used_command: null, }; default: return { success: false, version: null, output: null, error: `Unknown runtime: ${runtime}`, used_command: null, }; } } /** * * @param runtime * @param runtime */ async getRuntimeHelp(runtime: "vllm" | "llamacpp"): Promise<{ config: string | null; error: string | null }> { if (runtime === "vllm") { return getVllmConfigHelp(); } return getLlamacppConfigHelp(this.deps.config); } } export const createEngineCoordinator = (deps: CoordinatorDeps): EngineCoordinator => { return new EngineCoordinator(deps); };