From c36a048dbbdf516712e67bbdcf0d145af105772b Mon Sep 17 00:00:00 2001 From: sol Date: Sun, 8 Mar 2026 12:00:13 +0000 Subject: [PATCH] fix: stale sessions permanently ignored + CLI missing custom post type Two bugs fixed: 1. Session monitor stale session bug: Sessions that were stale on first poll got added to _knownSessions but never re-checked, even after their transcript became active. Now stale sessions are tracked separately in _staleSessions and re-checked on every poll cycle. 2. CLI live-status tool: create/update commands were creating plain text posts without the custom_livestatus post type or plugin props. The Mattermost webapp plugin only renders posts with type=custom_livestatus. Now all CLI commands set the correct post type and livestatus props. --- src/live-status.js | 44 +++++++++++++++++++++++++++++++++++------- src/session-monitor.js | 27 ++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/live-status.js b/src/live-status.js index 6ad0938..45cadd4 100755 --- a/src/live-status.js +++ b/src/live-status.js @@ -242,15 +242,37 @@ async function createPost(text, cmd) { process.exit(1); } try { + const agentName = options.agent || 'unknown'; + const statusMap = { create: 'running', update: 'running', complete: 'complete', error: 'error' }; + const cmdKey = cmd || 'create'; let payload; if (options.rich) { payload = { channel_id: CONFIG.channel_id, - message: '', - props: { attachments: [buildAttachment(cmd || 'create', text)] }, + message: text, + type: 'custom_livestatus', + props: { + attachments: [buildAttachment(cmdKey, text)], + livestatus: { + agent_id: agentName, + status: statusMap[cmdKey] || 'running', // eslint-disable-line security/detect-object-injection + lines: text.split('\n'), + }, + }, }; } else { - payload = { channel_id: CONFIG.channel_id, message: text }; + payload = { + channel_id: CONFIG.channel_id, + message: text, + type: 'custom_livestatus', + props: { + livestatus: { + agent_id: agentName, + status: statusMap[cmdKey] || 'running', // eslint-disable-line security/detect-object-injection + lines: text.split('\n'), + }, + }, + }; } if (options.replyTo) payload.root_id = options.replyTo; const result = await request('POST', '/posts', payload); @@ -271,18 +293,26 @@ async function updatePost(postId, text, cmd) { process.exit(1); } try { + const agentName = options.agent || 'unknown'; + const statusMap = { create: 'running', update: 'running', complete: 'complete', error: 'error' }; + const cmdKey = cmd || 'update'; + const livestatusProps = { + agent_id: agentName, + status: statusMap[cmdKey] || 'running', // eslint-disable-line security/detect-object-injection + lines: text.split('\n'), + }; + if (options.rich) { await request('PUT', `/posts/${postId}`, { id: postId, - message: '', - props: { attachments: [buildAttachment(cmd || 'update', text)] }, + message: text, + props: { attachments: [buildAttachment(cmdKey, text)], livestatus: livestatusProps }, }); } else { - const current = await request('GET', `/posts/${postId}`); await request('PUT', `/posts/${postId}`, { id: postId, message: text, - props: current.props, + props: { livestatus: livestatusProps }, }); } console.log('updated'); diff --git a/src/session-monitor.js b/src/session-monitor.js index 0390c41..f5c50f1 100644 --- a/src/session-monitor.js +++ b/src/session-monitor.js @@ -46,6 +46,8 @@ class SessionMonitor extends EventEmitter { // Map this._knownSessions = new Map(); + // Set — sessions that were skipped as stale; re-check on next poll + this._staleSessions = new Set(); // Cache: "user:XXXX" -> channelId (resolved DM channels) this._dmChannelCache = new Map(); this._pollTimer = null; @@ -175,6 +177,8 @@ class SessionMonitor extends EventEmitter { if (dmIdx >= 0 && parts[dmIdx + 1]) { return parts[dmIdx + 1]; // eslint-disable-line security/detect-object-injection } + // agent:main:mattermost:direct:USER_ID — DM sessions use "direct" prefix + // Channel ID must be resolved via API (returns null here; resolveChannelFromEntry handles it) return null; } @@ -343,9 +347,9 @@ class SessionMonitor extends EventEmitter { } } - // Detect added sessions + // Detect new or previously-stale sessions for (const [sessionKey, entry] of currentSessions) { - if (!this._knownSessions.has(sessionKey)) { + if (!this._knownSessions.has(sessionKey) || this._staleSessions.has(sessionKey)) { this._onSessionAdded(entry); } } @@ -354,6 +358,14 @@ class SessionMonitor extends EventEmitter { for (const [sessionKey] of this._knownSessions) { if (!currentSessions.has(sessionKey)) { this._onSessionRemoved(sessionKey); + this._staleSessions.delete(sessionKey); + } + } + + // Clean up stale entries for sessions no longer in sessions.json + for (const sessionKey of this._staleSessions) { + if (!currentSessions.has(sessionKey)) { + this._staleSessions.delete(sessionKey); } } @@ -374,13 +386,16 @@ class SessionMonitor extends EventEmitter { } // Skip stale sessions — only track if transcript was modified in last 5 minutes - // This prevents creating status boxes for every old session in sessions.json + // This prevents creating status boxes for every old session in sessions.json. + // Stale sessions are tracked in _staleSessions and re-checked on every poll + // so they get picked up as soon as the transcript becomes active again. try { // eslint-disable-next-line security/detect-non-literal-fs-filename const stat = fs.statSync(transcriptFile); const ageMs = Date.now() - stat.mtimeMs; const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes if (ageMs > STALE_THRESHOLD_MS) { + this._staleSessions.add(sessionKey); if (this.logger) { this.logger.debug( { sessionKey, ageS: Math.floor(ageMs / 1000) }, @@ -390,7 +405,8 @@ class SessionMonitor extends EventEmitter { return; } } catch (_e) { - // File doesn't exist — skip silently + // File doesn't exist — skip silently but track as stale for re-check + this._staleSessions.add(sessionKey); if (this.logger) { this.logger.debug( { sessionKey, transcriptFile }, @@ -400,6 +416,9 @@ class SessionMonitor extends EventEmitter { return; } + // Session is fresh — remove from stale tracking + this._staleSessions.delete(sessionKey); + // Sub-agents always pass through — they inherit parent channel via watcher-manager const isSubAgent = !!spawnedBy;