diff --git a/public/index.html b/public/index.html
index 7964e5e..19b17ae 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 488e241..538cfb1 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 160999d..a426346 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 9fa4872..3769fa8 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);