From f1d3ae9c4cb197f591d1ccce696bb7ae6cb5f779 Mon Sep 17 00:00:00 2001 From: Xen Date: Mon, 9 Mar 2026 21:13:18 +0000 Subject: [PATCH] fix: concurrent session-added dedup via sessionAddInProgress set Root cause of double status boxes: lock file event + ghost watch both fire at the same time on reactivation. Both call clearCompleted+pollNow, both session-added events reach the handler before activeBoxes.has() returns true for either, so two status boxes are created. Fix: sessionAddInProgress Set gates the handler. First caller proceeds, second caller sees the key in-progress and returns immediately. Cleared on success (after activeBoxes.set) and on error (before return). --- src/watcher-manager.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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.