From 6f932e201762844decd5338f4fd31095b26914af Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Fri, 20 Mar 2026 17:55:41 -0700 Subject: [PATCH] fix(ai): surface model download errors and prevent silent retry loops Model downloads that fail (e.g., when Ollama is too old for a model) were silently retrying 40 times with no UI feedback. Now errors are broadcast via SSE and shown in the Active Model Downloads section. Version mismatch errors use UnrecoverableError to fail immediately instead of retrying. Stale failed jobs are cleared on retry so users aren't permanently blocked. Co-Authored-By: Claude Opus 4.6 (1M context) --- admin/app/jobs/download_model_job.ts | 19 ++++++++-- admin/app/services/ollama_service.ts | 27 ++++++++++++-- .../components/ActiveModelDownloads.tsx | 37 +++++++++++++------ .../inertia/hooks/useOllamaModelDownloads.ts | 15 +++++++- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/admin/app/jobs/download_model_job.ts b/admin/app/jobs/download_model_job.ts index ccc5207..61908e4 100644 --- a/admin/app/jobs/download_model_job.ts +++ b/admin/app/jobs/download_model_job.ts @@ -1,4 +1,4 @@ -import { Job } from 'bullmq' +import { Job, UnrecoverableError } from 'bullmq' import { QueueService } from '#services/queue_service' import { createHash } from 'crypto' import logger from '@adonisjs/core/services/logger' @@ -63,6 +63,10 @@ export class DownloadModelJob { logger.error( `[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}` ) + // Don't retry errors that will never succeed (e.g., Ollama version too old) + if (result.retryable === false) { + throw new UnrecoverableError(result.message) + } throw new Error(`Failed to initiate download for model: ${result.message}`) } @@ -85,6 +89,15 @@ export class DownloadModelJob { const queue = queueService.getQueue(this.queue) const jobId = this.getJobId(params.modelName) + // Clear any previous failed job so a fresh attempt can be dispatched + const existing = await queue.getJob(jobId) + if (existing) { + const state = await existing.getState() + if (state === 'failed') { + await existing.remove() + } + } + try { const job = await queue.add(this.key, params, { jobId, @@ -104,9 +117,9 @@ export class DownloadModelJob { } } catch (error) { if (error.message.includes('job already exists')) { - const existing = await queue.getJob(jobId) + const active = await queue.getJob(jobId) return { - job: existing, + job: active, created: false, message: `Job already exists for model ${params.modelName}`, } diff --git a/admin/app/services/ollama_service.ts b/admin/app/services/ollama_service.ts index 24367a1..fa7b9f9 100644 --- a/admin/app/services/ollama_service.ts +++ b/admin/app/services/ollama_service.ts @@ -51,7 +51,7 @@ export class OllamaService { * @param model Model name to download * @returns Success status and message */ - async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> { + async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string; retryable?: boolean }> { try { await this._ensureDependencies() if (!this.ollama) { @@ -86,11 +86,21 @@ export class OllamaService { logger.info(`[OllamaService] Model "${model}" downloaded successfully.`) return { success: true, message: 'Model downloaded successfully.' } } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) logger.error( - `[OllamaService] Failed to download model "${model}": ${error instanceof Error ? error.message : error - }` + `[OllamaService] Failed to download model "${model}": ${errorMessage}` ) - return { success: false, message: 'Failed to download model.' } + + // Check for version mismatch (Ollama 412 response) + const isVersionMismatch = errorMessage.includes('newer version of Ollama') + const userMessage = isVersionMismatch + ? 'This model requires a newer version of Ollama. Please update AI Assistant from the Apps page.' + : `Failed to download model: ${errorMessage}` + + // Broadcast failure to connected clients so UI can show the error + this.broadcastDownloadError(model, userMessage) + + return { success: false, message: userMessage, retryable: !isVersionMismatch } } } @@ -379,6 +389,15 @@ export class OllamaService { return models } + private broadcastDownloadError(model: string, error: string) { + transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, { + model, + percent: -1, + error, + timestamp: new Date().toISOString(), + }) + } + private broadcastDownloadProgress(model: string, percent: number) { transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, { model, diff --git a/admin/inertia/components/ActiveModelDownloads.tsx b/admin/inertia/components/ActiveModelDownloads.tsx index d1d0b85..c927126 100644 --- a/admin/inertia/components/ActiveModelDownloads.tsx +++ b/admin/inertia/components/ActiveModelDownloads.tsx @@ -1,6 +1,7 @@ import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads' import HorizontalBarChart from './HorizontalBarChart' import StyledSectionHeader from './StyledSectionHeader' +import { IconAlertTriangle } from '@tabler/icons-react' interface ActiveModelDownloadsProps { withHeader?: boolean @@ -17,19 +18,31 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) downloads.map((download) => (
- + {download.error ? ( +
+ +
+

{download.model}

+

{download.error}

+
+
+ ) : ( + + )}
)) ) : ( diff --git a/admin/inertia/hooks/useOllamaModelDownloads.ts b/admin/inertia/hooks/useOllamaModelDownloads.ts index 4417321..8fc5460 100644 --- a/admin/inertia/hooks/useOllamaModelDownloads.ts +++ b/admin/inertia/hooks/useOllamaModelDownloads.ts @@ -5,6 +5,7 @@ export type OllamaModelDownload = { model: string percent: number timestamp: string + error?: string } export default function useOllamaModelDownloads() { @@ -17,7 +18,19 @@ export default function useOllamaModelDownloads() { setDownloads((prev) => { const updated = new Map(prev) - if (data.percent >= 100) { + if (data.percent === -1) { + // Download failed — show error state, auto-remove after 15 seconds + updated.set(data.model, data) + const errorTimeout = setTimeout(() => { + timeoutsRef.current.delete(errorTimeout) + setDownloads((current) => { + const next = new Map(current) + next.delete(data.model) + return next + }) + }, 15000) + timeoutsRef.current.add(errorTimeout) + } else if (data.percent >= 100) { // If download is complete, keep it for a short time before removing to allow UI to show 100% progress updated.set(data.model, data) const timeout = setTimeout(() => {