daemon-state.test.ts
1 import assert from 'node:assert/strict' 2 import fs from 'node:fs' 3 import os from 'node:os' 4 import path from 'node:path' 5 import { after, before, describe, it } from 'node:test' 6 7 const originalEnv = { 8 DATA_DIR: process.env.DATA_DIR, 9 WORKSPACE_DIR: process.env.WORKSPACE_DIR, 10 SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE, 11 SWARMCLAW_DAEMON_AUTOSTART: process.env.SWARMCLAW_DAEMON_AUTOSTART, 12 SWARMCLAW_DAEMON_BACKGROUND_SERVICES: process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES, 13 } 14 15 let tempDir = '' 16 let mod: typeof import('@/lib/server/runtime/daemon-state') 17 18 before(async () => { 19 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-daemon-state-')) 20 process.env.DATA_DIR = path.join(tempDir, 'data') 21 process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace') 22 process.env.SWARMCLAW_BUILD_MODE = '1' 23 process.env.SWARMCLAW_DAEMON_AUTOSTART = '0' 24 mod = await import('@/lib/server/runtime/daemon-state') 25 }) 26 27 after(async () => { 28 try { await mod.stopDaemon({ source: 'test-cleanup' }) } catch { /* ignore */ } 29 for (const [key, val] of Object.entries(originalEnv)) { 30 if (val === undefined) delete process.env[key] 31 else process.env[key] = val 32 } 33 fs.rmSync(tempDir, { recursive: true, force: true }) 34 }) 35 36 // ── shouldNotifyProviderReachabilityIssue ──────────────────────────────── 37 38 describe('shouldNotifyProviderReachabilityIssue', () => { 39 it('returns false for openclaw provider', () => { 40 assert.equal(mod.shouldNotifyProviderReachabilityIssue('openclaw'), false) 41 }) 42 43 it('returns true for other providers', () => { 44 assert.equal(mod.shouldNotifyProviderReachabilityIssue('openai'), true) 45 assert.equal(mod.shouldNotifyProviderReachabilityIssue('anthropic'), true) 46 assert.equal(mod.shouldNotifyProviderReachabilityIssue('ollama'), true) 47 }) 48 }) 49 50 // ── shouldSuppressSessionHeartbeatHealthAlert ─────────────────────────── 51 52 describe('shouldSuppressSessionHeartbeatHealthAlert', () => { 53 it('suppresses workbench user sessions', () => { 54 assert.equal( 55 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'My Chat', user: 'workbench', shortcutForAgentId: undefined }), 56 true, 57 ) 58 }) 59 60 it('suppresses comparison-bench user sessions', () => { 61 assert.equal( 62 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'My Chat', user: 'comparison-bench', shortcutForAgentId: undefined }), 63 true, 64 ) 65 }) 66 67 it('suppresses sessions with wb- prefix in id', () => { 68 assert.equal( 69 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 'wb-test-123', name: 'My Chat', user: 'human', shortcutForAgentId: undefined }), 70 true, 71 ) 72 }) 73 74 it('suppresses sessions with cmp- prefix in id', () => { 75 assert.equal( 76 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 'cmp-test-456', name: 'My Chat', user: 'human', shortcutForAgentId: undefined }), 77 true, 78 ) 79 }) 80 81 it('suppresses sessions with wb- prefix in shortcutForAgentId', () => { 82 assert.equal( 83 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'My Chat', user: 'human', shortcutForAgentId: 'wb-agent' }), 84 true, 85 ) 86 }) 87 88 it('suppresses sessions named "workbench ..."', () => { 89 assert.equal( 90 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Workbench test run', user: 'human', shortcutForAgentId: undefined }), 91 true, 92 ) 93 }) 94 95 it('suppresses sessions named "assistant benchmark ..."', () => { 96 assert.equal( 97 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Assistant Benchmark v2', user: 'human', shortcutForAgentId: undefined }), 98 true, 99 ) 100 }) 101 102 it('suppresses sessions named "comparison ..."', () => { 103 assert.equal( 104 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Comparison run', user: 'human', shortcutForAgentId: undefined }), 105 true, 106 ) 107 }) 108 109 it('does not suppress normal sessions', () => { 110 assert.equal( 111 mod.shouldSuppressSessionHeartbeatHealthAlert({ id: 's1', name: 'Daily standup', user: 'admin', shortcutForAgentId: undefined }), 112 false, 113 ) 114 }) 115 116 it('handles null/undefined user gracefully', () => { 117 assert.equal( 118 mod.shouldSuppressSessionHeartbeatHealthAlert({ 119 id: 's1', 120 name: 'Chat', 121 user: undefined as unknown as string, 122 shortcutForAgentId: undefined, 123 }), 124 false, 125 ) 126 }) 127 }) 128 129 // ── shouldSuppressSyntheticAgentHealthAlert ────────────────────────────── 130 131 describe('shouldSuppressSyntheticAgentHealthAlert', () => { 132 it('suppresses wb- prefix agents', () => { 133 assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('wb-test-agent'), true) 134 }) 135 136 it('suppresses cmp- prefix agents', () => { 137 assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('cmp-benchmark-agent'), true) 138 }) 139 140 it('does not suppress normal agents', () => { 141 assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('my-agent'), false) 142 }) 143 144 it('is case-insensitive for prefix matching', () => { 145 assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('WB-uppercase'), true) 146 assert.equal(mod.shouldSuppressSyntheticAgentHealthAlert('CMP-upper'), true) 147 }) 148 }) 149 150 // ── buildSessionHeartbeatHealthDedupKey ────────────────────────────────── 151 152 describe('buildSessionHeartbeatHealthDedupKey', () => { 153 it('builds key for stale state', () => { 154 assert.equal( 155 mod.buildSessionHeartbeatHealthDedupKey('session-abc', 'stale'), 156 'health-alert:session-heartbeat:stale:session-abc', 157 ) 158 }) 159 160 it('builds key for auto-disabled state', () => { 161 assert.equal( 162 mod.buildSessionHeartbeatHealthDedupKey('session-xyz', 'auto-disabled'), 163 'health-alert:session-heartbeat:auto-disabled:session-xyz', 164 ) 165 }) 166 167 it('includes session id in key', () => { 168 const key = mod.buildSessionHeartbeatHealthDedupKey('unique-id-42', 'stale') 169 assert.ok(key.includes('unique-id-42')) 170 }) 171 }) 172 173 // ── isDaemonBackgroundServicesEnabled ──────────────────────────────────── 174 175 describe('isDaemonBackgroundServicesEnabled', () => { 176 it('defaults to true when env var is not set', () => { 177 const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 178 delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 179 try { 180 assert.equal(mod.isDaemonBackgroundServicesEnabled(), true) 181 } finally { 182 if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved 183 } 184 }) 185 186 it('returns false when env var is "false"', () => { 187 const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 188 process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = 'false' 189 try { 190 assert.equal(mod.isDaemonBackgroundServicesEnabled(), false) 191 } finally { 192 if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved 193 else delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 194 } 195 }) 196 197 it('returns true when env var is "true"', () => { 198 const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 199 process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = 'true' 200 try { 201 assert.equal(mod.isDaemonBackgroundServicesEnabled(), true) 202 } finally { 203 if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved 204 else delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 205 } 206 }) 207 208 it('returns false when env var is "0"', () => { 209 const saved = process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 210 process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = '0' 211 try { 212 assert.equal(mod.isDaemonBackgroundServicesEnabled(), false) 213 } finally { 214 if (saved !== undefined) process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = saved 215 else delete process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES 216 } 217 }) 218 }) 219 220 // ── ensureDaemonStarted ───────────────────────────────────────────────── 221 222 describe('ensureDaemonStarted', () => { 223 it('returns false when autostart is disabled', () => { 224 assert.equal(mod.ensureDaemonStarted('test'), false) 225 }) 226 }) 227 228 // ── startDaemon / stopDaemon / getDaemonStatus ────────────────────────── 229 230 describe('daemon start/stop lifecycle', () => { 231 it('getDaemonStatus shows not running initially', async () => { 232 await mod.stopDaemon({ source: 'test' }) 233 const status = mod.getDaemonStatus() 234 assert.equal(status.running, false) 235 }) 236 237 it('startDaemon sets running to true', async () => { 238 mod.startDaemon({ source: 'test', manualStart: true }) 239 try { 240 const status = mod.getDaemonStatus() 241 assert.equal(status.running, true) 242 assert.equal(status.schedulerActive, true) 243 } finally { 244 await mod.stopDaemon({ source: 'test' }) 245 } 246 }) 247 248 it('stopDaemon sets running to false', async () => { 249 mod.startDaemon({ source: 'test', manualStart: true }) 250 await mod.stopDaemon({ source: 'test' }) 251 const status = mod.getDaemonStatus() 252 assert.equal(status.running, false) 253 }) 254 255 it('double startDaemon does not throw', async () => { 256 mod.startDaemon({ source: 'test', manualStart: true }) 257 try { 258 assert.doesNotThrow(() => mod.startDaemon({ source: 'test-again' })) 259 const status = mod.getDaemonStatus() 260 assert.equal(status.running, true) 261 } finally { 262 await mod.stopDaemon({ source: 'test' }) 263 } 264 }) 265 266 it('does not start when another process holds the daemon lease', async () => { 267 const storage = await import('@/lib/server/storage') 268 assert.equal(storage.tryAcquireRuntimeLock('daemon-primary', 'other-process', 60_000), true) 269 try { 270 const started = mod.startDaemon({ source: 'test-lock', manualStart: true }) 271 assert.equal(started, false) 272 assert.equal(mod.getDaemonStatus().running, false) 273 } finally { 274 storage.releaseRuntimeLock('daemon-primary', 'other-process') 275 await mod.stopDaemon({ source: 'test-lock-cleanup' }) 276 } 277 }) 278 279 it('manualStop prevents ensureDaemonStarted from restarting', async () => { 280 const saved = process.env.SWARMCLAW_DAEMON_AUTOSTART 281 process.env.SWARMCLAW_DAEMON_AUTOSTART = '1' 282 try { 283 mod.startDaemon({ source: 'test', manualStart: true }) 284 await mod.stopDaemon({ source: 'test', manualStop: true }) 285 const started = mod.ensureDaemonStarted('test') 286 assert.equal(started, false) 287 assert.equal(mod.getDaemonStatus().running, false) 288 } finally { 289 await mod.stopDaemon({ source: 'cleanup' }) 290 if (saved !== undefined) process.env.SWARMCLAW_DAEMON_AUTOSTART = saved 291 else delete process.env.SWARMCLAW_DAEMON_AUTOSTART 292 } 293 }) 294 295 it('getDaemonStatus includes heartbeat and health info', async () => { 296 mod.startDaemon({ source: 'test', manualStart: true }) 297 try { 298 const status = mod.getDaemonStatus() 299 assert.ok('heartbeat' in status) 300 assert.ok('health' in status) 301 assert.ok('queueLength' in status) 302 assert.ok('autostartEnabled' in status) 303 assert.ok('backgroundServicesEnabled' in status) 304 } finally { 305 await mod.stopDaemon({ source: 'test' }) 306 } 307 }) 308 309 it('stopDaemon is idempotent', async () => { 310 await mod.stopDaemon({ source: 'first' }) 311 await assert.doesNotReject(() => mod.stopDaemon({ source: 'second' })) 312 assert.equal(mod.getDaemonStatus().running, false) 313 }) 314 })