fix(cookbook): prevent duplicate dependency installs

Track active dependency install tasks in the Cookbook dependency list so running installs render as a downloading state instead of another install action.

Refresh dependency state when install tasks finish or are removed after stop/cancel, preventing stale downloading pills in the Dependencies tab.

Fixes #3778
This commit is contained in:
Matyas Fenyves 2026-06-10 15:36:49 +02:00
parent 25c9e735ef
commit 63ae69ea29
3 changed files with 121 additions and 7 deletions

View file

@ -1067,6 +1067,36 @@ export function _persistEnvState() {
// ── Dependencies ──
// Category colors removed — using theme CSS classes instead
const _pendingDepInstalls = new Set();
const _ACTIVE_DEP_STATUSES = new Set(['queued', 'running']);
function _depHostKey(host) {
const h = String(host || '').trim();
return (!h || h === 'local' || h === 'localhost' || h === '127.0.0.1') ? 'local' : h;
}
function _depInstallKey(pipName, host = '', port = '', envPath = '') {
return [
String(pipName || '').trim(),
_depHostKey(host),
String(port || '').trim(),
String(envPath || '').trim(),
].join('\n');
}
function _taskDepInstallKey(task) {
if (!task?.payload?._dep || !task.payload.repo_id) return '';
return _depInstallKey(
task.payload.repo_id,
task.remoteHost || task.payload.remote_host || '',
task.sshPort || task.payload.ssh_port || '',
task.payload.env_path || task.payload._envPath || ''
);
}
function _isActiveDepTask(task) {
return !!task?.payload?._dep && _ACTIVE_DEP_STATUSES.has(task.status || '');
}
async function _fetchDependencies() {
const list = document.getElementById('cookbook-deps-list');
@ -1128,9 +1158,31 @@ async function _fetchDependencies() {
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
const _systemInstallable = new Set(['tmux']);
const _activeDepTasks = new Map();
_loadTasks().forEach(task => {
if (!_isActiveDepTask(task)) return;
const key = _taskDepInstallKey(task);
if (key && !_activeDepTasks.has(key)) _activeDepTasks.set(key, task);
});
_pendingDepInstalls.forEach(key => {
if (key && !_activeDepTasks.has(key)) _activeDepTasks.set(key, { _pending: true });
});
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
const _depTarget = (isLocal) => isLocal
? { host: '', port: '', envPath: '' }
: { host: _depHost, port: _depPort, envPath: _depVenv };
const _activeDepFor = (pkg, isLocal) => {
if (!pkg?.pip) return null;
const target = _depTarget(isLocal);
return _activeDepTasks.get(_depInstallKey(pkg.pip, target.host, target.port, target.envPath)) || null;
};
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked, activeDep) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
if (activeDep) {
const session = activeDep.sessionId ? ` (${activeDep.sessionId})` : '';
return `<span class="cookbook-dep-tag cookbook-dep-downloading" title="Dependency install is already running${esc(session)}">downloading</span>`;
}
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
if (pkg.installed && pkg.pip_update_available === false && pkg.name !== 'llama_cpp') {
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
@ -1174,6 +1226,7 @@ async function _fetchDependencies() {
const isLocal = pkg.target === 'local';
const isSystemDep = pkg.kind === 'system';
const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name);
const activeDep = _activeDepFor(pkg, isLocal);
const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : '';
const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : '';
// Inline rebuild/reinstall tag. Styled as a .cookbook-dep-tag so it
@ -1183,7 +1236,11 @@ async function _fetchDependencies() {
// diagnosis-style `_launchServeTask` with `pip install --force-reinstall`
// so the user can watch the pip install in the Running tab.
let _rebuildBtn = '';
if (pkg.name === 'vllm' && pkg.installed) {
if (activeDep) {
_rebuildBtn = '';
} else if (pkg.name === 'llama_cpp') {
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build).">Rebuild</button>`;
} else if (pkg.name === 'vllm' && pkg.installed) {
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="vllm" title="Force-reinstall vLLM (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
} else if (pkg.name === 'sglang' && pkg.installed) {
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
@ -1229,7 +1286,7 @@ async function _fetchDependencies() {
+ _rebuildBtn
+ _buildDepsBtn
+ `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>`
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked)
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked, activeDep)
+ recipeCaret
+ `</div>`
+ recipePanel;
@ -1417,6 +1474,28 @@ async function _fetchDependencies() {
}
const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || '');
const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || '');
const activeKey = _depInstallKey(
pipName,
targetRemoteHost || '',
isLocalOnly ? '' : (_getPort(targetRemoteHost) || ''),
targetEnvPath || ''
);
if (_pendingDepInstalls.has(activeKey) || _loadTasks().some(task => _isActiveDepTask(task) && _taskDepInstallKey(task) === activeKey)) {
uiModule.showToast(`${pkgName} is already downloading on ${targetHost}.`);
if (statusEl) {
statusEl.textContent = 'downloading';
statusEl.disabled = true;
statusEl.classList.add('cookbook-dep-downloading');
}
return;
}
_pendingDepInstalls.add(activeKey);
if (statusEl) {
statusEl.textContent = 'downloading';
statusEl.disabled = true;
statusEl.classList.add('cookbook-dep-downloading');
statusEl.title = `${pkgName} is already downloading`;
}
// Always go through `python -m pip` so the leading token is `python`
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
@ -1488,13 +1567,20 @@ async function _fetchDependencies() {
action: 'OK',
onAction: () => {},
});
_pendingDepInstalls.delete(activeKey);
if (statusEl) {
statusEl.textContent = upgrade ? 'Update' : 'Install';
statusEl.disabled = false;
statusEl.classList.remove('cookbook-dep-downloading');
statusEl.title = '';
}
return;
}
// _dep flags this as a pip dependency/driver install (not a servable
// model) so the running-task card doesn't offer a "Serve →" button.
const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', ssh_port: isLocalOnly ? '' : (_getPort(targetRemoteHost) || ''), _dep: true, _dep_action: upgrade ? 'update' : 'install', env_path: targetEnvPath || '', platform: targetPlatform || '' };
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
_pendingDepInstalls.delete(activeKey);
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
} catch (err) {
uiModule.showToast('Install failed: ' + err.message, {
@ -1502,6 +1588,13 @@ async function _fetchDependencies() {
action: 'OK',
onAction: () => {},
});
_pendingDepInstalls.delete(activeKey);
if (statusEl) {
statusEl.textContent = upgrade ? 'Update' : 'Install';
statusEl.disabled = false;
statusEl.classList.remove('cookbook-dep-downloading');
statusEl.title = '';
}
}
}

