diff --git a/src/watcher-manager.js b/src/watcher-manager.js index e2baaf0..c28e4c8 100644 --- a/src/watcher-manager.js +++ b/src/watcher-manager.js @@ -160,6 +160,8 @@ async function startDaemon() { // Shared state // Map const activeBoxes = new Map(); + // Guard against concurrent session-added events for the same key (e.g. lock + ghost fire simultaneously) + const sessionAddInProgress = new Set(); // Completed sessions: Map // Tracks sessions that went idle so we can reuse their post on reactivation @@ -256,6 +258,19 @@ async function startDaemon() { monitor.on('session-added', async (info) => { const { sessionKey, transcriptFile, spawnedBy, channelId, rootPostId, agentId } = info; + // Guard: prevent duplicate concurrent session-added for same key. + // Happens when lock file event + ghost watch both fire simultaneously, + // both call pollNow(), and both session-added events land before activeBoxes is updated. + if (sessionAddInProgress.has(sessionKey)) { + logger.debug({ sessionKey }, 'session-added already in progress — dedup skip'); + return; + } + if (activeBoxes.has(sessionKey)) { + logger.debug({ sessionKey }, 'session-added for already-active session — skip'); + return; + } + sessionAddInProgress.add(sessionKey); + // Skip if no channel if (!channelId) { logger.debug({ sessionKey }, 'No channel for session — skipping'); @@ -374,6 +389,7 @@ async function startDaemon() { } catch (err) { logger.error({ sessionKey, err }, 'Failed to create status post'); globalMetrics.lastError = err.message; + sessionAddInProgress.delete(sessionKey); return; } } @@ -386,6 +402,7 @@ async function startDaemon() { usePlugin: usePlugin && !!pluginClient, // track which mode this session uses children: new Map(), }); + sessionAddInProgress.delete(sessionKey); globalMetrics.activeSessions = activeBoxes.size; // Register in watcher.