Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 38 additions & 16 deletions public/kanban.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Expand Down Expand Up @@ -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(){
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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', {
Expand Down Expand Up @@ -2580,6 +2581,7 @@
}
}

let _dlgDetailPollTimer = null;
async function openDelegationDetail(delegationId) {
const del = _activeDelegations.find(d => d.id === delegationId);
if (!del) return;
Expand All @@ -2595,20 +2597,30 @@
}
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;
}
} catch {}
}

function closeDelegationDetail() {
clearInterval(_dlgDetailPollTimer);
_dlgDetailPollTimer = null;
closeOverlay('delegationDetailOv');
_activeDelegationDetailId = null;
}
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
34 changes: 33 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 });
Expand Down
27 changes: 27 additions & 0 deletions test/kanban-delegation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
5 changes: 5 additions & 0 deletions test/responsive-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Loading