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.
This commit is contained in:
sol
2026-03-08 12:00:13 +00:00
parent 4d644e7a43
commit c36a048dbb
2 changed files with 60 additions and 11 deletions

View File

@@ -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');

View File

@@ -46,6 +46,8 @@ class SessionMonitor extends EventEmitter {
// Map<sessionKey, sessionEntry>
this._knownSessions = new Map();
// Set<sessionKey> — 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;