diff --git a/src/status-watcher.js b/src/status-watcher.js index f40f3bc..358e25e 100644 --- a/src/status-watcher.js +++ b/src/status-watcher.js @@ -225,19 +225,31 @@ class StatusWatcher extends EventEmitter { // Emit 'session-lock' so watcher-manager can trigger immediate reactivation // without waiting for the first JSONL write. if (fullPath.endsWith('.jsonl.lock')) { - // Derive the JSONL path and find the session key const jsonlPath = fullPath.slice(0, -5); // strip '.lock' - const sessionKey = this.fileToSession.get(jsonlPath) || - this.fileToSession.get('\x00ghost:' + jsonlPath.replace('\x00ghost:', '')); - if (sessionKey) { - const originalKey = sessionKey.startsWith('\x00ghost:') ? sessionKey.slice(7) : sessionKey; - if (this.logger) { - this.logger.info({ sessionKey: originalKey }, 'Lock file detected — session started, triggering early reactivation'); + const lockExists = (() => { try { fs.statSync(fullPath); return true; } catch(_) { return false; } })(); + + // Resolve session key from known sessions or ghost watch + let sessionKey = this.fileToSession.get(jsonlPath); + if (sessionKey && sessionKey.startsWith('\x00ghost:')) sessionKey = sessionKey.slice(7); + + if (lockExists) { + // Lock file CREATED — gateway started processing user message. + // Earliest possible signal: fires before any JSONL write. + if (sessionKey) { + if (this.logger) this.logger.info({ sessionKey }, 'Lock file created — session active, triggering early reactivation'); + this.emit('session-lock', sessionKey); + } else { + this.emit('session-lock-path', jsonlPath); } - this.emit('session-lock', originalKey); } else { - // Unknown session — emit with path so watcher-manager can look it up - this.emit('session-lock-path', jsonlPath); + // Lock file DELETED — gateway finished the turn and sent final reply. + // Immediate idle signal: no need to wait for cache-ttl or 60s timeout. + if (sessionKey) { + if (this.logger) this.logger.info({ sessionKey }, 'Lock file deleted — turn complete, marking session done immediately'); + this.emit('session-lock-released', sessionKey); + } else { + this.emit('session-lock-released-path', jsonlPath); + } } return; } @@ -490,6 +502,22 @@ class StatusWatcher extends EventEmitter { * Schedule an idle check for a session. * @private */ + /** + * Immediately mark a session as done without waiting for idle timeout. + * Called when the lock file is deleted — the gateway has sent the final reply. + * @param {string} sessionKey + */ + triggerIdle(sessionKey) { + const state = this.sessions.get(sessionKey); + if (!state) return; + // Cancel any pending timers + if (state.idleTimer) { clearTimeout(state.idleTimer); state.idleTimer = null; } + if (state._filePollTimer) { clearInterval(state._filePollTimer); state._filePollTimer = null; } + if (this.logger) this.logger.info({ sessionKey }, 'triggerIdle: lock released — marking session done immediately'); + state.status = 'done'; + this.emit('session-idle', sessionKey, this._sanitizeState(state)); + } + _scheduleIdleCheck(sessionKey, state) { if (state.idleTimer) { clearTimeout(state.idleTimer); diff --git a/src/watcher-manager.js b/src/watcher-manager.js index a90f245..02c1c9e 100644 --- a/src/watcher-manager.js +++ b/src/watcher-manager.js @@ -387,7 +387,6 @@ async function startDaemon() { // Lock file for a session the watcher doesn't know about yet — look it up by path watcher.on('session-lock-path', (jsonlPath) => { - // Find the session key by matching sessionFile in sessions.json const sessionKey = monitor.findSessionByFile(jsonlPath); if (sessionKey) { logger.info({ sessionKey }, 'Lock file (path lookup): session activated — early reactivation'); @@ -396,6 +395,22 @@ async function startDaemon() { } }); + // ---- Lock file released: turn complete, mark done immediately ---- + // Gateway deletes .jsonl.lock when the final reply is sent to the user. + // This is the most reliable "done" signal — no waiting for cache-ttl or 60s idle. + watcher.on('session-lock-released', (sessionKey) => { + logger.info({ sessionKey }, 'Lock file released — turn complete, triggering immediate idle'); + watcher.triggerIdle(sessionKey); + }); + + watcher.on('session-lock-released-path', (jsonlPath) => { + const sessionKey = monitor.findSessionByFile(jsonlPath); + if (sessionKey) { + logger.info({ sessionKey }, 'Lock file released (path lookup) — triggering immediate idle'); + watcher.triggerIdle(sessionKey); + } + }); + // ---- Session Update (from watcher) ---- watcher.on('session-update', (sessionKey, state) => { const box = activeBoxes.get(sessionKey);