Problem
server/lib/downloadPreflight.js's streamResumableDownload (added in #5817) intentionally keeps a ${destPath}.partial (plus a .partial.etag sidecar) after any transport failure, so a retry can Range-resume — only a user-initiated cancel discards it. Before this change, downloadToFile/streamToFile deleted the temp file on any stream failure.
That's the correct behavior for a retry that actually happens soon. But if the user never retries — network drops mid-transfer, they pick a different model, close the tab — the partial sits on disk forever. Nothing sweeps it:
listLoras() (server/services/loras.js) filters .partial files out by extension, so they're invisible to the LoRA manager UI and its disk-usage readout.
- No startup/periodic cleanup exists anywhere in the tree —
grep -rn '\.partial' under server/ only finds the writers (downloadPreflight.js, specDecodeModels.js, loras.js), never a sweeper.
A 12 GB LoRA or a 20 GB GGUF that dies on a dropped connection then sits invisible in data/loras/ or the spec-decode temp dir indefinitely — silently eating exactly the disk space the whole preflight feature exists to protect.
Decision
Add a startup + periodic age-based sweep, not a UI-surfacing-only fix — surfacing in the UI doesn't reclaim the space, and a stale .partial a user never intended to resume is dead weight regardless of whether they can see it.
- New helper, e.g.
sweepOrphanedPartials(dirs, { maxAgeMs }) in server/lib/downloadPreflight.js (or a sibling module it exports from) — given one or more directories, readdir for *.partial (and their *.partial.etag siblings), stat each, and unlink any partial (+ its etag sidecar) whose mtimeMs is older than maxAgeMs. A sane default cutoff is 7 days — long enough that a user who steps away overnight and resumes tomorrow isn't punished, short enough that a genuinely abandoned transfer doesn't linger for months.
- Wire it into
server/services/bootstrap.js following the existing "Legacy artifact prune" pattern there (search pruneLegacyFiles around line ~667) — a boot-time async step, not a blocking one, over the known download destinations: PATHS.loras (server/lib/fileUtils.js), the spec-decode preset paths (server/lib/specDecodePresets.js / wherever resolveSpecModelPath lands), and the Ollama/LM Studio model dirs (ollamaManager.getModelsDir() / lmStudioManager.getModelsDir()).
- Do NOT sweep a
.partial that has an in-flight download actively writing to it — the safest guard is the age cutoff itself (an active transfer's mtimeMs keeps advancing as bytes land), but double-check against each surface's own "is a download active for this dest" tracking (e.g. specDecodeModels.js's in-flight state map) before deleting, so a sweep tick that lands mid-download can't race a live write.
- Add a focused unit test for the sweep helper (old partial removed, in-flight/recent partial kept, missing dir handled) — this is exactly the kind of pure edge-case logic AGENTS.md's Test Strategy calls out for a dedicated test rather than only an integration boundary test.
Acceptance criteria
- A
.partial (+ .partial.etag) older than the cutoff is removed on boot and on a periodic tick, across every destination the four #5817 download entry points can leave one in.
- A recent or actively-downloading
.partial is never touched by the sweep.
- New/updated tests cover the sweep helper directly.
Problem
server/lib/downloadPreflight.js's
streamResumableDownload(added in #5817) intentionally keeps a${destPath}.partial(plus a.partial.etagsidecar) after any transport failure, so a retry can Range-resume — only a user-initiated cancel discards it. Before this change,downloadToFile/streamToFiledeleted the temp file on any stream failure.That's the correct behavior for a retry that actually happens soon. But if the user never retries — network drops mid-transfer, they pick a different model, close the tab — the partial sits on disk forever. Nothing sweeps it:
listLoras()(server/services/loras.js) filters.partialfiles out by extension, so they're invisible to the LoRA manager UI and its disk-usage readout.grep -rn '\.partial'underserver/only finds the writers (downloadPreflight.js,specDecodeModels.js,loras.js), never a sweeper.A 12 GB LoRA or a 20 GB GGUF that dies on a dropped connection then sits invisible in
data/loras/or the spec-decode temp dir indefinitely — silently eating exactly the disk space the whole preflight feature exists to protect.Decision
Add a startup + periodic age-based sweep, not a UI-surfacing-only fix — surfacing in the UI doesn't reclaim the space, and a stale
.partiala user never intended to resume is dead weight regardless of whether they can see it.sweepOrphanedPartials(dirs, { maxAgeMs })inserver/lib/downloadPreflight.js(or a sibling module it exports from) — given one or more directories,readdirfor*.partial(and their*.partial.etagsiblings),stateach, and unlink any partial (+ its etag sidecar) whosemtimeMsis older thanmaxAgeMs. A sane default cutoff is 7 days — long enough that a user who steps away overnight and resumes tomorrow isn't punished, short enough that a genuinely abandoned transfer doesn't linger for months.server/services/bootstrap.jsfollowing the existing "Legacy artifact prune" pattern there (searchpruneLegacyFilesaround line ~667) — a boot-time async step, not a blocking one, over the known download destinations:PATHS.loras(server/lib/fileUtils.js), the spec-decode preset paths (server/lib/specDecodePresets.js/ whereverresolveSpecModelPathlands), and the Ollama/LM Studio model dirs (ollamaManager.getModelsDir()/lmStudioManager.getModelsDir())..partialthat has an in-flight download actively writing to it — the safest guard is the age cutoff itself (an active transfer'smtimeMskeeps advancing as bytes land), but double-check against each surface's own "is a download active for this dest" tracking (e.g.specDecodeModels.js's in-flight state map) before deleting, so a sweep tick that lands mid-download can't race a live write.Acceptance criteria
.partial(+.partial.etag) older than the cutoff is removed on boot and on a periodic tick, across every destination the four#5817download entry points can leave one in..partialis never touched by the sweep.