fix: stale offset reset + incremental _knownSessions tracking

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.
This commit is contained in:
sol
2026-03-09 18:40:38 +00:00
parent f545cb00be
commit b0cb6db2a3
2 changed files with 22 additions and 15 deletions

View File

@@ -379,27 +379,31 @@ class SessionMonitor extends EventEmitter {
// Detect new or previously-stale sessions // Detect new or previously-stale sessions
for (const [sessionKey, entry] of currentSessions) { for (const [sessionKey, entry] of currentSessions) {
if (!this._knownSessions.has(sessionKey) || this._staleSessions.has(sessionKey)) { const isKnown = this._knownSessions.has(sessionKey);
// Skip sessions in completed cooldown — only allow re-detection once the const isStale = this._staleSessions.has(sessionKey);
// transcript has gone stale (file not modified in >5 min). This prevents
// the reactivation loop caused by the gateway appending bookkeeping events if (!isKnown || isStale) {
// to the JSONL after a session's agent turn finishes. // 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)) { if (this._completedSessions.has(sessionKey)) {
const completedAt = this._completedSessions.get(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; const sessionUpdatedAt = entry.updatedAt || 0;
if (sessionUpdatedAt > completedAt) { if (sessionUpdatedAt > completedAt) {
// New turn started after completion — reactivate // New turn started after completion — reactivate
this._completedSessions.delete(sessionKey); this._completedSessions.delete(sessionKey);
} else { } 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; continue;
} }
} }
this._knownSessions.set(sessionKey, entry);
this._onSessionAdded(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) { for (const [sessionKey] of this._knownSessions) {
if (!currentSessions.has(sessionKey)) { if (!currentSessions.has(sessionKey)) {
this._onSessionRemoved(sessionKey); this._onSessionRemoved(sessionKey);
this._knownSessions.delete(sessionKey);
this._staleSessions.delete(sessionKey); this._staleSessions.delete(sessionKey);
this._completedSessions.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.
} }
/** /**

View File

@@ -258,16 +258,18 @@ class StatusWatcher extends EventEmitter {
const stat = fs.fstatSync(fd); const stat = fs.fstatSync(fd);
const fileSize = stat.size; 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 (fileSize < state.lastOffset) {
if (this.logger) { if (this.logger) {
this.logger.warn( this.logger.warn(
{ sessionKey, fileSize, lastOffset: state.lastOffset }, { 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.lastOffset = fileSize;
state.lines = ['[session compacted - continuing]']; state.lines = [];
state.pendingToolCalls = 0; state.pendingToolCalls = 0;
} }