fix(models): stop reporting errored downloads as still downloading (#926)

TaskManager.error_download() intentionally keeps a failed task in the
active list (status="error") so /tasks/active can surface the error
and retry UI — but /models/status derived its "downloading" flag from
the same unfiltered list. One failed download therefore showed the
model as downloading:true / downloaded:false for the life of the
process, masking the model's real cache state (even a fully valid
on-disk cache) until an app restart. Likely behind endless-spinner
reports like #181 and the restart-fixes-it pattern in #883.

Add TaskManager.get_pending_downloads() (downloading/extracting only)
and use it in /models/status; /tasks/active behavior is unchanged.

Fixes #925


Claude-Session: https://claude.ai/code/session_011iwL9AyeAWgz2jpgcHxJpC

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Daniel Knoodle
2026-07-20 12:39:58 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent b680097dfb
commit 2c9d02af62
3 changed files with 68 additions and 1 deletions
@@ -0,0 +1,51 @@
"""Errored downloads must not be reported as still downloading.
A failed download intentionally stays in the TaskManager with
``status="error"`` so ``/tasks/active`` can surface the error and retry
UI — but ``/models/status`` derives its ``downloading`` flag from the
same list. Without a status filter, one failed download shows the model
as "downloading" forever and masks its real cache state until the app
restarts (issue #925, symptom reports like #181).
"""
from backend.utils.tasks import TaskManager
def test_errored_download_is_not_pending():
tm = TaskManager()
tm.start_download("whisper-turbo")
assert [t.model_name for t in tm.get_pending_downloads()] == ["whisper-turbo"]
tm.error_download("whisper-turbo", "boom")
assert tm.get_pending_downloads() == []
# Still visible to /tasks/active for the error/retry UI.
active = tm.get_active_downloads()
assert [t.model_name for t in active] == ["whisper-turbo"]
assert active[0].status == "error"
assert active[0].error == "boom"
def test_retry_after_error_is_pending_again():
tm = TaskManager()
tm.start_download("qwen3-4b")
tm.error_download("qwen3-4b", "boom")
tm.start_download("qwen3-4b")
assert [t.model_name for t in tm.get_pending_downloads()] == ["qwen3-4b"]
def test_completed_download_is_removed_everywhere():
tm = TaskManager()
tm.start_download("whisper-turbo")
tm.complete_download("whisper-turbo")
assert tm.get_pending_downloads() == []
assert tm.get_active_downloads() == []
def test_cancel_dismisses_errored_download():
tm = TaskManager()
tm.start_download("whisper-turbo")
tm.error_download("whisper-turbo", "boom")
assert tm.cancel_download("whisper-turbo") is True
assert tm.get_active_downloads() == []
assert tm.get_pending_downloads() == []