fix: reactivate session on new turn via sessions.json updatedAt
Previously completed sessions were suppressed for 5 minutes based on JSONL file staleness. With fast-idle (cache-ttl detection), sessions complete in ~3s — but the gateway immediately appends the next user message, keeping the file 'fresh'. This blocked reactivation entirely. Fix: compare sessions.json updatedAt against the completion timestamp. If the gateway updated the session AFTER we marked it complete, a new turn has started — reactivate immediately. Pure infrastructure: timestamp comparison between two on-disk files. No AI model state or memory involved.
This commit is contained in:
@@ -48,6 +48,9 @@ class SessionMonitor extends EventEmitter {
|
|||||||
this._knownSessions = new Map();
|
this._knownSessions = new Map();
|
||||||
// Set<sessionKey> — sessions that were skipped as stale; re-check on next poll
|
// Set<sessionKey> — sessions that were skipped as stale; re-check on next poll
|
||||||
this._staleSessions = new Set();
|
this._staleSessions = new Set();
|
||||||
|
// Map<sessionKey, expiresAt> — sessions that completed idle; suppressed from re-detection
|
||||||
|
// until the transcript file stops being written to (checked on each poll).
|
||||||
|
this._completedSessions = new Map();
|
||||||
// Cache: "user:XXXX" -> channelId (resolved DM channels)
|
// Cache: "user:XXXX" -> channelId (resolved DM channels)
|
||||||
this._dmChannelCache = new Map();
|
this._dmChannelCache = new Map();
|
||||||
this._pollTimer = null;
|
this._pollTimer = null;
|
||||||
@@ -80,10 +83,29 @@ class SessionMonitor extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Remove a session from known sessions so it can be re-detected on next poll.
|
* Remove a session from known sessions so it can be re-detected on next poll.
|
||||||
* Called when the watcher marks a session as idle/done.
|
* Called when the watcher marks a session as idle/done.
|
||||||
|
*
|
||||||
|
* The session is placed in a cooldown set (_completedSessions). It will only be
|
||||||
|
* re-emitted as 'session-added' once the transcript file goes stale (>5 min no
|
||||||
|
* writes), preventing the complete→reactivate loop that occurs when the gateway
|
||||||
|
* keeps appending events to a session file after the agent finishes its turn.
|
||||||
|
*
|
||||||
* @param {string} sessionKey
|
* @param {string} sessionKey
|
||||||
*/
|
*/
|
||||||
forgetSession(sessionKey) {
|
forgetSession(sessionKey) {
|
||||||
this._knownSessions.delete(sessionKey);
|
this._knownSessions.delete(sessionKey);
|
||||||
|
// Mark as completed — suppress re-detection while transcript is still active.
|
||||||
|
// The stale check in _handleNewSession will naturally unblock re-detection
|
||||||
|
// once the file stops being modified (>5 min gap).
|
||||||
|
this._completedSessions.set(sessionKey, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly clear a session from the completed cooldown, allowing immediate re-detection.
|
||||||
|
* Use this when a new gateway session replaces an old one with the same key.
|
||||||
|
* @param {string} sessionKey
|
||||||
|
*/
|
||||||
|
clearCompleted(sessionKey) {
|
||||||
|
this._completedSessions.delete(sessionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,6 +372,25 @@ 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)) {
|
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.
|
||||||
|
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
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
this._onSessionAdded(entry);
|
this._onSessionAdded(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,6 +400,7 @@ class SessionMonitor extends EventEmitter {
|
|||||||
if (!currentSessions.has(sessionKey)) {
|
if (!currentSessions.has(sessionKey)) {
|
||||||
this._onSessionRemoved(sessionKey);
|
this._onSessionRemoved(sessionKey);
|
||||||
this._staleSessions.delete(sessionKey);
|
this._staleSessions.delete(sessionKey);
|
||||||
|
this._completedSessions.delete(sessionKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user