diff --git a/public/index.html b/public/index.html index b39e82c..ed5908c 100644 --- a/public/index.html +++ b/public/index.html @@ -2667,8 +2667,8 @@ .modal .modal-body textarea { min-height: 200px; } #cfgModal .modal { min-width: 0 !important; - width: calc(100vw - 16px) !important; - max-width: calc(100vw - 16px) !important; + width: 100% !important; + max-width: calc(100vw - 24px) !important; resize: none !important; } .dir-modal, .ssh-host-modal, .cmd-modal { diff --git a/public/kanban.html b/public/kanban.html index 38bd881..58ac6a5 100644 --- a/public/kanban.html +++ b/public/kanban.html @@ -313,7 +313,7 @@ .ov { padding: 8px; } .modal { max-width: calc(100vw - 16px) !important; max-height: 92vh; max-height: 92dvh; box-sizing: border-box !important; } .grid2 { grid-template-columns: 1fr; } - .card-actions { opacity: 1; flex-wrap: wrap; justify-content: flex-end; gap: 2px; } + .card-actions { display: flex !important; opacity: 1; flex-wrap: wrap; justify-content: flex-end; gap: 2px; } /* Prevent iOS auto-zoom on focus (requires ≥ 16px) */ .inp, .sel, .proj-dd-search input { font-size: 16px; } } @@ -1213,20 +1213,19 @@ } // Self-heal on tab focus, visibility change, and network reconnection (#107) -document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - refresh(true); - startAutoRefresh(); - } -}); -window.addEventListener('focus', () => { - refresh(true); - startAutoRefresh(); -}); -window.addEventListener('online', () => { +let _lastKbResync = 0; +function triggerKbResync() { + const now = Date.now(); + if (now - _lastKbResync < 1500) return; + _lastKbResync = now; refresh(true); startAutoRefresh(); +} +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') triggerKbResync(); }); +window.addEventListener('focus', triggerKbResync); +window.addEventListener('online', triggerKbResync); // ─── Stats ───────────────────────────────────────────────────────────────── function updateStats(){ @@ -2533,6 +2532,8 @@ const taskPrompt = (tk.title + (tk.description ? '\n\n' + tk.description : '')).trim(); const taskInput = $i('dlgTask'); if (taskInput) taskInput.value = taskPrompt; + const handoffRadio = document.querySelector('input[name="dlgMode"][value="handoff"]'); + if (handoffRadio) handoffRadio.checked = true; closeModal(); openOverlay('delegateOv', { initialFocus: '#dlgTask', onEscape: closeDelegateModal }); } @@ -2551,7 +2552,7 @@ const effort = $i('dlgEffort')?.value || undefined; if (!agentId) { toast(t('dlg.no_agents'), true); return; } - if (!task) { toast(t('modal.name'), true); return; } + if (!task) { toast(t('dlg.task.ph'), true); return; } try { const r = await apiFetch('/api/delegate', { @@ -2580,6 +2581,7 @@ } } +let _dlgDetailPollTimer = null; async function openDelegationDetail(delegationId) { const del = _activeDelegations.find(d => d.id === delegationId); if (!del) return; @@ -2595,13 +2597,21 @@ } await updateDelegationDialogView(delegationId); openOverlay('delegationDetailOv', { initialFocus: '#dlgMsgInp', onEscape: closeDelegationDetail }); + clearInterval(_dlgDetailPollTimer); + _dlgDetailPollTimer = setInterval(() => { + if (!_activeDelegationDetailId) { + clearInterval(_dlgDetailPollTimer); + return; + } + updateDelegationDialogView(_activeDelegationDetailId); + }, 2500); } async function updateDelegationDialogView(delegationId) { try { const res = await apiFetch(`/api/delegate/${delegationId}/dialog`).then(r => r.json()); const box = $i('dlgDetailBox'); - if (box) { + if (box && res && res.dialog !== undefined) { box.textContent = res.dialog || '...'; box.scrollTop = box.scrollHeight; } @@ -2609,6 +2619,8 @@ } function closeDelegationDetail() { + clearInterval(_dlgDetailPollTimer); + _dlgDetailPollTimer = null; closeOverlay('delegationDetailOv'); _activeDelegationDetailId = null; } @@ -2619,11 +2631,16 @@ const msg = inp?.value?.trim(); if (!msg) return; try { - await apiFetch(`/api/delegate/${_activeDelegationDetailId}/message`, { + const r = await apiFetch(`/api/delegate/${_activeDelegationDetailId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: msg }), }); + if (!r.ok) { + const err = await r.json().catch(() => ({})); + toast(err.error || t('toast.err'), true); + return; + } if (inp) inp.value = ''; await updateDelegationDialogView(_activeDelegationDetailId); } catch (e) { @@ -2634,7 +2651,12 @@ async function stopActiveDelegation() { if (!_activeDelegationDetailId) return; try { - await apiFetch(`/api/delegate/${_activeDelegationDetailId}`, { method: 'DELETE' }); + const r = await apiFetch(`/api/delegate/${_activeDelegationDetailId}`, { method: 'DELETE' }); + if (!r.ok) { + const err = await r.json().catch(() => ({})); + toast(err.error || t('toast.err'), true); + return; + } toast(t('dlg.toast.stopped')); closeDelegationDetail(); await refresh(true); diff --git a/server.js b/server.js index f03c197..10a89d4 100644 --- a/server.js +++ b/server.js @@ -1814,6 +1814,7 @@ const MAX_TASK_WORKERS = Math.max(1, parseInt(process.env.MAX_TASK_WORKERS || '5 const taskRunning = new Set(); // task IDs currently executing const runningTaskAborts = new Map(); // taskId → AbortController const stoppingTasks = new Set(); // task IDs being manually stopped (onDone must not overwrite status) +const activeDelegations = new Map(); // delegationId -> { id, agentId, mode, workdir, delegationDir, startedAt, watcher, taskId } // task IDs started via the independent-worker path. Not a cap counter — the cap is // global (see processQueue) — this only tells /api/running-sessions whether a task row // still has a worker behind it. @@ -2893,6 +2894,17 @@ setInterval(() => { continue; // worker is alive } } + // Check if task is actively delegated to an external agent + let isDelegated = false; + for (const d of activeDelegations.values()) { + if (d.taskId === task.id) { + isDelegated = true; + break; + } + } + if (isDelegated) { + continue; // actively being handled by external agent, do not evict + } // Worker is dead — recover log.warn(`[watchdog] task "${task.title}" (${task.id}) stuck in_progress with no live worker, recovering`); if (task.worker_pid) killByPid(task.worker_pid); @@ -10705,7 +10717,6 @@ app.post('/api/tunnel/stop', (_, res) => { // CROSS-AGENT DELEGATION // ============================================ -const activeDelegations = new Map(); // delegationId -> { id, agentId, mode, workdir, delegationDir, startedAt, watcher } const CROSSWORK_DIR = '.crosswork'; function getDelegationDir(workdir, delegationId) { @@ -11263,6 +11274,19 @@ app.post('/api/delegate', express.json(), (req, res) => { let session = sessionId ? stmts.getSession.get(sessionId) : null; const taskRow = taskId ? stmts.getTask.get(taskId) : null; + if (taskId && !taskRow) { + return res.status(404).json({ error: 'Task not found' }); + } + if (taskRow && (taskRow.status === 'done' || taskRow.status === 'cancelled')) { + return res.status(409).json({ error: `Cannot delegate a task that is already ${taskRow.status}` }); + } + if (taskId) { + for (const d of activeDelegations.values()) { + if (d.taskId === taskId) { + return res.status(409).json({ error: `Task is already being delegated to ${d.agentLabel || d.agentId}` }); + } + } + } if (!session && taskRow?.session_id) { session = stmts.getSession.get(taskRow.session_id); } @@ -11429,6 +11453,14 @@ app.delete('/api/delegate/:id', (req, res) => { if (delegation.watcher) { try { delegation.watcher.close(); } catch {} } // Remove state file so it won't be restored on next restart try { fs.unlinkSync(path.join(delegation.delegationDir, 'state.json')); } catch {} + if (delegation.taskId) { + try { + const task = stmts.getTask.get(delegation.taskId); + if (task && task.status === 'in_progress') { + db.prepare(`UPDATE tasks SET status='todo', updated_at=datetime('now') WHERE id=?`).run(delegation.taskId); + } + } catch {} + } activeDelegations.delete(req.params.id); log.info('Delegation stopped', { delegationId: req.params.id }); res.json({ ok: true }); diff --git a/test/kanban-delegation.test.js b/test/kanban-delegation.test.js index 089d956..999330f 100644 --- a/test/kanban-delegation.test.js +++ b/test/kanban-delegation.test.js @@ -76,5 +76,32 @@ check('kanban.html defines delegation lifecycle functions', () => { assert.ok(kbSrc.includes('async function sendDelegationMsg()'), 'missing sendDelegationMsg'); }); +check('server.js watchdog spares active delegated tasks from eviction', () => { + const wdBlock = srvSrc.slice(srvSrc.indexOf('// Watchdog: detect tasks stuck')); + assert.ok(wdBlock.includes('d.taskId === task.id'), 'watchdog does not check activeDelegations for taskId'); + assert.ok(wdBlock.includes('if (isDelegated)'), 'watchdog does not skip recovering delegated tasks'); +}); + +check('server.js /api/delegate validates task status and blocks double delegation', () => { + assert.ok(srvSrc.includes("taskRow.status === 'done' || taskRow.status === 'cancelled'"), 'missing done/cancelled task check'); + assert.ok(srvSrc.includes('Task is already being delegated to'), 'missing already-delegated task check'); +}); + +check('server.js DELETE /api/delegate/:id reverts in_progress task to todo', () => { + const delBlock = srvSrc.slice(srvSrc.indexOf("app.delete('/api/delegate/:id'")); + assert.ok(delBlock.includes("status='todo'"), 'DELETE /api/delegate does not revert task status'); +}); + +check('kanban.html delegation dialog viewer polls for updates and verifies response ok', () => { + assert.ok(kbSrc.includes('_dlgDetailPollTimer = setInterval'), 'missing interval polling in openDelegationDetail'); + assert.ok(kbSrc.includes('clearInterval(_dlgDetailPollTimer)'), 'missing clearInterval in closeDelegationDetail'); + assert.ok(kbSrc.includes('if (!r.ok)'), 'missing r.ok check in delegation actions'); +}); + +check('kanban.html debounces self-heal reconnect events', () => { + assert.ok(kbSrc.includes('function triggerKbResync()'), 'missing triggerKbResync function'); + assert.ok(kbSrc.includes('now - _lastKbResync < 1500'), 'missing debounce timing check'); +}); + if (failed) { console.log(`\n${failed} test(s) failed`); process.exit(1); } console.log('\nAll kanban-delegation tests passed'); diff --git a/test/responsive-ui.test.js b/test/responsive-ui.test.js index 18fcbf2..7dd5d03 100644 --- a/test/responsive-ui.test.js +++ b/test/responsive-ui.test.js @@ -47,5 +47,10 @@ check('project dropdown buttons set title attribute for full name tooltips', () assert.ok(/ddName\.title\s*=\s*fullName/.test(kbSrc), 'kanban.html should set title on project name'); }); +check('kanban.html mobile media query forces card-actions display on touch screens', () => { + const media800 = kbSrc.slice(kbSrc.indexOf('@media (max-width: 800px)')); + assert.ok(/\.card-actions\s*\{[^}]*display:\s*flex\s*!important/.test(media800), 'missing display: flex !important for card-actions in mobile query'); +}); + if (failed) { console.log(`\n${failed} test(s) failed`); process.exit(1); } console.log('\nAll responsive-ui tests passed');