monitor-agent-unit.test.js
1 /** 2 * Monitor Agent Unit Tests 3 * 4 * Tests for MonitorAgent focusing on: 5 * - Pure helper methods (groupByMessage, withinOneHour, getStageOrder) 6 * - File position persistence (loadFilePositions, saveFilePositions) 7 * - readIncrementally with position tracking 8 * - processTask routing for all task types 9 * - checkLoops, checkBlockedTasks, checkAgentHealth logic 10 * - ensureRecurringTasks 11 * - checkSLOCompliance 12 */ 13 14 import { test, describe, before, after } from 'node:test'; 15 import assert from 'node:assert/strict'; 16 import Database from 'better-sqlite3'; 17 import { existsSync, unlinkSync, writeFileSync, mkdirSync, rmSync } from 'fs'; 18 import { join, dirname } from 'path'; 19 import { fileURLToPath } from 'url'; 20 21 const __filename = fileURLToPath(import.meta.url); 22 const __dirname = dirname(__filename); 23 const projectRoot = join(__dirname, '../..'); 24 25 const TEST_DB_PATH = join('/tmp', `test-monitor-unit-${Date.now()}.db`); 26 const TEST_LOG_DIR = join(projectRoot, 'tests/fixtures/monitor-logs'); 27 28 // MUST set before monitor.js imports (it opens DB at module level) 29 process.env.DATABASE_PATH = TEST_DB_PATH; 30 // Prevent agents from spawning real subprocesses during tests 31 process.env.AGENT_IMMEDIATE_INVOCATION = 'false'; 32 33 // Create DB synchronously BEFORE importing agent modules 34 // Also clean up WAL/SHM journal files from any prior run 35 for (const ext of ['', '-wal', '-shm']) { 36 try { 37 unlinkSync(TEST_DB_PATH + ext); 38 } catch { 39 /* ignore */ 40 } 41 } 42 const sharedDb = new Database(TEST_DB_PATH); 43 sharedDb.exec(` 44 CREATE TABLE IF NOT EXISTS agent_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, task_type TEXT NOT NULL, assigned_to TEXT NOT NULL, created_by TEXT, status TEXT DEFAULT 'pending', priority INTEGER DEFAULT 5, context_json TEXT, result_json TEXT, parent_task_id INTEGER, error_message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME, completed_at DATETIME, retry_count INTEGER DEFAULT 0); 45 CREATE TABLE IF NOT EXISTS agent_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, agent_name TEXT NOT NULL, log_level TEXT, message TEXT NOT NULL, data_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 46 CREATE TABLE IF NOT EXISTS agent_state (agent_name TEXT PRIMARY KEY, last_active DATETIME DEFAULT CURRENT_TIMESTAMP, current_task_id INTEGER, status TEXT DEFAULT 'idle', metrics_json TEXT); 47 CREATE TABLE IF NOT EXISTS agent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, from_agent TEXT NOT NULL, to_agent TEXT NOT NULL, message_type TEXT, content TEXT NOT NULL, metadata_json TEXT, context_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, read_at DATETIME); 48 CREATE TABLE IF NOT EXISTS human_review_queue (id INTEGER PRIMARY KEY AUTOINCREMENT, file TEXT NOT NULL, reason TEXT NOT NULL, type TEXT NOT NULL, priority TEXT NOT NULL, metadata TEXT, status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 49 CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, description TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP); 50 CREATE TABLE IF NOT EXISTS sites (id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT, landing_page_url TEXT, status TEXT DEFAULT 'found', error_message TEXT, score REAL, grade TEXT, recapture_count INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 51 CREATE TABLE IF NOT EXISTS pipeline_metrics (id INTEGER PRIMARY KEY AUTOINCREMENT, stage_name TEXT NOT NULL, sites_processed INTEGER DEFAULT 0, sites_succeeded INTEGER DEFAULT 0, sites_failed INTEGER DEFAULT 0, duration_ms INTEGER NOT NULL, started_at DATETIME NOT NULL, finished_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 52 CREATE TABLE IF NOT EXISTS agent_outcomes (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, agent_name TEXT NOT NULL, outcome TEXT NOT NULL, context_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 53 CREATE TABLE IF NOT EXISTS site_status (id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER, status TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 54 CREATE TABLE IF NOT EXISTS cron_locks (lock_key TEXT PRIMARY KEY, acquired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, description TEXT); 55 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('monitor', 'idle'); 56 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('triage', 'idle'); 57 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('developer', 'idle'); 58 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('qa', 'idle'); 59 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('security', 'idle'); 60 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('architect', 'idle'); 61 `); 62 63 // ATTACH in-memory databases as ops and tel so queries like ops.settings, tel.agent_tasks resolve 64 sharedDb.exec(` 65 ATTACH ':memory:' AS ops; 66 ATTACH ':memory:' AS tel; 67 CREATE TABLE IF NOT EXISTS ops.settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, description TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP); 68 CREATE TABLE IF NOT EXISTS tel.agent_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, task_type TEXT NOT NULL, assigned_to TEXT NOT NULL, created_by TEXT, status TEXT DEFAULT 'pending', priority INTEGER DEFAULT 5, context_json TEXT, result_json TEXT, parent_task_id INTEGER, error_message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME, completed_at DATETIME, retry_count INTEGER DEFAULT 0); 69 CREATE TABLE IF NOT EXISTS tel.agent_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, agent_name TEXT NOT NULL, log_level TEXT, message TEXT NOT NULL, data_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 70 CREATE TABLE IF NOT EXISTS tel.agent_state (agent_name TEXT PRIMARY KEY, last_active DATETIME DEFAULT CURRENT_TIMESTAMP, current_task_id INTEGER, status TEXT DEFAULT 'idle', metrics_json TEXT); 71 CREATE TABLE IF NOT EXISTS tel.agent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, from_agent TEXT NOT NULL, to_agent TEXT NOT NULL, message_type TEXT, content TEXT NOT NULL, metadata_json TEXT, context_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, read_at DATETIME); 72 CREATE TABLE IF NOT EXISTS tel.agent_outcomes (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, agent_name TEXT NOT NULL, task_type TEXT NOT NULL, outcome TEXT NOT NULL, context_json TEXT, result_json TEXT, duration_ms INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 73 CREATE TABLE IF NOT EXISTS tel.pipeline_metrics (id INTEGER PRIMARY KEY AUTOINCREMENT, stage_name TEXT NOT NULL, sites_processed INTEGER DEFAULT 0, sites_succeeded INTEGER DEFAULT 0, sites_failed INTEGER DEFAULT 0, duration_ms INTEGER NOT NULL, started_at DATETIME NOT NULL, finished_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 74 CREATE TABLE IF NOT EXISTS tel.structured_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, agent_name TEXT, task_id INTEGER, level TEXT, message TEXT, data_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 75 INSERT OR IGNORE INTO tel.agent_state (agent_name, status) VALUES ('monitor', 'idle'); 76 INSERT OR IGNORE INTO tel.agent_state (agent_name, status) VALUES ('triage', 'idle'); 77 INSERT OR IGNORE INTO tel.agent_state (agent_name, status) VALUES ('developer', 'idle'); 78 INSERT OR IGNORE INTO tel.agent_state (agent_name, status) VALUES ('qa', 'idle'); 79 INSERT OR IGNORE INTO tel.agent_state (agent_name, status) VALUES ('security', 'idle'); 80 INSERT OR IGNORE INTO tel.agent_state (agent_name, status) VALUES ('architect', 'idle'); 81 `); 82 83 import { resetDb as resetBaseDb } from '../../src/agents/base-agent.js'; 84 import { resetDb as resetSLODb } from '../../src/agents/utils/slo-tracker.js'; 85 import { MonitorAgent, resetDb as resetMonitorDb } from '../../src/agents/monitor.js'; 86 87 // Initialize one shared agent for task-execution tests (avoids ~18s per-test startup) 88 let agent; 89 before(async () => { 90 mkdirSync(TEST_LOG_DIR, { recursive: true }); 91 // Inject sharedDb into monitor.js so both use the SAME connection (avoids 2-connection issues) 92 resetMonitorDb(sharedDb); 93 agent = new MonitorAgent(); 94 await agent.initialize(); 95 }); 96 97 after(() => { 98 // Detach monitor's db reference (don't close - sharedDb.close() handles it) 99 resetMonitorDb(null); 100 resetBaseDb(); 101 resetSLODb(); 102 try { 103 sharedDb.close(); 104 } catch { 105 /* ignore */ 106 } 107 for (const ext of ['', '-wal', '-shm']) { 108 try { 109 unlinkSync(TEST_DB_PATH + ext); 110 } catch { 111 /* ignore */ 112 } 113 } 114 try { 115 rmSync(TEST_LOG_DIR, { recursive: true, force: true }); 116 } catch { 117 /* ignore */ 118 } 119 }); 120 121 function clearTables() { 122 sharedDb.exec(` 123 DELETE FROM agent_tasks; 124 DELETE FROM agent_logs; 125 DELETE FROM agent_messages; 126 DELETE FROM human_review_queue; 127 DELETE FROM settings; 128 DELETE FROM sites; 129 DELETE FROM pipeline_metrics; 130 DELETE FROM agent_outcomes; 131 DELETE FROM site_status; 132 UPDATE agent_state SET status = 'idle', current_task_id = NULL, metrics_json = NULL; 133 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('monitor', 'idle'); 134 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('triage', 'idle'); 135 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('developer', 'idle'); 136 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('qa', 'idle'); 137 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('security', 'idle'); 138 INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('architect', 'idle'); 139 `); 140 } 141 142 function getTask(taskType, context = {}) { 143 const r = sharedDb 144 .prepare( 145 `INSERT INTO agent_tasks (task_type, assigned_to, priority, context_json, status) 146 VALUES (?, ?, ?, ?, 'running')` 147 ) 148 .run(taskType, 'monitor', 5, JSON.stringify(context)); 149 return sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(r.lastInsertRowid); 150 } 151 152 // ----------------------------------------------------------------------- 153 // Pure helper method tests (no DB needed, no agent.initialize()) 154 // ----------------------------------------------------------------------- 155 156 describe('MonitorAgent - groupByMessage', () => { 157 test('groups error lines by message content', () => { 158 const a = new MonitorAgent(); 159 const lines = [ 160 '[2025-01-01T10:00:00Z] [ERROR] Connection refused', 161 '[2025-01-01T10:01:00Z] [ERROR] Connection refused', 162 '[2025-01-01T10:02:00Z] [ERROR] Timeout error', 163 ]; 164 const groups = a.groupByMessage(lines); 165 assert.ok('Connection refused' in groups); 166 assert.equal(groups['Connection refused'].length, 2); 167 assert.equal(groups['Timeout error'].length, 1); 168 }); 169 170 test('returns empty object for empty input', () => { 171 assert.deepEqual(new MonitorAgent().groupByMessage([]), {}); 172 }); 173 174 test('ignores non-ERROR lines (INFO, WARN)', () => { 175 const a = new MonitorAgent(); 176 assert.deepEqual(a.groupByMessage(['[INFO] Service started', '[WARN] Low disk space']), {}); 177 }); 178 }); 179 180 describe('MonitorAgent - withinOneHour', () => { 181 test('returns true for two errors 30 minutes apart', () => { 182 const a = new MonitorAgent(); 183 const now = new Date(); 184 const thirtyMinsAgo = new Date(now.getTime() - 30 * 60 * 1000); 185 assert.equal( 186 a.withinOneHour([ 187 `[${thirtyMinsAgo.toISOString()}] [ERROR] Msg`, 188 `[${now.toISOString()}] [ERROR] Msg`, 189 ]), 190 true 191 ); 192 }); 193 194 test('returns false for two errors 2 hours apart', () => { 195 const a = new MonitorAgent(); 196 const now = new Date(); 197 const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); 198 assert.equal( 199 a.withinOneHour([ 200 `[${twoHoursAgo.toISOString()}] [ERROR] Msg`, 201 `[${now.toISOString()}] [ERROR] Msg`, 202 ]), 203 false 204 ); 205 }); 206 207 test('returns false for empty or single-item arrays', () => { 208 const a = new MonitorAgent(); 209 assert.equal(a.withinOneHour([]), false); 210 assert.equal(a.withinOneHour(['[2025-01-01T10:00:00Z] [ERROR] One']), false); 211 }); 212 }); 213 214 describe('MonitorAgent - getStageOrder', () => { 215 test('returns correct order for all pipeline stages', () => { 216 const a = new MonitorAgent(); 217 assert.equal(a.getStageOrder('found'), 1); 218 assert.equal(a.getStageOrder('assets_captured'), 2); 219 assert.equal(a.getStageOrder('prog_scored'), 3); 220 assert.equal(a.getStageOrder('semantic_scored'), 4); 221 assert.equal(a.getStageOrder('vision_scored'), 4); 222 assert.equal(a.getStageOrder('enriched'), 5); 223 assert.equal(a.getStageOrder('proposals_drafted'), 6); 224 assert.equal(a.getStageOrder('outreach_partial'), 7); 225 assert.equal(a.getStageOrder('outreach_sent'), 8); 226 }); 227 228 test('returns 0 for unknown status', () => { 229 assert.equal(new MonitorAgent().getStageOrder('unknown_stage'), 0); 230 }); 231 }); 232 233 // ----------------------------------------------------------------------- 234 // File position persistence tests (DB needed, no initialize()) 235 // ----------------------------------------------------------------------- 236 237 describe('MonitorAgent - file position persistence', () => { 238 test('saveFilePositions stores to settings table', () => { 239 clearTables(); 240 const a = new MonitorAgent(); 241 a.lastReadPositions = { '/tmp/test.log': 1234 }; 242 a.saveFilePositions(); 243 const row = sharedDb 244 .prepare(`SELECT value FROM settings WHERE key = 'monitor_file_positions'`) 245 .get(); 246 assert.ok(row); 247 assert.equal(JSON.parse(row.value)['/tmp/test.log'], 1234); 248 }); 249 250 test('loadFilePositions reads back saved positions', () => { 251 clearTables(); 252 sharedDb 253 .prepare( 254 `INSERT OR REPLACE INTO settings (key, value, description) VALUES ('monitor_file_positions', ?, 'test')` 255 ) 256 .run(JSON.stringify({ '/tmp/test.log': 5678 })); 257 const a = new MonitorAgent(); 258 a.lastReadPositions = {}; 259 a.loadFilePositions(); 260 assert.equal(a.lastReadPositions['/tmp/test.log'], 5678); 261 }); 262 263 test('loadFilePositions handles missing settings without throwing', () => { 264 clearTables(); 265 const a = new MonitorAgent(); 266 a.lastReadPositions = {}; 267 assert.doesNotThrow(() => a.loadFilePositions()); 268 assert.deepEqual(a.lastReadPositions, {}); 269 }); 270 271 test('loadFilePositions resets to empty on invalid JSON', () => { 272 clearTables(); 273 sharedDb 274 .prepare( 275 `INSERT OR REPLACE INTO settings (key, value, description) VALUES ('monitor_file_positions', ?, 'test')` 276 ) 277 .run('{{not valid json}}'); 278 const a = new MonitorAgent(); 279 a.lastReadPositions = { '/tmp/old.log': 100 }; 280 a.loadFilePositions(); 281 assert.deepEqual(a.lastReadPositions, {}); 282 }); 283 }); 284 285 // ----------------------------------------------------------------------- 286 // readIncrementally tests 287 // ----------------------------------------------------------------------- 288 289 describe('MonitorAgent - readIncrementally', () => { 290 test('reads matching ERROR lines from a log file', async () => { 291 const logFile = join(TEST_LOG_DIR, 'read-test.log'); 292 writeFileSync( 293 logFile, 294 '[2025-01-01T10:00:00Z] [ERROR] Test error\n[2025-01-01T10:01:00Z] [INFO] Info msg\n' 295 ); 296 const a = new MonitorAgent(); 297 const matches = await a.readIncrementally(logFile, /\[ERROR\]/); 298 assert.ok(matches.length >= 1); 299 assert.ok(matches[0].includes('[ERROR]')); 300 try { 301 unlinkSync(logFile); 302 } catch { 303 /* ignore */ 304 } 305 }); 306 307 test('returns empty array for non-existent file', async () => { 308 const a = new MonitorAgent(); 309 const matches = await a.readIncrementally('/nonexistent/path/does-not-exist.log', /\[ERROR\]/); 310 assert.deepEqual(matches, []); 311 }); 312 313 test('tracks non-zero file position after first read', async () => { 314 const logFile = join(TEST_LOG_DIR, 'pos-track.log'); 315 writeFileSync(logFile, '[ERROR] First error line\n'); 316 const a = new MonitorAgent(); 317 await a.readIncrementally(logFile, /\[ERROR\]/); 318 assert.ok(a.lastReadPositions[logFile] > 0); 319 try { 320 unlinkSync(logFile); 321 } catch { 322 /* ignore */ 323 } 324 }); 325 326 test('resets position when file shrinks (log rotation)', async () => { 327 const logFile = join(TEST_LOG_DIR, 'rotation-test.log'); 328 writeFileSync( 329 logFile, 330 '[ERROR] Old content that is long enough to establish a file position for rotation test\n[ERROR] Second old line\n' 331 ); 332 const a = new MonitorAgent(); 333 await a.readIncrementally(logFile, /\[ERROR\]/); 334 // Simulate log rotation with smaller file 335 writeFileSync(logFile, '[ERROR] New rotated content\n'); 336 const afterRotation = await a.readIncrementally(logFile, /\[ERROR\]/); 337 assert.ok(afterRotation.length >= 1); 338 assert.ok(afterRotation[0].includes('rotated')); 339 try { 340 unlinkSync(logFile); 341 } catch { 342 /* ignore */ 343 } 344 }); 345 }); 346 347 // ----------------------------------------------------------------------- 348 // Task execution tests (use shared initialized agent) 349 // ----------------------------------------------------------------------- 350 351 describe('MonitorAgent - checkLoops', () => { 352 test('detects site retry loops (recapture_count > 3)', async () => { 353 clearTables(); 354 355 sharedDb 356 .prepare('INSERT INTO sites (domain, status, recapture_count) VALUES (?, ?, ?)') 357 .run('loop.com', 'failing', 5); 358 sharedDb 359 .prepare('INSERT INTO sites (domain, status, recapture_count) VALUES (?, ?, ?)') 360 .run('ok.com', 'prog_scored', 1); 361 const task = getTask('check_loops'); 362 await agent.processTask(task); 363 const result = JSON.parse( 364 sharedDb.prepare('SELECT result_json FROM agent_tasks WHERE id = ?').get(task.id).result_json 365 ); 366 assert.equal(result.site_retry_loops, 1); 367 assert.ok(result.loops.some(l => l.type === 'site_retry_loop')); 368 }); 369 370 test('detects agent task bounce loops (>3 children per parent)', async () => { 371 clearTables(); 372 373 const parentId = sharedDb 374 .prepare( 375 `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('classify_error', 'triage', 'completed')` 376 ) 377 .run().lastInsertRowid; 378 for (let i = 0; i < 4; i++) { 379 sharedDb 380 .prepare( 381 'INSERT INTO agent_tasks (task_type, assigned_to, parent_task_id) VALUES (?, ?, ?)' 382 ) 383 .run('fix_bug', 'developer', parentId); 384 } 385 const task = getTask('check_loops'); 386 await agent.processTask(task); 387 const result = JSON.parse( 388 sharedDb.prepare('SELECT result_json FROM agent_tasks WHERE id = ?').get(task.id).result_json 389 ); 390 assert.equal(result.agent_bounce_loops, 1); 391 assert.ok(result.loops.some(l => l.type === 'agent_bounce_loop')); 392 }); 393 394 test('returns zero counts when no loops present', async () => { 395 clearTables(); 396 397 const task = getTask('check_loops'); 398 await agent.processTask(task); 399 const result = JSON.parse( 400 sharedDb.prepare('SELECT result_json FROM agent_tasks WHERE id = ?').get(task.id).result_json 401 ); 402 assert.equal(result.total_loops, 0); 403 assert.equal(result.site_retry_loops, 0); 404 assert.equal(result.agent_bounce_loops, 0); 405 }); 406 }); 407 408 describe('MonitorAgent - checkBlockedTasks', () => { 409 test('creates triage task for blocked tasks older than 2 hours', async () => { 410 clearTables(); 411 412 sharedDb 413 .prepare( 414 `INSERT INTO agent_tasks (task_type, assigned_to, status, error_message, created_at) 415 VALUES ('fix_bug', 'developer', 'blocked', 'Cannot reproduce', datetime('now', '-3 hours'))` 416 ) 417 .run(); 418 const task = getTask('check_blocked_tasks'); 419 await agent.processTask(task); 420 const updated = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(task.id); 421 assert.equal(updated.status, 'completed'); 422 const result = JSON.parse(updated.result_json); 423 assert.equal(result.total_blocked, 1); 424 assert.equal(result.triage_created, 1); 425 const triage = sharedDb 426 .prepare( 427 `SELECT * FROM agent_tasks WHERE task_type = 'classify_error' AND assigned_to = 'triage'` 428 ) 429 .all(); 430 assert.ok(triage.length >= 1); 431 }); 432 433 test('skips triage when matching triage_error task already exists', async () => { 434 clearTables(); 435 436 const blockedId = sharedDb 437 .prepare( 438 `INSERT INTO agent_tasks (task_type, assigned_to, status, created_at) 439 VALUES ('fix_bug', 'developer', 'blocked', datetime('now', '-3 hours'))` 440 ) 441 .run().lastInsertRowid; 442 sharedDb 443 .prepare( 444 `INSERT INTO agent_tasks (task_type, assigned_to, parent_task_id, status) VALUES ('triage_error', 'triage', ?, 'pending')` 445 ) 446 .run(blockedId); 447 const task = getTask('check_blocked_tasks'); 448 await agent.processTask(task); 449 const result = JSON.parse( 450 sharedDb.prepare('SELECT result_json FROM agent_tasks WHERE id = ?').get(task.id).result_json 451 ); 452 assert.equal(result.triage_created, 0); 453 }); 454 455 test('ignores tasks blocked less than 2 hours ago', async () => { 456 clearTables(); 457 458 sharedDb 459 .prepare( 460 `INSERT INTO agent_tasks (task_type, assigned_to, status, created_at) 461 VALUES ('fix_bug', 'developer', 'blocked', datetime('now', '-30 minutes'))` 462 ) 463 .run(); 464 const task = getTask('check_blocked_tasks'); 465 await agent.processTask(task); 466 const result = JSON.parse( 467 sharedDb.prepare('SELECT result_json FROM agent_tasks WHERE id = ?').get(task.id).result_json 468 ); 469 assert.equal(result.total_blocked, 0); 470 }); 471 }); 472 473 describe('MonitorAgent - checkAgentHealth', () => { 474 test('completes with no task history in DB', async () => { 475 clearTables(); 476 477 const task = getTask('check_agent_health'); 478 await agent.processTask(task); 479 const updated = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(task.id); 480 assert.equal(updated.status, 'completed'); 481 }); 482 483 test('blocks developer agent with >30% failure rate', async () => { 484 clearTables(); 485 486 for (let i = 0; i < 10; i++) { 487 sharedDb 488 .prepare( 489 `INSERT INTO agent_tasks (task_type, assigned_to, status, created_at) VALUES ('fix_bug', 'developer', 'failed', datetime('now', '-1 hours'))` 490 ) 491 .run(); 492 } 493 for (let i = 0; i < 2; i++) { 494 sharedDb 495 .prepare( 496 `INSERT INTO agent_tasks (task_type, assigned_to, status, created_at) VALUES ('fix_bug', 'developer', 'completed', datetime('now', '-2 hours'))` 497 ) 498 .run(); 499 } 500 const task = getTask('check_agent_health'); 501 await agent.processTask(task); 502 const devState = sharedDb 503 .prepare(`SELECT * FROM agent_state WHERE agent_name = 'developer'`) 504 .get(); 505 assert.equal(devState.status, 'blocked'); 506 }); 507 508 test('does not block agent with healthy failure rate (<30%)', async () => { 509 clearTables(); 510 511 for (let i = 0; i < 8; i++) { 512 sharedDb 513 .prepare( 514 `INSERT INTO agent_tasks (task_type, assigned_to, status, created_at) VALUES ('fix_bug', 'developer', 'completed', datetime('now', '-1 hours'))` 515 ) 516 .run(); 517 } 518 sharedDb 519 .prepare( 520 `INSERT INTO agent_tasks (task_type, assigned_to, status, created_at) VALUES ('fix_bug', 'developer', 'failed', datetime('now', '-1 hours'))` 521 ) 522 .run(); 523 const task = getTask('check_agent_health'); 524 await agent.processTask(task); 525 const devState = sharedDb 526 .prepare(`SELECT * FROM agent_state WHERE agent_name = 'developer'`) 527 .get(); 528 assert.notEqual(devState.status, 'blocked'); 529 }); 530 }); 531 532 describe('MonitorAgent - ensureRecurringTasks', () => { 533 test('creates all 6 recurring task types when none exist', async () => { 534 clearTables(); 535 536 await agent.ensureRecurringTasks(); 537 for (const taskType of [ 538 'scan_logs', 539 'check_agent_health', 540 'check_process_compliance', 541 'detect_anomaly', 542 'check_pipeline_health', 543 'check_slo_compliance', 544 ]) { 545 const created = sharedDb 546 .prepare( 547 `SELECT * FROM agent_tasks WHERE assigned_to = 'monitor' AND task_type = ? AND status IN ('pending', 'running')` 548 ) 549 .get(taskType); 550 assert.ok(created, `Should create recurring task: ${taskType}`); 551 } 552 }); 553 554 test('does not duplicate when pending task already exists', async () => { 555 clearTables(); 556 557 sharedDb 558 .prepare( 559 `INSERT INTO agent_tasks (task_type, assigned_to, status, priority, context_json) VALUES ('scan_logs', 'monitor', 'pending', 5, '{}')` 560 ) 561 .run(); 562 await agent.ensureRecurringTasks(); 563 const { cnt } = sharedDb 564 .prepare( 565 `SELECT COUNT(*) as cnt FROM agent_tasks WHERE assigned_to = 'monitor' AND task_type = 'scan_logs' AND status = 'pending'` 566 ) 567 .get(); 568 assert.equal(cnt, 1); 569 }); 570 }); 571 572 describe('MonitorAgent - processTask routing', () => { 573 test('delegates implement_feature away from monitor', async () => { 574 clearTables(); 575 576 const task = getTask('implement_feature', { description: 'Test' }); 577 await agent.processTask(task); 578 const updated = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(task.id); 579 // Original task is completed with delegation info 580 assert.equal(updated.status, 'completed'); 581 const result = JSON.parse(updated.result_json || '{}'); 582 assert.equal(result.delegated, true, 'Task should be marked as delegated'); 583 // A new task should be created for the correct agent (developer) 584 const delegated = sharedDb 585 .prepare( 586 `SELECT * FROM agent_tasks WHERE task_type = 'implement_feature' AND assigned_to != 'monitor' ORDER BY id DESC LIMIT 1` 587 ) 588 .get(); 589 assert.ok(delegated, 'A delegated task should exist for another agent'); 590 }); 591 592 test('parses string context_json without throwing', async () => { 593 clearTables(); 594 595 const r = sharedDb 596 .prepare( 597 `INSERT INTO agent_tasks (task_type, assigned_to, priority, context_json, status) VALUES ('check_loops', 'monitor', 5, '{"test":true}', 'running')` 598 ) 599 .run(); 600 const task = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(r.lastInsertRowid); 601 await agent.processTask(task); 602 const updated = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(task.id); 603 assert.equal(updated.status, 'completed'); 604 }); 605 }); 606 607 describe('MonitorAgent - checkSLOCompliance', () => { 608 test('completes with total_slos, violations, compliance_rate fields', async () => { 609 clearTables(); 610 resetSLODb(); 611 612 const task = getTask('check_slo_compliance'); 613 await agent.processTask(task); 614 const updated = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(task.id); 615 assert.equal(updated.status, 'completed'); 616 const result = JSON.parse(updated.result_json); 617 assert.ok(typeof result.total_slos === 'number'); 618 assert.ok(typeof result.violations === 'number'); 619 assert.ok(typeof result.compliance_rate === 'number'); 620 }); 621 }); 622 623 describe('MonitorAgent - checkPipelineHealth', () => { 624 test('completes successfully with empty DB', async () => { 625 clearTables(); 626 627 const task = getTask('check_pipeline_health'); 628 await agent.processTask(task); 629 const updated = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(task.id); 630 assert.equal(updated.status, 'completed'); 631 }); 632 }); 633 634 describe('MonitorAgent - scan_logs', () => { 635 test('completes with total_errors and loops_detected fields', async () => { 636 clearTables(); 637 638 const task = getTask('scan_logs', { days: 1 }); 639 await agent.processTask(task); 640 const updated = sharedDb.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(task.id); 641 assert.equal(updated.status, 'completed'); 642 const result = JSON.parse(updated.result_json); 643 // Verify result has the expected shape (actual count depends on log files present) 644 assert.ok(typeof result.total_errors === 'number', 'total_errors should be a number'); 645 assert.ok(typeof result.loops_detected === 'number', 'loops_detected should be a number'); 646 assert.ok(result.total_errors >= 0, 'total_errors should be non-negative'); 647 }); 648 });