fix(cookbook): prevent auto-retry from restarting user-stopped downloads (#1778)

Two related bugs in the Cookbook task lifecycle:

1. "Stop all" fired kills via .click() inside a synchronous forEach but
   showed the success toast immediately after — the toast appeared before
   any of the async kill requests had been sent, giving the user false
   confidence the tasks were stopped.

2. The download auto-retry logic (triggered when DOWNLOAD_FAILED appears
   in the task output) had no way to distinguish a network interruption
   from a deliberate user stop. A download stopped via "Stop all" or the
   individual Stop button could be silently restarted up to two times by
   the background monitor.

Fix: persist _userStopped: true to localStorage at the moment the user
clicks Stop (individually) or Stop all. The auto-retry guard checks this
flag before relaunching the download. The flag is written BEFORE the
kill requests fire so there is no window where the monitor can race.

Fixes #1458
This commit is contained in:
Lucas Daniel
2026-06-03 01:22:39 -03:00
committed by GitHub
parent ed7956cbd3
commit 1d99429ba0

View File

@@ -1705,6 +1705,9 @@ export function _renderRunningTab() {
const running = _loadTasks().filter(t => (t.remoteHost || '') === host && t.status === 'running');
if (!running.length) { uiModule.showToast(`Nothing running on ${_serverName(host)}`); return; }
if (!await window.styledConfirm(`Stop ${running.length} running task${running.length > 1 ? 's' : ''} on ${_serverName(host)}?`, { confirmText: 'Stop all' })) return;
// Mark every task as user-stopped BEFORE firing the kills so that the
// download auto-retry logic never restarts a task the user just stopped.
running.forEach(t => _updateTask(t.sessionId, { _userStopped: true }));
// Reuse each task's own Stop action so it does the full teardown
// (send C-c, drop the endpoint, mark stopped) consistently.
running.forEach(t => {
@@ -2166,6 +2169,7 @@ export function _renderRunningTab() {
const badge = el.querySelector('.cookbook-task-status');
if (badge) { badge.textContent = 'stopping...'; badge.className = 'cookbook-task-status cookbook-task-stopping'; }
el.dataset.status = 'stopped';
_updateTask(task.sessionId, { _userStopped: true });
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
// Drop the model endpoint so the picker stops listing it.
if (task.type === 'serve' && task.payload) {
@@ -2549,7 +2553,7 @@ async function _reconnectTask(el, task) {
const _accessDenied = /Access to model.*is restricted|gated repo|GatedRepoError|401 Unauthorized|403 Forbidden|not in the authorized list|awaiting a review|must (?:be authenticated|have access)/i.test(snapshot);
const _dlKey = task.payload?.repo_id || task.name;
const _dlN = _dlRetryCount.get(_dlKey) || 0;
if (!_accessDenied && task.type === 'download' && task.payload && _dlN < _DL_MAX_AUTO_RETRY) {
if (!_accessDenied && !task._userStopped && task.type === 'download' && task.payload && _dlN < _DL_MAX_AUTO_RETRY) {
// Auto-retry: kill the dead session and re-launch (resumes from
// the cached .incomplete files) after a short delay.
_dlRetryCount.set(_dlKey, _dlN + 1);