Skip to content

feat: re-download outdated model weights as a cancellable batch - #31

Merged
smb merged 3 commits into
mainfrom
feat/bulk-redownload-outdated
Sep 4, 2026
Merged

smb merged 3 commits into
mainfrom
feat/bulk-redownload-outdated

Conversation

@smb

@smb smb commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #29

Summary

The bulk Re-download outdated removed before #24 landed comes back with the four things it was missing. Three commits: a behaviour-neutral package split, the session API change, the feature.

  • Confirmation. Names the count and the size in the units the manager already prints (worst case "13 model weight(s) (6.3 GiB)"), says each old copy is deleted only once its replacement verifies, and says the other files in the obsolete directory stay until Delete outdated.
  • Cancel. fetch() gains an optional cancellation check; the batch owns a CancellationState that reaches the downloader, so Cancel, Esc and the title-bar X stop it within one chunk (or the transport's 60 s stall bound). The dialog stays open and reports what it managed instead of hiding with work still running.
  • Shutdown. ModelManagerController.close() cancels before it waits, so quitting no longer blocks behind a running fetch on the manager lock.
  • Batch progress. The status line carries the model, its position and the rate.

Two decisions deviate from the issue's wish list and are recorded in its thread:

  • Delete after verify, per model rather than clearing the namespace up front. remove_obsolete_versions() takes an optional model id. Transient double disk is one weight, not the set, and the old weights stay valid for the previous build until replaced.
  • Stop at the first failure rather than continuing. Because deletion is per model, a stopped batch leaves the notice, the rows and the button truthful and one click resumes; continuing would work through up to nine further 60 s timeouts on a dead network and needs failure aggregation.

Lock discipline. While a worker thread lives, the GUI thread reads nothing that takes the manager lock; rows refresh once at the end. The test fake enforces this by raising if its active id is read during a fetch, so every test in the file checks the rule rather than one test asserting it.

jobs/models/session.py grows 432 to 439 lines, agreed with the maintainer before the baseline was updated. ui/model_manager.py became a package first (largest unit 464 lines) because the module stood at 753 against the 800-line budget.

Review

Design by Fable against the analysis in the #24 thread, implementation by the Codex model from that design, then reviewed.

Test plan

  • uv run ruff check . && uv run mypy src && QT_QPA_PLATFORM=offscreen uv run pytest -q: 1618 passed, 4 skipped; 1604 before this branch.
  • New: the confirmation text and size, per-model fetch-then-delete order, batch position in the status line, cancel stopping the batch and keeping the rest outdated, Esc cancelling instead of hiding, dismissal ignored during a removal, stop at the first failure, close() cancelling before waiting, a single download cancelled without a warning box, refusal while a job runs, and the fake's lock-rule assertion across all of them.

🤖 Generated with Claude Code

smb and others added 3 commits September 4, 2026 12:39
model_manager.py stood at 753 lines against the 800-line module budget,
so the next feature would have had to be squeezed in or would have
pushed a compliant module over the ceiling. The bulk re-download of
outdated weights (#29) needs the room.

The module becomes a package with the layering it already had
implicitly: _entries holds the row model and the cache lookups,
_dialog the widget, _workers the three background workers, and
_controller the protocol and the controller that wires them. The
largest unit is 303 lines. __init__ re-exports the three names the two
importers in the repository use, so no call site changes and no
compatibility shim is needed: the UI tests monkeypatch Qt classes, not
module globals.

Code moved, nothing rewritten; a sorted-line comparison of the old
module against the concatenated units differs only in import
statements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fetch() hard-coded the manager's own cancellation state, which in
production is the preview job's and is false whenever no job runs. A
dialog that downloads a weight could therefore pass a progress callback
but had no way to stop what it started. It now takes an optional
cancellation check and keeps the manager's when none is given, so every
existing caller is unchanged.

remove_obsolete_versions() takes an optional model id so one outdated
weight can be removed once its replacement has been verified, instead
of clearing the whole namespace before the first download. The catalog
resolves the id, so the extra path segment is always a known model, and
the symlink and directory guards are untouched.

Growth of the frozen jobs/models/session.py from 432 to 439 lines was
agreed with the maintainer before the baseline was updated, as the
guardrails require.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bulk action removed before #24 landed started up to 6.35 GiB of
downloads from one unconfirmed click, survived closing the dialog and
left shutdown blocked on the manager lock. It returns with the four
things that were missing.

A confirmation names the count and the size in the units the manager
already prints, and says both that each old copy is deleted only once
its replacement verifies and that the other files in the obsolete
directory stay until Delete outdated. The batch owns a cancellation
state that reaches the downloader, so Cancel, Esc and the title-bar X
all stop it within one chunk; the dialog stays open and reports what it
managed. Controller close() cancels, joins, releases the busy state and
closes the dialog, so quitting the application no longer blocks behind
a running fetch. The status line carries the model, its position in the
batch and the transfer rate.

The first failure stops the batch. Because each old weight is deleted
only after its replacement is verified, what remains is truthful: the
notice, the rows and the button describe exactly the models still to
fetch, and one click resumes. That is why there is no failure
aggregation and no second summary format.

While a worker thread lives, the GUI thread reads nothing that takes
the manager lock; rows refresh once at the end. The test fake raises
when its active id is read during a fetch, and the batch test blocks on
its second model so the slots that run between models are covered by
that guard rather than racing past it.

Closes #29

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@smb
smb force-pushed the feat/bulk-redownload-outdated branch from 1057fbe to b7c1d43 Compare September 4, 2026 10:40
@smb

smb commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Reviewed by Fable against the design and the guardrails, with the three commits in hand. It found no lock-rule violation in the code, no repeat of the #27 thread-lifetime shape, no path that escapes the cache root, and no vacuous test. Five things were worth acting on and are in the branch now (the two feature commits were amended, so the branch was force-pushed with lease):

  • A guardrail violation in a commit body. The split commit stated a test pass count, which G1a forbids outright. Reworded to state what was actually verified about the move instead.
  • close() did not close the dialog. It called dialog.close() while the dialog was still busy, so the new reject() override turned that into a cancel request on a controller whose state had just been cleared, and the dialog stayed visible. It now releases the busy state after joining, then closes.
  • The lock guard was weaker than the commit claimed. The fake raises when its active id is read during a fetch, but every blocking test blocked the first model, so the slots that run between models raced past the guard. The successful-batch test now blocks on its second model, which puts those slots inside a held lock. The commit body no longer overstates the coverage.
  • Two missing pieces from the design's test list: the close-cancels test now covers the single-download path as well as the batch, and the full-batch test asserts the notice still shows the untouched non-V1 file's size.

Three findings are deliberately left, and two of them are the design being wrong rather than the code:

  • Cancel during a stalled connection is reported as a failure. download.py checks cancellation before the read, so a transport timeout surfaces first; on the single path that means the warning box the user was told Cancel avoids. Rare, and the fix belongs in the cancellation check order, not here.
  • A delete that fails after a verified download is reported as that model's download failure, so the count and the message understate what is on disk; the rows are truthful after the refresh. The design accepted this to avoid a special case.
  • The per-model deletion checks the leaf for a symlink, not the version directory. A user-planted symlink at cache_root/2.0.72 would be followed where the whole-directory call skipped it. No observed trigger, so G1 says leave it.

@smb
smb merged commit cac89ba into main Sep 4, 2026
10 of 11 checks passed
@smb
smb deleted the feat/bulk-redownload-outdated branch September 4, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bulk re-download of outdated weights with confirmation, cancel and batch progress

1 participant