/ src / lib / server / runtime / scheduler.test.ts
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  })