diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 47f7a1b62..61dadd51d 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -3533,12 +3533,22 @@ async function _pollBackgroundStatus() { // dead-session check inspects). Recover "done" from the retained output's // exit-0 sentinel so a clean install isn't downgraded to crashed. const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output); + // A finished model download whose tmux pane is gone is also reported + // "stopped" (the dead-session check can miss the landed snapshot). + // Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted + // only after the runner exits 0 — so a completed download isn't + // downgraded to crashed. This background poll runs blind (no live + // stream to debounce against), so unlike the reconnect loop it keys + // off the conclusive exit sentinel only, never the `/snapshots/` path, + // which can be printed mid-stream for multi-file downloads. + const downloadDone = task.type === 'download' + && String(task.output || '').includes('DOWNLOAD_OK'); const nextStatus = live.status === 'completed' ? 'done' : (live.status === 'error' ? 'error' : (live.status === 'stopped' - ? (depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')) + ? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')) : null)); if (nextStatus && task.status !== nextStatus) { updates.status = nextStatus; diff --git a/tests/test_cookbook_dependency_completion_regression.py b/tests/test_cookbook_dependency_completion_regression.py index 1533bdaca..1427cebaa 100644 --- a/tests/test_cookbook_dependency_completion_regression.py +++ b/tests/test_cookbook_dependency_completion_regression.py @@ -74,7 +74,23 @@ def test_background_poll_recovers_done_for_stopped_dependency_install(): source = _read("static/js/cookbookRunning.js") assert "const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);" in source - assert "depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')" in source + assert "(depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')" in source + + +def test_background_poll_recovers_done_for_completed_download(): + """When the backend reports a finished model download as "stopped" (its + tmux pane is gone after DOWNLOAD_OK, so the dead-session check can miss the + landed snapshot), the reconciler must recover "done" from the terminal + DOWNLOAD_OK sentinel instead of downgrading the card to crashed. The + background poll keys off DOWNLOAD_OK only (not the "/snapshots/" path, which + can appear mid-stream for multi-file downloads).""" + source = _read("static/js/cookbookRunning.js") + + normalized = " ".join(source.split()) + assert ( + "const downloadDone = task.type === 'download' " + "&& String(task.output || '').includes('DOWNLOAD_OK');" + ) in normalized def test_dependency_install_payload_keeps_env_path_for_refresh():