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:
@@ -140,11 +140,15 @@ class StatusWatcher extends EventEmitter {
|
|||||||
|
|
||||||
if (state.idleTimer) clearTimeout(state.idleTimer);
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
||||||
if (state._filePollTimer) clearInterval(state._filePollTimer);
|
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);
|
this.sessions.delete(sessionKey);
|
||||||
|
|
||||||
if (this.logger) {
|
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);
|
const sessionKey = this.fileToSession.get(fullPath);
|
||||||
if (!sessionKey) return;
|
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);
|
const state = this.sessions.get(sessionKey);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
|
|||||||
@@ -326,11 +326,23 @@ async function startDaemon() {
|
|||||||
});
|
});
|
||||||
globalMetrics.activeSessions = activeBoxes.size;
|
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 savedState = savedOffsets[sessionKey]; // eslint-disable-line security/detect-object-injection
|
||||||
const initialState = savedState
|
let initialState;
|
||||||
? { lastOffset: savedState.lastOffset, startTime: savedState.startTime, agentId }
|
if (completed) {
|
||||||
: { agentId };
|
// 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);
|
watcher.addSession(sessionKey, transcriptFile, initialState);
|
||||||
|
|
||||||
// Process any pending sub-agents that were waiting for this parent
|
// 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');
|
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) ----
|
// ---- Session Update (from watcher) ----
|
||||||
watcher.on('session-update', (sessionKey, state) => {
|
watcher.on('session-update', (sessionKey, state) => {
|
||||||
const box = activeBoxes.get(sessionKey);
|
const box = activeBoxes.get(sessionKey);
|
||||||
|
|||||||
Reference in New Issue
Block a user