fix: session reactivation and stale offset bugs

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.
This commit is contained in:
sol
2026-03-09 15:53:14 +00:00
parent b320bcf843
commit 9a50bb9d55
2 changed files with 44 additions and 6 deletions

View File

@@ -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;

View File

@@ -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);