task-manager-coverage2.test.js
1 /** 2 * task-manager-coverage2.test.js 3 * 4 * Covers previously uncovered paths in src/agents/utils/task-manager.js: 5 * - getTaskById with malformed context_json (lines 500-511) 6 * - getTaskById with malformed result_json (lines 500-511) 7 * - spawnAgentAsync pool-limit path (lines 670-689) 8 * - spawnAgentAsync rate-limit active path (lines 691-707) 9 * - spawnAgentAsync full spawn path (lines 708-738) 10 * 11 * Uses AGENT_SPAWN_LOAD_THRESHOLD=10 to bypass the CPU load guard so 12 * subsequent branches are reachable in CI/test environments. 13 */ 14 15 import { test, describe, beforeEach, afterEach } from 'node:test'; 16 import assert from 'node:assert/strict'; 17 import Database from 'better-sqlite3'; 18 import { mkdtempSync, rmSync } from 'fs'; 19 import { tmpdir } from 'os'; 20 import { join } from 'path'; 21 22 import { 23 getTaskById, 24 spawnAgentAsync, 25 resetDb, 26 resetDbConnection, 27 } from '../../src/agents/utils/task-manager.js'; 28 29 // ─── Schema ─────────────────────────────────────────────────────────────────── 30 31 const SCHEMA = ` 32 CREATE TABLE agent_tasks ( 33 id INTEGER PRIMARY KEY AUTOINCREMENT, 34 task_type TEXT NOT NULL, 35 assigned_to TEXT NOT NULL, 36 created_by TEXT, 37 status TEXT DEFAULT 'pending', 38 priority INTEGER DEFAULT 5, 39 context_json TEXT, 40 result_json TEXT, 41 parent_task_id INTEGER, 42 error_message TEXT, 43 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 44 started_at DATETIME, 45 completed_at DATETIME, 46 retry_count INTEGER DEFAULT 0 47 ); 48 CREATE TABLE agent_logs ( 49 id INTEGER PRIMARY KEY AUTOINCREMENT, 50 task_id INTEGER, 51 agent_name TEXT NOT NULL, 52 log_level TEXT, 53 message TEXT NOT NULL, 54 data_json TEXT, 55 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 56 ); 57 CREATE TABLE agent_state ( 58 agent_name TEXT PRIMARY KEY, 59 status TEXT DEFAULT 'idle', 60 current_task_id INTEGER, 61 last_heartbeat DATETIME, 62 last_active DATETIME, 63 config_json TEXT 64 ); 65 CREATE TABLE cron_locks ( 66 lock_key TEXT PRIMARY KEY, 67 description TEXT, 68 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 69 ); 70 `; 71 72 // ─── Lifecycle ──────────────────────────────────────────────────────────────── 73 74 let testDir; 75 let dbPath; 76 let db; 77 78 beforeEach(() => { 79 testDir = mkdtempSync(join(tmpdir(), 'tm-cov2-')); 80 dbPath = join(testDir, 'test.db'); 81 db = new Database(dbPath); 82 db.pragma('foreign_keys = OFF'); 83 db.exec(SCHEMA); 84 85 process.env.DATABASE_PATH = dbPath; 86 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 87 // Override CPU load threshold so load guard never triggers in test environment 88 process.env.AGENT_SPAWN_LOAD_THRESHOLD = '10'; 89 90 resetDb(); 91 resetDbConnection(); 92 }); 93 94 afterEach(() => { 95 delete process.env.AGENT_SPAWN_LOAD_THRESHOLD; 96 delete process.env.DATABASE_PATH; 97 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 98 try { 99 db.close(); 100 } catch { 101 /* already closed */ 102 } 103 resetDb(); 104 resetDbConnection(); 105 try { 106 rmSync(testDir, { recursive: true }); 107 } catch { 108 /* ignore cleanup errors */ 109 } 110 }); 111 112 // ─── getTaskById: malformed JSON paths ─────────────────────────────────────── 113 114 describe('getTaskById - malformed JSON (lines 500-511)', () => { 115 test('returns null context_json when stored JSON is malformed', () => { 116 const r = db 117 .prepare( 118 "INSERT INTO agent_tasks (task_type, assigned_to, context_json) VALUES ('fix_bug', 'developer', 'invalid{json')" 119 ) 120 .run(); 121 const task = getTaskById(r.lastInsertRowid); 122 assert.ok(task !== null, 'task should be found by ID'); 123 assert.strictEqual(task.context_json, null, 'malformed context_json should become null'); 124 }); 125 126 test('returns null result_json when stored JSON is malformed', () => { 127 const r = db 128 .prepare( 129 "INSERT INTO agent_tasks (task_type, assigned_to, result_json) VALUES ('run_tests', 'qa', '{bad:json:')" 130 ) 131 .run(); 132 const task = getTaskById(r.lastInsertRowid); 133 assert.ok(task !== null, 'task should be found by ID'); 134 assert.strictEqual(task.result_json, null, 'malformed result_json should become null'); 135 }); 136 137 test('returns null context_json and null result_json when both are malformed', () => { 138 const r = db 139 .prepare( 140 "INSERT INTO agent_tasks (task_type, assigned_to, context_json, result_json) VALUES ('fix_bug', 'developer', 'not-json', '[bad')" 141 ) 142 .run(); 143 const task = getTaskById(r.lastInsertRowid); 144 assert.strictEqual(task.context_json, null); 145 assert.strictEqual(task.result_json, null); 146 }); 147 148 test('parses valid JSON normally (regression check)', () => { 149 const r = db 150 .prepare( 151 "INSERT INTO agent_tasks (task_type, assigned_to, context_json, result_json) VALUES ('fix_bug', 'developer', '{\"key\":1}', '{\"ok\":true}')" 152 ) 153 .run(); 154 const task = getTaskById(r.lastInsertRowid); 155 assert.deepStrictEqual(task.context_json, { key: 1 }); 156 assert.deepStrictEqual(task.result_json, { ok: true }); 157 }); 158 }); 159 160 // ─── spawnAgentAsync: pool-limit path (lines 670-689) ──────────────────────── 161 162 describe('spawnAgentAsync - pool limit path (lines 670-689)', () => { 163 test('returns early when agent pool is at limit (runningCount >= typeMax)', () => { 164 // 'security' has no POOL_DEFAULTS entry → typeMax = 1 165 // Insert one active security agent → runningCount=1 >= typeMax=1 → pool full 166 db.prepare( 167 "INSERT INTO agent_state (agent_name, status, last_active) VALUES ('security', 'working', datetime('now'))" 168 ).run(); 169 170 // Should not throw — pool-limit branch returns silently 171 assert.doesNotThrow(() => spawnAgentAsync('security', 42)); 172 }); 173 174 test('pool limit with triage agent (typeMax=1)', () => { 175 db.prepare( 176 "INSERT INTO agent_state (agent_name, status, last_active) VALUES ('triage', 'working', datetime('now'))" 177 ).run(); 178 179 assert.doesNotThrow(() => spawnAgentAsync('triage', 43)); 180 }); 181 182 test('pool limit with architect agent (typeMax=1)', () => { 183 db.prepare( 184 "INSERT INTO agent_state (agent_name, status, last_active) VALUES ('architect', 'working', datetime('now'))" 185 ).run(); 186 187 assert.doesNotThrow(() => spawnAgentAsync('architect', 44)); 188 }); 189 }); 190 191 // ─── spawnAgentAsync: rate-limit path (lines 691-707) ──────────────────────── 192 193 describe('spawnAgentAsync - rate limit path (lines 691-707)', () => { 194 test('returns early when global spawn rate limit is active', () => { 195 // Insert a very recent spawn record (within 60 seconds) 196 db.prepare( 197 "INSERT INTO cron_locks (lock_key, updated_at) VALUES ('agent_spawn_ratelimit', datetime('now'))" 198 ).run(); 199 200 // No agent_state for triage → pool OK; rate limit triggers early return 201 assert.doesNotThrow(() => spawnAgentAsync('triage', 50)); 202 }); 203 204 test('rate limit with fresh spawn record (5 seconds ago)', () => { 205 db.prepare( 206 "INSERT INTO cron_locks (lock_key, updated_at) VALUES ('agent_spawn_ratelimit', datetime('now', '-5 seconds'))" 207 ).run(); 208 209 assert.doesNotThrow(() => spawnAgentAsync('security', 51)); 210 }); 211 }); 212 213 // ─── spawnAgentAsync: full spawn path (lines 708-738) ──────────────────────── 214 215 describe('spawnAgentAsync - full spawn path (lines 708-738)', () => { 216 test('proceeds to spawn when load OK, pool OK, and no rate limit', () => { 217 // No agent_state entry for 'monitor' → runningCount=0 < typeMax=1 → pool OK 218 // No cron_locks entry → rate limit OK 219 // AGENT_SPAWN_LOAD_THRESHOLD=10 → load check passes 220 221 // Should not throw — spawn call is made (child runs asynchronously) 222 assert.doesNotThrow(() => spawnAgentAsync('monitor', 60)); 223 }); 224 225 test('proceeds to spawn when rate limit is expired (>60 seconds old)', () => { 226 // Old rate limit record (expired) 227 db.prepare( 228 "INSERT INTO cron_locks (lock_key, updated_at) VALUES ('agent_spawn_ratelimit', datetime('now', '-120 seconds'))" 229 ).run(); 230 231 // Rate limit expired → should proceed to actual spawn 232 assert.doesNotThrow(() => spawnAgentAsync('triage', 61)); 233 }); 234 235 test('spawn path with developer agent (typeMax=3, pool available)', () => { 236 // developer has typeMax=3, no entries → runningCount=0 < 3 → pool OK 237 // No rate limit → proceed to spawn 238 assert.doesNotThrow(() => spawnAgentAsync('developer', 62)); 239 }); 240 241 test('spawn path with qa agent (typeMax=3, one running)', () => { 242 // qa has typeMax=3 243 // Insert one active qa entry → runningCount=1 < 3 → pool OK 244 db.prepare( 245 "INSERT INTO agent_state (agent_name, status, last_active) VALUES ('qa', 'working', datetime('now'))" 246 ).run(); 247 248 assert.doesNotThrow(() => spawnAgentAsync('qa', 63)); 249 }); 250 });