From bb48ff1ebbe7cc930ac367437a2cfa3c6d6d75cb Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Sun, 20 Sep 2026 21:23:06 +0200 Subject: [PATCH] fix(sync): restore UI state and task sync after reconnect or sleep (#107) --- public/index.html | 26 ++++++++++++++++++++------ public/kanban.html | 16 ++++++++++++++++ server.js | 23 +++++++++++++++++++++-- test/interrupted-recovery.test.js | 30 ++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/public/index.html b/public/index.html index 7964e5e8..19b17ae5 100644 --- a/public/index.html +++ b/public/index.html @@ -7382,10 +7382,10 @@

Delegate to Agent

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})); @@ -7420,6 +7420,12 @@

Delegate to Agent

// 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 @@ -7447,8 +7453,16 @@

Delegate to Agent

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. diff --git a/public/kanban.html b/public/kanban.html index 488e241e..538cfb1f 100644 --- a/public/kanban.html +++ b/public/kanban.html @@ -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'); diff --git a/server.js b/server.js index 160999d1..a4263469 100644 --- a/server.js +++ b/server.js @@ -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 }); }); @@ -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); diff --git a/test/interrupted-recovery.test.js b/test/interrupted-recovery.test.js index 9fa48721..3769fa89 100644 --- a/test/interrupted-recovery.test.js +++ b/test/interrupted-recovery.test.js @@ -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);