Skip to content

Commit 9480b2e

Browse files
chriscrosstalkclaude
authored andcommitted
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) <noreply@anthropic.com>
1 parent 1e66d3c commit 9480b2e

File tree

4 files changed

+78
-20
lines changed

4 files changed

+78
-20
lines changed

admin/app/jobs/download_model_job.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Job } from 'bullmq'
1+
import { Job, UnrecoverableError } from 'bullmq'
22
import { QueueService } from '#services/queue_service'
33
import { createHash } from 'crypto'
44
import logger from '@adonisjs/core/services/logger'
@@ -63,6 +63,10 @@ export class DownloadModelJob {
6363
logger.error(
6464
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
6565
)
66+
// Don't retry errors that will never succeed (e.g., Ollama version too old)
67+
if (result.retryable === false) {
68+
throw new UnrecoverableError(result.message)
69+
}
6670
throw new Error(`Failed to initiate download for model: ${result.message}`)
6771
}
6872

@@ -85,6 +89,15 @@ export class DownloadModelJob {
8589
const queue = queueService.getQueue(this.queue)
8690
const jobId = this.getJobId(params.modelName)
8791

92+
// Clear any previous failed job so a fresh attempt can be dispatched
93+
const existing = await queue.getJob(jobId)
94+
if (existing) {
95+
const state = await existing.getState()
96+
if (state === 'failed') {
97+
await existing.remove()
98+
}
99+
}
100+
88101
try {
89102
const job = await queue.add(this.key, params, {
90103
jobId,
@@ -104,9 +117,9 @@ export class DownloadModelJob {
104117
}
105118
} catch (error) {
106119
if (error.message.includes('job already exists')) {
107-
const existing = await queue.getJob(jobId)
120+
const active = await queue.getJob(jobId)
108121
return {
109-
job: existing,
122+
job: active,
110123
created: false,
111124
message: `Job already exists for model ${params.modelName}`,
112125
}

admin/app/services/ollama_service.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class OllamaService {
5151
* @param model Model name to download
5252
* @returns Success status and message
5353
*/
54-
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> {
54+
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string; retryable?: boolean }> {
5555
try {
5656
await this._ensureDependencies()
5757
if (!this.ollama) {
@@ -86,11 +86,21 @@ export class OllamaService {
8686
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
8787
return { success: true, message: 'Model downloaded successfully.' }
8888
} catch (error) {
89+
const errorMessage = error instanceof Error ? error.message : String(error)
8990
logger.error(
90-
`[OllamaService] Failed to download model "${model}": ${error instanceof Error ? error.message : error
91-
}`
91+
`[OllamaService] Failed to download model "${model}": ${errorMessage}`
9292
)
93-
return { success: false, message: 'Failed to download model.' }
93+
94+
// Check for version mismatch (Ollama 412 response)
95+
const isVersionMismatch = errorMessage.includes('newer version of Ollama')
96+
const userMessage = isVersionMismatch
97+
? 'This model requires a newer version of Ollama. Please update AI Assistant from the Apps page.'
98+
: `Failed to download model: ${errorMessage}`
99+
100+
// Broadcast failure to connected clients so UI can show the error
101+
this.broadcastDownloadError(model, userMessage)
102+
103+
return { success: false, message: userMessage, retryable: !isVersionMismatch }
94104
}
95105
}
96106

@@ -379,6 +389,15 @@ export class OllamaService {
379389
return models
380390
}
381391

392+
private broadcastDownloadError(model: string, error: string) {
393+
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
394+
model,
395+
percent: -1,
396+
error,
397+
timestamp: new Date().toISOString(),
398+
})
399+
}
400+
382401
private broadcastDownloadProgress(model: string, percent: number) {
383402
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
384403
model,

admin/inertia/components/ActiveModelDownloads.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
22
import HorizontalBarChart from './HorizontalBarChart'
33
import StyledSectionHeader from './StyledSectionHeader'
4+
import { IconAlertTriangle } from '@tabler/icons-react'
45

56
interface ActiveModelDownloadsProps {
67
withHeader?: boolean
@@ -17,19 +18,31 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
1718
downloads.map((download) => (
1819
<div
1920
key={download.model}
20-
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
21+
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
22+
download.error ? 'border-red-400' : 'border-desert-stone-light'
23+
}`}
2124
>
22-
<HorizontalBarChart
23-
items={[
24-
{
25-
label: download.model,
26-
value: download.percent,
27-
total: '100%',
28-
used: `${download.percent.toFixed(1)}%`,
29-
type: 'ollama-model',
30-
},
31-
]}
32-
/>
25+
{download.error ? (
26+
<div className="flex items-start gap-3">
27+
<IconAlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
28+
<div>
29+
<p className="font-medium text-text-primary">{download.model}</p>
30+
<p className="text-sm text-red-600 mt-1">{download.error}</p>
31+
</div>
32+
</div>
33+
) : (
34+
<HorizontalBarChart
35+
items={[
36+
{
37+
label: download.model,
38+
value: download.percent,
39+
total: '100%',
40+
used: `${download.percent.toFixed(1)}%`,
41+
type: 'ollama-model',
42+
},
43+
]}
44+
/>
45+
)}
3346
</div>
3447
))
3548
) : (

admin/inertia/hooks/useOllamaModelDownloads.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type OllamaModelDownload = {
55
model: string
66
percent: number
77
timestamp: string
8+
error?: string
89
}
910

1011
export default function useOllamaModelDownloads() {
@@ -17,7 +18,19 @@ export default function useOllamaModelDownloads() {
1718
setDownloads((prev) => {
1819
const updated = new Map(prev)
1920

20-
if (data.percent >= 100) {
21+
if (data.percent === -1) {
22+
// Download failed — show error state, auto-remove after 15 seconds
23+
updated.set(data.model, data)
24+
const errorTimeout = setTimeout(() => {
25+
timeoutsRef.current.delete(errorTimeout)
26+
setDownloads((current) => {
27+
const next = new Map(current)
28+
next.delete(data.model)
29+
return next
30+
})
31+
}, 15000)
32+
timeoutsRef.current.add(errorTimeout)
33+
} else if (data.percent >= 100) {
2134
// If download is complete, keep it for a short time before removing to allow UI to show 100% progress
2235
updated.set(data.model, data)
2336
const timeout = setTimeout(() => {

0 commit comments

Comments
 (0)