From 9a50bb9d55dcaa51c46c01628ab2a02981779672 Mon Sep 17 00:00:00 2001 From: sol Date: Mon, 9 Mar 2026 15:53:14 +0000 Subject: [PATCH] fix: session reactivation and stale offset bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: session-monitor suppressed reactivation for 5min (file staleness check) Fix: compare sessions.json updatedAt against completedAt timestamp instead. If updatedAt > completedAt, a new gateway turn started — reactivate immediately. Bug 2: watcher-manager passed stale saved offset on reactivation The saved offset pointed near end-of-file from the prior session, so only 1-2 lines were read (usually just cache-ttl), triggering immediate fast-idle with no content shown. Fix: reactivated sessions always start from current file position (new content only), same as brand-new sessions. Result: after completing a turn, the next message correctly reactivates the status box and streams tool calls/content in real time. --- src/status-watcher.js | 20 ++++++++++++++++++-- src/watcher-manager.js | 30 ++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/status-watcher.js b/src/status-watcher.js index 8ed1732..1294eda 100644 --- a/src/status-watcher.js +++ b/src/status-watcher.js @@ -140,11 +140,15 @@ class StatusWatcher extends EventEmitter { if (state.idleTimer) clearTimeout(state.idleTimer); if (state._filePollTimer) clearInterval(state._filePollTimer); - this.fileToSession.delete(state.transcriptFile); + + // Keep fileToSession mapping alive so fs.watch still fires for this file. + // Mark it as a "ghost" — changes trigger 'session-file-changed' so the + // session-monitor can immediately re-detect without waiting for its poll. + this.fileToSession.set(state.transcriptFile, '\x00ghost:' + sessionKey); this.sessions.delete(sessionKey); if (this.logger) { - this.logger.debug({ sessionKey }, 'Session removed from watcher'); + this.logger.debug({ sessionKey }, 'Session removed from watcher (ghost watch active)'); } } @@ -222,6 +226,18 @@ class StatusWatcher extends EventEmitter { const sessionKey = this.fileToSession.get(fullPath); if (!sessionKey) return; + // Ghost watch: file changed for a completed session — signal immediate re-detection + if (sessionKey.startsWith('\x00ghost:')) { + const originalKey = sessionKey.slice(7); + // Remove ghost so we don't fire repeatedly + this.fileToSession.delete(fullPath); + if (this.logger) { + this.logger.info({ sessionKey: originalKey }, 'fs.watch: file change on completed session — triggering reactivation'); + } + this.emit('session-reactivate', originalKey); + return; + } + const state = this.sessions.get(sessionKey); if (!state) return; diff --git a/src/watcher-manager.js b/src/watcher-manager.js index 6cecda9..46a9c4a 100644 --- a/src/watcher-manager.js +++ b/src/watcher-manager.js @@ -326,11 +326,23 @@ async function startDaemon() { }); globalMetrics.activeSessions = activeBoxes.size; - // Register in watcher + // Register in watcher. + // For reactivated sessions (completedBoxes had an entry), always start from + // current file size so we only stream NEW content from this turn forward. + // Using a stale savedOffset from a prior run would skip past the current turn + // (file grew since last offset was saved) and show nothing. const savedState = savedOffsets[sessionKey]; // eslint-disable-line security/detect-object-injection - const initialState = savedState - ? { lastOffset: savedState.lastOffset, startTime: savedState.startTime, agentId } - : { agentId }; + let initialState; + if (completed) { + // Reactivation: start fresh from current file position (this turn only) + initialState = { agentId }; + } else if (savedState) { + // Restart recovery: resume from saved offset + initialState = { lastOffset: savedState.lastOffset, startTime: savedState.startTime, agentId }; + } else { + // New session: start from current file position + initialState = { agentId }; + } watcher.addSession(sessionKey, transcriptFile, initialState); // Process any pending sub-agents that were waiting for this parent @@ -353,6 +365,16 @@ async function startDaemon() { logger.debug({ sessionKey }, 'Session removed from sessions.json'); }); + // ---- Ghost reactivation (from watcher fs.watch on completed session file) ---- + // Fires immediately when the transcript file changes after a session completes. + // Clears the completedSessions cooldown so the next monitor poll re-detects instantly. + watcher.on('session-reactivate', (sessionKey) => { + logger.info({ sessionKey }, 'Ghost watch triggered reactivation — clearing completed cooldown'); + monitor.clearCompleted(sessionKey); + // Force an immediate poll so the session is re-added without waiting 2s + monitor.pollNow(); + }); + // ---- Session Update (from watcher) ---- watcher.on('session-update', (sessionKey, state) => { const box = activeBoxes.get(sessionKey);