feat: Phase 2 — session monitor, lifecycle, watcher manager
Phase 2 (Session Monitor + Lifecycle): - src/session-monitor.js: polls sessions.json every 2s for new/ended sessions - Detects agents via transcriptDir subdirectory scan - Resolves channelId/rootPostId from session key format - Emits session-added/session-removed events - Handles multi-agent environments - Falls back to defaultChannel for non-MM sessions - src/watcher-manager.js: top-level orchestrator - Starts session-monitor, status-watcher, health-server - Creates/updates Mattermost status posts on session events - Sub-agent linking: children embedded in parent status - Offset persistence (save/restore lastOffset on restart) - Post recovery on restart (search channel history for marker) - SIGTERM/SIGINT graceful shutdown: mark all boxes interrupted - CLI: node watcher-manager.js start|stop|status - MAX_ACTIVE_SESSIONS enforcement Integration tests: - test/integration/session-monitor.test.js: 14 tests - Session detection, removal, multi-agent, malformed JSON handling - test/integration/status-watcher.test.js: 13 tests - JSONL parsing, tool_call/result pairs, idle detection, offset recovery All 86 tests pass (59 unit + 27 integration). make check clean.
This commit is contained in:
281
src/session-monitor.js
Normal file
281
src/session-monitor.js
Normal file
@@ -0,0 +1,281 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* session-monitor.js — Polls sessions.json every 2s to detect new/ended sessions.
|
||||
*
|
||||
* sessions.json format (per agent):
|
||||
* {
|
||||
* "agent:main:mattermost:channel:abc123:thread:xyz": {
|
||||
* "sessionId": "uuid",
|
||||
* "spawnedBy": null | "agent:main:...",
|
||||
* "spawnDepth": 0,
|
||||
* "label": "proj035-planner",
|
||||
* "channel": "mattermost"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Emits:
|
||||
* 'session-added' ({ sessionKey, transcriptFile, spawnedBy, channelId, rootPostId, agentId })
|
||||
* 'session-removed' (sessionKey)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
class SessionMonitor extends EventEmitter {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.transcriptDir - Base /home/node/.openclaw/agents directory
|
||||
* @param {number} [opts.pollMs] - Poll interval in ms (default 2000)
|
||||
* @param {string|null} [opts.defaultChannel] - Fallback channel ID for non-MM sessions
|
||||
* @param {object} [opts.logger] - pino logger
|
||||
*/
|
||||
constructor(opts) {
|
||||
super();
|
||||
this.transcriptDir = opts.transcriptDir;
|
||||
this.pollMs = opts.pollMs || 2000;
|
||||
this.defaultChannel = opts.defaultChannel || null;
|
||||
this.logger = opts.logger || null;
|
||||
|
||||
// Map<sessionKey, sessionEntry>
|
||||
this._knownSessions = new Map();
|
||||
this._pollTimer = null;
|
||||
this._running = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
|
||||
// Initial scan
|
||||
this._poll();
|
||||
|
||||
this._pollTimer = setInterval(() => {
|
||||
this._poll();
|
||||
}, this.pollMs);
|
||||
|
||||
if (this.logger) this.logger.info({ pollMs: this.pollMs }, 'SessionMonitor started');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._running = false;
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
if (this.logger) this.logger.info('SessionMonitor stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agent directories under transcriptDir.
|
||||
* @private
|
||||
* @returns {string[]} Agent IDs
|
||||
*/
|
||||
_getAgentDirs() {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
return fs.readdirSync(this.transcriptDir).filter((name) => {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
return fs.statSync(path.join(this.transcriptDir, name)).isDirectory();
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read sessions.json for a given agent.
|
||||
* @private
|
||||
* @param {string} agentId
|
||||
* @returns {object} Sessions map
|
||||
*/
|
||||
_readSessionsJson(agentId) {
|
||||
const sessionsPath = path.join(this.transcriptDir, agentId, 'sessions', 'sessions.json');
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const raw = fs.readFileSync(sessionsPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the transcript file path for a session.
|
||||
* @private
|
||||
* @param {string} agentId
|
||||
* @param {string} sessionId - UUID
|
||||
* @returns {string}
|
||||
*/
|
||||
_transcriptPath(agentId, sessionId) {
|
||||
return path.join(this.transcriptDir, agentId, 'sessions', `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse channel ID from session key.
|
||||
* Session key format: "agent:main:mattermost:channel:{channelId}:thread:{threadId}"
|
||||
* or: "agent:main:mattermost:dm:{userId}"
|
||||
* @param {string} sessionKey
|
||||
* @returns {string|null}
|
||||
*/
|
||||
static parseChannelId(sessionKey) {
|
||||
const parts = sessionKey.split(':');
|
||||
// agent:main:mattermost:channel:CHANNEL_ID:...
|
||||
const chanIdx = parts.indexOf('channel');
|
||||
if (chanIdx >= 0 && parts[chanIdx + 1]) {
|
||||
return parts[chanIdx + 1]; // eslint-disable-line security/detect-object-injection
|
||||
}
|
||||
// agent:main:mattermost:dm:USER_ID (use as channel)
|
||||
const dmIdx = parts.indexOf('dm');
|
||||
if (dmIdx >= 0 && parts[dmIdx + 1]) {
|
||||
return parts[dmIdx + 1]; // eslint-disable-line security/detect-object-injection
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse root post ID (thread ID) from session key.
|
||||
* @param {string} sessionKey
|
||||
* @returns {string|null}
|
||||
*/
|
||||
static parseRootPostId(sessionKey) {
|
||||
const parts = sessionKey.split(':');
|
||||
const threadIdx = parts.indexOf('thread');
|
||||
if (threadIdx >= 0 && parts[threadIdx + 1]) {
|
||||
return parts[threadIdx + 1]; // eslint-disable-line security/detect-object-injection
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract agent ID from session key.
|
||||
* @param {string} sessionKey
|
||||
* @returns {string}
|
||||
*/
|
||||
static parseAgentId(sessionKey) {
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts[0] === 'agent' && parts[1]) return parts[1];
|
||||
return parts[0] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a session is a Mattermost session.
|
||||
* @param {string} sessionKey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isMattermostSession(sessionKey) {
|
||||
return sessionKey.includes(':mattermost:') || sessionKey.includes(':mm:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll all agents' sessions.json files for changes.
|
||||
* @private
|
||||
*/
|
||||
_poll() {
|
||||
if (!this._running) return;
|
||||
|
||||
const agentDirs = this._getAgentDirs();
|
||||
const currentSessions = new Map();
|
||||
|
||||
for (const agentId of agentDirs) {
|
||||
const sessions = this._readSessionsJson(agentId);
|
||||
for (const [sessionKey, entry] of Object.entries(sessions)) {
|
||||
const sessionId = entry.sessionId || entry.uuid;
|
||||
if (!sessionId) continue;
|
||||
|
||||
currentSessions.set(sessionKey, {
|
||||
agentId,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
spawnedBy: entry.spawnedBy || null,
|
||||
spawnDepth: entry.spawnDepth || 0,
|
||||
label: entry.label || null,
|
||||
channel: entry.channel || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect added sessions
|
||||
for (const [sessionKey, entry] of currentSessions) {
|
||||
if (!this._knownSessions.has(sessionKey)) {
|
||||
this._onSessionAdded(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect removed sessions
|
||||
for (const [sessionKey] of this._knownSessions) {
|
||||
if (!currentSessions.has(sessionKey)) {
|
||||
this._onSessionRemoved(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
this._knownSessions = currentSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a newly detected session.
|
||||
* @private
|
||||
*/
|
||||
_onSessionAdded(entry) {
|
||||
const { agentId, sessionKey, sessionId, spawnedBy, spawnDepth, label } = entry;
|
||||
|
||||
const transcriptFile = this._transcriptPath(agentId, sessionId);
|
||||
|
||||
// Resolve channel ID from session key
|
||||
let channelId = SessionMonitor.parseChannelId(sessionKey);
|
||||
|
||||
// Fall back to default channel for non-MM sessions
|
||||
if (!channelId && !SessionMonitor.isMattermostSession(sessionKey)) {
|
||||
channelId = this.defaultChannel;
|
||||
if (!channelId) {
|
||||
if (this.logger) {
|
||||
this.logger.debug({ sessionKey }, 'Skipping non-MM session (no channel, no default)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rootPostId = SessionMonitor.parseRootPostId(sessionKey);
|
||||
const parsedAgentId = SessionMonitor.parseAgentId(sessionKey);
|
||||
|
||||
if (this.logger) {
|
||||
this.logger.info({ sessionKey, agentId, channelId, spawnedBy }, 'Session detected');
|
||||
}
|
||||
|
||||
this.emit('session-added', {
|
||||
sessionKey,
|
||||
transcriptFile,
|
||||
spawnedBy,
|
||||
spawnDepth,
|
||||
channelId,
|
||||
rootPostId,
|
||||
agentId: label || parsedAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a removed session.
|
||||
* @private
|
||||
*/
|
||||
_onSessionRemoved(sessionKey) {
|
||||
if (this.logger) {
|
||||
this.logger.info({ sessionKey }, 'Session ended');
|
||||
}
|
||||
this.emit('session-removed', sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of currently known sessions.
|
||||
* @returns {Map}
|
||||
*/
|
||||
getKnownSessions() {
|
||||
return new Map(this._knownSessions);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SessionMonitor };
|
||||
Reference in New Issue
Block a user