fix(local-models): use pm pins and resumable component downloads

Use PM for engine binaries, dependent runtime archives, and model files.
Keep one operation-owned pause event through installation and download.
Report whole-plan bytes and retain paused jobs across desktop remounts.

Preserve the completed lm-pm worktree as its own integration parent.
The owning session verified focused Python and desktop tests, actual
Windows ARM64 CUDA downloads, and rendered pause/resume controls.
Combined verification with the safety repairs follows in the merge.
This commit is contained in:
ethernet
2026-09-09 15:17:09 -04:00
parent 60a84773f5
commit bdb52ce711
47 changed files with 2360 additions and 1200 deletions

View File

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./client', () => ({
hermesApi: vi.fn(),
profileScoped: vi.fn(() => ({}))
}))
const client = await import('./client')
const { pauseLocalDownload, resumeLocalDownload } = await import('./local-models')
const hermesApi = vi.mocked(client.hermesApi)
beforeEach(() => {
vi.clearAllMocks()
})
describe('local download pause/resume API', () => {
it('pauseLocalDownload posts {job_id} to the existing pause route and returns the {ok,paused} contract', async () => {
hermesApi.mockResolvedValue({ ok: true, paused: true } as never)
const res = await pauseLocalDownload('job-7')
expect(hermesApi).toHaveBeenCalledTimes(1)
expect(hermesApi.mock.calls[0][0]).toMatchObject({
body: { job_id: 'job-7' },
method: 'POST',
path: '/api/local-models/download/pause'
})
expect(res).toEqual({ ok: true, paused: true })
})
it('resumeLocalDownload posts {job_id} to the existing resume route and returns the {ok,resumed} contract', async () => {
hermesApi.mockResolvedValue({ ok: true, resumed: false } as never)
const res = await resumeLocalDownload('job-8')
expect(hermesApi).toHaveBeenCalledTimes(1)
expect(hermesApi.mock.calls[0][0]).toMatchObject({
body: { job_id: 'job-8' },
method: 'POST',
path: '/api/local-models/download/resume'
})
// A false resumed flag (no parked download) reaches the caller so the
// UI can report "couldn't resume" rather than silently claiming success.
expect(res).toEqual({ ok: true, resumed: false })
})
it('a failed pause propagates as a rejection — never a silent success', async () => {
hermesApi.mockRejectedValue(new Error('unknown download job'))
await expect(pauseLocalDownload('gone')).rejects.toThrow('unknown download job')
})
})

View File

@@ -84,6 +84,28 @@ export function getLocalModelsJobs(): Promise<{ jobs: LocalRuntimeJob[] }> {
})
}
// Pause/resume a download-phase job (catalog model, quickstart, runtime
// install/update, HF-browsed). The backend answers {ok, paused} /
// {ok, resumed} — a false flag (no live download handle, e.g. a
// quickstart engine leg) is reported to the caller, not treated as success.
export function pauseLocalDownload(jobId: string): Promise<{ ok: boolean; paused: boolean }> {
return hermesApi<{ ok: boolean; paused: boolean }>({
...profileScoped(),
body: { job_id: jobId },
method: 'POST',
path: '/api/local-models/download/pause'
})
}
export function resumeLocalDownload(jobId: string): Promise<{ ok: boolean; resumed: boolean }> {
return hermesApi<{ ok: boolean; resumed: boolean }>({
...profileScoped(),
body: { job_id: jobId },
method: 'POST',
path: '/api/local-models/download/resume'
})
}
export function activateLocalModel(modelId: string): Promise<{ job_id: string }> {
return hermesApi<{ job_id: string }>({
...profileScoped(),

View File

@@ -0,0 +1,165 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import type * as RuntimeJobs from '@/store/local-runtime-jobs'
import type * as Notifications from '@/store/notifications'
import type { LocalRuntimeJob } from '@/types/hermes'
vi.mock('@/hermes', () => ({
pauseLocalDownload: vi.fn(),
resumeLocalDownload: vi.fn()
}))
vi.mock('@/store/notifications', async importOriginal => ({
...(await importOriginal<typeof Notifications>()),
notifyError: vi.fn()
}))
vi.mock('@/store/local-runtime-jobs', async importOriginal => ({
...(await importOriginal<typeof RuntimeJobs>()),
watchLocalRuntimeJobs: vi.fn()
}))
import { pauseLocalDownload, resumeLocalDownload } from '@/hermes'
import { isDownloadPhase, LocalModelDownloadActions } from './local-model-download-progress'
function job(overrides: Partial<LocalRuntimeJob>): LocalRuntimeJob {
return {
detail: '',
done_bytes: 40,
error: null,
job_id: 'j1',
kind: 'model-download',
model_id: 'm1',
phase: 'downloading',
status: 'running',
target: 'Qwen3.6 27B',
total_bytes: 100,
...overrides
}
}
function renderActions(current: LocalRuntimeJob) {
act(() => {
render(
<I18nProvider configClient={null}>
<LocalModelDownloadActions job={current} />
</I18nProvider>
)
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(pauseLocalDownload).mockResolvedValue({ ok: true, paused: true })
vi.mocked(resumeLocalDownload).mockResolvedValue({ ok: true, resumed: true })
})
afterEach(() => {
cleanup()
})
describe('LocalModelDownloadActions', () => {
it('running download with can_pause:true renders Pause; clicking sends pauseLocalDownload exactly once with the job id', async () => {
renderActions(job({ can_pause: true }))
const pause = screen.getByRole('button', { name: /pause/i })
fireEvent.click(pause)
await waitFor(() => {
expect(pauseLocalDownload).toHaveBeenCalledTimes(1)
})
expect(pauseLocalDownload).toHaveBeenCalledWith('j1')
expect(resumeLocalDownload).not.toHaveBeenCalled()
})
it('running download without explicit can_pause shows NO control (no guessing about old backends)', () => {
renderActions(job({}))
expect(screen.queryByRole('button', { name: /pause/i })).toBeNull()
})
it('can_pause:false hides the control (server start / default assignment)', () => {
renderActions(job({ can_pause: false }))
expect(screen.queryByRole('button', { name: /pause/i })).toBeNull()
})
it('pause_requested renders a disabled pending control, not a vanished one', () => {
renderActions(job({ can_pause: false, pause_requested: true }))
const pending = screen.getByRole('button', { name: /pause/i })
expect((pending as HTMLButtonElement).disabled).toBe(true)
})
it('paused job renders the Paused label + Resume (can_resume:true); clicking sends resumeLocalDownload with the job id', async () => {
renderActions(job({ can_resume: true, status: 'paused' }))
expect(screen.getByText(/paused/i)).toBeTruthy()
const resume = screen.getByRole('button', { name: /resume/i })
fireEvent.click(resume)
await waitFor(() => {
expect(resumeLocalDownload).toHaveBeenCalledTimes(1)
})
expect(resumeLocalDownload).toHaveBeenCalledWith('j1')
expect(pauseLocalDownload).not.toHaveBeenCalled()
})
it('paused hides Resume when can_resume is not explicitly true', () => {
renderActions(job({ status: 'paused' }))
expect(screen.getByText(/paused/i)).toBeTruthy()
expect(screen.queryByRole('button', { name: /resume/i })).toBeNull()
})
it('a paused:false response is a benign race — truth is re-kicked, no false failure toast', async () => {
const { notifyError } = await import('@/store/notifications')
const { watchLocalRuntimeJobs } = await import('@/store/local-runtime-jobs')
// The job may have settled between render and click; the backend says
// nothing paused. Refresh the authoritative snapshot; do NOT toast a
// download failure the user never saw.
vi.mocked(pauseLocalDownload).mockResolvedValue({ ok: true, paused: false })
renderActions(job({ can_pause: true }))
fireEvent.click(screen.getByRole('button', { name: /pause/i }))
await waitFor(() => {
expect(watchLocalRuntimeJobs).toHaveBeenCalled()
})
expect(notifyError).not.toHaveBeenCalled()
})
it('a rejected pause propagates to the error path — never a silent success', async () => {
const { notifyError } = await import('@/store/notifications')
vi.mocked(pauseLocalDownload).mockRejectedValue(new Error('unknown download job'))
renderActions(job({ can_pause: true }))
fireEvent.click(screen.getByRole('button', { name: /pause/i }))
await waitFor(() => {
expect(notifyError).toHaveBeenCalledTimes(1)
})
})
it('no control on settled jobs', () => {
renderActions(job({ can_pause: true, status: 'done' }))
expect(screen.queryByRole('button', { name: /pause|resume/i })).toBeNull()
})
})
describe('isDownloadPhase', () => {
it('download phases cover every job kind that fetches bytes; finalization is not one', () => {
expect(isDownloadPhase(job({ kind: 'model-download' }))).toBe(true)
expect(isDownloadPhase(job({ kind: 'runtime-install', phase: 'downloading-runtime' }))).toBe(true)
expect(isDownloadPhase(job({ kind: 'quickstart', phase: 'downloading' }))).toBe(true)
expect(isDownloadPhase(job({ kind: 'quickstart', phase: 'setting-default' }))).toBe(false)
expect(isDownloadPhase(job({ kind: 'model-activate', phase: 'loading' }))).toBe(false)
})
})

View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { pauseLocalDownload, resumeLocalDownload } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2, Pause, Play } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
import { notifyError } from '@/store/notifications'
import type { LocalRuntimeJob } from '@/types/hermes'
import { Pill } from './primitives'
interface ProgressBarProps {
percent: number | undefined
paused?: boolean
}
interface LocalModelDownloadProps {
job: LocalRuntimeJob
}
export function ProgressBar({ percent, paused = false }: ProgressBarProps) {
const unknown = typeof percent !== 'number'
return (
<div
aria-valuemax={100}
aria-valuemin={0}
{...(unknown ? {} : { 'aria-valuenow': percent })}
className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)"
role="progressbar"
>
<div
className={cn(
'h-full rounded-full',
paused ? 'bg-muted-foreground/60' : 'bg-primary transition-[width] duration-300'
)}
style={{ width: `${Math.max(0, Math.min(100, percent ?? 0))}%` }}
/>
</div>
)
}
export function gbLabel(bytes: number | null | undefined): string {
if (bytes == null) {
return '—'
}
return `${(bytes / (1 << 30)).toFixed(1)} GB`
}
// Phases where bytes are actually moving (or parked mid-move). Quickstart
// recomputes percent against EACH stage's own download plan — the counter
// resets between stages by design, so it is only shown during a genuine
// download phase. Gate of last resort only: the backend's can_pause flag
// is the primary control gate.
const QUICKSTART_DOWNLOAD_PHASES = new Set([
'downloading',
'downloading-runtime',
'unpacking-runtime',
'verifying-runtime'
])
export function isDownloadPhase(job: LocalRuntimeJob): boolean {
if (job.kind === 'model-download' || job.kind === 'runtime-install') {
return true
}
return job.kind === 'quickstart' && QUICKSTART_DOWNLOAD_PHASES.has(job.phase)
}
// Progress bar + honest byte counter. The counter is suppressed outside
// download phases (a stage hand-off would otherwise read as progress loss);
// paused rows keep the frozen counter they parked with.
export function LocalModelDownloadProgress({ job }: LocalModelDownloadProps) {
const { t } = useI18n()
const copy = t.settings.localModels
const showCounter = isDownloadPhase(job)
return (
<div className="grid gap-1">
<ProgressBar paused={job.status === 'paused'} percent={job.percent} />
<p className="text-[0.68rem] text-muted-foreground">
{!showCounter || (!job.done_bytes && job.detail)
? job.detail
: copy.downloadProgress(gbLabel(job.done_bytes), gbLabel(job.total_bytes))}
</p>
</div>
)
}
export function LocalModelDownloadActions({ job }: LocalModelDownloadProps) {
const { t } = useI18n()
const copy = t.settings.localModels
const [busy, setBusy] = useState<boolean>(false)
const send = async (kind: 'pause' | 'resume'): Promise<void> => {
setBusy(true)
try {
// A false paused/resumed flag is usually a benign race (the job
// settled between render and click) — the authoritative refresh
// below decides what the row shows; only a real transport failure
// surfaces as an error.
if (kind === 'pause') {
await pauseLocalDownload(job.job_id)
} else {
await resumeLocalDownload(job.job_id)
}
watchLocalRuntimeJobs()
} catch (err) {
notifyError(err, copy.downloadFailed(job.target))
} finally {
setBusy(false)
}
}
if (job.status === 'paused') {
return (
<div className="flex items-center justify-end gap-2">
<Pill tone="warn">
<Pause className="mr-1 size-3" />
{copy.downloadPausedLabel}
</Pill>
{job.can_resume === true && (
<Button
className={cn(busy && '[&_svg]:animate-spin')}
disabled={busy}
onClick={() => void send('resume')}
size="sm"
variant="outline"
>
{busy ? <Loader2 /> : <Play />}
{copy.downloadResumeAction}
</Button>
)}
</div>
)
}
// No gate invented client-side: can_pause is the backend's explicit
// verdict; pause_requested keeps the control visible but disabled so a
// pending request never reads as "gone".
if (job.status !== 'running' || (!job.pause_requested && job.can_pause !== true)) {
return null
}
if (job.pause_requested) {
return (
<Button className={cn('[&_svg]:animate-spin')} disabled size="sm" variant="outline">
<Loader2 />
{copy.downloadPauseAction}
</Button>
)
}
return (
<Button
className={cn(busy && '[&_svg]:animate-spin')}
disabled={busy}
onClick={() => void send('pause')}
size="sm"
variant="outline"
>
{busy ? <Loader2 /> : <Pause />}
{copy.downloadPauseAction}
</Button>
)
}

View File

@@ -3,7 +3,7 @@ import { MemoryRouter, useLocation } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { $localRuntimeJobs } from '@/store/local-runtime-jobs'
import { $localRuntimeJobs, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
import { LocalModelsSettings } from './local-models-settings'
@@ -23,7 +23,9 @@ vi.mock('@/hermes', () => ({
getLocalRuntimeJob: vi.fn(),
installLocalRuntime: vi.fn(),
listHFRepoFiles: vi.fn(),
pauseLocalDownload: vi.fn(),
quickstartLocalModels: vi.fn(),
resumeLocalDownload: vi.fn(),
searchHFModels: vi.fn(),
sideloadLocalModel: vi.fn()
}))
@@ -116,9 +118,21 @@ function renderPane() {
// is one 'Configure…' click away. Render and click through.
async function renderFullPane() {
const result = renderPane()
const configure = await screen.findByRole('button', { name: /configure/i })
fireEvent.click(configure)
// Wait for status to load: the pane either shows the setup card (click
// through to the full pane) or, when an active runtime/model job routes
// straight to the full pane, the runtime section directly.
await waitFor(() => {
expect(
Boolean(screen.queryByRole('button', { name: /configure/i })) || screen.queryAllByText(/this machine/i).length > 0
).toBe(true)
})
const configure = screen.queryByRole('button', { name: /configure/i })
if (configure) {
fireEvent.click(configure)
}
return result
}
@@ -127,12 +141,18 @@ beforeEach(() => {
mocked.getLocalModelsStatus.mockResolvedValue(BASE_STATUS)
mocked.getLocalHardware.mockResolvedValue(BASE_HARDWARE)
mocked.getLocalCatalog.mockResolvedValue({ models: [FITTING_MODEL, SPILLED_MODEL, REFUSED_MODEL] })
mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] })
// The backend mock ECHOES the atom: the watcher's immediate poll reads
// seeded jobs instead of wiping them with a default {jobs:[]}.
mocked.getLocalModelsJobs.mockImplementation(async () => ({ jobs: [...$localRuntimeJobs.get()] }))
$localRuntimeJobs.set([])
})
afterEach(() => {
afterEach(async () => {
cleanup()
mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] })
await act(async () => {
watchLocalRuntimeJobs()
})
vi.clearAllMocks()
})
@@ -554,3 +574,200 @@ describe('quickstart completion navigation', () => {
expect(routeProbe).toHaveBeenCalledWith('/')
})
})
describe('pause / resume integration', () => {
beforeEach(() => {
vi.mocked(hermes.pauseLocalDownload).mockResolvedValue({ ok: true, paused: true })
vi.mocked(hermes.resumeLocalDownload).mockResolvedValue({ ok: true, resumed: true })
})
it('a running catalog download shows Pause; clicking sends the job id once', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: true,
runtime_backend: 'cuda'
})
$localRuntimeJobs.set([
{
job_id: 'j1',
kind: 'model-download',
target: 'Qwen3.6 27B',
model_id: FITTING_MODEL.id,
status: 'running',
phase: 'downloading',
detail: 'Qwen3.6 27B — 17.6 GB',
total_bytes: 100,
can_pause: true,
done_bytes: 40,
percent: 40,
error: null
}
])
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
fireEvent.click(screen.getByRole('button', { name: /pause/i }))
await waitFor(() => {
expect(hermes.pauseLocalDownload).toHaveBeenCalledTimes(1)
})
expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('j1')
expect(hermes.resumeLocalDownload).not.toHaveBeenCalled()
})
it('a paused catalog download keeps its row: Paused label, Resume button, progress bar retained', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: true,
runtime_backend: 'cuda'
})
$localRuntimeJobs.set([
{
job_id: 'j1',
kind: 'model-download',
target: 'Qwen3.6 27B',
model_id: FITTING_MODEL.id,
can_resume: true,
phase: 'downloading',
status: 'paused',
detail: 'Qwen3.6 27B — 17.6 GB',
total_bytes: 100,
done_bytes: 40,
percent: 40,
error: null
}
])
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
// Row survives with an honest parked label and a way forward.
expect(screen.getAllByText(/paused/i).length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: /resume/i })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /resume/i }))
await waitFor(() => {
expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('j1')
})
// The watcher re-kicked: an authoritative re-read happens after resume.
await waitFor(() => {
expect(mocked.getLocalModelsJobs.mock.calls.length).toBeGreaterThanOrEqual(2)
})
})
it('a paused quickstart stays pinned in the hero with a Resume control', async () => {
$localRuntimeJobs.set([
{
job_id: 'q1',
kind: 'quickstart',
target: 'Qwen3.6 27B',
model_id: 'qwen3.6-27b',
can_resume: true,
phase: 'downloading',
status: 'paused',
detail: '',
total_bytes: 100,
done_bytes: 30,
percent: 30,
error: null
}
])
renderPane()
await screen.findAllByText(/paused/i)
expect(screen.getByRole('button', { name: /resume/i })).toBeTruthy()
// Hero is pinned — no quickstart action button competes with it.
expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /resume/i }))
await waitFor(() => {
expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('q1')
})
})
it('a running quickstart hero shows Pause during the download stage', async () => {
$localRuntimeJobs.set([
{
job_id: 'q1',
kind: 'quickstart',
target: 'Qwen3.6 27B',
model_id: 'qwen3.6-27b',
can_pause: true,
phase: 'downloading',
status: 'running',
detail: '',
total_bytes: 100,
done_bytes: 30,
percent: 30,
error: null
}
])
renderPane()
await screen.findByText('Qwen3.6 27B')
expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
})
it('a running runtime install row shows Pause when the engine leg can park', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: false,
update_available: false
})
$localRuntimeJobs.set([
{
job_id: 'r1',
kind: 'runtime-install',
target: 'llamacpp',
model_id: null,
can_pause: true,
phase: 'downloading-runtime',
status: 'running',
detail: 'llamacpp b10290',
total_bytes: 100,
done_bytes: 10,
percent: 10,
error: null
}
])
renderPane()
await screen.findByText(/installing/i)
const pause = screen.getByRole('button', { name: /pause/i })
fireEvent.click(pause)
await waitFor(() => {
expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('r1')
})
})
it('quickstart hero suppresses the byte counter outside download phases', async () => {
$localRuntimeJobs.set([
{
job_id: 'q1',
kind: 'quickstart',
target: 'Qwen3.6 27B',
model_id: 'qwen3.6-27b',
status: 'running',
phase: 'installing-runtime',
detail: 'Unpacking runtime',
total_bytes: 100,
done_bytes: 100,
percent: 100,
error: null
}
])
renderPane()
await screen.findByText('Unpacking runtime')
// Stage detail yes; byte counter no — a 100% counter on an install
// phase would lie about the model leg still ahead.
expect(screen.queryByText(/of/)).toBeNull()
})
})

View File

