scheduler.test.ts
1 import fs from 'node:fs' 2 import os from 'node:os' 3 import path from 'node:path' 4 import { spawnSync } from 'node:child_process' 5 import assert from 'node:assert/strict' 6 import { describe, it } from 'node:test' 7 8 import { 9 resolveScheduleWakeSessionIdForTests, 10 shouldWakeScheduleSessionForTests, 11 } from '@/lib/server/runtime/scheduler' 12 13 const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..') 14 15 function runSchedulerWithTempDataDir(script: string) { 16 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-scheduler-test-')) 17 try { 18 const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], { 19 cwd: repoRoot, 20 env: { 21 ...process.env, 22 DATA_DIR: path.join(tempDir, 'data'), 23 WORKSPACE_DIR: path.join(tempDir, 'workspace'), 24 SWARMCLAW_BUILD_MODE: '1', 25 }, 26 encoding: 'utf-8', 27 }) 28 assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed') 29 const lines = (result.stdout || '') 30 .trim() 31 .split('\n') 32 .map((line) => line.trim()) 33 .filter(Boolean) 34 const jsonLine = [...lines].reverse().find((line) => line.startsWith('{')) 35 return JSON.parse(jsonLine || '{}') 36 } finally { 37 fs.rmSync(tempDir, { recursive: true, force: true }) 38 } 39 } 40 41 describe('scheduler wake targeting', () => { 42 it('prefers the originating session for schedule wakes', () => { 43 const sessionId = resolveScheduleWakeSessionIdForTests({ 44 id: 'sched-1', 45 name: 'Morning reminder', 46 agentId: 'agent-1', 47 taskPrompt: 'Remind me', 48 scheduleType: 'once', 49 status: 'active', 50 createdInSessionId: 'session-owner', 51 createdAt: Date.now(), 52 }, { 53 'agent-1': { 54 id: 'agent-1', 55 threadSessionId: 'thread-main', 56 }, 57 }) 58 59 assert.equal(sessionId, 'session-owner') 60 }) 61 62 it('falls back to the agent thread session when the originating session is missing', () => { 63 const sessionId = resolveScheduleWakeSessionIdForTests({ 64 id: 'sched-2', 65 name: 'Morning reminder', 66 agentId: 'agent-1', 67 taskPrompt: 'Remind me', 68 scheduleType: 'once', 69 status: 'active', 70 createdAt: Date.now(), 71 }, { 72 'agent-1': { 73 id: 'agent-1', 74 threadSessionId: 'thread-main', 75 }, 76 }) 77 78 assert.equal(sessionId, 'thread-main') 79 }) 80 81 it('only wakes sessions for wake-only schedules', () => { 82 assert.equal( 83 shouldWakeScheduleSessionForTests({ 84 id: 'sched-task', 85 name: 'Queued follow-up', 86 agentId: 'agent-1', 87 taskPrompt: 'Do the work', 88 scheduleType: 'once', 89 status: 'active', 90 taskMode: 'task', 91 createdAt: Date.now(), 92 }), 93 false, 94 ) 95 96 assert.equal( 97 shouldWakeScheduleSessionForTests({ 98 id: 'sched-wake', 99 name: 'Wake me up', 100 agentId: 'agent-1', 101 taskPrompt: 'Nudge the agent', 102 scheduleType: 'once', 103 status: 'active', 104 taskMode: 'wake_only', 105 createdAt: Date.now(), 106 }), 107 true, 108 ) 109 }) 110 111 it('keeps wake-only schedule runs out of the creator chat transcript', () => { 112 const output = runSchedulerWithTempDataDir(` 113 const storageMod = await import('@/lib/server/storage') 114 const schedulerMod = await import('@/lib/server/runtime/scheduler') 115 const systemEventsMod = await import('@/lib/server/runtime/system-events') 116 const heartbeatWakeMod = await import('@/lib/server/runtime/heartbeat-wake') 117 const storage = storageMod.default || storageMod 118 const scheduler = schedulerMod.default || schedulerMod 119 const systemEvents = systemEventsMod.default || systemEventsMod 120 const heartbeatWake = heartbeatWakeMod.default || heartbeatWakeMod 121 122 const now = Date.now() 123 const workspace = process.env.WORKSPACE_DIR 124 125 storage.saveAgents({ 126 'agent-1': { 127 id: 'agent-1', 128 name: 'Reminder Bot', 129 description: '', 130 systemPrompt: '', 131 provider: 'openai', 132 model: 'gpt-test', 133 threadSessionId: 'thread-main', 134 createdAt: now, 135 updatedAt: now, 136 }, 137 }) 138 139 storage.saveSessions({ 140 'session-owner': { 141 id: 'session-owner', 142 name: 'Owner Chat', 143 cwd: workspace, 144 user: 'tester', 145 provider: 'openai', 146 model: 'gpt-test', 147 claudeSessionId: null, 148 messages: [], 149 createdAt: now - 10_000, 150 lastActiveAt: now - 5_000, 151 active: true, 152 currentRunId: null, 153 agentId: 'agent-1', 154 }, 155 'thread-main': { 156 id: 'thread-main', 157 name: 'Reminder Bot', 158 cwd: workspace, 159 user: 'tester', 160 provider: 'openai', 161 model: 'gpt-test', 162 claudeSessionId: null, 163 messages: [], 164 createdAt: now - 10_000, 165 lastActiveAt: now - 5_000, 166 active: true, 167 currentRunId: null, 168 agentId: 'agent-1', 169 shortcutForAgentId: 'agent-1', 170 }, 171 }) 172 173 storage.saveSchedules({ 174 'sched-wake': { 175 id: 'sched-wake', 176 name: 'Wake silently', 177 agentId: 'agent-1', 178 taskPrompt: 'Check the inbox', 179 scheduleType: 'once', 180 taskMode: 'wake_only', 181 status: 'active', 182 runAt: now - 1_000, 183 nextRunAt: now - 1_000, 184 createdInSessionId: 'session-owner', 185 createdAt: now - 10_000, 186 updatedAt: now - 10_000, 187 }, 188 }) 189 190 await scheduler.runSchedulerTickForTests(now) 191 const wakes = heartbeatWake.snapshotPendingHeartbeatWakesForTests() 192 193 console.log(JSON.stringify({ 194 ownerMessages: storage.loadSessions()['session-owner'].messages, 195 systemEvents: systemEvents.peekSystemEvents('session-owner'), 196 deliveryModes: wakes.map((wake) => heartbeatWake.deriveHeartbeatWakeDeliveryMode(wake.events)), 197 })) 198 `) 199 200 assert.deepEqual(output.ownerMessages, []) 201 assert.deepEqual(output.systemEvents, []) 202 assert.deepEqual(output.deliveryModes, ['silent']) 203 }) 204 205 it('reuses a persistent mission for scheduled task runs', () => { 206 const output = runSchedulerWithTempDataDir(` 207 const storageMod = await import('@/lib/server/storage') 208 const schedulerMod = await import('@/lib/server/runtime/scheduler') 209 const storage = storageMod.default || storageMod 210 const scheduler = schedulerMod.default || schedulerMod 211 212 const now = Date.now() 213 storage.saveAgents({ 214 'agent-1': { 215 id: 'agent-1', 216 name: 'Scheduler Agent', 217 provider: 'ollama', 218 model: 'test-model', 219 systemPrompt: 'test', 220 threadSessionId: 'thread-main', 221 }, 222 }) 223 224 storage.saveSessions({ 225 'thread-main': { 226 id: 'thread-main', 227 name: 'Thread Main', 228 cwd: process.env.WORKSPACE_DIR, 229 user: 'tester', 230 provider: 'ollama', 231 model: 'test-model', 232 messages: [], 233 createdAt: now - 10_000, 234 lastActiveAt: now - 5_000, 235 active: true, 236 currentRunId: null, 237 agentId: 'agent-1', 238 shortcutForAgentId: 'agent-1', 239 }, 240 }) 241 242 storage.saveSchedules({ 243 'sched-task': { 244 id: 'sched-task', 245 name: 'Generate nightly report', 246 agentId: 'agent-1', 247 taskPrompt: 'Generate the nightly report and summarize the changes.', 248 scheduleType: 'interval', 249 intervalMs: 60000, 250 status: 'active', 251 runAt: now - 1_000, 252 nextRunAt: now - 1_000, 253 createdInSessionId: 'thread-main', 254 createdAt: now - 10_000, 255 updatedAt: now - 10_000, 256 }, 257 }) 258 259 await scheduler.runSchedulerTickForTests(now) 260 const afterFirst = storage.loadSchedules()['sched-task'] 261 const taskId = afterFirst.linkedTaskId 262 const firstTask = storage.loadTasks()[taskId] 263 264 afterFirst.nextRunAt = now - 1_000 265 storage.upsertSchedule('sched-task', afterFirst) 266 if (firstTask) { 267 firstTask.status = 'completed' 268 firstTask.completedAt = now 269 firstTask.updatedAt = now 270 storage.upsertTask(taskId, firstTask) 271 } 272 273 await scheduler.runSchedulerTickForTests(now + 61_000) 274 const afterSecond = storage.loadSchedules()['sched-task'] 275 const secondTask = storage.loadTasks()[afterSecond.linkedTaskId] 276 277 console.log(JSON.stringify({ 278 linkedMissionIdFirst: afterFirst.linkedMissionId || null, 279 linkedMissionIdSecond: afterSecond.linkedMissionId || null, 280 firstTaskMissionId: firstTask?.missionId || null, 281 secondTaskMissionId: secondTask?.missionId || null, 282 })) 283 `) 284 285 assert.ok(output.linkedMissionIdFirst) 286 assert.equal(output.linkedMissionIdSecond, output.linkedMissionIdFirst) 287 assert.equal(output.firstTaskMissionId, output.linkedMissionIdFirst) 288 assert.equal(output.secondTaskMissionId, output.linkedMissionIdFirst) 289 }) 290 291 it('can launch a structured session run from protocol-mode schedules', () => { 292 const output = runSchedulerWithTempDataDir(` 293 const storageMod = await import('@/lib/server/storage') 294 const schedulerMod = await import('@/lib/server/runtime/scheduler') 295 const protocolsMod = await import('@/lib/server/protocols/protocol-service') 296 const storage = storageMod.default || storageMod 297 const scheduler = schedulerMod.default || schedulerMod 298 const protocols = protocolsMod.default || protocolsMod 299 300 const now = Date.now() 301 storage.saveAgents({ 302 'agent-1': { 303 id: 'agent-1', 304 name: 'Session Agent', 305 provider: 'ollama', 306 model: 'test-model', 307 systemPrompt: 'test', 308 createdAt: now, 309 updatedAt: now, 310 }, 311 }) 312 313 storage.saveProtocolTemplates({ 314 'sched-protocol-template': { 315 id: 'sched-protocol-template', 316 name: 'Scheduler Protocol Template', 317 description: 'A test-only complete-immediately structured session.', 318 builtIn: false, 319 singleAgentAllowed: true, 320 tags: ['test'], 321 recommendedOutputs: [], 322 defaultPhases: [], 323 steps: [ 324 { id: 'complete', kind: 'complete', label: 'Complete' }, 325 ], 326 entryStepId: 'complete', 327 createdAt: now, 328 updatedAt: now, 329 }, 330 }) 331 332 storage.saveSchedules({ 333 'sched-protocol': { 334 id: 'sched-protocol', 335 name: 'Run a structured check-in', 336 agentId: 'agent-1', 337 taskPrompt: 'Run a quick structured status pass.', 338 taskMode: 'protocol', 339 protocolTemplateId: 'sched-protocol-template', 340 scheduleType: 'once', 341 status: 'active', 342 runAt: now - 1000, 343 nextRunAt: now - 1000, 344 createdAt: now - 1000, 345 updatedAt: now - 1000, 346 }, 347 }) 348 349 await scheduler.runSchedulerTickForTests(now) 350 const runs = protocols.listProtocolRuns({ scheduleId: 'sched-protocol' }) 351 352 console.log(JSON.stringify({ 353 count: runs.length, 354 sourceKind: runs[0]?.sourceRef?.kind || null, 355 templateId: runs[0]?.templateId || null, 356 transcriptChatroomId: runs[0]?.transcriptChatroomId || null, 357 })) 358 `) 359 360 assert.equal(output.count, 1) 361 assert.equal(output.sourceKind, 'schedule') 362 assert.equal(output.templateId, 'sched-protocol-template') 363 assert.ok(output.transcriptChatroomId) 364 }) 365 })