From b0cb6db2a3aec34058047a1e6e93e0522d91741d Mon Sep 17 00:00:00 2001 From: sol Date: Mon, 9 Mar 2026 18:40:38 +0000 Subject: [PATCH] fix: stale offset reset + incremental _knownSessions tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/status-watcher.js: - When lastOffset > fileSize (stale offset from compaction or previous session), reset offset to current file end rather than 0. Resetting to 0 caused re-parsing gigabytes of old content; resetting to fileSize means we only read new bytes from this point forward. This was the root cause of the status box receiving no updates — the offset was past EOF so every read returned 0 bytes silently. src/session-monitor.js: - _knownSessions is now maintained incrementally instead of being replaced wholesale at the end of every poll cycle. - Previously: _knownSessions = currentSessions at end of _poll() meant forgetSession() had no effect — the next poll immediately re-added the key as 'known' without firing _onSessionAdded, silently swallowing the reactivation. - Now: entries are added/updated individually, removals delete from the map directly. forgetSession() + clearCompleted() + pollNow() now correctly triggers reactivation. Verified: 3 consecutive 5s tests all show plugin KV updating with lines and timestamps. --- src/session-monitor.js | 27 ++++++++++++++++----------- src/status-watcher.js | 10 ++++++---- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/session-monitor.js b/src/session-monitor.js index 6272e0e..34abe0c 100644 --- a/src/session-monitor.js +++ b/src/session-monitor.js @@ -379,27 +379,31 @@ class SessionMonitor extends EventEmitter { // Detect new or previously-stale sessions for (const [sessionKey, entry] of currentSessions) { - if (!this._knownSessions.has(sessionKey) || this._staleSessions.has(sessionKey)) { - // Skip sessions in completed cooldown — only allow re-detection once the - // transcript has gone stale (file not modified in >5 min). This prevents - // the reactivation loop caused by the gateway appending bookkeeping events - // to the JSONL after a session's agent turn finishes. + const isKnown = this._knownSessions.has(sessionKey); + const isStale = this._staleSessions.has(sessionKey); + + if (!isKnown || isStale) { + // Skip sessions in completed cooldown — only allow re-detection when: + // (a) completedSessions cooldown was explicitly cleared (ghost watch / clearCompleted), OR + // (b) sessions.json shows a newer updatedAt than when we marked complete. if (this._completedSessions.has(sessionKey)) { const completedAt = this._completedSessions.get(sessionKey); - // Re-activate if sessions.json shows a newer updatedAt than when we - // marked the session complete. This is the infrastructure-level signal - // that the gateway started a new turn — no AI involvement, pure timestamp - // comparison between two on-disk data sources. const sessionUpdatedAt = entry.updatedAt || 0; if (sessionUpdatedAt > completedAt) { // New turn started after completion — reactivate this._completedSessions.delete(sessionKey); } else { - // No new turn yet — suppress re-detection + // No new turn yet — add to knownSessions silently so we don't re-emit + // on every poll cycle, but don't fire _onSessionAdded. + if (!isKnown) this._knownSessions.set(sessionKey, entry); continue; } } + this._knownSessions.set(sessionKey, entry); this._onSessionAdded(entry); + } else { + // Update the stored entry with latest data (e.g. updatedAt changes) + this._knownSessions.set(sessionKey, entry); } } @@ -407,6 +411,7 @@ class SessionMonitor extends EventEmitter { for (const [sessionKey] of this._knownSessions) { if (!currentSessions.has(sessionKey)) { this._onSessionRemoved(sessionKey); + this._knownSessions.delete(sessionKey); this._staleSessions.delete(sessionKey); this._completedSessions.delete(sessionKey); } @@ -419,7 +424,7 @@ class SessionMonitor extends EventEmitter { } } - this._knownSessions = currentSessions; + // Note: _knownSessions is now maintained incrementally above, not replaced wholesale. } /** diff --git a/src/status-watcher.js b/src/status-watcher.js index 1294eda..94c8da2 100644 --- a/src/status-watcher.js +++ b/src/status-watcher.js @@ -258,16 +258,18 @@ class StatusWatcher extends EventEmitter { const stat = fs.fstatSync(fd); const fileSize = stat.size; - // Detect file truncation (compaction) + // Detect file truncation (compaction) or stale offset from a previous session. + // In both cases reset to current file end — we don't want to re-parse old content, + // only new bytes written from this point forward. if (fileSize < state.lastOffset) { if (this.logger) { this.logger.warn( { sessionKey, fileSize, lastOffset: state.lastOffset }, - 'Transcript truncated (compaction detected) — resetting offset', + 'Transcript offset past file end (compaction/stale) — resetting to file end', ); } - state.lastOffset = 0; - state.lines = ['[session compacted - continuing]']; + state.lastOffset = fileSize; + state.lines = []; state.pendingToolCalls = 0; }