View file

@ -969,6 +969,9 @@ function _updateTask(sessionId, updates) {
if (uptime) uptime.style.display = 'none';
}
}
if (task?.type === 'download' && task.payload?._dep && updates.status && !['queued', 'running'].includes(task.status || '')) {
_refreshDepsAfterInstall(task);
}
}
function _refreshDepsAfterInstall(task) {
@ -980,8 +983,10 @@ function _refreshDepsAfterInstall(task) {
export function _removeTask(sessionId) {
_tombstoneTask(sessionId); // so sync/poll can't resurrect it
const tasks = _loadTasks().filter(t => t.sessionId !== sessionId);
_saveTasks(tasks);
const tasks = _loadTasks();
const task = tasks.find(t => t.sessionId === sessionId);
_saveTasks(tasks.filter(t => t.sessionId !== sessionId));
_refreshDepsAfterInstall(task);
_renderRunningTab();
}

View file

@ -20632,6 +20632,14 @@ body.gallery-selecting .gallery-dl-btn,
padding: 0 10px;
box-sizing: border-box;
}
.cookbook-dep-downloading {
background: color-mix(in srgb, var(--orange, #ffb86c) 18%, transparent);
color: var(--orange, #ffb86c);
border: 1px solid color-mix(in srgb, var(--orange, #ffb86c) 35%, transparent);
min-width: 75.85px;
padding: 0 10px;
box-sizing: border-box;
}
.cookbook-dep-na {
background: color-mix(in srgb, var(--fg) 8%, transparent);
color: color-mix(in srgb, var(--fg) 60%, transparent);
@ -20660,6 +20668,13 @@ body.gallery-selecting .gallery-dl-btn,
-webkit-appearance: none;
-moz-appearance: none;
}
.cookbook-dep-install.cookbook-dep-downloading {
background: color-mix(in srgb, var(--orange, #ffb86c) 18%, transparent);
color: var(--orange, #ffb86c);
border: 1px solid color-mix(in srgb, var(--orange, #ffb86c) 35%, transparent);
cursor: default;
opacity: 1;
}
/* Conditional line under the Download h2: only when the section is folded
(collapsed). When expanded, the body content provides separation; the
underline reads as clutter. */
@ -20693,6 +20708,7 @@ body.gallery-selecting .gallery-dl-btn,
padding-left: 0.5px;
}
.cookbook-dep-install:hover { opacity: 0.85; }
.cookbook-dep-install.cookbook-dep-downloading:hover { opacity: 1; }
/* Installed split button: "Installed" label + separator + caret; clicking it
opens the actions menu (Update). Replaces the old button. */
.cookbook-dep-installed-btn {