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:
251
test/integration/session-monitor.test.js
Normal file
251
test/integration/session-monitor.test.js
Normal file
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Integration tests for session-monitor.js
|
||||
* Tests session detection by writing mock sessions.json files.
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { SessionMonitor } = require('../../src/session-monitor');
|
||||
|
||||
function createTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-test-'));
|
||||
}
|
||||
|
||||
function writeSessionsJson(dir, agentId, sessions) {
|
||||
const agentDir = path.join(dir, agentId, 'sessions');
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentDir, 'sessions.json'), JSON.stringify(sessions, null, 2));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('SessionMonitor', () => {
|
||||
let tmpDir;
|
||||
let monitor;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (monitor) {
|
||||
monitor.stop();
|
||||
monitor = null;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
describe('parseChannelId()', () => {
|
||||
it('parses channel ID from mattermost session key', () => {
|
||||
const key = 'agent:main:mattermost:channel:abc123:thread:xyz';
|
||||
assert.equal(SessionMonitor.parseChannelId(key), 'abc123');
|
||||
});
|
||||
|
||||
it('parses DM channel', () => {
|
||||
const key = 'agent:main:mattermost:dm:user456';
|
||||
assert.equal(SessionMonitor.parseChannelId(key), 'user456');
|
||||
});
|
||||
|
||||
it('returns null for non-MM session', () => {
|
||||
const key = 'agent:main:hook:session:xyz';
|
||||
assert.equal(SessionMonitor.parseChannelId(key), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRootPostId()', () => {
|
||||
it('parses thread ID from session key', () => {
|
||||
const key = 'agent:main:mattermost:channel:abc123:thread:rootpost999';
|
||||
assert.equal(SessionMonitor.parseRootPostId(key), 'rootpost999');
|
||||
});
|
||||
|
||||
it('returns null if no thread', () => {
|
||||
const key = 'agent:main:mattermost:channel:abc123';
|
||||
assert.equal(SessionMonitor.parseRootPostId(key), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAgentId()', () => {
|
||||
it('extracts agent ID', () => {
|
||||
assert.equal(SessionMonitor.parseAgentId('agent:main:mattermost:channel:abc'), 'main');
|
||||
assert.equal(SessionMonitor.parseAgentId('agent:coder-agent:session'), 'coder-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMattermostSession()', () => {
|
||||
it('detects mattermost sessions', () => {
|
||||
assert.equal(SessionMonitor.isMattermostSession('agent:main:mattermost:channel:abc'), true);
|
||||
assert.equal(SessionMonitor.isMattermostSession('agent:main:hook:abc'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-added event', () => {
|
||||
it('emits session-added for new session in sessions.json', async () => {
|
||||
const sessionKey = 'agent:main:mattermost:channel:testchan:thread:testroot';
|
||||
const sessionId = 'aaaa-1111-bbbb-2222';
|
||||
|
||||
writeSessionsJson(tmpDir, 'main', {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
spawnedBy: null,
|
||||
spawnDepth: 0,
|
||||
label: null,
|
||||
channel: 'mattermost',
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new SessionMonitor({
|
||||
transcriptDir: tmpDir,
|
||||
pollMs: 50,
|
||||
});
|
||||
|
||||
const added = [];
|
||||
monitor.on('session-added', (info) => added.push(info));
|
||||
monitor.start();
|
||||
|
||||
await sleep(200);
|
||||
|
||||
assert.equal(added.length, 1);
|
||||
assert.equal(added[0].sessionKey, sessionKey);
|
||||
assert.equal(added[0].channelId, 'testchan');
|
||||
assert.equal(added[0].rootPostId, 'testroot');
|
||||
assert.ok(added[0].transcriptFile.endsWith(`${sessionId}.jsonl`));
|
||||
});
|
||||
|
||||
it('emits session-removed when session disappears', async () => {
|
||||
const sessionKey = 'agent:main:mattermost:channel:testchan:thread:testroot';
|
||||
const sessionId = 'cccc-3333-dddd-4444';
|
||||
|
||||
writeSessionsJson(tmpDir, 'main', {
|
||||
[sessionKey]: { sessionId, spawnedBy: null, spawnDepth: 0, channel: 'mattermost' },
|
||||
});
|
||||
|
||||
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
|
||||
|
||||
const removed = [];
|
||||
monitor.on('session-removed', (key) => removed.push(key));
|
||||
monitor.start();
|
||||
|
||||
await sleep(200);
|
||||
assert.equal(removed.length, 0);
|
||||
|
||||
// Remove the session
|
||||
writeSessionsJson(tmpDir, 'main', {});
|
||||
|
||||
await sleep(200);
|
||||
assert.equal(removed.length, 1);
|
||||
assert.equal(removed[0], sessionKey);
|
||||
});
|
||||
|
||||
it('skips non-MM sessions with no default channel', async () => {
|
||||
const sessionKey = 'agent:main:hook:session:xyz';
|
||||
writeSessionsJson(tmpDir, 'main', {
|
||||
[sessionKey]: { sessionId: 'hook-uuid', channel: 'hook' },
|
||||
});
|
||||
|
||||
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50, defaultChannel: null });
|
||||
|
||||
const added = [];
|
||||
monitor.on('session-added', (info) => added.push(info));
|
||||
monitor.start();
|
||||
|
||||
await sleep(200);
|
||||
assert.equal(added.length, 0);
|
||||
});
|
||||
|
||||
it('includes non-MM sessions when defaultChannel is set', async () => {
|
||||
const sessionKey = 'agent:main:hook:session:xyz';
|
||||
writeSessionsJson(tmpDir, 'main', {
|
||||
[sessionKey]: { sessionId: 'hook-uuid', channel: 'hook' },
|
||||
});
|
||||
|
||||
monitor = new SessionMonitor({
|
||||
transcriptDir: tmpDir,
|
||||
pollMs: 50,
|
||||
defaultChannel: 'default-channel-id',
|
||||
});
|
||||
|
||||
const added = [];
|
||||
monitor.on('session-added', (info) => added.push(info));
|
||||
monitor.start();
|
||||
|
||||
await sleep(200);
|
||||
assert.equal(added.length, 1);
|
||||
assert.equal(added[0].channelId, 'default-channel-id');
|
||||
});
|
||||
|
||||
it('detects multiple agents', async () => {
|
||||
writeSessionsJson(tmpDir, 'main', {
|
||||
'agent:main:mattermost:channel:c1:thread:t1': {
|
||||
sessionId: 'sess-main',
|
||||
spawnedBy: null,
|
||||
channel: 'mattermost',
|
||||
},
|
||||
});
|
||||
writeSessionsJson(tmpDir, 'coder-agent', {
|
||||
'agent:coder-agent:mattermost:channel:c2:thread:t2': {
|
||||
sessionId: 'sess-coder',
|
||||
spawnedBy: null,
|
||||
channel: 'mattermost',
|
||||
},
|
||||
});
|
||||
|
||||
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
|
||||
|
||||
const added = [];
|
||||
monitor.on('session-added', (info) => added.push(info));
|
||||
monitor.start();
|
||||
|
||||
await sleep(200);
|
||||
assert.equal(added.length, 2);
|
||||
const keys = added.map((s) => s.sessionKey).sort();
|
||||
assert.ok(keys.includes('agent:main:mattermost:channel:c1:thread:t1'));
|
||||
assert.ok(keys.includes('agent:coder-agent:mattermost:channel:c2:thread:t2'));
|
||||
});
|
||||
|
||||
it('handles malformed sessions.json gracefully', async () => {
|
||||
const agentDir = path.join(tmpDir, 'main', 'sessions');
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentDir, 'sessions.json'), 'not valid json');
|
||||
|
||||
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
|
||||
|
||||
const added = [];
|
||||
monitor.on('session-added', (info) => added.push(info));
|
||||
monitor.start();
|
||||
|
||||
await sleep(200);
|
||||
// Should not throw and should produce no sessions
|
||||
assert.equal(added.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKnownSessions()', () => {
|
||||
it('returns current known sessions', async () => {
|
||||
const sessionKey = 'agent:main:mattermost:channel:c1:thread:t1';
|
||||
writeSessionsJson(tmpDir, 'main', {
|
||||
[sessionKey]: { sessionId: 'test-uuid', channel: 'mattermost' },
|
||||
});
|
||||
|
||||
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
|
||||
monitor.start();
|
||||
|
||||
await sleep(200);
|
||||
|
||||
const sessions = monitor.getKnownSessions();
|
||||
assert.equal(sessions.size, 1);
|
||||
assert.ok(sessions.has(sessionKey));
|
||||
});
|
||||
});
|
||||
});
|
||||
267
test/integration/status-watcher.test.js
Normal file
267
test/integration/status-watcher.test.js
Normal file
@@ -0,0 +1,267 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Integration tests for status-watcher.js
|
||||
* Tests JSONL file watching and event emission.
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { StatusWatcher } = require('../../src/status-watcher');
|
||||
|
||||
function createTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'sw-test-'));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function appendLine(file, obj) {
|
||||
fs.appendFileSync(file, JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
describe('StatusWatcher', () => {
|
||||
let tmpDir;
|
||||
let watcher;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (watcher) {
|
||||
watcher.stop();
|
||||
watcher = null;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
describe('session management', () => {
|
||||
it('addSession() registers a session', () => {
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
|
||||
watcher.addSession('test:key', file);
|
||||
assert.ok(watcher.sessions.has('test:key'));
|
||||
assert.ok(watcher.fileToSession.has(file));
|
||||
});
|
||||
|
||||
it('removeSession() cleans up', () => {
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
|
||||
watcher.addSession('test:key', file);
|
||||
watcher.removeSession('test:key');
|
||||
assert.ok(!watcher.sessions.has('test:key'));
|
||||
assert.ok(!watcher.fileToSession.has(file));
|
||||
});
|
||||
|
||||
it('getSessionState() returns state', () => {
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
watcher.addSession('test:key', file);
|
||||
const state = watcher.getSessionState('test:key');
|
||||
assert.ok(state);
|
||||
assert.equal(state.sessionKey, 'test:key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSONL parsing', () => {
|
||||
it('reads existing content on addSession', () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
appendLine(file, { type: 'assistant', text: 'Hello world' });
|
||||
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const state = watcher.getSessionState('test:key');
|
||||
assert.ok(state.lines.length > 0);
|
||||
assert.ok(state.lines.some((l) => l.includes('exec')));
|
||||
});
|
||||
|
||||
it('emits session-update when file changes', async () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
watcher.start();
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const updates = [];
|
||||
watcher.on('session-update', (key, state) => updates.push({ key, state }));
|
||||
|
||||
await sleep(100);
|
||||
appendLine(file, { type: 'assistant', text: 'Starting task...' });
|
||||
await sleep(500);
|
||||
|
||||
assert.ok(updates.length > 0);
|
||||
assert.equal(updates[0].key, 'test:key');
|
||||
});
|
||||
|
||||
it('parses tool_call and tool_result pairs', () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
|
||||
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const state = watcher.getSessionState('test:key');
|
||||
assert.equal(state.pendingToolCalls, 0);
|
||||
// Line should show [OK]
|
||||
const execLine = state.lines.find((l) => l.includes('exec'));
|
||||
assert.ok(execLine);
|
||||
assert.ok(execLine.includes('[OK]'));
|
||||
});
|
||||
|
||||
it('tracks pendingToolCalls correctly', () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
|
||||
appendLine(file, { type: 'tool_call', name: 'Read', id: '2' });
|
||||
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const state = watcher.getSessionState('test:key');
|
||||
assert.equal(state.pendingToolCalls, 1); // Read still pending
|
||||
});
|
||||
|
||||
it('tracks token usage', () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
appendLine(file, { type: 'usage', input_tokens: 1000, output_tokens: 500 });
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const state = watcher.getSessionState('test:key');
|
||||
assert.equal(state.tokenCount, 1500);
|
||||
});
|
||||
|
||||
it('detects file truncation (compaction)', () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
|
||||
// Write some content
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ type: 'assistant', text: 'Hello' }) +
|
||||
'\n' +
|
||||
JSON.stringify({ type: 'tool_call', name: 'exec', id: '1' }) +
|
||||
'\n',
|
||||
);
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const state = watcher.getSessionState('test:key');
|
||||
const originalOffset = state.lastOffset;
|
||||
assert.ok(originalOffset > 0);
|
||||
|
||||
// Truncate the file (simulate compaction)
|
||||
fs.writeFileSync(file, JSON.stringify({ type: 'assistant', text: 'Resumed' }) + '\n');
|
||||
|
||||
// Force a re-read
|
||||
watcher._readFile('test:key', state);
|
||||
|
||||
// Offset should have reset
|
||||
assert.ok(
|
||||
state.lastOffset < originalOffset ||
|
||||
state.lines.includes('[session compacted - continuing]'),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles malformed JSONL lines gracefully', () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, 'not valid json\n{"type":"assistant","text":"ok"}\n');
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
|
||||
// Should not throw
|
||||
assert.doesNotThrow(() => {
|
||||
watcher.addSession('test:key', file);
|
||||
});
|
||||
|
||||
const state = watcher.getSessionState('test:key');
|
||||
assert.ok(state);
|
||||
assert.ok(state.lines.some((l) => l.includes('ok')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('idle detection', () => {
|
||||
it('emits session-idle after timeout with no activity', async () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
appendLine(file, { type: 'assistant', text: 'Starting...' });
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 }); // 200ms
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const idle = [];
|
||||
watcher.on('session-idle', (key) => idle.push(key));
|
||||
|
||||
await sleep(600);
|
||||
assert.ok(idle.length > 0);
|
||||
assert.equal(idle[0], 'test:key');
|
||||
});
|
||||
|
||||
it('resets idle timer on new activity', async () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
fs.writeFileSync(file, '');
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.3 }); // 300ms
|
||||
watcher.start();
|
||||
watcher.addSession('test:key', file);
|
||||
|
||||
const idle = [];
|
||||
watcher.on('session-idle', (key) => idle.push(key));
|
||||
|
||||
// Write activity at 150ms (before 300ms timeout)
|
||||
await sleep(150);
|
||||
appendLine(file, { type: 'assistant', text: 'Still working...' });
|
||||
|
||||
// At 350ms: idle timer was reset, so no idle yet
|
||||
await sleep(200);
|
||||
assert.equal(idle.length, 0);
|
||||
|
||||
// At 600ms: idle timer fires (150+300=450ms > 200+300=500ms... need to wait full 300ms)
|
||||
await sleep(400);
|
||||
assert.equal(idle.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offset recovery', () => {
|
||||
it('resumes from saved offset', () => {
|
||||
const file = path.join(tmpDir, 'test.jsonl');
|
||||
const line1 = JSON.stringify({ type: 'assistant', text: 'First' }) + '\n';
|
||||
const line2 = JSON.stringify({ type: 'assistant', text: 'Second' }) + '\n';
|
||||
fs.writeFileSync(file, line1 + line2);
|
||||
|
||||
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
|
||||
|
||||
// Start with offset at start of line2 (after line1)
|
||||
watcher.addSession('test:key', file, { lastOffset: line1.length });
|
||||
|
||||
const state = watcher.getSessionState('test:key');
|
||||
// Should only have parsed line2
|
||||
assert.ok(state.lines.some((l) => l.includes('Second')));
|
||||
assert.ok(!state.lines.some((l) => l.includes('First')));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user