/ __quarantined_tests__ / agents / monitor-agent-unit.test.js
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  });