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:
sol
2026-03-09 15:31:35 +00:00
parent b7c5124081
commit b320bcf843

View File

@@ -48,6 +48,9 @@ class SessionMonitor extends EventEmitter {
this._knownSessions = new Map();
// Set<sessionKey> — sessions that were skipped as stale; re-check on next poll
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)
this._dmChannelCache = new Map();
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.
* 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
*/
forgetSession(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
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.
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);
}
}
@@ -359,6 +400,7 @@ class SessionMonitor extends EventEmitter {
if (!currentSessions.has(sessionKey)) {
this._onSessionRemoved(sessionKey);
this._staleSessions.delete(sessionKey);
this._completedSessions.delete(sessionKey);
}
}