@@ -34,6 +34,7 @@ import {
Loader2,
Monitor,
Package,
Pause,
Search,
StopFilled,
Trash2,
@@ -47,29 +48,17 @@ import {
watchLocalRuntimeJobs
} from '@/store/local-runtime-jobs'
import { notify, notifyError } from '@/store/notifications'
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus } from '@/types/hermes'
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
import {
gbLabel,
isDownloadPhase,
LocalModelDownloadActions,
LocalModelDownloadProgress,
ProgressBar
} from './local-model-download-progress'
import { ListRow, Pill, SettingsContent, SettingsSection, SettingsSkeleton } from './primitives'
function ProgressBar({ percent }: { percent: number | undefined }) {
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${Math.max(2, Math.min(100, percent ?? 2))}%` }}
/>
</div>
)
}
function gbLabel(bytes: number | null | undefined): string {
if (!bytes) {
return '—'
}
return `${(bytes / (1 << 30)).toFixed(1)} GB`
}
// Catalog display order: what runs well leads. Resident (all on GPU)
// first, then spilled (works, slower), then doesn't-fit; catalog order
// (recommended first) holds within each band.
@@ -85,6 +74,12 @@ function fitRank(model: LocalCatalogModel): number {
return 2
}
// Still on its way (or parked mid-way): rows/hero stay visible while
// paused — a paused download vanishing reads as progress loss.
function isActiveStatus(status: LocalRuntimeJob['status']): boolean {
return status === 'paused' || status === 'running'
}
export function LocalModelsSettings() {
const { t } = useI18n()
const copy = t.settings.localModels
@@ -265,7 +260,7 @@ export function LocalModelsSettings() {
const navigate = useNavigate()
const seenQuickstarts = useRef(new Set<string>())
const runningQuickstart = jobs.find(j => j.kind === 'quickstart' && j.status === 'running')
const runningQuickstart = jobs.find(j => j.kind === 'quickstart' && isActiveStatus(j.status))
useEffect(() => {
// Event detection, not value mirroring: the ref only remembers which
@@ -306,7 +301,14 @@ export function LocalModelsSettings() {
const needsSetup = !status.runtime_installed || status.models.length === 0
const heroModel = catalog.find(c => c.recommended && c.fits) ?? catalog.find(c => c.fits) ?? null
if (qJob || (needsSetup && !configure && heroModel)) {
// An active runtime install/update or model download needs the FULL pane
// (its row lives there with its controls) — the setup hero must not hide
// it on a remount.
const otherActiveJob = jobs.some(
j => (j.kind === 'runtime-install' || j.kind === 'model-download') && isActiveStatus(j.status)
)
if ((qJob || (needsSetup && !configure && heroModel)) && !otherActiveJob) {
// Stage rail derived from the job phase: engine -> model -> finish.
const phase = qJob?.phase ?? ''
@@ -314,23 +316,17 @@ export function LocalModelsSettings() {
const stages = [copy.quickstartStageEngine, copy.quickstartStageModel, copy.quickstartStageFinish]
// The model-download leg blanks job.detail on purpose (pane rows
// render their own byte counter) — compose one here instead of
// falling back to runtime copy that would misname the stage.
const liveDetail =
qJob &&
(qJob.detail ||
(qJob.total_bytes
? copy.downloadProgress(gbLabel(qJob.done_bytes), gbLabel(qJob.total_bytes))
: copy.installing))
return (
<SettingsContent>
<div className="flex min-h-[60dvh] items-center justify-center">
<div className="w-full max-w-md text-center">
<div className="mx-auto mb-5 flex size-14 items-center justify-center rounded-2xl bg-primary/10">
{qJob ? (
<Loader2 className="size-7 animate-spin text-primary" />
qJob.status === 'paused' ? (
<Pause className="size-7 text-muted-foreground" />
) : (
<Loader2 className="size-7 animate-spin text-primary" />
)
) : (
<Cpu className="size-7 text-primary" />
)}
@@ -342,10 +338,28 @@ export function LocalModelsSettings() {
{qJob ? (
<>
<p className="mt-2 min-h-10 text-[0.8rem] leading-5 text-muted-foreground">{liveDetail}</p>
{/* Byte counter only while bytes actually move: each stage
recomputes percent against ITS OWN download plan, and a
stage hand-off resets the counter by design — showing it
across a hand-off would read as progress loss. Paused
rows keep the frozen counter they parked with. */}
<p className="mt-2 min-h-10 text-[0.8rem] leading-5 text-muted-foreground">
{qJob.status === 'paused'
? copy.downloadPausedLabel
: isDownloadPhase(qJob) && (qJob.detail || qJob.total_bytes)
? qJob.detail || copy.downloadProgress(gbLabel(qJob.done_bytes), gbLabel(qJob.total_bytes))
: qJob.detail || copy.installing}
</p>
<div className="mt-5">
<ProgressBar percent={qJob.percent} />
<ProgressBar paused={qJob.status === 'paused'} percent={qJob.percent} />
</div>
{/* Pause / Resume — the backend's can_pause/can_resume gate
when controls exist (engine legs, server start report
false); the paused state always offers Resume. */}
<div className="mt-5 flex items-center justify-center gap-3">
<LocalModelDownloadActions job={qJob} />
</div>
{/* Stage rail: engine -> model -> finish. */}
@@ -456,11 +470,16 @@ export function LocalModelsSettings() {
/>
) : rJob ? (
<ListRow
below={<ProgressBar percent={rJob.percent} />}
action={<LocalModelDownloadActions job={rJob} />}
below={<LocalModelDownloadProgress job={rJob} />}
description={rJob.detail || copy.installing}
title={
<span className="inline-flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" />
{rJob.status === 'paused' ? (
<Pause className="size-3.5" />
) : (
<Loader2 className="size-3.5 animate-spin" />
)}
{copy.installing}
</span>
}
@@ -493,11 +512,16 @@ export function LocalModelsSettings() {
{rJob && status.runtime_installed && (
<ListRow
below={<ProgressBar percent={rJob.percent} />}
action={<LocalModelDownloadActions job={rJob} />}
below={<LocalModelDownloadProgress job={rJob} />}
description={rJob.detail || copy.updating}
title={
<span className="inline-flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" />
{rJob.status === 'paused' ? (
<Pause className="size-3.5" />
) : (
<Loader2 className="size-3.5 animate-spin" />
)}
{copy.updating}
</span>
}
@@ -554,7 +578,9 @@ export function LocalModelsSettings() {
<div className="grid gap-1">
{sortedCatalog.map(model => {
const dJob = runningDownloadFor(jobs, model.id)
const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && j.status === 'running')
const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && isActiveStatus(j.status))
const activateTarget = model.downloaded_model_id ?? model.model_id
const isActive = Boolean(activateTarget && status.active_model_id === activateTarget)
const residency = activateTarget ? status.loaded_models[activateTarget] : undefined
@@ -634,7 +660,9 @@ export function LocalModelsSettings() {
</Button>
</Tip>
</div>
) : dJob ? undefined : (
) : dJob ? (
<LocalModelDownloadActions job={dJob} />
) : (
<Button
disabled={!model.fits || anyDownloadRunning || !status.runtime_installed}
onClick={() => void handleDownload(model)}
@@ -649,12 +677,14 @@ export function LocalModelsSettings() {
below={
dJob ? (
<div className="mt-2 grid gap-1">
<ProgressBar percent={dJob.percent} />
<ProgressBar paused={dJob.status === 'paused'} percent={dJob.percent} />
<p className="text-[0.68rem] text-muted-foreground">
{!dJob.done_bytes && dJob.detail
? dJob.detail
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
{dJob.status === 'paused'
? copy.downloadPausedLabel
: !dJob.done_bytes && dJob.detail
? dJob.detail
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
</p>
</div>
) : undefined
@@ -1067,13 +1097,17 @@ function BrowseSection({ onChanged }: { onChanged: () => void }) {
{dJob ? (
<>
<ProgressBar percent={dJob.percent} />
<ProgressBar paused={dJob.status === 'paused'} percent={dJob.percent} />
<span className="text-[0.68rem] text-muted-foreground">
{!dJob.done_bytes && dJob.detail
? dJob.detail
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
{dJob.status === 'paused'
? copy.downloadPausedLabel
: !dJob.done_bytes && dJob.detail
? dJob.detail
: copy.downloadProgress(gbLabel(dJob.done_bytes), gbLabel(dJob.total_bytes))}
</span>
<LocalModelDownloadActions job={dJob} />
</>
) : (
<span className="flex items-center justify-between gap-2">

View File

@@ -682,10 +682,19 @@ const LOCAL_PROVIDER_SLUG = 'llamacpp'
// above subscribes to download identity, not progress.
function DownloadingModelRow({ jobId, target }: { jobId: string; target: string }) {
const { t } = useI18n()
const copy = t.modelPicker
const copyPicker = t.modelPicker
const copyLocal = t.settings.localModels
// The row's own scalar slice: percent for live rows, status for the
// paused fork (a paused download must stay listed with its Paused pill,
// not vanish — progress loss is information loss).
const percent = useStoreSelector($localRuntimeJobs, jobs => jobs.find(job => job.job_id === jobId)?.percent ?? null)
const paused = useStoreSelector(
$localRuntimeJobs,
jobs => jobs.find(job => job.job_id === jobId)?.status === 'paused'
)
return (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'opacity-60')}
@@ -694,15 +703,25 @@ function DownloadingModelRow({ jobId, target }: { jobId: string; target: string
textValue=""
>
<span className="min-w-0 flex-1 truncate">{target}</span>
<span className="ml-auto flex shrink-0 items-center gap-1.5" title={copy.downloading}>
<span
className="ml-auto flex shrink-0 items-center gap-1.5"
title={paused ? copyLocal.downloadPausedLabel : copyPicker.downloading}
>
<span className="h-1 w-14 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-500"
className={cn(
'block h-full rounded-full',
paused ? 'bg-muted-foreground/60' : 'bg-primary transition-[width] duration-500'
)}
style={{ width: `${Math.max(2, percent ?? 0)}%` }}
/>
</span>
<span className="text-[0.62rem] tabular-nums text-(--ui-text-tertiary)">
{typeof percent === 'number' ? `${percent}%` : copy.downloading}
{paused
? copyLocal.downloadPausedLabel
: typeof percent === 'number'
? `${percent}%`
: copyPicker.downloading}
</span>
</span>
</DropdownMenuItem>

View File

@@ -8,12 +8,12 @@ import { modelSearchText } from '@/lib/model-search-text'
import { currentPickerSelection } from '@/lib/model-status-label'
import { foldIncludes, normalize } from '@/lib/text'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $localModelsEnabled } from '@/store/local-models-flag'
import { $localRuntimeJobs, runningModelDownloads, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
import type { LocalModelLoadProgress, ModelOptionProvider, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
import { cn } from '../lib/utils'
import { startManualOnboarding } from '../store/onboarding'
import { InlineNotice } from './notifications'
@@ -387,21 +387,33 @@ const LOCAL_PROVIDER_SLUG = 'llamacpp'
function DownloadingModelRow({ jobId, target }: { jobId: string; target: string }) {
const { t } = useI18n()
const copy = t.modelPicker
const copyLocal = t.settings.localModels
const percent = useStoreSelector($localRuntimeJobs, jobs => jobs.find(job => job.job_id === jobId)?.percent ?? null)
const paused = useStoreSelector(
$localRuntimeJobs,
jobs => jobs.find(job => job.job_id === jobId)?.status === 'paused'
)
return (
<CommandItem className="flex items-center gap-2 pl-6 font-mono opacity-60" disabled value={`downloading:${jobId}`}>
<span className="min-w-0 flex-1 truncate">{target}</span>
<span className="flex shrink-0 items-center gap-1.5" title={copy.downloading}>
<span
className="flex shrink-0 items-center gap-1.5"
title={paused ? copyLocal.downloadPausedLabel : copy.downloading}
>
<span className="h-1 w-16 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-500"
className={cn(
'block h-full rounded-full',
paused ? 'bg-muted-foreground/60' : 'bg-primary transition-[width] duration-500'
)}
style={{ width: `${Math.max(2, percent ?? 0)}%` }}
/>
</span>
<span className="text-[0.62rem] tabular-nums text-muted-foreground">
{typeof percent === 'number' ? `${percent}%` : copy.downloading}
{paused ? copyLocal.downloadPausedLabel : typeof percent === 'number' ? `${percent}%` : copy.downloading}
</span>
</span>
</CommandItem>

View File

@@ -0,0 +1,201 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LocalRuntimeJob } from '@/types/hermes'
// The BACKEND is the authority: a staged registry the poll reads from, so
// transitions arrive the way production sees them — via a poll response,
// never by mutating the cache directly.
const backend = vi.hoisted(() => ({ jobs: [] as LocalRuntimeJob[] }))
vi.mock('@/hermes', () => ({
getLocalModelsJobs: vi.fn(async () => ({ jobs: backend.jobs })),
getLocalModelsStatus: vi.fn(async () => ({ enabled: true, update_available: false }))
}))
vi.mock('@/i18n', () => ({
translateNow: (key: string, ...args: unknown[]) => (args.length ? `${key}:${args.join(',')}` : key)
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
const { $localRuntimeJobs, watchLocalRuntimeJobs } = await import('./local-runtime-jobs')
const { getLocalModelsJobs } = await import('@/hermes')
const { notify, notifyError } = await import('@/store/notifications')
function job(overrides: Partial<LocalRuntimeJob>): LocalRuntimeJob {
return {
detail: '',
done_bytes: 0,
error: null,
job_id: 'j1',
kind: 'model-download',
model_id: 'm1',
phase: 'downloading',
status: 'running',
target: 'Qwen3.6 27B',
total_bytes: 100,
...overrides
}
}
beforeEach(() => {
vi.clearAllMocks()
backend.jobs = []
$localRuntimeJobs.set([])
})
afterEach(async () => {
backend.jobs = []
watchLocalRuntimeJobs()
await vi.waitFor(() => expect($localRuntimeJobs.get()).toEqual([]))
})
const settle = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// Drive the poller: each advance lets the pending tick fire, the fetch read
// the staged backend snapshot, and the next tick re-arm.
// Stage-then-poll: the kick starts the loop AND the loop only re-arms while
// work is active, so each tick re-kicks (idempotent) then waits for the
// fetch to land the staged snapshot.
async function pollTick() {
watchLocalRuntimeJobs()
await settle(150)
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('local runtime jobs store — pause/settle contract', () => {
it('running→paused settles NOTHING: no error toast, job stays visible', async () => {
backend.jobs = [job({ done_bytes: 40 })]
await pollTick()
expect($localRuntimeJobs.get()[0]?.status).toBe('running')
// Backend pauses the job (status='paused', error=null).
backend.jobs = [job({ done_bytes: 40, status: 'paused' })]
await pollTick()
await pollTick()
expect(notifyError).not.toHaveBeenCalled()
expect(notify).not.toHaveBeenCalled()
const snapshot = $localRuntimeJobs.get()
expect(snapshot).toHaveLength(1)
expect(snapshot[0]?.status).toBe('paused')
})
it('a paused job that resumes and finishes notifies done exactly once (paused→done still notifies)', async () => {
backend.jobs = [job({ done_bytes: 40 })]
await pollTick()
backend.jobs = [job({ done_bytes: 40, status: 'paused' })]
await pollTick()
backend.jobs = [job({ done_bytes: 60 })]
await pollTick()
backend.jobs = [job({ done_bytes: 100, percent: 100, status: 'done', total_bytes: 100 })]
await pollTick()
expect(notify).toHaveBeenCalledTimes(1)
expect(vi.mocked(notify).mock.calls[0][0]).toMatchObject({ kind: 'success' })
expect(notifyError).not.toHaveBeenCalled()
})
it('running→error still toasts exactly once', async () => {
backend.jobs = [job({ done_bytes: 10, job_id: 'j-err' })]
await pollTick()
backend.jobs = [job({ done_bytes: 10, error: 'disk full', job_id: 'j-err', status: 'error' })]
await pollTick()
expect(notifyError).toHaveBeenCalledTimes(1)
expect(notify).not.toHaveBeenCalled()
})
it('keeps polling while a job is paused (a resume from another surface is witnessed)', async () => {
backend.jobs = [job({ status: 'paused' })]
await pollTick()
const callsAfterPause = vi.mocked(getLocalModelsJobs).mock.calls.length
expect(callsAfterPause).toBeGreaterThan(0)
// The paused cadence is slower (3s), not dead: wait past it and the
// poll fires again WITHOUT any new kick.
await settle(3_400)
expect(vi.mocked(getLocalModelsJobs).mock.calls.length).toBeGreaterThan(callsAfterPause)
// And a backend-side resume IS picked up with no local kick at all.
backend.jobs = [job({ done_bytes: 90 })]
await pollTick()
expect($localRuntimeJobs.get()[0]?.status).toBe('running')
})
it('coalesces refresh requests while one backend read is in flight', async () => {
let release: (value: { jobs: LocalRuntimeJob[] }) => void = () => {}
const pending = new Promise<{ jobs: LocalRuntimeJob[] }>(resolve => {
release = resolve
})
vi.mocked(getLocalModelsJobs).mockReturnValueOnce(pending)
watchLocalRuntimeJobs()
watchLocalRuntimeJobs()
watchLocalRuntimeJobs()
expect(getLocalModelsJobs).toHaveBeenCalledTimes(1)
backend.jobs = [job({ done_bytes: 40 })]
release({ jobs: [] })
await vi.waitFor(() => expect(getLocalModelsJobs).toHaveBeenCalledTimes(2))
expect($localRuntimeJobs.get()[0]?.done_bytes).toBe(40)
})
it('does not re-set the atom when a poll returns the identical payload', async () => {
backend.jobs = [job({ done_bytes: 40 })]
await pollTick()
expect($localRuntimeJobs.get()).toHaveLength(1)
const reference = $localRuntimeJobs.get()
await pollTick()
await pollTick()
// Same reference preserved — no-op polls never hand React fresh arrays.
expect($localRuntimeJobs.get()).toBe(reference)
})
it('equality covers control flags, ranges, percent, detail and error — not just done_bytes', async () => {
backend.jobs = [job({ done_bytes: 40, ranges: { 'model.gguf': [[0, 100]] } })]
await pollTick()
const first = $localRuntimeJobs.get()
// Backend flips can_pause:false (e.g. a phase change) with identical bytes.
backend.jobs = [job({ can_pause: false, done_bytes: 40, ranges: { 'model.gguf': [[0, 100]] } })]
await pollTick()
const second = $localRuntimeJobs.get()
expect(second).not.toBe(first)
expect(second[0]?.can_pause).toBe(false)
// A ranges-only change re-publishes too.
const before = $localRuntimeJobs.get()
backend.jobs = [
job({
can_pause: false,
done_bytes: 40,
ranges: {
'model.gguf': [
[0, 100],
[100, 200]
]
}
})
]
await pollTick()
expect($localRuntimeJobs.get()).not.toBe(before)
expect($localRuntimeJobs.get()[0]?.ranges?.['model.gguf']).toEqual([
[0, 100],
[100, 200]
])
})
})

View File

@@ -15,33 +15,78 @@ import type { LocalRuntimeJob } from '@/types/hermes'
export const $localRuntimeJobs = atom<readonly LocalRuntimeJob[]>([])
const POLL_ACTIVE_MS = 700
const POLL_PAUSED_MS = 3_000
let timer: null | number = null
let polling = false
// Jobs we've already toasted for, so a poll race can't double-notify.
const settledNotified = new Set<string>()
function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) {
if (a.length !== b.length) {
// The active set: running OR paused. Paused is NOT settled — it is user
// intent, not failure — and a paused job that later runs again must stay
// tracked so its eventual done/error notifies exactly once.
function isActive(status: LocalRuntimeJob['status']): boolean {
return status === 'paused' || status === 'running'
}
// A job is the same observation unless ANY user-visible payload changed —
// bytes, percent, phase, detail, error, control flags, and the per-file
// ranges. The backend re-sorts jobs per poll, so compare by id, not index.
function jobEqual(a: LocalRuntimeJob, b: LocalRuntimeJob): boolean {
return (
a.job_id === b.job_id &&
a.status === b.status &&
a.phase === b.phase &&
a.detail === b.detail &&
a.done_bytes === b.done_bytes &&
a.total_bytes === b.total_bytes &&
a.percent === b.percent &&
a.error === b.error &&
a.can_pause === b.can_pause &&
a.can_resume === b.can_resume &&
a.pause_requested === b.pause_requested &&
rangesEqual(a.ranges, b.ranges)
)
}
function rangesEqual(
a: Record<string, [number, number][]> | undefined,
b: Record<string, [number, number][]> | undefined
): boolean {
if (a === b) {
return true
}
if (!a || !b) {
return false
}
return a.every((job, i) => {
const other = b[i]
const keysA = Object.keys(a)
const keysB = Object.keys(b)
return (
job.job_id === other.job_id &&
job.status === other.status &&
job.phase === other.phase &&
job.done_bytes === other.done_bytes
)
if (keysA.length !== keysB.length) {
return false
}
return keysA.every(key => {
const ra = a[key]
const rb = b[key]
if (!ra || !rb || ra.length !== rb.length) {
return false
}
return ra.every((range, i) => range[0] === rb[i]?.[0] && range[1] === rb[i]?.[1])
})
}
function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) {
return a.length === b.length && a.every(job => b.some(other => jobEqual(job, other)))
}
function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly LocalRuntimeJob[]) {
const wasRunning = new Set(previous.filter(j => j.status === 'running').map(j => j.job_id))
const wasActive = new Set(previous.filter(j => isActive(j.status)).map(j => j.job_id))
for (const job of next) {
if (job.status === 'running' || !wasRunning.has(job.job_id) || settledNotified.has(job.job_id)) {
if (isActive(job.status) || !wasActive.has(job.job_id) || settledNotified.has(job.job_id)) {
continue
}
@@ -76,7 +121,13 @@ function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly Loca
}
}
async function poll() {
let inFlight = false
let refreshRequested = false
async function poll(): Promise<void> {
timer = null
inFlight = true
try {
const { jobs } = await getLocalModelsJobs()
const previous = $localRuntimeJobs.get()
@@ -86,57 +137,71 @@ async function poll() {
$localRuntimeJobs.set(jobs)
}
} catch {
// Backend unreachable — keep the last snapshot; the next poll retries.
// Preserve the last snapshot while the backend is unreachable.
} finally {
inFlight = false
}
const anyRunning = $localRuntimeJobs.get().some(j => j.status === 'running')
if (refreshRequested) {
refreshRequested = false
void poll()
if (anyRunning) {
timer = window.setTimeout(() => void poll(), POLL_ACTIVE_MS)
} else {
polling = false
timer = null
}
}
// Idempotent kick: start (or keep) the poll loop while work is in flight.
// Call after starting a job AND on app boot (to rediscover work started
// before a reload).
export function watchLocalRuntimeJobs() {
if (polling) {
return
}
polling = true
const jobs = $localRuntimeJobs.get()
const anyRunning = jobs.some(job => job.status === 'running')
if (anyRunning || jobs.some(job => job.status === 'paused')) {
timer = window.setTimeout(() => void poll(), anyRunning ? POLL_ACTIVE_MS : POLL_PAUSED_MS)
}
}
export function watchLocalRuntimeJobs(): void {
if (inFlight) {
refreshRequested = true
return
}
if (timer !== null) {
window.clearTimeout(timer)
timer = null
}
void poll()
}
// Selector: the running download job for a catalog model id, if any.
// Selector: the in-flight (running or paused) download job for a catalog
// model id, if any. Paused stays visible — the row parks, it doesn't
// vanish (progress loss is information loss).
export function runningDownloadFor(jobs: readonly LocalRuntimeJob[], modelId: string): LocalRuntimeJob | null {
return jobs.find(j => j.kind === 'model-download' && j.status === 'running' && j.model_id === modelId) ?? null
return jobs.find(j => j.kind === 'model-download' && isActive(j.status) && j.model_id === modelId) ?? null
}
// Selector: every model on its way to the library right now — plain
// downloads plus quickstart runs while they are still fetching bytes
// (later quickstart phases mean the model is staged and activating).
// The model picker renders these as disabled progress rows.
const DOWNLOAD_PHASES = new Set(['starting', 'installing-runtime', 'downloading'])
// (later quickstart phases mean the model is staged and activating),
// paused ones included. The model picker renders these as disabled
// progress rows.
const DOWNLOAD_PHASES = new Set([
'starting',
'installing-runtime',
'downloading-runtime',
'unpacking-runtime',
'verifying-runtime',
'downloading'
])
export function runningModelDownloads(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob[] {
return jobs.filter(
j =>
j.status === 'running' &&
(j.kind === 'model-download' || (j.kind === 'quickstart' && DOWNLOAD_PHASES.has(j.phase)))
isActive(j.status) && (j.kind === 'model-download' || (j.kind === 'quickstart' && DOWNLOAD_PHASES.has(j.phase)))
)
}
export function runningRuntimeInstall(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob | null {
return jobs.find(j => j.kind === 'runtime-install' && j.status === 'running') ?? null
return jobs.find(j => j.kind === 'runtime-install' && isActive(j.status)) ?? null
}
// One engine-update toast per app session: checked at boot (after the

View File

@@ -1349,13 +1349,23 @@ export interface LocalRuntimeJob {
kind: 'model-activate' | 'model-download' | 'quickstart' | 'runtime-install'
target: string
model_id: string | null
status: 'running' | 'done' | 'error'
status: 'done' | 'error' | 'paused' | 'running'
phase: string
detail: string
total_bytes: number | null
done_bytes: number
percent?: number
error: string | null
/** Which file ranges of the source plan are fetched vs banked — present
* while a plan (multi-file or cached+fresh mix) is in flight. */
ranges?: Record<string, [number, number][]>
/** Control flags: false while the job is in a phase that cannot park
* (server start, default assignment, non-download quickstart legs). */
can_pause?: boolean
can_resume?: boolean
/** The backend has accepted a pause request; the status flips when the
* downloader actually parks. */
pause_requested?: boolean
}
export interface ActionResponse {

View File

@@ -2335,8 +2335,7 @@ DEFAULT_CONFIG = {
"local_runtime": {
# Off = detection-only (Hermes still finds an external llama-server you run).
"enabled": False,
# Pinned llama.cpp release tag; bumped by Hermes releases after validation.
"tag": "b10679",
# Engine versions and every dependent library are pinned by pm/lock.json.
# auto = CUDA on NVIDIA, Metal on macOS, Vulkan on other GPUs, else CPU. Explicit:
# cuda|metal|vulkan|hip|cpu.
"backend": "auto",

View File

@@ -1,13 +1,16 @@
"""Managed llama.cpp runtime.
``binaries`` resolves/downloads/verifies official llama.cpp release zips into
``$HERMES_HOME/runtimes/llamacpp/<tag>/``; ``supervisor`` spawns and supervises one llama-server in
router mode (readiness is a touch generation, never health-200 alone); ``detect`` finds an
already-running llama-server (external or ours).
``binaries`` answers which backend this machine uses and which PM-pinned
engine is installed (the engine bytes live in pm's machine-wide store);
``bootstrap`` boots the managed server from what is installed — never a
download; ``supervisor`` spawns and supervises one llama-server in router
mode (readiness is a touch generation, never health-200 alone); ``detect``
finds an already-running llama-server (external or ours).
"""
from hermes_cli.local_runtime.binaries import ( # noqa: F401
BinaryResolutionError, ensure_runtime_installed, resolve_assets, select_backend)
BACKEND_PACKAGES, BinaryResolutionError, Engine, ensure_engine,
installed_engine, pinned_tag, resolve_backend, select_backend)
from hermes_cli.local_runtime.bootstrap import ensure_local_runtime, shutdown_local_runtime # noqa: F401
from hermes_cli.local_runtime.context_policy import ( # noqa: F401
FLOOR, growth_decision, initial_window, ladder, launch_args)

View File

@@ -1,304 +1,128 @@
"""Binary acquisition for the managed llama.cpp runtime."""
"""Hardware selection for PM-owned llama.cpp binaries; mutable runtime state stays separate."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import platform
import shutil
import subprocess
import urllib.request
import zipfile
from dataclasses import dataclass, field
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import pm
from pm.downloader import ProgressFn
logger = logging.getLogger(__name__)
RELEASE_URL = "https://github.com/ggml-org/llama.cpp/releases/download/{tag}/{asset}"
# Windows CUDA zips ship per CUDA major; the runtime zip must be paired with its cudart zip so
# end users need no toolkit. 13.3 verified on 13.1 and 13.2 drivers.
_WIN_CUDA_VERSION = "13.3"
# arm64 Windows CUDA prebuilts landed upstream (~b1036x) on CUDA 13.4. Tags at or before b10290
# don't have them; resolution succeeds and the download 404s honestly if a user pins backward.
_WIN_CUDA_VERSION_ARM64 = "13.4"
def default_tag() -> str:
"""Fallback when the config section is missing entirely; DEFAULT_CONFIG owns the shipped tag."""
from hermes_cli.config_defaults import DEFAULT_CONFIG
return DEFAULT_CONFIG["local_runtime"]["tag"]
BACKEND_PACKAGES = {
"cuda": "llamacpp-cuda",
"vulkan": "llamacpp-vulkan",
"metal": "llamacpp-metal",
"hip": "llamacpp-hip",
"cpu": "llamacpp-cpu",
}
class BinaryResolutionError(RuntimeError):
"""No usable asset combination for this platform/backend."""
"""The requested backend has no pinned or installed engine."""
@dataclass
class AssetPlan:
"""The exact zips one runtime install needs, in extraction order."""
@dataclass(frozen=True)
class Engine:
backend: str
tag: str
backend: str # cuda | metal | vulkan | hip | cpu
assets: list[str] = field(default_factory=list)
@property
def install_dir(self) -> Path:
return runtimes_root() / self.tag / self.backend
binary: Path
def runtimes_root() -> Path:
"""Machine-scoped, deliberately NOT profile-scoped: engine binaries, presets and server state
describe this machine's hardware and its one managed server (stable port) — a second profile
re-downloading the engine or fighting over the port would be the bug. Profile-scoped things
(default model, enabled) live in each profile's config.yaml."""
"""Machine-scoped presets and server state. Binaries belong to PM's store."""
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "runtimes" / "llamacpp"
def manifest_verified(manifest: Path) -> bool:
"""True when an install manifest records a verified_version (missing/damaged -> False)."""
try:
return bool(json.loads(manifest.read_text(encoding="utf-8-sig")).get("verified_version"))
except (json.JSONDecodeError, OSError):
return False
def _release_number(tag: str) -> int:
digits = "".join(ch for ch in tag if ch.isdigit())
return int(digits) if digits else 0
def installed_tags() -> list[str]:
"""Tags with a verified install, newest first by release number. The boot ladder and the
update check both read installed-ness from here — one resolver, every caller."""
root = runtimes_root()
if not root.exists():
return []
found = {entry.name for entry in root.iterdir()
if entry.is_dir() and entry.name != "downloads"
and any(manifest_verified(m) for m in entry.glob("*/manifest.json"))}
return sorted(found, key=_release_number, reverse=True)
def _host_os_arch() -> tuple[str, str]:
"""(os, arch) normalized to release-asset vocabulary. PITFALL: PROCESSOR_ARCHITECTURE lies
under x64 emulation on ARM64 Windows, and platform.machine() reads the same env on some
Pythons — so on Windows prefer PROCESSOR_IDENTIFIER's text when present."""
system = platform.system().lower()
os_name = {"windows": "win", "darwin": "macos", "linux": "ubuntu"}.get(system, system)
arch = "arm64" if platform.machine().lower() in ("arm64", "aarch64") else "x64"
if os_name == "win":
ident = os.environ.get("PROCESSOR_IDENTIFIER", "").lower()
if "armv8" in ident or "arm " in ident:
arch = "arm64"
return os_name, arch
def pinned_tag(backend: str) -> str:
version = pm.Lockfile(pm.paths.lockfile_path()).version(BACKEND_PACKAGES[backend])
if version is None:
raise BinaryResolutionError(f"llama.cpp {backend} has no PM version pin")
return f"b{version}"
def select_backend(gpu_vendor: str | None, os_name: str | None = None) -> str:
"""CUDA if NVIDIA, Metal on macOS, Vulkan if a non-NVIDIA GPU is present, else CPU.
``--list-devices`` validates post-install; the supervisor's touch generation is ground truth."""
if os_name is None:
os_name, _ = _host_os_arch()
os_name = "macos" if pm.current_target().startswith("darwin-") else "other"
if os_name == "macos":
return "metal"
vendor = (gpu_vendor or "").lower()
if "nvidia" in vendor:
return "cuda"
if vendor in ("amd", "intel") or "radeon" in vendor or "arc" in vendor:
if any(name in vendor for name in ("amd", "intel", "radeon", "arc")):
return "vulkan"
return "cpu"
# Per-OS (human label, {backend: asset-name templates}). Windows CUDA pairs the runtime zip with
# its cudart zip; ubuntu ships tarballs, win ships zips.
_ASSET_TEMPLATES = {
"ubuntu": ("linux", {
"vulkan": ["llama-{tag}-bin-ubuntu-vulkan-{arch}.tar.gz"],
"hip": ["llama-{tag}-bin-ubuntu-rocm-7.2-{arch}.tar.gz"],
"cpu": ["llama-{tag}-bin-ubuntu-{arch}.tar.gz"],
}),
"win": ("windows", {
"cuda": ["llama-{tag}-bin-win-cuda-{cuda_ver}-{arch}.zip",
"cudart-llama-bin-win-cuda-{cuda_ver}-{arch}.zip"],
"vulkan": ["llama-{tag}-bin-win-vulkan-x64.zip"],
"hip": ["llama-{tag}-bin-win-hip-radeon-x64.zip"],
"cpu": ["llama-{tag}-bin-win-cpu-{arch}.zip"],
}),
}
def _candidates(requested: str, gpu_vendor: str | None, target: str) -> tuple[str, ...]:
if requested != "auto":
if requested not in BACKEND_PACKAGES:
raise BinaryResolutionError(f"unknown backend {requested}")
return (requested,)
preferred = select_backend(gpu_vendor, "macos" if target.startswith("darwin-") else "other")
fallback = ("vulkan", "cpu") if gpu_vendor else ("cpu",)
return tuple(dict.fromkeys((preferred, *fallback)))
def resolve_assets(tag: str, backend: str, os_name: str | None = None,
arch: str | None = None) -> AssetPlan:
"""Compose the asset list for (tag, backend, platform). Raises BinaryResolutionError for pairs
the release ships no artifact for; callers fall back down the ladder cuda -> vulkan -> cpu."""
host_os, host_arch = _host_os_arch()
os_name = os_name or host_os
arch = arch or host_arch
if os_name == "macos":
# macOS tarballs are unified (Metal built in).
return AssetPlan(tag, backend, [f"llama-{tag}-bin-macos-{arch}.tar.gz"])
if os_name not in _ASSET_TEMPLATES:
raise BinaryResolutionError(f"unsupported platform {os_name}-{arch}")
if os_name == "ubuntu" and backend == "cuda":
# No prebuilt Linux CUDA zips at current tags — Linux CUDA users build from source or
# use vulkan; the resolver is honest about it.
raise BinaryResolutionError(
f"no prebuilt linux CUDA asset at {tag}; use vulkan/cpu or a source build")
if os_name == "win" and backend == "vulkan" and arch == "arm64":
raise BinaryResolutionError(f"no win-vulkan-arm64 asset at {tag}")
label, templates = _ASSET_TEMPLATES[os_name]
if backend not in templates:
raise BinaryResolutionError(f"unsupported {label} backend {backend}")
cuda_ver = _WIN_CUDA_VERSION_ARM64 if arch == "arm64" else _WIN_CUDA_VERSION
return AssetPlan(tag, backend, [t.format(tag=tag, arch=arch, cuda_ver=cuda_ver)
for t in templates[backend]])
def unavailable_reason(backend: str, target: str | None = None) -> str | None:
name = BACKEND_PACKAGES.get(backend)
if name is None:
return f"unknown backend {backend}"
target = target or pm.current_target()
reason = pm.get_package(name).missing_reason(target)
if reason:
return reason
lock = pm.Lockfile(pm.paths.lockfile_path())
if not lock.version(name) or not lock.artifacts(name, target):
return f"{name} is not pinned for {target}"
return None
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def resolve_backend(requested: str = "auto", *, gpu_vendor: str | None = None,
target: str | None = None) -> str:
"""Explicit choices are strict. Auto only falls back to compatible pinned builds."""
if requested == "auto" and target is None and gpu_vendor is None:
from hermes_cli.local_runtime.bootstrap import _detect_gpu_vendor
gpu_vendor = _detect_gpu_vendor()
target = target or pm.current_target()
reasons = []
for backend in _candidates(requested, gpu_vendor, target):
reason = unavailable_reason(backend, target)
if reason is None:
return backend
reasons.append(reason)
raise BinaryResolutionError("; ".join(reasons))
def _download(url: str, dest: Path,
progress: "Callable[[int, int], None] | None" = None) -> None:
"""Stream url -> dest. ``progress(done_bytes, total_bytes)`` ticks per chunk (total 0 when
the server sends no Content-Length) — a several-hundred-MB archive must never look hung."""
logger.info("downloading %s", url)
tmp = dest.with_suffix(dest.suffix + ".part")
with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as f:
total = int(r.headers.get("Content-Length") or 0)
done = 0
while True:
chunk = r.read(1 << 20)
if not chunk:
break
f.write(chunk)
done += len(chunk)
if progress is not None:
progress(done, total)
tmp.replace(dest)
def installed_engine(backend: str = "auto", *, allow_outdated: bool = True) -> Engine | None:
"""Boot may retain a prior PM pin, but never installs or adopts unmanaged bytes."""
vendor = None
if backend == "auto":
from hermes_cli.local_runtime.bootstrap import _detect_gpu_vendor
vendor = _detect_gpu_vendor()
for candidate in _candidates(backend, vendor, pm.current_target()):
found = pm.installed_package(BACKEND_PACKAGES[candidate], allow_outdated=allow_outdated)
if found is not None and found.binary is not None:
return Engine(candidate, f"b{found.version}", found.binary)
return None
def _extract(archive: Path, dest: Path,
progress: "Callable[[int, int], None] | None" = None) -> None:
"""Extract member by member so ``progress(done, total)`` can tick in uncompressed bytes."""
if archive.name.endswith(".zip"):
opener, list_members, size = zipfile.ZipFile, "infolist", "file_size"
kwargs = {}
else:
import tarfile
opener, list_members, size = tarfile.open, "getmembers", "size"
kwargs = {"filter": "data"}
with opener(archive) as ar:
members = getattr(ar, list_members)()
total = sum(getattr(m, size) for m in members)
done = 0
for m in members:
ar.extract(m, dest, **kwargs)
done += getattr(m, size)
if progress is not None:
progress(done, total)
def server_binary(install_dir: Path) -> Path:
"""Locate llama-server within an extracted runtime (zips differ in whether they nest a
build/bin directory)."""
names = ("llama-server.exe", "llama-server")
for name in names:
direct = install_dir / name
if direct.exists():
return direct
for name in names:
hits = sorted(install_dir.rglob(name))
if hits:
return hits[0]
raise BinaryResolutionError(f"llama-server not found under {install_dir}")
def verify_install(install_dir: Path, tag: str) -> str:
"""Run --version; require the tag's build number in the output (printed WITHOUT the 'b')."""
exe = server_binary(install_dir)
out = subprocess.run([str(exe), "--version"], capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=60, cwd=str(exe.parent))
text = (out.stdout + out.stderr).strip()
if tag.lstrip("b") not in text:
raise BinaryResolutionError(
f"version check failed for {exe}: expected {tag}, got: {text[:120]}")
return text.splitlines()[0] if text else ""
def prune_old_tags(keep: list[str]) -> None:
"""Retain only the tags in ``keep`` (current + previous — N-1 rollback). The shared
``downloads/`` archive cache is not a tag and always survives."""
root = runtimes_root()
if not root.exists():
return
for entry in root.iterdir():
if entry.is_dir() and entry.name != "downloads" and entry.name not in keep:
shutil.rmtree(entry, ignore_errors=True)
logger.info("pruned old runtime %s", entry.name)
def ensure_runtime_installed(tag: str, backend: str,
expected_sha256: dict[str, str] | None = None,
progress: "Callable[[str, int, int, str], None] | None" = None) -> Path:
"""Idempotent: resolve, download, verify, extract, version-check; returns the install dir.
``expected_sha256`` pins hashes per asset; without pins the computed hash is recorded in the
manifest (trust on first download, verified on every reinstall). ``progress(stage, done,
total, label)`` ticks through download/extract/verify."""
plan = resolve_assets(tag, backend)
install_dir = plan.install_dir
manifest_path = install_dir / "manifest.json"
if manifest_verified(manifest_path):
return install_dir
install_dir.mkdir(parents=True, exist_ok=True)
downloads = runtimes_root() / "downloads"
downloads.mkdir(parents=True, exist_ok=True)
def stage_progress(stage: str, label: str):
if progress is None:
return None
tick = progress
return lambda d, t: tick(stage, d, t, label)
recorded: dict[str, str] = {}
n_assets = len(plan.assets)
for i, asset in enumerate(plan.assets, 1):
label = f"{i}/{n_assets}" if n_assets > 1 else ""
archive = downloads / asset
if not archive.exists():
_download(RELEASE_URL.format(tag=tag, asset=asset), archive,
progress=stage_progress("download", label))
if progress is not None:
progress("verify", 0, 0, label)
digest = _sha256(archive)
expected = (expected_sha256 or {}).get(asset)
if expected and digest != expected:
archive.unlink(missing_ok=True)
raise BinaryResolutionError(
f"sha256 mismatch for {asset}: expected {expected}, got {digest}")
recorded[asset] = digest
_extract(archive, install_dir, progress=stage_progress("extract", label))
if progress is not None:
progress("verify", 0, 0, "")
version = verify_install(install_dir, tag)
manifest_path.write_text(json.dumps({"tag": tag, "backend": plan.backend, "assets": recorded,
"verified_version": version}, indent=2), encoding="utf-8")
logger.info("installed llama.cpp %s (%s): %s", tag, backend, version)
return install_dir
def ensure_engine(backend: str, *, progress: Callable[[str, int, int, str], None] | None = None,
pause_event: threading.Event | None = None,
download_progress: ProgressFn | None = None) -> Engine:
"""Only deliberate install/update jobs call this. PM verifies every archive and publishes."""
resolved = resolve_backend(backend)
pm.ensure(BACKEND_PACKAGES[resolved], explicit=True, progress=progress,
pause_event=pause_event, download_progress=download_progress)
engine = installed_engine(resolved, allow_outdated=False)
if engine is None:
raise BinaryResolutionError(f"llama.cpp {resolved} install has no usable pinned binary")
return engine
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----

View File

@@ -206,35 +206,19 @@ def ensure_local_runtime(config: dict, force: bool = False) -> "object | None":
_stop_state_server(state)
try:
from hermes_cli.local_runtime.binaries import (
default_tag, ensure_runtime_installed, installed_tags, select_backend)
from hermes_cli.local_runtime.binaries import installed_engine
from hermes_cli.local_runtime.supervisor import LlamaServerSupervisor
backend = section.get("backend", "auto")
if backend == "auto":
backend = select_backend(_detect_gpu_vendor())
# Boot ladder: serve what is INSTALLED, never download here. The configured tag is
# preferred; when it isn't installed yet, the newest installed tag serves and the status
# endpoint reports the pending update — the download is a deliberate click in the pane,
# not a boot-path surprise (a multi-minute inline download here is exactly how the
# onboarding bounce returns).
tag = section.get("tag") or default_tag()
have = installed_tags()
if tag not in have:
if not have:
logger.info("local runtime enabled but no build installed; "
"install happens in the Local Models pane")
return None
logger.info("configured tag %s not installed; serving %s "
"(update is a click in Local Models)", tag, have[0])
tag = have[0]
install_dir = ensure_runtime_installed(tag, backend)
engine = installed_engine(section.get("backend", "auto"))
if engine is None:
logger.info("local runtime enabled but no PM engine installed; use the Local Models pane")
return None
mdir = models_dir()
mdir.mkdir(parents=True, exist_ok=True)
preset_path = _generate_presets(mdir, runtimes_root() / "presets.ini")
sup = LlamaServerSupervisor(install_dir, mdir, preset_path=preset_path,
sup = LlamaServerSupervisor(engine.binary, mdir, preset_path=preset_path,
models_max=int(section.get("models_max", 4)),
port=int(section.get("port", 0)) or None)
try:
@@ -246,7 +230,7 @@ def ensure_local_runtime(config: dict, force: bool = False) -> "object | None":
sup.stop()
raise
_SUPERVISOR = sup
logger.info("managed llama-server up at %s (backend=%s tag=%s)", sup.base_url, backend, tag)
logger.info("managed llama-server up at %s (backend=%s tag=%s)", sup.base_url, engine.backend, engine.tag)
_start_idle_sweeper(sup)
return sup
except Exception as exc: # noqa: BLE001 — never break session start

View File

@@ -140,20 +140,12 @@ def _kick_managed_boot(config: dict | None) -> None:
def _boot_in_flight(config: dict | None) -> bool:
"""True when the managed runtime is enabled and installed (a verified-manifest scan under
runtimes_root(), NOT a bare ``server_binary()`` call — that needs an install_dir, and calling
it bare once made this gate throw-and-return False forever, disabling the boot wait)."""
"""Wait only when an enabled runtime has a PM-installed engine to boot."""
with suppress(Exception):
config = _load_config_if_none(config)
if not ((config or {}).get("local_runtime") or {}).get("enabled"):
return False
from hermes_cli.local_runtime.binaries import manifest_verified, runtimes_root
from hermes_cli.local_runtime.binaries import installed_engine
for manifest in runtimes_root().glob("*/*/manifest.json"):
try:
if manifest_verified(manifest):
return True
except (ValueError, OSError):
continue
return False
return installed_engine(config["local_runtime"].get("backend", "auto")) is not None
return False

View File

@@ -198,15 +198,13 @@ def _engine_device_pool() -> "tuple[int, bool | None] | None":
fallback when the driver API is unreachable: asks the exact binary that will do the
allocating. Carries no integrated verdict — callers must gate it."""
with suppress(Exception): # a probe miss must never block budgeting
from hermes_cli.local_runtime.binaries import installed_tags, runtimes_root, server_binary
from hermes_cli.config import get_config_value
from hermes_cli.local_runtime.binaries import installed_engine
tags = installed_tags()
if not tags:
engine = installed_engine(get_config_value("local_runtime.backend", "auto"))
if engine is None:
return None
backend_dirs = [d for d in (runtimes_root() / tags[0]).iterdir() if d.is_dir()]
if not backend_dirs:
return None
exe = server_binary(backend_dirs[0])
exe = engine.binary
out = subprocess.run([str(exe), "--list-devices"], capture_output=True,
text=True, timeout=30, cwd=str(exe.parent))
if out.returncode != 0:

View File

@@ -21,7 +21,7 @@ import urllib.error
import urllib.request
from pathlib import Path
from hermes_cli.local_runtime.binaries import server_binary, runtimes_root
from hermes_cli.local_runtime.binaries import runtimes_root
logger = logging.getLogger(__name__)
@@ -99,12 +99,15 @@ class LlamaServerSupervisor:
# comes back to.
IDLE_UNLOAD_S = 15 * 60
def __init__(self, install_dir: Path, models_dir: Path, *,
def __init__(self, binary: Path, models_dir: Path, *,
models_max: int = 4, port: int | None = None,
extra_args: list[str] | None = None,
log_path: Path | None = None,
preset_path: Path | None = None):
self.install_dir = Path(install_dir)
# The exact engine binary (PM store path, backend-selected), handed
# in by boot — the supervisor never discovers binaries itself: a
# legacy-directory scan could resurrect bytes pm did not pin.
self.binary = Path(binary)
self.models_dir = Path(models_dir)
self.models_max = models_max
self.port = port or _stable_port()
@@ -146,7 +149,7 @@ class LlamaServerSupervisor:
# ── lifecycle ────────────────────────────────────────────
def _spawn(self) -> None:
exe = server_binary(self.install_dir)
exe = self.binary
cmd = [
str(exe),
"--host", "127.0.0.1",
@@ -278,7 +281,7 @@ class LlamaServerSupervisor:
try:
import psutil
exe = str(server_binary(self.install_dir))
exe = str(self.binary)
except Exception: # noqa: BLE001
return
own_pid = self.proc.pid if self.proc is not None else None

View File

@@ -50,9 +50,7 @@ _RUNNING: Dict[str, Dict[str, Any]] = {}
_QUICKSTART_LOCK = threading.Lock()
_LLAMACPP_PROVIDERS = ("llamacpp", "llama.cpp", "llama-cpp")
_SPLIT_PART_RE = r"-\d{5}-of-\d{5}"
# One TCP stream to a CDN rarely fills a fast line; 8 ranged connections into a preallocated file saturate gigabit.
_DOWNLOAD_CONNECTIONS = 8
_CHUNK = 4 << 20
_DOWNLOAD_PHASES = frozenset({"starting", "installing-runtime", "downloading-runtime", "downloading"})
_SERVER_START_FAILED = "The local server could not start — check the runtime is installed"
@@ -128,7 +126,7 @@ def _job(kind: str, target: str, model_id: str | None = None) -> Dict[str, Any]:
job = {
"job_id": uuid.uuid4().hex[:12], "kind": kind, "target": target,
"model_id": model_id, # catalog id for downloads; None otherwise
"status": "running", # running | done | error
"status": "running", # running | paused | done | error
"phase": "starting", # human-readable step name
"detail": "", "total_bytes": None, "done_bytes": 0, "started_at": time.time(), "error": None,
}
@@ -139,14 +137,29 @@ def _job(kind: str, target: str, model_id: str | None = None) -> Dict[str, Any]:
def _job_view(job: Dict[str, Any]) -> Dict[str, Any]:
out = dict(job)
running = _RUNNING.get(job["job_id"], {})
pause = running.get("pause")
out["pause_requested"] = bool(pause is not None and pause.is_set() and out["status"] == "running")
out["can_pause"] = bool(pause is not None and out["status"] == "running"
and out["phase"] in _DOWNLOAD_PHASES and not out["pause_requested"])
out["can_resume"] = out["status"] == "paused" and "resume" in running
if out["total_bytes"]:
out["percent"] = min(100, round(out["done_bytes"] / out["total_bytes"] * 100))
return out
def _check_job_pause(job: Dict[str, Any]) -> None:
pause = _RUNNING.get(job["job_id"], {}).get("pause")
if pause is not None and pause.is_set():
raise DownloadPaused("download paused")
def _step(job: Dict[str, Any], phase: str, detail: str) -> None:
job["phase"] = phase
job["detail"] = detail
with _JOBS_LOCK:
if job.get("phase") in _DOWNLOAD_PHASES and phase not in _DOWNLOAD_PHASES:
_check_job_pause(job)
job["phase"] = phase
job["detail"] = detail
def _finish(job: Dict[str, Any], detail: str) -> None:
@@ -159,6 +172,7 @@ def _spawn_job(job: Dict[str, Any], name: str, body: Callable[[], None], *, fail
resumable: bool = False) -> None:
"""One worker per job. Pauses retain ownership; terminal outcomes release it."""
guard = threading.Lock()
pause = threading.Event()
def _run():
status = "done"
@@ -176,22 +190,25 @@ def _spawn_job(job: Dict[str, Any], name: str, body: Callable[[], None], *, fail
job["error"] = str(exc)
finally:
try:
if status != "paused":
_RUNNING.pop(job["job_id"], None)
if on_exit is not None:
on_exit()
if status != "paused" and on_exit is not None:
on_exit()
finally:
job["status"] = status
guard.release()
with _JOBS_LOCK:
if status != "paused":
_RUNNING.pop(job["job_id"], None)
job["status"] = status
guard.release()
def start(*, initial: bool = False) -> bool:
if not guard.acquire(blocking=False):
return False
if not initial and job["status"] != "paused":
guard.release()
return False
job["status"] = "running"
job["error"] = None
with _JOBS_LOCK:
if not initial and job["status"] != "paused":
guard.release()
return False
pause.clear()
job["status"] = "running"
job["error"] = None
try:
threading.Thread(target=_run, daemon=True, name=name).start()
except Exception:
@@ -204,7 +221,7 @@ def _spawn_job(job: Dict[str, Any], name: str, body: Callable[[], None], *, fail
return True
if resumable:
_RUNNING[job["job_id"]] = {"resume": start}
_RUNNING[job["job_id"]] = {"resume": start, "pause": pause}
start(initial=True)
@@ -244,30 +261,22 @@ def _set_runtime_enabled(enabled: bool) -> dict:
return config
def _runtime_target(requested: str | None = None) -> "tuple[str, str]":
"""(tag, backend) the runtime routes act on: configured tag or release default; ``auto`` -> detected GPU vendor."""
def _runtime_target(requested: str | None = None) -> tuple[str, str]:
"""Resolve only reviewed PM artifacts; hardware work stays off the event loop."""
section = _runtime_section()
tag = section.get("tag") or binaries.default_tag()
backend = requested or section.get("backend", "auto")
if backend == "auto":
backend = binaries.select_backend(bootstrap._detect_gpu_vendor())
return tag, backend
def _resolve_assets_or_400(tag: str, backend: str):
"""Resolve first so an impossible combination fails the POST, not the job."""
with _http_error(400):
return binaries.resolve_assets(tag, backend)
backend = binaries.resolve_backend(backend)
return binaries.pinned_tag(backend), backend
def _engine_too_old(min_engine: str) -> bool:
"""True when the installed llama.cpp predates a model's requirement. Tags are release numbers (b10362);
no engine installed compares as too old only when the model states a requirement."""
def newest_installed() -> int:
tags = binaries.installed_tags() or [binaries.default_tag()]
return max(int(t.lstrip("b")) for t in tags if t.lstrip("b").isdigit())
return bool(min_engine) and _quiet(lambda: newest_installed() < int(min_engine.lstrip("b")), False)
if not min_engine:
return False
requested = _runtime_section().get("backend", "auto")
engine = binaries.installed_engine(requested)
tag = engine.tag if engine is not None else _runtime_target()[0]
return int(tag.removeprefix("b")) < int(min_engine.removeprefix("b"))
def _eligible_entries():
@@ -331,94 +340,6 @@ def _variant_files_on_disk(model_id: str) -> "list[Path]":
return files
def _probe_range_support(url: str) -> int:
"""Total size when the server honors Range requests, else 0. 401/403 = gated repo or wrong catalog
repo — raise a plain-language message, not a bare status."""
req = urllib.request.Request(url, headers={"Range": "bytes=0-0"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
content_range = r.headers.get("Content-Range", "") if r.status == 206 else ""
if "/" in content_range:
return int(content_range.rsplit("/", 1)[1])
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
raise RuntimeError("The model host refused the download (gated or moved). "
"This is a catalog problem, not yours — please report it.") from exc
raise
except Exception: # noqa: BLE001
pass
return 0
def download_file(url: str, dest: Path, job: Dict[str, Any], *, base_done: int = 0, keep_totals: bool = False) -> None:
"""Download url -> dest with byte progress on ``job``; ranged-parallel when the server supports it,
single-stream otherwise. Never leaves a .part. Completeness is checked only against what the SERVER
declared (range-probe total / Content-Length), never the CATALOG (its sizes may lag a re-upload), so a
dropped connection still errors instead of staging a truncated file. Multi-file variants: ``base_done``
offsets progress onto earlier files; ``keep_totals=True`` keeps the per-file size from overwriting the
variant's total."""
tmp = dest.with_suffix(".part")
dest.parent.mkdir(parents=True, exist_ok=True)
file_done = [0]
progress_lock = threading.Lock()
errors: list[Exception] = []
def pump(r, f) -> None:
for chunk in iter(lambda: r.read(_CHUNK), b""):
f.write(chunk)
with progress_lock:
file_done[0] += len(chunk)
job["done_bytes"] = base_done + file_done[0]
def fetch_range(start: int, end: int) -> None:
try:
req = urllib.request.Request(url, headers={"Range": f"bytes={start}-{end}"})
with urllib.request.urlopen(req, timeout=120) as r, open(tmp, "r+b") as f:
f.seek(start)
pump(r, f)
except Exception as exc: # noqa: BLE001
errors.append(exc)
try:
# Probe and preallocation take real seconds on a 20+ GB file — narrate them, or the pane shows a dead '— of X GB'.
job["detail"] = "Connecting"
total = _probe_range_support(url)
if total:
if not keep_totals:
job["total_bytes"] = total
# Preallocate so each worker writes at its own offset.
job["detail"] = f"Reserving {_human_gb(total)} of disk space"
with open(tmp, "wb") as f:
f.truncate(total)
job["detail"] = ""
n = _DOWNLOAD_CONNECTIONS
threads = [threading.Thread(target=fetch_range, daemon=True, name=f"lm-dl-{i}",
args=(i * total // n, (i + 1) * total // n - 1)) for i in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
if errors:
raise errors[0]
if file_done[0] != total:
raise RuntimeError(f"download incomplete ({file_done[0]} of {total} bytes)")
else:
# No range support: single stream; completeness judged by the server's
# own Content-Length when it sent one — never the catalog.
with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as f:
length = int(r.headers.get("Content-Length") or 0)
if length and not keep_totals:
job["total_bytes"] = length
pump(r, f)
if length and file_done[0] != length:
raise RuntimeError(f"Download ended at {file_done[0]:,} bytes but the server "
f"said {length:,} — connection dropped? Removed; try again")
shutil.move(str(tmp), str(dest))
except Exception:
tmp.unlink(missing_ok=True)
raise
def _download_plan(entry, variant) -> list:
"""Everything a variant needs: split parts + mmproj/draft assets, as (url, dest, bytes) tuples."""
plan = [(_hf_url(entry.repo, a.path), bootstrap.models_dir() / a.local_name, a.size_bytes) for a in variant.files]
@@ -428,47 +349,23 @@ def _download_plan(entry, variant) -> list:
def _run_download_plan(job: Dict[str, Any], plan: list, label: str) -> None:
"""Download every missing file in ``plan`` via pm.downloader (one resumable
Download per job); already-present files count toward progress without a
transfer. Raises DownloadPaused straight through: the caller's worker
parks the job — the sequence must never advance past a pause. Handle
lifecycle (dl registration, resume handle, _RUNNING cleanup) belongs to
_spawn_job, not here."""
_step(job, "downloading", f"{label} — {_human_gb(sum(p[2] for p in plan))}")
done_before = 0
for url, dest, size in plan:
if not dest.exists():
_download_job(job, [(url, dest, size)])
job["phase"] = "downloading"
done_before += size
job["done_bytes"] = done_before
"""The downloader counts completed files and every part of this plan."""
_step(job, "downloading", f"Downloading {label}")
_download_job(job, plan)
def _download_progress_hook(job: Dict[str, Any]):
def tick(done: int, total: int, ranges: dict) -> None:
with _JOBS_LOCK:
job.update(done_bytes=done, total_bytes=total or None, ranges=ranges)
return tick
def _download_job(job: Dict[str, Any], plan) -> None:
"""Run a plan of (url, dest, size) downloads as ONE resumable Download.
Completion and range progress feed the job dict (done_bytes /
total_bytes / ranges — the bar reads the aggregate, the detail view
reads the bitmap; both come from the same callback). Sources carry NO
hash by design: catalog sizes may lag an upstream re-upload, so
completeness is judged by the downloader against the server's declared
total, never the catalog. On pause the downloader raises
DownloadPaused and this propagates to the caller: the worker parks the
job 'paused' with its partials intact for a later resume. The live
handle is kept OFF the job dict (which stays JSON-serializable for the
poll route) under _RUNNING.
"""
dl = Download([Source(url, dest) for url, dest, _ in plan])
_RUNNING.setdefault(job["job_id"], {})["dl"] = dl
def tick(done: int, total: int, ranges: dict) -> None:
job["done_bytes"] = done
job["total_bytes"] = total
job["ranges"] = ranges
try:
dl.run(progress=tick)
finally:
_RUNNING.get(job["job_id"], {}).pop("dl", None)
"""Model bytes use the same resumable transfer as pinned PM archives."""
running = _RUNNING.get(job["job_id"], {})
dl = Download([Source(url, dest) for url, dest, _ in plan], pause_event=running.get("pause"))
dl.run(progress=_download_progress_hook(job))
def _loaded_models(running: Dict[str, Any]) -> "tuple[Dict[str, str], Dict[str, Any]]":
@@ -495,13 +392,6 @@ def _loaded_models(running: Dict[str, Any]) -> "tuple[Dict[str, str], Dict[str,
return loaded, placement
def _installed_backend(tag: str) -> str | None:
"""Name of the first backend dir under ``tag`` with a working server binary."""
root = binaries.runtimes_root() / tag
dirs = sorted(p for p in root.iterdir() if p.is_dir()) if root.exists() else []
return next((d.name for d in dirs if _quiet(lambda: binaries.server_binary(d), None) is not None), None)
def _staged_row(gguf: Path) -> Dict[str, Any]:
model_id = _model_id_for(gguf)
# Split models: report the whole variant's bytes, not one part's.
@@ -527,11 +417,13 @@ def local_models_status():
"""Cheap, immediate: config state + installed runtime + staged models + supervisor state (GPU facts live
in /hardware). Sync def on purpose: blocking urlopen/scans run in the threadpool."""
section = _runtime_section()
configured_tag = section.get("tag") or binaries.default_tag()
have = binaries.installed_tags()
# The tag actually serving (boot ladder: configured if installed, else newest installed).
tag = configured_tag if configured_tag in have else (have[0] if have else configured_tag)
runtime_backend = _installed_backend(tag)
engine = binaries.installed_engine(section.get("backend", "auto"))
runtime_backend = engine.backend if engine is not None else None
configured_tag = binaries.pinned_tag(runtime_backend) if runtime_backend else _runtime_target()[0]
tag = engine.tag if engine is not None else configured_tag
import pm
current = pm.installed_package(binaries.BACKEND_PACKAGES[runtime_backend]) if runtime_backend else None
mdir = bootstrap.models_dir()
running = _state_endpoint()
# Resident models from the live router ({} when down): Loaded pills + eject. A failed read is never
@@ -540,9 +432,7 @@ def local_models_status():
lambda: _loaded_models(running), ({}, {}), warn="loaded-models read failed: %r")
return {
"enabled": bool(section.get("enabled")), "tag": tag, "configured_tag": configured_tag,
# Update pending = engine in use (enabled + something installed) and the configured tag
# (pinned or release default) isn't on disk. The download is a button click, never automatic.
"update_available": bool(section.get("enabled") and have and configured_tag not in have),
"update_available": bool(section.get("enabled") and engine is not None and current is None),
"runtime_installed": runtime_backend is not None, "runtime_backend": runtime_backend,
"server_running": running is not None, "server_base_url": (running or {}).get("base_url"),
"active_model_id": _active_llamacpp_model_id(), "loaded_models": loaded,
@@ -596,7 +486,8 @@ _QUANT_REASON_COMPACT = "Compact build sized for this machine ({quant}) — larg
def _catalog_row(entry, budget, recommended, recommended_reason, staged_ids) -> Dict[str, Any]:
choice = catalog.select_variant(entry, budget)
# Any variant of this family on disk counts as downloaded.
dl = next((v for v in entry.variants if v.model_id in staged_ids), None)
dl = next((v for v in entry.variants if v.model_id in staged_ids
and all(dest.is_file() for _, dest, _ in _download_plan(entry, v))), None)
row: Dict[str, Any] = {
"id": entry.id, "display_name": entry.display_name, "description": entry.description,
"native_context": entry.n_ctx_train, "native_context_label": _k_label(entry.n_ctx_train),
@@ -669,68 +560,43 @@ def local_models_catalog():
# ── runtime install (job) ────────────────────────────────────
def _runtime_progress_hook(job: Dict[str, Any]):
"""Adapter: ensure_runtime_installed's progress stream -> job fields, throttled to ~4 updates/s. Byte
counters are CUMULATIVE across the plan (a multi-asset engine reads as one growing download, total
growing as each asset's size becomes known); unpack/verify keep the counters — a bar bouncing back to
zero after the bytes finished reads as failure."""
state = {"last": 0.0, "banked": 0, "asset": None, "asset_total": 0}
"""PM owns byte accounting; stages describe work without resetting it."""
phases = {
"download": ("downloading-runtime", "Downloading the local engine"),
"unpack": ("unpacking-runtime", "Unpacking the local engine"),
"verify": ("verifying-runtime", "Verifying the local engine"),
}
def hook(stage: str, done: int, total: int, label: str) -> None:
now = time.monotonic()
if now - state["last"] < 0.25 and done < total:
return
state["last"] = now
suffix = f" ({label})" if label else ""
if stage == "download":
if label != state["asset"]:
# Previous asset finished: bank its bytes so the counters keep climbing instead of restarting.
state["banked"] += state["asset_total"]
state["asset"] = label
state["asset_total"] = total or done
plan_done = state["banked"] + done
plan_total = state["banked"] + (total or 0)
_step(job, "downloading-runtime", f"Downloading the local engine{suffix} — {_human_gb(plan_done)}"
+ (f" of {_human_gb(plan_total)}" if total else ""))
job["done_bytes"] = plan_done
job["total_bytes"] = plan_total or None
elif stage == "extract":
pct = f" — {min(100, round(done / total * 100))}%" if total else ""
_step(job, "unpacking-runtime", f"Unpacking the engine{suffix}{pct}")
else: # verify
_step(job, "verifying-runtime", f"Verifying the engine{suffix}")
phase, detail = phases[stage]
_step(job, phase, detail + (f" ({label})" if label else ""))
return hook
def _restart_on_new_tag(job: Dict[str, Any], tag: str, previous: list) -> bool:
"""Engine update path: a server already running on an older tag moves to the new one now — the click was
the consent. Fresh installs (no server) skip this; Use/boot handles their start."""
if bootstrap.get_supervisor() is None or not previous or tag in previous:
return False
_step(job, "restarting", "Switching the running server to the new build")
bootstrap.shutdown_local_runtime()
bootstrap.ensure_local_runtime(_load_config(), force=True)
return True
def _install_engine_job(job: Dict[str, Any], backend: str):
_step(job, "installing-runtime", "Preparing the pinned local engine")
return binaries.ensure_engine(
backend, progress=_runtime_progress_hook(job),
download_progress=_download_progress_hook(job),
pause_event=_RUNNING[job["job_id"]]["pause"],
)
@router.post("/api/local-models/runtime/install")
async def local_models_runtime_install(body: RuntimeInstallBody):
def local_models_runtime_install(body: RuntimeInstallBody):
tag, backend = _runtime_target(body.backend)
plan = _resolve_assets_or_400(tag, backend)
job = _job("runtime-install", f"llama.cpp {tag} ({backend})")
def _run():
previous = binaries.installed_tags()
_step(job, "downloading", f"Fetching {len(plan.assets)} package(s) for {backend}")
binaries.ensure_runtime_installed(tag, backend, progress=_runtime_progress_hook(job))
# Restart failure is logged only: the new build is installed either way and the next boot serves it.
restarted = _quiet(lambda: _restart_on_new_tag(job, tag, previous), False, warn="post-update restart skipped: %s")
# N-1 retention, only after the new tag verified: keep it + the newest previous build as the rollback pin target.
_quiet(lambda: binaries.prune_old_tags([tag] + [t for t in previous if t != tag][:1]), None,
warn="runtime prune skipped: %s")
_finish(job, f"llama.cpp {tag} ready ({backend})" + (" — server restarted on the new build" if restarted else ""))
def run():
previous = binaries.installed_engine(backend)
engine = _install_engine_job(job, backend)
running = bootstrap.get_supervisor()
if running is not None and previous != engine:
_step(job, "restarting", "Switching the running server to the pinned build")
bootstrap.refresh_local_runtime()
_finish(job, f"llama.cpp {tag} ready ({backend})")
_spawn_job(job, "lr-runtime-install", _run, fail_msg="runtime install failed: %s")
_spawn_job(job, "lr-runtime-install", run, fail_msg="runtime install failed: %s", resumable=True)
return {"job_id": job["job_id"], "backend": backend, "tag": tag}
@@ -751,19 +617,15 @@ def _download_target(model_id: str):
@router.post("/api/local-models/download")
async def local_models_download(body: ModelDownloadBody):
def local_models_download(body: ModelDownloadBody):
"""Accepts either a family id (downloads this machine's selected variant) or an exact variant model_id."""
entry, variant = _download_target(body.model_id)
if variant.model_id in bootstrap.staged_model_ids():
return {"job_id": None, "already_downloaded": True, "model_id": variant.model_id}
plan = _download_plan(entry, variant)
if plan and all(dest.is_file() for _, dest, _ in plan):
return {"job_id": None, "already_downloaded": True, "model_id": variant.model_id}
job = _job("model-download", f"{entry.display_name} ({variant.quant})", model_id=entry.id)
job["total_bytes"] = sum(p[2] for p in plan)
def _run():
job["phase"] = "downloading"
job["detail"] = f"{entry.display_name} — {_human_gb(job['total_bytes'])}"
_download_job(job, plan)
_run_download_plan(job, plan, entry.display_name)
_finish(job, f"{entry.display_name} ready")
_refresh_runtime("post-download runtime refresh skipped")
@@ -773,29 +635,24 @@ async def local_models_download(body: ModelDownloadBody):
@router.post("/api/local-models/download/pause")
async def local_models_download_pause(body: JobIdBody):
"""Pause a running model download. The downloader's live handle is
checked off the job dict (which stays JSON-serializable for the poll
route); when none is active (already done/paused) this is a no-op."""
running = _RUNNING.get(body.job_id)
if running is None:
"""Pause the download phase of a model, component or quickstart job."""
job = _JOBS.get(body.job_id)
if job is None:
raise HTTPException(status_code=404, detail="unknown download job")
dl = running.get("dl")
if dl is None:
return {"ok": True, "paused": False}
dl.pause()
with _JOBS_LOCK:
if not _job_view(job)["can_pause"]:
return {"ok": True, "paused": False}
_RUNNING[body.job_id]["pause"].set()
return {"ok": True, "paused": True}
@router.post("/api/local-models/download/resume")
async def local_models_download_resume(body: JobIdBody):
"""Resume a paused model download."""
running = _RUNNING.get(body.job_id)
if running is None:
"""Restart the same job; PM reuses verified files and durable ranges."""
if body.job_id not in _JOBS:
raise HTTPException(status_code=404, detail="unknown download job")
resume = running.get("resume")
if resume is None:
return {"ok": True, "resumed": False}
return {"ok": True, "resumed": resume()}
resume = _RUNNING.get(body.job_id, {}).get("resume")
return {"ok": True, "resumed": bool(resume and resume())}
@router.delete("/api/local-models/models/{model_id}")
@@ -833,34 +690,33 @@ def _quickstart_target(body: QuickstartBody, budget):
@router.post("/api/local-models/quickstart")
async def local_models_quickstart(body: QuickstartBody):
def local_models_quickstart(body: QuickstartBody):
"""One job: install the runtime (if missing), download this machine's build of the recommended model (if
missing), make it the default. Each leg is the same code the individual routes run, so 'Configure' and
quickstart can never disagree. Preflight rejects (no servable entry, engine too old) fail the POST
synchronously so the button can explain itself; everything slow runs in the job with phase/byte progress."""
entry, variant = _quickstart_target(body, hardware.probe_budget(planning=True))
tag, backend = _runtime_target()
need_runtime = not binaries.installed_tags()
if need_runtime:
_resolve_assets_or_400(tag, backend)
need_download = variant.model_id not in bootstrap.staged_model_ids()
download_plan = _download_plan(entry, variant) if need_download else []
need_runtime = binaries.installed_engine(backend) is None
download_plan = _download_plan(entry, variant)
need_download = any(not dest.is_file() for _, dest, _ in download_plan)
if not need_download:
download_plan = []
download_bytes = sum(p[2] for p in download_plan)
if not _QUICKSTART_LOCK.acquire(blocking=False):
raise HTTPException(status_code=409, detail="Setup is already running")
job = _job("quickstart", entry.display_name, model_id=entry.id)
job["total_bytes"] = download_bytes or None
def _run():
if need_runtime:
_step(job, "installing-runtime", "Installing the local engine")
binaries.ensure_runtime_installed(tag, backend, progress=_runtime_progress_hook(job))
if need_runtime and binaries.installed_engine(backend) is None:
_install_engine_job(job, backend)
if need_download:
# The runtime leg repurposed the byte counters for its own stages — reset them to the model plan.
job["done_bytes"] = 0
job["total_bytes"] = download_bytes
# Each phase has its own complete download plan. Resume within a
# phase retains counters until PM reports the durable bytes.
if job["phase"] != "downloading":
with _JOBS_LOCK:
job.update(done_bytes=0, total_bytes=None, ranges={})
_run_download_plan(job, download_plan, entry.display_name)
# Activate: same sequence as /activate's job body.
_step(job, "starting-server", "Starting the local server")
_ensure_server(job, _set_runtime_enabled(True), variant.model_id,
fail_detail="The local server could not start — open Local Models for details",
skip_msg="quickstart rescan check skipped")
@@ -958,17 +814,19 @@ async def local_models_activate(body: ModelActivateBody):
async def local_models_jobs():
"""All recent jobs, running first — the pane and app-level poller rediscover in-flight work here after a remount."""
with _JOBS_LOCK:
jobs = sorted(_JOBS.values(), key=lambda j: (j["status"] != "running", -j["started_at"]))
return {"jobs": [_job_view(job) for job in jobs[:20]]}
jobs = sorted(_JOBS.values(), key=lambda job: -job["started_at"])
active = [job for job in jobs if job["status"] in ("running", "paused")]
recent = [job for job in jobs if job["status"] in ("done", "error")][:20]
return {"jobs": [_job_view(job) for job in [*active, *recent]]}
@router.get("/api/local-models/jobs/{job_id}")
async def local_models_job(job_id: str):
with _JOBS_LOCK:
job = _JOBS.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
return _job_view(job)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
return _job_view(job)
# ── Hugging Face browser: search, repo files, arbitrary download ─
@@ -1003,17 +861,13 @@ async def local_models_download_browsed(body: BrowsedDownloadBody):
return {"job_id": None, "already_downloaded": True, "model_id": model_id}
job = _job("model-download", f"{model_id} (from {body.repo})", model_id=model_id)
def _fetch():
job["phase"] = "downloading"
for p in paths:
dest = bootstrap.models_dir() / p.rsplit("/", 1)[-1]
if dest.exists():
continue
download_file(_hf_url(body.repo, urllib.parse.quote(p)), dest, job,
base_done=int(job.get("done_bytes") or 0), keep_totals=bool(job.get("total_bytes")))
job["phase"] = "downloading"
plan = [(_hf_url(body.repo, urllib.parse.quote(path)),
bootstrap.models_dir() / path.rsplit("/", 1)[-1], 0) for path in paths]
_spawn_job(job, "lm-download-browsed", _fetch, download_label=model_id)
def _fetch():
_run_download_plan(job, plan, model_id)
_spawn_job(job, "lm-download-browsed", _fetch, download_label=model_id, resumable=True)
return {"job_id": job["job_id"], "model_id": model_id}

View File

@@ -19,6 +19,7 @@ from pm.ensure import (
ensure,
env_for,
is_installed,
installed_package,
lazy_installs_allowed,
sync_venv,
uv,
@@ -33,6 +34,7 @@ __all__ = [
"ensure",
"env_for",
"is_installed",
"installed_package",
"adopt",
"check",
"activate",

View File

@@ -14,7 +14,7 @@ exactly the ranges the bitmap says are missing.
The progress callback reports the whole job AND the per-dest bitmap:
``progress(overall_done, overall_total, ranges)`` where ``ranges`` maps
``dest.name`` to half-open [start, end) runs. ``done_bytes`` is the sum;
the destination path to half-open [start, end) runs. ``done_bytes`` is the sum;
``ranges`` is the shape — one datum, two resolutions.
"""
@@ -176,6 +176,10 @@ def gc_protected_names(
protected.add(entry.name)
except OSError:
protected.add(entry.name) # unreadable → assume live
for name in tuple(protected):
path = Path(name)
if path.suffix in (".part", ".ranges"):
protected.update({path.with_suffix(".part").name, path.with_suffix(".ranges").name})
return protected
@@ -202,6 +206,7 @@ class Download:
resume: bool = True,
connections: int = CONNECTIONS,
partials_dir: Optional[Path] = None,
pause_event: Optional[threading.Event] = None,
):
self.sources = [Source(s.url, Path(s.dest), s.sha256) for s in sources]
self.resume = resume
@@ -212,7 +217,8 @@ class Download:
from pm import paths
self.partials_dir = paths.partials_root()
self._paused = threading.Event()
self._owns_pause_event = pause_event is None
self._paused = pause_event if pause_event is not None else threading.Event()
def pause(self) -> None:
"""Request a stop between chunks; run() raises DownloadPaused."""
@@ -220,56 +226,76 @@ class Download:
def run(self, progress: Optional[ProgressFn] = None) -> list[Path]:
"""Fetch every source; return the moved destination paths."""
self._paused.clear()
if self._owns_pause_event:
self._paused.clear()
self._check_pause()
self.partials_dir.mkdir(parents=True, exist_ok=True)
for source in self.sources:
if not (source.url.startswith("https://")
or source.url.startswith(_LOOPBACK)):
raise ValueError(f"refusing non-https url: {source.url}")
# Probe every source up front so overall_total is the whole job.
probed = [] # (source, total, range_supported)
# Probe the whole plan before reporting a denominator. A missing
# Content-Length keeps the bar indeterminate until that file ends.
probed = []
totals: dict[str, int] = {}
unknown: set[str] = set()
coverage: dict[str, _Ranges] = {}
for source in self.sources:
if _existing_dest_ok(source):
probed.append((source, source.dest.stat().st_size, True))
continue
# A pre-existing dest that fails its pinned hash is stale — clear
# it so the fetch below replaces it rather than trusting it.
if source.dest.exists():
source.dest.unlink(missing_ok=True)
total, supported = self._probe(source.url)
probed.append((source, total, supported))
overall_total = sum(t for _, t, _ in probed)
self._check_pause()
key = str(source.dest)
cached = _existing_dest_ok(source)
if cached:
total, supported = source.dest.stat().st_size, True
covered = [(0, total)]
else:
if source.dest.exists():
source.dest.unlink(missing_ok=True)
total, supported = self._probe(source.url)
covered = self._partial_ranges(source.url, total) if supported else []
if not total:
unknown.add(key)
totals[key] = total
coverage[key] = _coalesce(covered)
probed.append((source, total, supported, cached))
progress_lock = threading.Lock()
def report(key: str, written: _Ranges) -> None:
with progress_lock:
# The last key identifies the source reporting this tick.
coverage.pop(key, None)
coverage[key] = list(written)
if progress is not None:
done = sum(b - a for rows in coverage.values() for a, b in rows)
progress(done, 0 if unknown else sum(totals.values()), dict(coverage))
moved: list[Path] = []
done_base = 0
completed: dict = {}
for source, total, supported in probed:
if _existing_dest_ok(source):
size = source.dest.stat().st_size
completed[source.dest.name] = [(0, size)]
done_base += size
moved.append(source.dest)
continue
for source, total, supported, cached in probed:
self._check_pause()
key = str(source.dest)
report(key, coverage[key])
if not cached:
def tick(written: _Ranges) -> None:
report(key, written)
def tick(written: _Ranges) -> None:
if progress is not None:
ranges = dict(completed)
ranges[source.dest.name] = written
progress(done_base + sum(b - a for a, b in written),
overall_total, ranges)
if total and supported:
self._fetch_ranged(source, total, tick)
else:
self._fetch_single(source, total, tick)
done_base += total
completed[source.dest.name] = [(0, total)]
if total and supported:
self._fetch_ranged(source, total, tick)
else:
self._fetch_single(source, total, tick)
size = source.dest.stat().st_size
totals[key] = size
unknown.discard(key)
report(key, [(0, size)])
moved.append(source.dest)
return moved
# ── internals ─────────────────────────────────────────────
def _check_pause(self) -> None:
if self._paused.is_set():
raise DownloadPaused("download paused")
@staticmethod
def _probe(url: str) -> tuple[int, bool]:
"""(total bytes, range_supported). A server that ignores Range
@@ -306,6 +332,17 @@ class Download:
except (OSError, ValueError):
return []
def _partial_ranges(self, url: str, total: int) -> _Ranges:
key = self._key(url)
part = self.partials_dir / f"{key}.part"
if not part.is_file():
return []
size = part.stat().st_size
covered = self._load_sidecar(self.partials_dir / f"{key}.ranges")
if any(a < 0 or b < a or b > min(size, total) for a, b in covered):
return []
return _coalesce(covered)
@staticmethod
def _write_sidecar(side: Path, covered: _Ranges) -> None:
side.parent.mkdir(parents=True, exist_ok=True)
@@ -315,9 +352,7 @@ class Download:
key = self._key(source.url)
part = self.partials_dir / f"{key}.part"
side = self.partials_dir / f"{key}.ranges"
covered = self._load_sidecar(side)
if any(b > total for _, b in covered):
covered = [] # server now serves a smaller file: stale partial
covered = self._partial_ranges(source.url, total)
# Create the file WITHOUT truncating an existing partial (resume),
# then size it to total (extends with zeros / shrinks stale tails).
open(part, "ab").close()
@@ -358,7 +393,7 @@ class Download:
with lock:
covered[:] = _coalesce(covered + [(start, pos)])
written[0] += len(chunk)
tick(list(covered))
tick(list(covered))
except Exception as exc: # noqa: BLE001
with lock:
errors.append(exc)
@@ -402,6 +437,7 @@ class Download:
self._write_sidecar(side, covered)
raise DownloadError(
f"download incomplete ({written[0]} of {total} bytes)")
self._write_sidecar(side, covered)
self._finalize(source, part, side)
def _fetch_single(self, source: Source, total: int, tick) -> None:
@@ -443,9 +479,11 @@ class Download:
except Exception:
self._write_sidecar(side, covered)
raise
self._write_sidecar(side, covered)
self._finalize(source, part, side)
def _finalize(self, source: Source, part: Path, side: Path) -> None:
self._check_pause()
if source.sha256:
actual = _sha256_file(part)
if actual != source.sha256:
@@ -454,6 +492,7 @@ class Download:
raise HashError(
f"sha256 mismatch for {source.url}: pinned "
f"{source.sha256}, got {actual}")
self._check_pause()
source.dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(part), str(source.dest))
side.unlink(missing_ok=True)

View File

@@ -6,10 +6,13 @@ from __future__ import annotations
import json
import logging
import shutil
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from pm import paths
from pm.downloader import DownloadPaused, ProgressFn
from pm.lock import Facts, Lockfile
from pm.package import InstallError, Package, Runner, StatePackage, compose_env
from pm.registry import get_package, walk
@@ -42,17 +45,49 @@ def _store() -> Store:
return Store(paths.store_root())
def _installed_location(package: Package, lockfile: Lockfile, target: str, *, verify: bool = False):
"""Select matching shipped bytes first, then an added pinned store entry."""
def _installed_location(package: Package, lockfile: Lockfile, target: str, *,
verify: bool = False, allow_outdated: bool = False):
"""Prefer the current pin. An explicit read may retain a prior PM install."""
roots = dict.fromkeys([paths.store_root(), paths.writable_store_root()])
fallback = None
for root in roots:
store = Store(root)
facts = _facts() if root == paths.store_root() else Facts(root / "facts.json")
fact = facts.get(package.name)
if not fact or not facts.installed(package.name, None, root):
continue
binary = package.binary(store.entry(fact["entry"]), target)
if binary is not None and not binary.is_file():
continue
if verify and not _entry_verified(package, fact, store, target):
continue
if facts.installed(package.name, lockfile.version(package.name), root,
_identity(lockfile, package.name, target)):
if not verify or _entry_verified(package, facts.get(package.name), store, target):
return facts, store
return None
return facts, store
if (allow_outdated and fact.get("target") == target
and fact.get("artifacts") and fact.get("digest")):
fallback = facts, store
return fallback
@dataclass(frozen=True)
class InstalledPackage:
path: Path
version: str
binary: Path | None
def installed_package(name: str, *, allow_outdated: bool = False) -> InstalledPackage | None:
"""Read the selected PM entry without installing or changing its facts."""
package = get_package(name)
target = current_target()
location = _installed_location(package, _lockfile(), target, allow_outdated=allow_outdated)
if location is None:
return None
facts, store = location
fact = facts.get(name)
entry = store.entry(fact["entry"])
return InstalledPackage(entry, fact["version"], package.binary(entry, target))
def _identity(lockfile: Lockfile, name: str, target: str):
@@ -179,6 +214,8 @@ def _install(
store: Store,
target: str,
progress=None,
pause_event: threading.Event | None = None,
download_progress: ProgressFn | None = None,
) -> None:
version = lockfile.version(package.name)
if version is None:
@@ -194,6 +231,8 @@ def _install(
entry_name = package.store_entry(version, target)
with store.install_lock():
if pause_event is not None and pause_event.is_set():
raise DownloadPaused("install paused")
facts.reload()
entry = store.entry(entry_name)
previous_entry = store.entry(f".previous-{entry_name}")
@@ -219,12 +258,20 @@ def _install(
staged = scratch / "tree"
previous = facts.get(package.name)
try:
for index, artifact in enumerate(artifacts):
def tick(done, total, ranges):
if progress is not None:
active = next(reversed(ranges))
index = next(i for i, artifact in enumerate(artifacts) if artifact["url"] == active)
label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else ""
progress("download", done, total, label)
if download_progress is not None:
download_progress(done, total, ranges)
archives = store.fetch_many(artifacts, scratch, progress=tick, pause_event=pause_event)
for index, archive in enumerate(archives):
if pause_event is not None and pause_event.is_set():
raise DownloadPaused("install paused")
label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else ""
archive = store.fetch(
artifact["url"], artifact["sha256"], scratch,
progress=_artifact_progress(progress, index, len(artifacts)),
)
if progress is not None:
progress("unpack", 0, 0, label)
if index == 0:
@@ -235,7 +282,11 @@ def _install(
extra = scratch / f"extra-{index}"
package.unpack(archive, extra, target)
merge_tree(extra, staged)
if pause_event is not None and pause_event.is_set():
raise DownloadPaused("install paused")
package.stage(store, staged, version, target)
if progress is not None:
progress("verify", 0, 0, "")
reason = package.verify(staged, target)
if reason:
raise InstallError(package.name, f"staged entry failed verification: {reason}")
@@ -257,7 +308,7 @@ def _install(
raise
if previous_entry.exists():
_remove_entry(store, previous_entry.name)
except InstallError:
except (InstallError, DownloadPaused):
raise
except Exception as e:
raise InstallError(package.name, f"install failed: {e}") from e
@@ -393,6 +444,8 @@ def ensure(
base_env: Optional[dict] = None,
explicit: bool = False,
progress=None,
pause_event: threading.Event | None = None,
download_progress: ProgressFn | None = None,
) -> Runner:
"""``explicit`` marks a deliberate install command (`hermes pm
install`, `hermes pm bundle`) — those ARE the remedy the lazy-install
@@ -415,7 +468,8 @@ def ensure(
store = Store(paths.writable_store_root())
facts = _facts() if store.root == paths.store_root() else Facts(store.root / "facts.json")
for package in missing:
_install(package, lockfile, facts, store, target, progress=progress)
_install(package, lockfile, facts, store, target, progress=progress,
pause_event=pause_event, download_progress=download_progress)
return Runner(name, env_for(name, base_env=base_env))

View File

@@ -225,6 +225,19 @@
},
"version": "10362"
},
"llamacpp-hip": {
"artifacts": {
"linux-x64": {
"sha256": "8df777b54d88405fb5d8bb8207b80ae0d2051cb03f5a7dd69f4b1f67f94f3f2b",
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-ubuntu-rocm-7.14-x64.tar.gz"
},
"win32-x64": {
"sha256": "fcd1899e9577aa8805e761eb8feb8fde028644975b8c7270edd66c60bcceceb4",
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-win-rocm-7.14-x64.zip"
}
},
"version": "10362"
},
"llamacpp-metal": {
"artifacts": {
"darwin-arm64": {

View File

@@ -1102,6 +1102,16 @@ class LlamaCppVulkan(LlamaCpp):
}
@register
class LlamaCppHip(LlamaCpp):
name = "llamacpp-hip"
backend = "hip"
assets = {
"win32-x64": "win-rocm-7.14-x64",
"linux-x64": "ubuntu-rocm-7.14-x64",
}
@register
class LlamaCppMetal(LlamaCpp):
"""macOS archives are unified builds with Metal compiled in."""

View File

@@ -7,6 +7,7 @@ import platform
import shutil
import stat
import sys
import threading
import tempfile
import time
from contextlib import contextmanager
@@ -127,24 +128,6 @@ def hash_url(url: str) -> str:
return digest.hexdigest()
def download(url: str, dest: Path, sha256: str, progress=None) -> Path:
"""Fetch url into dest dir, hash-verified, via the resumable downloader.
The digest is proven before the caller ever sees the file.
``progress(done, total)`` ticks per chunk — a several-hundred-MB engine
archive on a slow line must never look hung. Partial state lives in
the downloader's writable partials area (outside scratch, keyed by
sha256(url)), so an interrupted = failed fetch resumes on the next
call instead of re-fetching the whole archive. Non-https/non-loopback
urls are refused by Download itself (ValueError)."""
from pm.downloader import Download, Source
dest.mkdir(parents=True, exist_ok=True)
archive = dest / url.rsplit("/", 1)[-1]
p = (lambda d, t, r: progress(d, t)) if progress is not None else None
Download([Source(url, archive, sha256)]).run(progress=p)
return archive
def extract(archive: Path, dest: Path) -> None:
import tarfile
@@ -272,26 +255,50 @@ class Store:
def published(self, name: str) -> bool:
return self.entry(name).is_dir()
def fetch(self, url: str, sha256: str, scratch: Path, progress=None) -> Path:
"""Verified archive for url, from the store if already fetched.
The cache entry is `fetch-<full sha256>/` holding the single file.
The cached file is RE-HASHED against the requested digest before
it is returned: the cache lives on a mutable disk, so trust is
re-proven, not assumed. On mismatch the entry is deleted and the
archive re-downloaded (the 100MB+ re-hash is install-time only)."""
entry_name = f"fetch-{sha256}"
entry = self.entry(entry_name)
if entry.is_dir():
files = [p for p in entry.iterdir() if p.is_file()]
if len(files) == 1 and sha256_file(files[0]) == sha256:
return files[0]
shutil.rmtree(entry, ignore_errors=True)
archive = download(url, scratch, sha256, progress=progress)
staged = scratch / entry_name
staged.mkdir(parents=True)
archive.rename(staged / archive.name)
published = self.publish(staged, entry_name)
return published / archive.name
def fetch(self, url: str, sha256: str, scratch: Path, progress=None, *, pause_event: threading.Event | None = None) -> Path:
tick = (lambda done, total, ranges: progress(done, total)) if progress is not None else None
return self.fetch_many([{"url": url, "sha256": sha256}], scratch,
progress=tick, pause_event=pause_event)[0]
def fetch_many(self, artifacts: list[dict], scratch: Path, *, progress=None,
pause_event: threading.Event | None = None) -> list[Path]:
"""Fetch a pinned plan with one aggregate progress stream.
Completed archives enter the cache even if a later source pauses.
The downloader owns partial bytes outside this disposable scratch.
"""
from pm.downloader import Download, DownloadPaused, Source
sources = []
for artifact in artifacts:
if pause_event is not None and pause_event.is_set():
raise DownloadPaused("download paused")
url, digest = artifact["url"], artifact["sha256"]
entry_name = f"fetch-{digest}"
entry = self.entry(entry_name)
files = list(entry.iterdir()) if entry.is_dir() else []
if len(files) == 1 and files[0].is_file() and sha256_file(files[0]) == digest:
destination = files[0]
else:
if entry.exists():
shutil.rmtree(entry)
destination = scratch / entry_name / url.rsplit("/", 1)[-1]
sources.append(Source(url, destination, digest))
urls = {str(source.dest): source.url for source in sources}
def tick(done, total, ranges):
if progress is not None:
progress(done, total, {urls[key]: rows for key, rows in ranges.items()})
try:
Download(sources, pause_event=pause_event).run(progress=tick)
finally:
# Only finalized, hash-verified files can exist at these paths.
for source in sources:
if source.dest.is_relative_to(scratch) and source.dest.is_file():
self.publish(source.dest.parent, f"fetch-{source.sha256}")
return [self.entry(f"fetch-{source.sha256}") / source.dest.name for source in sources]
@contextmanager
def scratch(self):

View File

@@ -83,7 +83,7 @@ def test_boot_replaces_incumbent_with_stale_presets(hermes_home, monkeypatch):
# Fail fast once boot proper begins — reaching it IS the assertion.
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.ensure_runtime_installed", fake_boot)
"hermes_cli.local_runtime.binaries.installed_engine", fake_boot)
result = boot.ensure_local_runtime({"local_runtime": {"enabled": True}})
assert stopped.get("pid") == 12345, "stale incumbent was not stopped"

View File

@@ -111,8 +111,11 @@ def test_loader_ignores_unknown_fields():
def test_min_engine_gate(monkeypatch):
from hermes_cli.web_routers.local_models import _engine_too_old
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_tags",
lambda: ["b10362"])
from hermes_cli.local_runtime.binaries import Engine
from pathlib import Path
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_engine",
lambda *args, **kwargs: Engine("cpu", "b10362", Path("unused")))
assert _engine_too_old("") is False, "no requirement, no gate"
assert _engine_too_old("b10000") is False, "installed engine suffices"
assert _engine_too_old("b10363") is True, "newer requirement gates"

View File

@@ -102,24 +102,9 @@ def test_browsed_download_stages_and_bounces(client, tmp_path, monkeypatch):
bounce the router — the seam that makes it a NORMAL model."""
body = b"GGUF" + b"\x00" * 60
class FakeResponse:
headers = {"Content-Length": str(len(body))}
from tests.hermes_cli.test_local_models_routes import _FakeRangeOpener
def __init__(self):
self._data = body
def read(self, n=-1):
out, self._data = self._data, b""
return out
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr("urllib.request.urlopen",
lambda *a, **k: FakeResponse())
monkeypatch.setattr("pm.downloader._OPENER", _FakeRangeOpener(body))
bounced = {}
monkeypatch.setattr(
"hermes_cli.local_runtime.bootstrap.refresh_local_runtime",

View File

@@ -202,11 +202,8 @@ def test_reap_orphans_kills_only_our_parentless_binaries(tmp_path, monkeypatch):
AccessDenied=_NoSuch,
)
monkeypatch.setitem(__import__("sys").modules, "psutil", fake_psutil)
monkeypatch.setattr("hermes_cli.local_runtime.supervisor.server_binary",
lambda install_dir: exe)
sup = LlamaServerSupervisor.__new__(LlamaServerSupervisor)
sup.install_dir = tmp_path
sup.binary = exe
sup.proc = None
sup._reap_orphaned_children()

View File

@@ -0,0 +1,101 @@
"""Desktop component jobs pause real PM archive transfers before activation."""
from __future__ import annotations
import hashlib
import threading
import pytest
import pm
from pm import paths, registry
from pm.lock import Facts, Lockfile
from pm.packages import LlamaCppCpu
from tests.hermes_cli.test_local_download_jobs import client, poll # noqa: F401
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
from tests.pm.test_install_download_control import archive
@pytest.mark.parametrize("endpoint", ["runtime/install", "quickstart"])
def test_component_job_pause_retains_pin_and_stops_sequence(client, monkeypatch, tmp_path, dl_server, endpoint):
from hermes_cli.web_routers import local_models as lm
class ArchiveEngine(LlamaCppCpu):
probe_version = False
package = ArchiveEngine()
monkeypatch.setitem(registry._packages, package.name, package)
root = tmp_path / "store"
lock_path = tmp_path / "lock.json"
monkeypatch.setattr(paths, "store_root", lambda: root)
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
monkeypatch.setattr(lm, "_runtime_section", lambda: {"backend": "cpu"})
target = pm.current_target()
binary_name = package.binary(root, target).name
files = {binary_name: b"archive engine fixture", "runtime.dll": bytes(range(256)) * (32 * 1024)}
pins = []
for index, (name, body) in enumerate(files.items()):
payload = archive({name: body})
path = f"/component-{index}.zip"
RangeHandler.payloads[path] = payload
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
lock = Lockfile(lock_path)
lock.set_pin(package.name, "10362", {target: pins})
lock.save()
entry = lm.catalog.CATALOG[0]
variant = entry.variants[0]
monkeypatch.setattr(lm, "_quickstart_target", lambda *args: (entry, variant))
model = lm.bootstrap.models_dir() / "test.gguf"
RangeHandler.payloads["/model"] = b"model fixture"
monkeypatch.setattr(lm, "_download_plan", lambda *args: [(url(dl_server, "/model"), model, 13)])
calls = []
monkeypatch.setattr(lm, "_ensure_server", lambda *args, **kwargs: calls.append("server"))
monkeypatch.setattr(lm, "_assign_default", lambda *args: calls.append("default"))
monkeypatch.setattr(lm.bootstrap, "get_supervisor", lambda: None)
flowing, release = threading.Event(), threading.Event()
real_hook = lm._runtime_progress_hook
def observed_hook(job):
hook = real_hook(job)
first_archive_bytes = 0
def tick(stage, done, total, label):
nonlocal first_archive_bytes
hook(stage, done, total, label)
if stage == "download" and label == "1/2":
first_archive_bytes = done
if stage == "download" and label == "2/2" and first_archive_bytes < done < total and not flowing.is_set():
flowing.set()
assert release.wait(15)
return tick
monkeypatch.setattr(lm, "_runtime_progress_hook", observed_hook)
response = client.post(f"/api/local-models/{endpoint}", json={"backend": "cpu"} if endpoint == "runtime/install" else {})
assert response.status_code == 200, response.text
job_id = response.json()["job_id"]
try:
assert flowing.wait(5), "runtime did not enter PM's pinned archive transfer"
paused = client.post("/api/local-models/download/pause", json={"job_id": job_id})
assert paused.json()["paused"], paused.text
finally:
release.set()
job = poll(client, job_id)
assert job["status"] == "paused", job
assert Facts(paths.facts_path()).get(package.name) is None
assert calls == [] and not model.exists()
assert job["can_resume"]
assert list(paths.partials_root().glob("*.ranges"))
assert client.post("/api/local-models/download/resume", json={"job_id": job_id}).json()["resumed"]
job = poll(client, job_id, until=("done", "error"))
assert job["status"] == "done", job
selected = pm.installed_package(package.name)
assert selected is not None
for name, body in files.items():
assert (selected.path / name).read_bytes() == body
assert Facts(paths.facts_path()).get(package.name)["artifacts"] == [p["sha256"] for p in pins]
if endpoint == "quickstart":
assert calls == ["server", "default"] and model.read_bytes() == RangeHandler.payloads["/model"]
assert job_id not in lm._RUNNING

View File

@@ -0,0 +1,117 @@
"""Local-model jobs use PM transfers for every entry point."""
from __future__ import annotations
import threading
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
from hermes_cli.web_routers import local_models as lm
monkeypatch.setattr(lm, "_JOBS", {})
monkeypatch.setattr(lm, "_RUNNING", {})
monkeypatch.setattr(lm.bootstrap, "refresh_local_runtime", lambda: False)
app = FastAPI()
app.include_router(lm.router)
with TestClient(app) as client:
yield client
def poll(client, job_id, until=("done", "error", "paused")):
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
response = client.get(f"/api/local-models/jobs/{job_id}")
assert response.status_code == 200, response.text
job = response.json()
if job["status"] in until:
return job
time.sleep(0.01)
raise AssertionError(f"job did not settle: {job}")
def test_browsed_download_can_pause_and_resume_pm_transfer(client, monkeypatch, dl_server):
from hermes_cli.web_routers import local_models as lm
payload = bytes(range(256)) * (32 * 1024)
RangeHandler.payloads["/test.gguf"] = payload
monkeypatch.setattr(lm, "_hf_url", lambda repo, path: url(dl_server, "/test.gguf"))
flowing, release = threading.Event(), threading.Event()
real_download = lm.Download
class ObservedDownload(real_download):
def run(self, progress=None):
def tick(done, total, ranges):
if progress is not None:
progress(done, total, ranges)
if 0 < done < total and not flowing.is_set():
flowing.set()
assert release.wait(15)
return super().run(progress=tick)
monkeypatch.setattr(lm, "Download", ObservedDownload)
response = client.post("/api/local-models/download-browsed", json={"repo": "a/b", "paths": ["test.gguf"]})
job_id = response.json()["job_id"]
try:
assert flowing.wait(5), "browsed download bypassed PM's downloader"
assert client.post("/api/local-models/download/pause", json={"job_id": job_id}).json()["paused"]
finally:
release.set()
job = poll(client, job_id)
assert job["status"] == "paused", job
assert job["can_resume"] and not job["can_pause"]
assert job["error"] is None
before = job["done_bytes"]
assert before > 0
assert client.post("/api/local-models/download/resume", json={"job_id": job_id}).json()["resumed"]
job = poll(client, job_id, until=("done", "error"))
assert job["status"] == "done", job
assert job["done_bytes"] == job["total_bytes"] == len(payload)
assert (lm.bootstrap.models_dir() / "test.gguf").read_bytes() == payload
assert not job["can_pause"] and not job["can_resume"]
def test_quickstart_plan_progress_uses_actual_whole_plan_bytes(client, monkeypatch, dl_server, tmp_path):
from hermes_cli.web_routers import local_models as lm
bodies = {"first": b"first", "second": b"second"}
RangeHandler.payloads = {"/" + name: body for name, body in bodies.items()}
plan = [(url(dl_server, "/" + name), tmp_path / (name + ".gguf"), 1000) for name in bodies]
job = lm._job("quickstart", "test")
lm._run_download_plan(job, plan, "test")
expected = sum(map(len, bodies.values()))
assert job["done_bytes"] == job["total_bytes"] == expected
assert sum(end - start for rows in job["ranges"].values() for start, end in rows) == expected
def test_existing_weights_do_not_skip_missing_companion_download(client, monkeypatch, dl_server, tmp_path):
from dataclasses import replace
from hermes_cli.local_runtime.catalog import AssetFile
from hermes_cli.web_routers import local_models as lm
entry = replace(lm.catalog.CATALOG[0], mmproj=AssetFile("projector.gguf", 1000), draft=None)
variant = entry.variants[0]
monkeypatch.setattr(lm, "_download_target", lambda model_id: (entry, variant))
monkeypatch.setattr(lm.bootstrap, "staged_model_ids", lambda: [variant.model_id])
weights = tmp_path / "weights.gguf"
weights.write_bytes(b"already downloaded")
companion = tmp_path / "assets" / "projector.gguf"
RangeHandler.payloads["/projector"] = b"projector bytes"
plan = [(url(dl_server, "/weights"), weights, 1000),
(url(dl_server, "/projector"), companion, 1000)]
monkeypatch.setattr(lm, "_download_plan", lambda *args: plan)
response = client.post("/api/local-models/download", json={"model_id": entry.id})
assert response.json().get("job_id"), "weights-only staging incorrectly skipped the projector"
job = poll(client, response.json()["job_id"])
assert job["status"] == "done", job
assert companion.read_bytes() == RangeHandler.payloads["/projector"]
assert job["done_bytes"] == weights.stat().st_size + companion.stat().st_size

View File

@@ -0,0 +1,37 @@
"""Paused work remains discoverable, and terminal phase changes honor pause intent."""
from __future__ import annotations
import threading
import pytest
from pm.downloader import DownloadPaused
from tests.hermes_cli.test_local_download_jobs import client # noqa: F401
def test_jobs_keep_every_active_download_ahead_of_bounded_history(client):
from hermes_cli.web_routers import local_models as lm
paused = lm._job("runtime-install", "engine")
paused["status"] = "paused"
for index in range(25):
job = lm._job("model-download", f"model-{index}")
job["status"] = "done"
active = [lm._job("model-download", f"active-{index}") for index in range(25)]
response = client.get("/api/local-models/jobs")
ids = {job["job_id"] for job in response.json()["jobs"]}
assert {paused["job_id"], *(job["job_id"] for job in active)} <= ids
def test_pause_request_prevents_transition_into_activation(client):
from hermes_cli.web_routers import local_models as lm
job = lm._job("quickstart", "model")
job["phase"] = "downloading"
pause = threading.Event()
lm._RUNNING[job["job_id"]] = {"pause": pause}
assert client.post("/api/local-models/download/pause", json={"job_id": job["job_id"]}).json()["paused"]
with pytest.raises(DownloadPaused):
lm._step(job, "starting-server", "Starting")
assert job["phase"] == "downloading"

View File

@@ -231,7 +231,10 @@ def test_download_already_downloaded_short_circuits(client, monkeypatch):
lambda **kw: budget)
choice = select_variant(CATALOG[0], budget)
assert choice is not None
_write_fake_gguf(models_dir() / choice.variant.files[0].local_name)
from hermes_cli.web_routers.local_models import _download_plan
for _, dest, _ in _download_plan(CATALOG[0], choice.variant):
_write_fake_gguf(dest)
r = client.post("/api/local-models/download", json={"model_id": CATALOG[0].id})
assert r.status_code == 200
assert r.json()["already_downloaded"] is True
@@ -253,11 +256,15 @@ def test_runtime_install_rejects_impossible_combo(client, monkeypatch):
"""Impossible platform/backend combos fail the POST itself with the
resolver's honest message — not a background job that dies silently.
(win-arm64-vulkan; the old cuda case became real upstream at ~b1036x.)"""
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries._host_os_arch", lambda: ("win", "arm64"))
r = client.post("/api/local-models/runtime/install", json={"backend": "vulkan"})
from pm import paths
from pm.lock import Lockfile
lock_path = Path(paths.partials_root()).parent / "unavailable-lock.json"
Lockfile(lock_path).save()
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
r = client.post("/api/local-models/runtime/install", json={"backend": "cpu"})
assert r.status_code == 400
assert "arm64" in r.json()["detail"]
assert "not pinned" in r.json()["detail"]
def test_job_poll_unknown_404s(client):
@@ -630,8 +637,10 @@ def test_quickstart_pause_stops_the_sequence(client, monkeypatch, dl_server,
_serve_plan(monkeypatch, dl_server, tmp_path / "partials",
{"QsPartA": _BIG_BODY, "QsPartB": _BIG_BODY})
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_tags",
lambda: ["b99999"]) # runtime leg already satisfied
from hermes_cli.local_runtime.binaries import Engine
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_engine",
lambda *args, **kwargs: Engine("cpu", "b99999", Path("unused")))
monkeypatch.setattr(lm, "_runtime_target",
lambda requested=None: ("b1", "cpu"))

View File

@@ -15,6 +15,7 @@ from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from hermes_cli.local_runtime.binaries import Engine
@pytest.fixture
@@ -67,10 +68,10 @@ def test_quickstart_runs_all_three_legs(client, monkeypatch, tmp_path):
# Leg 1: no runtime installed yet; install is the stubbed binaries call.
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.installed_tags", lambda: [])
"hermes_cli.local_runtime.binaries.installed_engine", lambda *args, **kwargs: None)
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
lambda tag, backend, progress=None: calls.append("install"))
"hermes_cli.local_runtime.binaries.ensure_engine",
lambda backend, **kwargs: calls.append("install"))
# Leg 2: nothing staged; the download writes the files the plan names.
def _fake_download(job, plan):
@@ -120,17 +121,20 @@ def test_quickstart_skips_satisfied_legs(client, monkeypatch):
calls: list[str] = []
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.installed_tags", lambda: ["b10362"])
"hermes_cli.local_runtime.binaries.installed_engine", lambda *args, **kwargs: Engine("cpu", "b10362", Path("unused")))
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
lambda tag, backend, progress=None: calls.append("install"))
"hermes_cli.local_runtime.binaries.ensure_engine",
lambda backend, **kwargs: calls.append("install"))
# Every catalog variant reads as staged.
# Every model file and companion is already present.
from hermes_cli.local_runtime.catalog import CATALOG
from hermes_cli.web_routers.local_models import _download_plan
all_ids = {v.model_id for e in CATALOG for v in e.variants}
monkeypatch.setattr(
"hermes_cli.local_runtime.bootstrap.staged_model_ids", lambda: all_ids)
for entry in CATALOG:
for variant in entry.variants:
for _, dest, _ in _download_plan(entry, variant):
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"downloaded fixture")
monkeypatch.setattr(
"hermes_cli.web_routers.local_models._download_job",
lambda *a, **k: calls.append("download"))
@@ -166,7 +170,7 @@ def quickstart_ready(monkeypatch):
from hermes_cli.local_runtime.catalog import VariantChoice
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.installed_tags", lambda: ["b10362"])
"hermes_cli.local_runtime.binaries.installed_engine", lambda *args, **kwargs: Engine("cpu", "b10362", Path("unused")))
monkeypatch.setattr(
"hermes_cli.local_runtime.catalog.select_variant",
lambda entry, budget: VariantChoice(variant=entry.variants[0],

View File

@@ -17,12 +17,7 @@ from pathlib import Path
import pytest
from hermes_cli.local_runtime.binaries import (
AssetPlan,
BinaryResolutionError,
resolve_assets,
select_backend,
)
from hermes_cli.local_runtime.binaries import BinaryResolutionError, resolve_backend, select_backend
from hermes_cli.local_runtime.detect import DetectedServer, probe_port
@@ -169,53 +164,23 @@ def test_probe_dead_port_returns_none():
# ── binary resolver (Rollout 2) ──────────────────────────────
@pytest.mark.parametrize("os_name,arch,backend,ok", [
("win", "x64", "cuda", True),
("win", "x64", "vulkan", True),
("win", "x64", "cpu", True),
("win", "arm64", "cpu", True),
("win", "arm64", "cuda", True), # upstream ships these since ~b1036x (CUDA 13.4)
("win", "arm64", "vulkan", False),
("macos", "arm64", "metal", True),
("ubuntu", "x64", "vulkan", True),
("ubuntu", "x64", "cpu", True),
("ubuntu", "x64", "cuda", False), # no prebuilt linux CUDA
@pytest.mark.parametrize("target,backend,ok", [
("win32-x64", "cuda", True),
("win32-x64", "vulkan", True),
("win32-arm64", "cpu", True),
("win32-arm64", "cuda", True),
("win32-arm64", "vulkan", False),
("darwin-arm64", "metal", True),
("linux-x64", "vulkan", True),
("linux-x64", "cpu", True),
("linux-x64", "cuda", False),
])
def test_resolver_platform_matrix(os_name, arch, backend, ok):
def test_resolver_platform_matrix(target, backend, ok):
if ok:
plan = resolve_assets("b10290", backend, os_name=os_name, arch=arch)
assert plan.assets, "resolvable combination must yield assets"
# Invariant: every asset names the tag or is a paired runtime zip.
for asset in plan.assets:
assert "b10290" in asset or asset.startswith("cudart-")
assert resolve_backend(backend, target=target) == backend
else:
with pytest.raises(BinaryResolutionError):
resolve_assets("b10290", backend, os_name=os_name, arch=arch)
def test_windows_cuda_pairs_cudart():
"""Windows CUDA must ship the runtime zip — users have no toolkit."""
plan = resolve_assets("b10290", "cuda", os_name="win", arch="x64")
assert any(a.startswith("cudart-") for a in plan.assets)
def test_windows_cuda_arm64_pairs_cudart_on_its_own_version():
"""arm64 CUDA rides its own CUDA line (13.4 at b10362, verified live):
both zips must agree on version and name the arch."""
plan = resolve_assets("b10362", "cuda", os_name="win", arch="arm64")
assert len(plan.assets) == 2
assert all("arm64" in a for a in plan.assets)
versions = {a.split("cuda-")[1].split("-")[0] for a in plan.assets}
assert len(versions) == 1, f"paired zips disagree on CUDA version: {plan.assets}"
assert any(a.startswith("cudart-") for a in plan.assets)
assert any(a.startswith("llama-") for a in plan.assets)
def test_install_dir_is_profile_scoped(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
plan = AssetPlan(tag="b10290", backend="cuda")
assert str(tmp_path) in str(plan.install_dir)
assert "runtimes" in plan.install_dir.parts
resolve_backend(backend, target=target)
@pytest.mark.parametrize("vendor,os_name,expected", [
@@ -232,28 +197,6 @@ def test_backend_selection(vendor, os_name, expected):
assert select_backend(vendor, os_name=os_name) == expected
def test_sha256_mismatch_rejects(tmp_path, monkeypatch):
"""A pinned hash that doesn't match the download must hard-fail."""
from hermes_cli.local_runtime import binaries
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
# Pre-place a wrong-content "download" so no network is touched. The
# asset name is host-dependent (win/.zip, ubuntu/.tar.gz, macos/.zip)
# — resolve it the way the installer will, so the poisoned file is the
# one it verifies on every CI platform.
plan = binaries.resolve_assets("b10290", "cpu")
asset = plan.assets[0]
downloads = binaries.runtimes_root() / "downloads"
downloads.mkdir(parents=True)
(downloads / asset).write_bytes(b"not the real archive")
with pytest.raises(BinaryResolutionError, match="sha256 mismatch"):
binaries.ensure_runtime_installed(
"b10290", "cpu",
expected_sha256={asset: "0" * 64})
# The poisoned download must not survive for a retry to trust.
assert not (downloads / asset).exists()
# ── supervisor contracts (stubbed; no GPU) ───────────────────
@@ -262,7 +205,7 @@ def _make_supervisor(tmp_path, port):
from hermes_cli.local_runtime.supervisor import LlamaServerSupervisor
sup = LlamaServerSupervisor(
install_dir=tmp_path, models_dir=tmp_path, port=port)
binary=tmp_path / "llama-server", models_dir=tmp_path, port=port)
return sup
@@ -519,25 +462,17 @@ def test_resolution_kicks_boot_when_no_thread_is_booting(tmp_path, monkeypatch):
def test_boot_in_flight_real_gate(tmp_path, monkeypatch):
"""_boot_in_flight exercised FOR REAL (the previous regression test
monkeypatched it — and the real one threw TypeError on every call,
silently disabling the boot wait). Enabled + verified manifest on
disk -> True; either missing -> False."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
import pm
from hermes_cli.local_runtime import endpoint as ep
from hermes_cli.local_runtime.binaries import runtimes_root
from tests.hermes_cli.test_local_runtime_updates import record_engine
enabled = {"local_runtime": {"enabled": True}}
# Not installed yet -> False.
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
store = tmp_path / "store"
monkeypatch.setattr(pm.paths, "store_root", lambda: store)
enabled = {"local_runtime": {"enabled": True, "backend": "cpu"}}
assert ep._boot_in_flight(enabled) is False
# Verified install manifest -> True.
install = runtimes_root() / "b10290" / "cuda"
install.mkdir(parents=True)
(install / "manifest.json").write_text(
json.dumps({"tag": "b10290", "verified_version": "5015 (abc)"}),
encoding="utf-8")
record_engine(store, "10290")
assert ep._boot_in_flight(enabled) is True
# Disabled -> False even when installed.
assert ep._boot_in_flight({"local_runtime": {"enabled": False}}) is False
@@ -625,7 +560,7 @@ def test_bootstrap_skips_boot_with_no_staged_models(tmp_path, monkeypatch):
called["spawn"] = True
raise AssertionError("must not reach install/spawn")
monkeypatch.setattr("hermes_cli.local_runtime.binaries.ensure_runtime_installed", _boom)
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_engine", _boom)
result = bs.ensure_local_runtime({"local_runtime": {"enabled": True}})
assert result is None
assert called["spawn"] is False
@@ -759,7 +694,7 @@ def test_local_runtime_config_defaults_shape():
cfg = DEFAULT_CONFIG["local_runtime"]
assert cfg["enabled"] is False
assert isinstance(cfg["tag"], str) and cfg["tag"].startswith("b")
assert "tag" not in cfg # PM owns the version pin.
forbidden = [k for k in cfg if "context" in k or "ctx" in k or "vram" in k or "kv" in k]
assert forbidden == [], f"policy constants leaked into config: {forbidden}"
@@ -793,7 +728,7 @@ def test_bootstrap_reuses_running_server(tmp_path, monkeypatch, stub_server):
called = []
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
"hermes_cli.local_runtime.binaries.installed_engine",
lambda *a, **k: called.append(1))
assert bootstrap.ensure_local_runtime({"local_runtime": {"enabled": True}}) is None
assert called == []
@@ -812,6 +747,6 @@ def test_bootstrap_failure_never_raises(tmp_path, monkeypatch):
raise RuntimeError("no network")
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.ensure_runtime_installed", boom)
"hermes_cli.local_runtime.binaries.installed_engine", boom)
result = bootstrap.ensure_local_runtime({"local_runtime": {"enabled": True}})
assert result is None # no exception escaped

View File

@@ -0,0 +1,34 @@
"""Backend selection uses reviewed target pins and never installs on lookup."""
import pytest
import pm
from hermes_cli.local_runtime import binaries
from pm import paths
from pm.lock import Lockfile
@pytest.mark.parametrize("target,vendor,expected", [
("linux-x64", "nvidia", "vulkan"),
("linux-arm64", "nvidia", "vulkan"),
("win32-arm64", "nvidia", "cuda"),
("win32-arm64", "AMD Radeon", "cpu"),
("win32-x64", "AMD Radeon", "vulkan"),
("win32-x64", None, "cpu"),
("darwin-arm64", None, "metal"),
])
def test_auto_backend_uses_only_compatible_pins(target, vendor, expected):
assert binaries.resolve_backend("auto", gpu_vendor=vendor, target=target) == expected
with pytest.raises(binaries.BinaryResolutionError):
binaries.resolve_backend("cuda", target="linux-x64")
def test_missing_pin_is_refused_instead_of_constructing_download_url(tmp_path, monkeypatch):
lock_path = tmp_path / "lock.json"
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
lock = Lockfile(lock_path)
lock.set_pin("llamacpp-cpu", "123", {})
lock.save()
assert binaries.pinned_tag("cpu") == "b123"
with pytest.raises(binaries.BinaryResolutionError, match="not pinned"):
binaries.resolve_backend("cpu", target=pm.current_target())

View File

@@ -1,123 +1,86 @@
"""Engine-update contracts (Rollout 4 follow-up):
- default tag flows from DEFAULT_CONFIG unless the user pinned;
- boot serves what is INSTALLED, never downloads (the ladder);
- update_available only when the local engine is enabled AND installed
AND the configured tag is missing on disk;
- the update itself is a button-driven job, and prune keeps N-1.
"""
"""Engine updates read PM pins; boot retains installed bytes without downloading."""
from __future__ import annotations
import json
import pytest
import pm
from pm import paths
from pm.lock import Facts, Lockfile
from pm.store import tree_digest
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
def runtime_env(tmp_path, monkeypatch):
home = tmp_path / "home"
store = home / "tools"
lock_path = tmp_path / "lock.json"
monkeypatch.setenv("HERMES_HOME", str(home))
return home
monkeypatch.setattr(paths, "store_root", lambda: store)
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
lock = Lockfile(lock_path)
lock.set_pin("llamacpp-cpu", "10412", {pm.current_target(): {
"url": "https://example.invalid/cpu.zip", "sha256": "a" * 64,
}})
lock.save()
return store, lock
def _install_fake_tag(home, tag: str, backend: str = "cuda") -> None:
d = home / "runtimes" / "llamacpp" / tag / backend
d.mkdir(parents=True)
(d / "manifest.json").write_text(json.dumps({
"tag": tag, "backend": backend, "assets": {},
"verified_version": f"version: {tag.lstrip('b')}",
}), encoding="utf-8")
# server_binary() looks for the executable name per-OS; give it both.
(d / "llama-server.exe").write_bytes(b"MZ fake")
(d / "llama-server").write_bytes(b"\x7fELF fake")
def record_engine(store, version, digest="b" * 64):
package = pm.get_package("llamacpp-cpu")
target = pm.current_target()
entry = store / package.store_entry(version, target)
entry.mkdir(parents=True, exist_ok=True)
binary = package.binary(entry, target)
binary.write_bytes(b"installed engine fixture")
Facts(store / "facts.json").record(package.name, version, entry.name, {}, store,
target=target, artifacts=[digest], digest=tree_digest(entry))
return binary
def test_installed_tags_newest_first(hermes_home):
from hermes_cli.local_runtime.binaries import installed_tags
def test_status_uses_pm_pin_and_ignores_legacy_tag(runtime_env, monkeypatch):
from hermes_cli.web_routers import local_models as lm
assert installed_tags() == []
_install_fake_tag(hermes_home, "b10290")
_install_fake_tag(hermes_home, "b10412")
assert installed_tags() == ["b10412", "b10290"]
store, lock = runtime_env
section = {"enabled": True, "backend": "cpu", "tag": "b99999"}
monkeypatch.setattr(lm, "_runtime_section", lambda: section)
monkeypatch.setattr(lm, "_state_endpoint", lambda: None)
record_engine(store, "10362")
status = lm.local_models_status()
assert status["tag"] == "b10362"
assert status["configured_tag"] == "b" + lock.version("llamacpp-cpu")
assert status["runtime_installed"] and status["update_available"]
section["enabled"] = False
assert not lm.local_models_status()["update_available"]
section["enabled"] = True
record_engine(store, lock.version("llamacpp-cpu"), "a" * 64)
assert not lm.local_models_status()["update_available"]
def test_default_tag_flows_from_default_config(hermes_home):
"""Unpinned users inherit the Hermes-release default (deep-merge);
the shipped default must be a plausible rolling tag."""
from hermes_cli.config import load_config
from hermes_cli.config_defaults import DEFAULT_CONFIG
def test_boot_uses_previous_pm_engine_without_installing(runtime_env, monkeypatch):
from hermes_cli.local_runtime import bootstrap, endpoint, supervisor
default_tag = DEFAULT_CONFIG["local_runtime"]["tag"]
assert default_tag.startswith("b") and default_tag.lstrip("b").isdigit()
assert load_config()["local_runtime"]["tag"] == default_tag
store, _ = runtime_env
binary = record_engine(store, "10362")
monkeypatch.setattr(bootstrap, "_SUPERVISOR", None)
monkeypatch.setattr(endpoint, "_state_endpoint", lambda: None)
monkeypatch.setattr(bootstrap, "_generate_presets", lambda *args: None)
monkeypatch.setattr(bootstrap, "_start_idle_sweeper", lambda *args: None)
def forbidden(*args, **kwargs):
raise AssertionError("boot attempted a PM install")
def test_update_available_requires_enabled_and_installed(hermes_home, monkeypatch):
"""The flag's truth table: enabled+installed+configured-missing only."""
from fastapi.testclient import TestClient
monkeypatch.setattr(pm, "ensure", forbidden)
spawned = []
from hermes_cli import web_server
def start(self):
spawned.append(self.binary)
client = TestClient(web_server.app)
# Same auth pattern as the other local-models route tests.
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
def status():
r = client.get("/api/local-models/status")
assert r.status_code == 200, r.text
return r.json()
import hermes_cli.web_routers.local_models as lm
# Case 1: enabled, configured newer than installed -> update available.
monkeypatch.setattr(lm, "_runtime_section",
lambda: {"enabled": True, "tag": "b10412"})
_install_fake_tag(hermes_home, "b10290")
s = status()
assert s["update_available"] is True
assert s["configured_tag"] == "b10412"
assert s["tag"] == "b10290" # serving what's installed
# Case 2: configured tag installed -> no update.
_install_fake_tag(hermes_home, "b10412")
s = status()
assert s["update_available"] is False
assert s["tag"] == "b10412"
# Case 3: disabled -> never flagged, even with a mismatch.
monkeypatch.setattr(lm, "_runtime_section",
lambda: {"enabled": False, "tag": "b10999"})
assert status()["update_available"] is False
def test_boot_never_downloads_missing_tag(hermes_home, monkeypatch):
"""The ladder: configured-but-not-installed serves the newest installed
tag; nothing installed means no boot (and NO download either way)."""
from hermes_cli.local_runtime import bootstrap
calls = []
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries.ensure_runtime_installed",
lambda tag, backend, **kw: calls.append(tag) or (_ for _ in ()).throw(
AssertionError("boot must not reach install for missing tags")))
# Nothing installed: returns None before any install attempt.
cfg = {"local_runtime": {"enabled": True, "tag": "b10412"}}
assert bootstrap.ensure_local_runtime(cfg) is None
assert calls == []
def test_prune_keeps_n_minus_one(hermes_home):
from hermes_cli.local_runtime.binaries import installed_tags, prune_old_tags
for tag in ("b10100", "b10200", "b10290"):
_install_fake_tag(hermes_home, tag)
prune_old_tags(["b10290", "b10200"])
assert installed_tags() == ["b10290", "b10200"]
# downloads/ cache dir must survive pruning when present.
downloads = hermes_home / "runtimes" / "llamacpp" / "downloads"
downloads.mkdir(exist_ok=True)
prune_old_tags(["b10290"])
assert downloads.exists()
monkeypatch.setattr(supervisor.LlamaServerSupervisor, "start", start)
config = {"local_runtime": {"enabled": True, "backend": "cpu", "tag": "b99999"}}
assert bootstrap.ensure_local_runtime(config, force=True) is not None
assert spawned == [binary]
monkeypatch.setattr(bootstrap, "_SUPERVISOR", None)
binary.unlink()
assert bootstrap.ensure_local_runtime(config, force=True) is None
assert spawned == [binary]

View File

@@ -1,166 +1,23 @@
"""Runtime install progress: the slow legs must tick, never hang silently.
"""PM download bytes and install phases remain separate, faithful UI facts."""
The incident: 'Installing runtime…' sat frozen for minutes on a slow
line — ensure_runtime_installed downloaded and extracted with no
progress stream, so both the quickstart hero and the pane's install row
showed a dead bar. Contract: _download and _extract tick per chunk /
per member, ensure_runtime_installed forwards a staged stream, and the
router's hook translates it into live job fields."""
from __future__ import annotations
import io
import zipfile
from pathlib import Path
import hermes_cli.local_runtime.binaries as binaries
from hermes_cli.web_routers.local_models import _job, _runtime_progress_hook
from hermes_cli.web_routers.local_models import _download_progress_hook, _job, _runtime_progress_hook
def _make_zip(path: Path, names_sizes: dict[str, int]) -> None:
with zipfile.ZipFile(path, "w") as z:
for name, size in names_sizes.items():
z.writestr(name, b"x" * size)
def test_extract_ticks_per_member(tmp_path):
archive = tmp_path / "runtime.zip"
_make_zip(archive, {"a.bin": 1000, "b.bin": 3000, "c.bin": 500})
ticks: list[tuple[int, int]] = []
binaries._extract(archive, tmp_path / "out",
progress=lambda d, t: ticks.append((d, t)))
assert len(ticks) == 3
total = 4500
assert all(t == total for _, t in ticks)
assert [d for d, _ in ticks] == sorted(d for d, _ in ticks)
assert ticks[-1][0] == total
assert (tmp_path / "out" / "b.bin").stat().st_size == 3000
def test_download_ticks_with_content_length(tmp_path, monkeypatch):
payload = b"y" * (3 << 20) # 3 MiB -> several 1 MiB chunks
class _Resp(io.BytesIO):
headers = {"Content-Length": str(len(payload))}
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(binaries.urllib.request, "urlopen",
lambda url, timeout=0: _Resp(payload))
ticks: list[tuple[int, int]] = []
dest = tmp_path / "asset.zip"
binaries._download("http://x/asset.zip", dest,
progress=lambda d, t: ticks.append((d, t)))
assert dest.read_bytes() == payload
assert len(ticks) >= 3
assert ticks[-1] == (len(payload), len(payload))
def test_ensure_runtime_installed_forwards_staged_progress(tmp_path, monkeypatch):
"""The full install path emits download -> verify -> extract stages
(per asset) and a final verify, all through one callback."""
monkeypatch.setattr(binaries, "runtimes_root", lambda: tmp_path)
class _Plan:
assets = ["a.zip", "b.zip"]
backend = "cuda"
install_dir = tmp_path / "b1" / "cuda"
monkeypatch.setattr(binaries, "resolve_assets", lambda tag, backend: _Plan())
monkeypatch.setattr(binaries, "verify_install", lambda d, t: "ok")
def _fake_download(url, dest, progress=None):
_make_zip(dest, {"f.bin": 2048})
if progress is not None:
progress(1024, 2048)
progress(2048, 2048)
monkeypatch.setattr(binaries, "_download", _fake_download)
events: list[tuple[str, str]] = []
binaries.ensure_runtime_installed(
"b1", "cuda",
progress=lambda stage, d, t, label: events.append((stage, label)))
stages = [s for s, _ in events]
assert "download" in stages and "extract" in stages and "verify" in stages
# Two assets -> per-asset labels on the slow stages.
assert ("download", "1/2") in events and ("download", "2/2") in events
assert ("extract", "1/2") in events and ("extract", "2/2") in events
# Stage order per asset: download before extract.
assert stages.index("download") < stages.index("extract")
def test_progress_hook_translates_stages_to_job_fields():
job = _job("quickstart", "Test Model")
hook = _runtime_progress_hook(job)
hook("download", 5 << 20, 100 << 20, "1/2")
def test_runtime_stages_preserve_pm_whole_plan_progress():
job = _job("runtime-install", "engine")
stage = _runtime_progress_hook(job)
transfer = _download_progress_hook(job)
stage("download", 40, 100, "1/2")
transfer(40, 100, {"engine.zip": [(0, 40)], "dlls.zip": []})
assert job["phase"] == "downloading-runtime"
assert "1/2" in job["detail"]
assert job["done_bytes"] == 5 << 20
assert job["total_bytes"] == 100 << 20
# Rapid second tick inside the throttle window is dropped...
hook("download", 6 << 20, 100 << 20, "1/2")
assert job["done_bytes"] == 5 << 20
# ...but a terminal tick (done == total) always lands.
hook("download", 100 << 20, 100 << 20, "1/2")
assert job["done_bytes"] == 100 << 20
hook("extract", 10, 100, "")
assert job["phase"] in ("downloading-runtime", "unpacking-runtime")
job2 = _job("runtime-install", "x")
hook2 = _runtime_progress_hook(job2)
hook2("extract", 100, 100, "")
assert job2["phase"] == "unpacking-runtime"
hook2("verify", 0, 0, "")
assert job2["phase"] == "verifying-runtime"
assert job2["total_bytes"] is None # indeterminate bar, not a stuck 0%
def test_progress_hook_accumulates_across_assets(monkeypatch):
"""A two-asset engine reads as ONE growing download: the second asset's
bytes stack on the first's instead of restarting the bar at zero, and
unpack/verify leave the finished download's counters standing."""
# Drive the throttle's clock so every tick lands (the real hook drops
# sub-250ms non-terminal ticks; this test is about arithmetic, not
# pacing — pacing has its own assertions above).
from hermes_cli.web_routers import local_models as lm
clock = {"now": 0.0}
def fake_monotonic():
clock["now"] += 1.0
return clock["now"]
monkeypatch.setattr(lm.time, "monotonic", fake_monotonic)
job = _job("runtime-install", "engine")
hook = _runtime_progress_hook(job)
hook("download", 40 << 20, 40 << 20, "1/2")
assert job["done_bytes"] == 40 << 20
assert job["total_bytes"] == 40 << 20
# Second asset starts: counters continue from the first asset's total.
hook("download", 0, 60 << 20, "2/2")
assert job["done_bytes"] == 40 << 20
assert job["total_bytes"] == 100 << 20
hook("download", 60 << 20, 60 << 20, "2/2")
assert job["done_bytes"] == 100 << 20
assert job["total_bytes"] == 100 << 20
# Unpack and verify narrate without rewinding the finished bar.
hook("extract", 1, 100, "2/2")
stage("download", 50, 100, "2/2")
transfer(50, 100, {"engine.zip": [(0, 40)], "dlls.zip": [(0, 10)]})
assert job["done_bytes"] == 50 and job["total_bytes"] == 100
transfer(100, 100, {"engine.zip": [(0, 40)], "dlls.zip": [(0, 60)]})
stage("unpack", 0, 0, "2/2")
assert job["phase"] == "unpacking-runtime"
assert job["done_bytes"] == 100 << 20
hook("verify", 0, 0, "")
assert job["done_bytes"] == job["total_bytes"] == 100
stage("verify", 0, 0, "")
assert job["phase"] == "verifying-runtime"
assert job["done_bytes"] == 100 << 20
assert sum(end - start for rows in job["ranges"].values() for start, end in rows) == 100

View File

@@ -0,0 +1,109 @@
"""Opt-in live acceptance: download the actual pinned CUDA engine and run it."""
from __future__ import annotations
import hashlib
import json
import subprocess
import threading
import time
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pm
from hermes_cli.local_runtime import binaries
from hermes_cli.web_routers import local_models as lm
from pm import paths
from pm.lock import Facts, Lockfile
@pytest.mark.platforms("windows", arch="arm64")
def test_live_pinned_cuda_download_pause_resume_and_execute(tmp_path, monkeypatch, record_property):
home = tmp_path / "home"
store = home / "tools"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(paths, "store_root", lambda: store)
monkeypatch.setattr(lm, "_JOBS", {})
monkeypatch.setattr(lm, "_RUNNING", {})
app = FastAPI()
app.include_router(lm.router)
pinned = Lockfile(paths.lockfile_path()).artifacts("llamacpp-cuda", pm.current_target())
flowing = threading.Event()
release = threading.Event()
snapshots = []
stage_hook = lm._runtime_progress_hook
byte_hook = lm._download_progress_hook
def observed_progress(job):
original = byte_hook(job)
def tick(done, total, ranges):
original(done, total, ranges)
snapshots.append((done, total))
return tick
def observed_stage(job):
original = stage_hook(job)
first_archive_bytes = 0
def tick(stage, done, total, label):
nonlocal first_archive_bytes
original(stage, done, total, label)
if stage == "download" and label == "1/2":
first_archive_bytes = done
if stage == "download" and label == "2/2" and first_archive_bytes < done < total and not flowing.is_set():
flowing.set()
assert release.wait(30)
return tick
monkeypatch.setattr(lm, "_runtime_progress_hook", observed_stage)
monkeypatch.setattr(lm, "_download_progress_hook", observed_progress)
def wait_job(client, job_id, states, timeout=240):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
row = client.get(f"/api/local-models/jobs/{job_id}").json()
if row["status"] in states:
return row
time.sleep(0.05)
raise AssertionError(row)
with TestClient(app) as client:
response = client.post("/api/local-models/runtime/install", json={"backend": "cuda"})
assert response.status_code == 200, response.text
job_id = response.json()["job_id"]
try:
assert flowing.wait(240), client.get(f"/api/local-models/jobs/{job_id}").text
assert client.post("/api/local-models/download/pause", json={"job_id": job_id}).json()["paused"]
finally:
release.set()
paused = wait_job(client, job_id, {"paused", "done", "error"})
assert paused["status"] == "paused", paused
assert binaries.installed_engine("cuda") is None
assert client.post("/api/local-models/download/resume", json={"job_id": job_id}).json()["resumed"]
completed = wait_job(client, job_id, {"done", "error"})
assert completed["status"] == "done", completed
engine = binaries.installed_engine("cuda", allow_outdated=False)
assert engine is not None
fact = Facts(paths.facts_path()).get("llamacpp-cuda")
assert fact["artifacts"] == [asset["sha256"] for asset in pinned]
assert [done for done, _ in snapshots] == sorted(done for done, _ in snapshots)
assert completed["done_bytes"] == completed["total_bytes"] > 0
receipt = {"target": pm.current_target(), "tag": engine.tag,
"download_bytes": completed["done_bytes"], "pause_bytes": paused["done_bytes"],
"artifacts": pinned, "binary": str(engine.binary), "commands": {}}
for argument in ("--version", "--list-devices"):
run = subprocess.run([str(engine.binary), argument], cwd=engine.binary.parent,
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
assert run.returncode == 0, run.stdout + run.stderr
receipt["commands"][argument] = run.stdout + run.stderr
dlls = sorted(engine.binary.parent.glob("*.dll"))
assert dlls
receipt["dlls"] = [dll.name for dll in dlls]
evidence = tmp_path / "acceptance.json"
evidence.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
record_property("acceptance", json.dumps(receipt))
print(json.dumps(receipt, indent=2))

View File

@@ -0,0 +1,34 @@
"""A resume bitmap is usable only while its partial file still contains those bytes."""
import hashlib
import json
import os
import time
from pm.downloader import Download, Source, gc_protected_names
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
def test_missing_partial_cannot_turn_a_bitmap_into_completed_model(tmp_path, dl_server):
payload = b"real model bytes"
RangeHandler.payloads["/model"] = payload
source_url = url(dl_server, "/model")
partials = tmp_path / "partials"
partials.mkdir()
key = hashlib.sha256(source_url.encode()).hexdigest()
(partials / f"{key}.ranges").write_text(json.dumps([[0, len(payload)]]), encoding="utf-8")
dest = tmp_path / "model.gguf"
Download([Source(source_url, dest)], partials_dir=partials).run()
assert dest.read_bytes() == payload
def test_gc_protects_both_halves_of_live_partial(tmp_path):
partials = tmp_path / "partials"
partials.mkdir()
part = partials / "key.part"
side = partials / "key.ranges"
part.write_bytes(b"partial")
side.write_text("[]", encoding="utf-8")
old = time.time() - 100
os.utime(side, (old, old))
assert {part.name, side.name} <= gc_protected_names(partials, grace_seconds=30)

View File

@@ -0,0 +1,43 @@
"""Download progress describes actual whole-plan bytes on fresh and resumed runs."""
from __future__ import annotations
from pm.downloader import Download, Source
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
def test_progress_counts_cached_files_and_distinguishes_equal_basenames(tmp_path, dl_server):
bodies = {"/a": b"first", "/b": b"second-file"}
RangeHandler.payloads = bodies
sources = [Source(url(dl_server, path), tmp_path / path[1:] / "model.bin") for path in bodies]
expected = sum(map(len, bodies.values()))
for attempt in range(2):
ticks = []
Download(sources, partials_dir=tmp_path / "partials").run(
progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
assert ticks, f"attempt {attempt} did not report its completed files"
done, total, ranges = ticks[-1]
assert done == total == expected
assert len(ranges) == len(sources)
assert sum(end - start for rows in ranges.values() for start, end in rows) == done
def test_unknown_length_sources_do_not_lose_completed_bytes(tmp_path, dl_server, monkeypatch):
RangeHandler.payloads = {"/a": b"first", "/b": b"second-file"}
RangeHandler.no_range = True
send_header = RangeHandler.send_header
def without_length(self, name, value):
if name.lower() != "content-length":
send_header(self, name, value)
monkeypatch.setattr(RangeHandler, "send_header", without_length)
ticks = []
sources = [Source(url(dl_server, path), tmp_path / path[1:]) for path in RangeHandler.payloads]
Download(sources, partials_dir=tmp_path / "partials").run(
progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
expected = sum(source.dest.stat().st_size for source in sources)
assert ticks[-1][:2] == (expected, expected)
assert all(sum(end - start for rows in ranges.values() for start, end in rows) == done
for done, _, ranges in ticks)
assert [done for done, _, _ in ticks] == sorted(done for done, _, _ in ticks)

View File

@@ -60,8 +60,8 @@ def test_progress_carries_overall_and_ranges(dl_server, tmp_path):
final_d, final_t, final_r = seen[-1]
assert final_d == 3000
assert final_t == 3000
assert final_r["a.bin"] == [(0, 1000)]
assert final_r["b.bin"] == [(0, 2000)]
assert final_r[str(d1)] == [(0, 1000)]
assert final_r[str(d2)] == [(0, 2000)]
# ── optional hash ─────────────────────────────────────────────
@@ -260,7 +260,7 @@ def test_no_range_fallback_downloads_full_body(dl_server, tmp_path):
assert _Handler.ranges_seen == [] # never used Range
d, t, r = seen[-1]
assert d == t == len(payload)
assert r["nr.bin"] == [(0, len(payload))]
assert r[str(dest)] == [(0, len(payload))]
def test_no_range_short_body_raises_and_leaves_no_dest(dl_server, tmp_path):
@@ -343,7 +343,7 @@ def test_resume_first_progress_shows_durable_prefix(dl_server, tmp_path):
d0, t0, r0 = seen[0]
assert d0 > 0 # overall_done starts above zero (durable prefix + new)
assert t0 == total
ranges = r0["rp.bin"]
ranges = r0[str(dest)]
assert len(ranges) == 1 # coalesced
assert ranges[0][0] == 0 # the durable prefix is a covered range
assert ranges[0][1] > 0

View File

@@ -0,0 +1,104 @@
"""Install controls reach real archive transfers without publishing partial packages."""
from __future__ import annotations
import hashlib
import io
import threading
import zipfile
import pytest
import pm
from pm import paths, registry
from pm.downloader import DownloadPaused
from pm.lock import Facts, Lockfile
from pm.package import Package
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
class ComponentPackage(Package):
name = "download-components"
def archive(files: dict[str, bytes]) -> bytes:
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as stream:
for name, body in files.items():
stream.writestr(name, body)
return output.getvalue()
def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server):
root = tmp_path / "store"
lock_path = tmp_path / "lock.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setattr(paths, "store_root", lambda: root)
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
monkeypatch.setitem(registry._packages, ComponentPackage.name, ComponentPackage())
contents = {"engine.dat": b"engine component", "runtime.dll": bytes(range(256)) * (64 * 1024)}
payloads = [archive({name: body}) for name, body in contents.items()]
pins = []
for index, payload in enumerate(payloads):
path = f"/component-{index}.zip"
RangeHandler.payloads[path] = payload
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
lock = Lockfile(lock_path)
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
lock.save()
pause = threading.Event()
def progress(stage, done, total, label):
if stage == "download" and label == "2/2" and len(payloads[0]) < done < total:
pause.set()
with pytest.raises(DownloadPaused):
pm.ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause)
assert Facts(paths.facts_path()).get(ComponentPackage.name) is None
assert list(paths.partials_root().glob("*.ranges"))
first_requests = [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"]
assert first_requests
assert (root / f"fetch-{pins[0]['sha256']}").is_dir()
pause.clear()
pm.ensure(ComponentPackage.name, explicit=True, pause_event=pause)
fact = Facts(paths.facts_path()).get(ComponentPackage.name)
assert fact["artifacts"] == [pin["sha256"] for pin in pins]
for name, body in contents.items():
assert (root / fact["entry"] / name).read_bytes() == body
assert [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] == first_requests
assert not list(paths.partials_root().glob("*.part"))
def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server):
root = tmp_path / "store"
lock_path = tmp_path / "lock.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setattr(paths, "store_root", lambda: root)
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
monkeypatch.setitem(registry._packages, ComponentPackage.name, ComponentPackage())
payloads = [archive({"engine.dat": b"engine"}), archive({"runtime.dll": b"runtime"})]
pins = []
for index, payload in enumerate(payloads):
path = f"/component-{index}.zip"
RangeHandler.payloads[path] = payload
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
lock = Lockfile(lock_path)
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
lock.save()
store = pm.Store(root)
with store.scratch() as scratch:
store.fetch(pins[0]["url"], pins[0]["sha256"], scratch)
ticks = []
stages = []
pm.ensure(ComponentPackage.name, explicit=True,
progress=lambda *args: stages.append(args),
download_progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
expected = sum(map(len, payloads))
assert ticks and all(total == expected for _, total, _ in ticks)
assert ticks[0][0] == len(payloads[0])
assert ticks[-1][0] == expected
assert all(sum(end - start for rows in ranges.values() for start, end in rows) == done
for done, _, ranges in ticks)
assert [done for done, _, _ in ticks] == sorted(done for done, _, _ in ticks)
assert {stage for stage, *_ in stages} >= {"download", "unpack", "verify"}

View File

@@ -0,0 +1,30 @@
"""Installed runtime lookup uses the same shipped/addition precedence as ensure."""
from __future__ import annotations
import pm
from pm import paths
from pm.lock import Lockfile
from tests.pm.test_pm_authority import pm_env, served # noqa: F401
def test_installed_lookup_prefers_current_pin_then_recorded_fallback(pm_env, tmp_path, monkeypatch):
pm.ensure("faketool", explicit=True)
original = pm.installed_package("faketool")
assert original is not None and original.binary.is_file()
lock = Lockfile(pm_env["lockfile_path"])
lock.set_pin("faketool", "2.0", {"any": {
"url": pm_env["base_url"] + "/faketool-2.0.tar.gz", "sha256": "b" * 64,
}})
lock.save()
assert pm.installed_package("faketool") is None
assert pm.installed_package("faketool", allow_outdated=True) == original
shipped = tmp_path / "sealed" / "tools"
shipped.mkdir(parents=True)
monkeypatch.setattr(paths, "store_root", lambda: shipped)
monkeypatch.setattr(paths, "writable_store_root", lambda: pm_env["runtime"])
assert pm.installed_package("faketool", allow_outdated=True) == original
original.binary.unlink()
assert pm.installed_package("faketool", allow_outdated=True) is None

View File

@@ -0,0 +1,26 @@
"""Every supported local engine backend installs only reviewed PM artifacts."""
import pm
from pm import paths
from pm.lock import Lockfile
from pm.store import ALL_TARGETS
def test_llamacpp_backends_have_pins_for_their_supported_targets():
lock = Lockfile(paths.lockfile_path())
for backend in ("cpu", "cuda", "vulkan", "metal", "hip"):
name = f"llamacpp-{backend}"
package = pm.get_package(name)
version = lock.version(name)
assert version
for target in ALL_TARGETS:
artifacts = lock.artifacts(name, target)
if package.missing_reason(target):
assert not artifacts
continue
assert artifacts
assert [a["url"] for a in artifacts] == package.fetch_urls(version, target)
assert all(len(bytes.fromhex(a["sha256"])) == 32 for a in artifacts)
if backend == "cuda":
assert len(artifacts) == 2
assert any("cudart-" in a["url"] for a in artifacts)

View File

@@ -886,6 +886,9 @@ def test_store_path_dirs_include_node_npm_when_installed(tmp_path, monkeypatch):
entry = f"{name}-1"
entry_dir = store / entry
entry_dir.mkdir(parents=True, exist_ok=True)
binary = registry.get_package(name).binary(entry_dir, current_target())
binary.parent.mkdir(parents=True, exist_ok=True)
binary.write_bytes(b"installed tool")
facts.record(
name, "1", entry,
{"PATH": ["{store}/" + entry]}, store_root=store,

View File

@@ -21,9 +21,20 @@ builds require the `--local` launch flag. A runtime can already be bundled;
its absence triggers the managed-tool install path, not an arbitrary latest
llama.cpp download.
Model downloads support pause and resume. Partial downloads use PM's writable
`cache/partials` area, outside a signed app package. Model weights and managed
engine binaries are separate downloads.
Use **Pause** and **Resume** on engine installs, engine updates, catalog models,
Hugging Face downloads, and quickstart. Paused jobs remain visible when you
leave and reopen Local Models. Pause stops the transfer at a chunk boundary;
unpacking, verification, and server activation are separate phases.
PM downloads every engine component, including CUDA runtime DLLs, from the
URLs and SHA-256 pins in `pm/lock.json`. Model weights, split GGUF parts,
vision projectors, and draft models use the same downloader. Byte progress
covers the whole download plan, including completed files and resumed ranges.
Quickstart labels engine and model progress as separate stages.
Partial downloads use PM's writable `cache/partials` area, outside a signed
app package. Range-capable hosts resume missing bytes. Hosts without Range
support restart the current file. Completed files are reused.
## Getting started
@@ -123,23 +134,27 @@ local_runtime:
enabled: false # true = start the managed server with Hermes.
# The desktop "Use" button sets this automatically.
backend: auto # auto | cuda | metal | vulkan | hip | cpu
tag: b10362 # pinned llama.cpp release; Hermes updates it with
# each release after re-validation
```
Models and runtime builds live under the Hermes home directory
(`models/` and `runtimes/llamacpp/`). Selecting a local model as your
main model uses the standard `model.provider: llamacpp` +
`model.default` settings — the same shape as every other provider.
Engine versions come only from PM's lockfile, not a `local_runtime.tag`
override. Boot uses an installed PM engine without downloading. When a new
pin is available, install it with the desktop update button.
Models live in the machine-shared `models/` directory. Engine binaries live
in PM's store; `runtimes/llamacpp/` holds mutable presets and server state.
Selecting a local model as your main model uses the standard
`model.provider: llamacpp` + `model.default` settings.
## Requirements and limits
- **Windows and Linux:** NVIDIA GPU (CUDA) or CPU. **macOS:** Apple
Silicon (Metal). Vulkan builds serve AMD GPUs.
- **Windows:** CUDA on supported NVIDIA targets, Vulkan on x64, or CPU.
**Linux:** Vulkan or CPU; the pinned release has no prebuilt CUDA archive.
**macOS:** Metal or CPU. HIP/ROCm is an explicit choice on supported x64
targets. Unsupported backend/target pairs fail before any download.
- A GPU with 8 GB+ of memory runs the small catalog models comfortably;
16 GB+ runs the 27–35B models at high quality.
- Model downloads are byte-size checked against the catalog during the
transfer; an incomplete download is deleted and reported, never
half-used. (Only the runtime engine zips are SHA-256 verified.)
- Model completeness is checked against the server's response, not catalog
size estimates. Interrupted transfers retain partials for resume; incomplete
files are not published. Engine archives are SHA-256 verified before use.
- Deleting a model removes every file it staged, including vision
adapters and speculative-decoding companions.