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
26 changes: 20 additions & 6 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7382,10 +7382,10 @@ <h2 id="dlgModalTitle" data-i18n="dlg.title">Delegate to Agent</h2>
loadFiles();
loadHist();
loadActivity();
// On page refresh: currentSessionId is null (restoreUIState doesn't call loadSess),
// but activeTabId may point to a real session — load it now so history appears.
if (!currentSessionId && activeTabId && openTabs.find(t => t.id === activeTabId && !t.isNew)) {
loadSess(activeTabId); // loadSess sets currentSessionId and sends start_session at the end
// On page refresh or reconnect after sleep/drop:
// loadSess syncs any tasks or final messages that finished while away (#107)
if (activeTabId && openTabs.find(t => t.id === activeTabId && !t.isNew)) {
loadSess(activeTabId);
} else if (currentSessionId && ws.readyState === 1) {
// Re-attach to active session after a reconnect
ws.send(JSON.stringify({ type: 'start_session', sessionId: currentSessionId, mode: curMode, agentMode: curAgent, model: curModel}));
Expand Down Expand Up @@ -7420,6 +7420,12 @@ <h2 id="dlgModalTitle" data-i18n="dlg.title">Delegate to Agent</h2>

// Stop reconnect loop on page unload to avoid pointless connections
window.addEventListener('beforeunload', () => { _intentionalClose = true; if (_reconnectTimer) clearTimeout(_reconnectTimer); });
window.addEventListener('online', () => {
if (!ws || ws.readyState !== 1) {
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
if (!_intentionalClose) connect();
}
});

// Self-heal on refocus. While a tab/window is hidden, the browser throttles or
// suspends it: a queued task that dequeues and starts streaming in the background
Expand Down Expand Up @@ -7447,8 +7453,16 @@ <h2 id="dlgModalTitle" data-i18n="dlg.title">Delegate to Agent</h2>
if (!_intentionalClose) connect();
return;
}
// Socket alive — replay any buffered stream we may have missed while throttled.
if (currentSessionId) ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: currentSessionId }));
// Socket alive — replay any buffered stream we may have missed while throttled,
// and resync session state if a background task completed while hidden (#107).
if (currentSessionId) {
ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: currentSessionId }));
fetch(`/api/sessions/${currentSessionId}`).then(r => r.json()).then(d => {
if (!d.isChatRunning && !d.hasRunningTask && openTabs.find(t => t.id === currentSessionId)?.generating) {
loadSess(currentSessionId);
}
}).catch(() => {});
}
subscribeAllTabs();
// Same recovery for the visible terminal tab, if any — its own WebSocket is
// independent of the chat socket above and dies the same way while backgrounded.
Expand Down
16 changes: 16 additions & 0 deletions public/kanban.html
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,22 @@
refreshTimer=setTimeout(async()=>{await refresh(false);startAutoRefresh();},delay);
}

// 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', () => {
refresh(true);
startAutoRefresh();
});

// ─── Stats ─────────────────────────────────────────────────────────────────
function updateStats(){
const el=$i('kbStats');
Expand Down
23 changes: 21 additions & 2 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,18 @@ function killByPid(pid) {
} catch {} // Process may already be dead (ESRCH)
}

// Test whether a process with the given PID is currently running.
function isPidAlive(pid) {
const n = Number(pid);
if (!Number.isInteger(n) || n <= 0) return false;
try {
process.kill(n, 0);
return true;
} catch (e) {
return e.code === 'EPERM'; // Process exists but owned by different user
}
}

[WORKDIR, SKILLS_DIR].forEach(d => {
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
});
Expand Down Expand Up @@ -2869,11 +2881,18 @@ setTimeout(() => {

// Watchdog: detect tasks stuck in 'in_progress' with no live worker process.
// Runs every 60s. If a task is in_progress in DB but not in taskRunning (memory),
// the worker died without cleanup — recover the task.
// or if its worker_pid died, recover the task.
setInterval(() => {
const inProg = stmts.getInProgressTasks.all();
for (const task of inProg) {
if (taskRunning.has(task.id)) continue; // worker is alive
if (taskRunning.has(task.id)) {
if (task.worker_pid && !isPidAlive(task.worker_pid)) {
log.warn(`[watchdog] task "${task.title}" (${task.id}) worker_pid ${task.worker_pid} died, evicting from taskRunning`);
taskRunning.delete(task.id);
} else {
continue; // worker is alive
}
}
// 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
30 changes: 30 additions & 0 deletions test/interrupted-recovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,5 +356,35 @@ check('setLastUserMsg is armed from processChat', /stmts\.setLastUserMsg\.run\(u
try { db.close(); } catch {}
for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch {} }

// ─── 6. Watchdog and Reconnect State Synchronization (#107) ─────────────────
console.log('\nwatchdog and reconnect state synchronization (#107):');
{
const srvPath = path.join(__dirname, '..', 'server.js');
const srvSrc = fs.readFileSync(srvPath, 'utf8');
check('server.js defines isPidAlive helper', /function isPidAlive\(pid\)/.test(srvSrc), true);
check('watchdog checks isPidAlive before treating in_progress task as alive',
/taskRunning\.has\(task\.id\)[\s\S]{0,200}isPidAlive\(task\.worker_pid\)/.test(srvSrc), true);
check('watchdog evicts dead worker_pid from taskRunning',
/taskRunning\.delete\(task\.id\)/.test(srvSrc), true);

const kbPath = path.join(__dirname, '..', 'public', 'kanban.html');
const kbSrc = fs.readFileSync(kbPath, 'utf8');
check('kanban.html listens to visibilitychange for self-healing after sleep',
/document\.addEventListener\('visibilitychange'/.test(kbSrc), true);
check('kanban.html listens to window focus',
/window\.addEventListener\('focus'/.test(kbSrc), true);
check('kanban.html listens to window online',
/window\.addEventListener\('online'/.test(kbSrc), true);

const idxPath = path.join(__dirname, '..', 'public', 'index.html');
const idxSrc = fs.readFileSync(idxPath, 'utf8');
check('index.html calls loadSess on activeTabId on reconnect',
/loadSess\(activeTabId\)/.test(idxSrc), true);
check('index.html listens to window online for reconnect',
/window\.addEventListener\('online'/.test(idxSrc), true);
check('index.html resyncs session if task finished while hidden',
/!d\.isChatRunning && !d\.hasRunningTask[\s\S]{0,100}loadSess\(currentSessionId\)/.test(idxSrc), true);
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
Loading