@swarmclawai/swarmclaw 1.5.68 → 1.5.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,12 @@ import type {
25
25
  } from '@/lib/server/daemon/types'
26
26
  import { DATA_DIR } from '@/lib/server/data-dir'
27
27
  import { loadEstopState } from '@/lib/server/runtime/estop'
28
- import { getDaemonStatus } from '@/lib/server/runtime/daemon-state/core'
28
+ import {
29
+ getDaemonHealthSummary,
30
+ getDaemonStatus,
31
+ startDaemon,
32
+ stopDaemon,
33
+ } from '@/lib/server/runtime/daemon-state/core'
29
34
  import { daemonAutostartEnvEnabled } from '@/lib/server/runtime/daemon-policy'
30
35
  import {
31
36
  releaseRuntimeLock,
@@ -166,6 +171,15 @@ type DaemonSnapshotResponse = {
166
171
  healthSummary: DaemonHealthSummaryPayload
167
172
  }
168
173
 
174
+ function getInProcessDaemonSnapshot(): DaemonSnapshotResponse | null {
175
+ const status = getDaemonStatus()
176
+ if (!status.running) return null
177
+ return {
178
+ status,
179
+ healthSummary: getDaemonHealthSummary() as DaemonHealthSummaryPayload,
180
+ }
181
+ }
182
+
169
183
  async function requestDaemon<T>(
170
184
  metadata: DaemonAdminMetadata,
171
185
  routePath: string,
@@ -352,18 +366,19 @@ export async function ensureDaemonProcessRunning(
352
366
  source: string,
353
367
  opts?: { manualStart?: boolean },
354
368
  ): Promise<boolean> {
355
- // In dev mode, the daemon may already be running in-process (same Next.js server)
356
- // without a daemon-admin.json file. Check in-process state first to avoid spawning
357
- // a subprocess that fails to acquire the already-held lease.
358
- const inProcessStatus = getDaemonStatus()
359
- if (inProcessStatus.running) return false
360
-
361
369
  const manualStart = opts?.manualStart === true
362
370
  const record = loadDaemonStatusRecord()
363
371
  if (loadEstopState().level !== 'none') return false
364
372
  if (!manualStart && !daemonAutostartEnvEnabled()) return false
365
373
  if (!manualStart && record.manualStopRequested) return false
366
374
 
375
+ const inProcessSnapshot = getInProcessDaemonSnapshot()
376
+ if (inProcessSnapshot) return false
377
+
378
+ const startedInProcess = startDaemon({ source, manualStart })
379
+ if (startedInProcess) return true
380
+ if (getInProcessDaemonSnapshot()) return false
381
+
367
382
  const live = await getLiveDaemonSnapshot()
368
383
  if (live?.status.running) return false
369
384
 
@@ -448,6 +463,28 @@ export async function stopDaemonProcess(opts?: {
448
463
  const source = opts?.source || 'unknown'
449
464
  const manualStop = opts?.manualStop === true
450
465
  const metadata = readDaemonAdminMetadata()
466
+ const inProcessSnapshot = getInProcessDaemonSnapshot()
467
+
468
+ if (inProcessSnapshot && (!metadata || metadata.pid === process.pid || !isProcessRunning(metadata.pid))) {
469
+ await stopDaemon({ source, manualStop })
470
+ clearDaemonAdminMetadata()
471
+ patchDaemonStatusRecord((current) => ({
472
+ ...current,
473
+ pid: null,
474
+ adminPort: null,
475
+ desiredState: 'stopped',
476
+ manualStopRequested: manualStop ? true : current.manualStopRequested,
477
+ stoppedAt: now(),
478
+ updatedAt: now(),
479
+ lastStopSource: source,
480
+ lastStatus: {
481
+ ...getDaemonStatus(),
482
+ manualStopRequested: manualStop ? true : current.manualStopRequested,
483
+ },
484
+ lastHealthSummary: getDaemonHealthSummary() as DaemonHealthSummaryPayload,
485
+ }))
486
+ return true
487
+ }
451
488
 
452
489
  if (!metadata || !isProcessRunning(metadata.pid)) {
453
490
  clearDaemonAdminMetadata()
@@ -510,12 +547,16 @@ export async function stopDaemonProcess(opts?: {
510
547
  }
511
548
 
512
549
  export async function getDaemonStatusSnapshot(): Promise<DaemonStatusPayload> {
550
+ const inProcessSnapshot = getInProcessDaemonSnapshot()
551
+ if (inProcessSnapshot) return inProcessSnapshot.status
513
552
  const live = await getLiveDaemonSnapshot()
514
553
  if (live) return live.status
515
554
  return buildFallbackStatus()
516
555
  }
517
556
 
518
557
  export async function getDaemonHealthSummarySnapshot(): Promise<DaemonHealthSummaryPayload> {
558
+ const inProcessSnapshot = getInProcessDaemonSnapshot()
559
+ if (inProcessSnapshot) return inProcessSnapshot.healthSummary
519
560
  const live = await getLiveDaemonSnapshot()
520
561
  if (live) return live.healthSummary
521
562
  return buildFallbackHealthSummary()
@@ -523,6 +564,8 @@ export async function getDaemonHealthSummarySnapshot(): Promise<DaemonHealthSumm
523
564
 
524
565
  export async function runDaemonHealthCheckViaAdmin(source: string): Promise<DaemonSnapshotResponse> {
525
566
  await ensureDaemonProcessRunning(source, { manualStart: true })
567
+ const inProcessSnapshot = getInProcessDaemonSnapshot()
568
+ if (inProcessSnapshot) return inProcessSnapshot
526
569
  const metadata = readDaemonAdminMetadata()
527
570
  if (!metadata) {
528
571
  return {
@@ -0,0 +1,164 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
4
+
5
+ test('defaultExecuteAgentTurn surfaces execution-log errors instead of returning a blank structured response', () => {
6
+ const output = runWithTempDataDir<{ error: string | null }>(`
7
+ const storageMod = await import('./src/lib/server/storage')
8
+ const protocolsMod = await import('./src/lib/server/protocols/protocol-service')
9
+ const protocolTurnMod = await import('./src/lib/server/protocols/protocol-agent-turn')
10
+ const streamMod = await import('./src/lib/server/chat-execution/stream-agent-chat')
11
+ const executionLogMod = await import('./src/lib/server/execution-log')
12
+
13
+ const storage = storageMod.default || storageMod
14
+ const protocols = protocolsMod.default || protocolsMod
15
+ const { defaultExecuteAgentTurn } = protocolTurnMod.default || protocolTurnMod
16
+ const { setStreamAgentChatForTest } = streamMod.default || streamMod
17
+ const { logExecution } = executionLogMod.default || executionLogMod
18
+
19
+ storage.upsertStoredItem('agents', 'agentA', {
20
+ id: 'agentA',
21
+ name: 'Agent A',
22
+ provider: 'openai',
23
+ model: 'gpt-4.1',
24
+ systemPrompt: 'test',
25
+ createdAt: 1,
26
+ updatedAt: 1,
27
+ })
28
+
29
+ const run = protocols.createProtocolRun({
30
+ title: 'Credential failure run',
31
+ templateId: 'single_agent_structured_run',
32
+ participantAgentIds: ['agentA'],
33
+ facilitatorAgentId: 'agentA',
34
+ autoStart: false,
35
+ }, { now: () => 1000 })
36
+
37
+ setStreamAgentChatForTest(async (opts) => {
38
+ logExecution(opts.session.id, 'error', 'Missing credentials. Please pass an apiKey.', {
39
+ agentId: opts.session.agentId,
40
+ })
41
+ return { fullText: '', finalResponse: '', toolEvents: [] }
42
+ })
43
+
44
+ try {
45
+ await defaultExecuteAgentTurn({
46
+ run,
47
+ phase: { id: 'respond', label: 'Respond', kind: 'round_robin' },
48
+ agentId: 'agentA',
49
+ prompt: 'Say something useful.',
50
+ })
51
+ console.log(JSON.stringify({ error: null }))
52
+ } catch (error) {
53
+ console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
54
+ } finally {
55
+ setStreamAgentChatForTest(null)
56
+ }
57
+ `, { prefix: 'swarmclaw-protocol-agent-turn-error-' })
58
+
59
+ assert.match(String(output.error || ''), /missing credentials/i)
60
+ })
61
+
62
+ test('defaultExecuteAgentTurn rejects blank structured responses even without a logged error', () => {
63
+ const output = runWithTempDataDir<{ error: string | null }>(`
64
+ const storageMod = await import('./src/lib/server/storage')
65
+ const protocolsMod = await import('./src/lib/server/protocols/protocol-service')
66
+ const protocolTurnMod = await import('./src/lib/server/protocols/protocol-agent-turn')
67
+ const streamMod = await import('./src/lib/server/chat-execution/stream-agent-chat')
68
+
69
+ const storage = storageMod.default || storageMod
70
+ const protocols = protocolsMod.default || protocolsMod
71
+ const { defaultExecuteAgentTurn } = protocolTurnMod.default || protocolTurnMod
72
+ const { setStreamAgentChatForTest } = streamMod.default || streamMod
73
+
74
+ storage.upsertStoredItem('agents', 'agentA', {
75
+ id: 'agentA',
76
+ name: 'Agent A',
77
+ provider: 'openai',
78
+ model: 'gpt-4.1',
79
+ systemPrompt: 'test',
80
+ createdAt: 1,
81
+ updatedAt: 1,
82
+ })
83
+
84
+ const run = protocols.createProtocolRun({
85
+ title: 'Blank response run',
86
+ templateId: 'single_agent_structured_run',
87
+ participantAgentIds: ['agentA'],
88
+ facilitatorAgentId: 'agentA',
89
+ autoStart: false,
90
+ }, { now: () => 1000 })
91
+
92
+ setStreamAgentChatForTest(async () => ({ fullText: '', finalResponse: '', toolEvents: [] }))
93
+
94
+ try {
95
+ await defaultExecuteAgentTurn({
96
+ run,
97
+ phase: { id: 'summarize', label: 'Summarize', kind: 'summarize' },
98
+ agentId: 'agentA',
99
+ prompt: 'Summarize.',
100
+ })
101
+ console.log(JSON.stringify({ error: null }))
102
+ } catch (error) {
103
+ console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
104
+ } finally {
105
+ setStreamAgentChatForTest(null)
106
+ }
107
+ `, { prefix: 'swarmclaw-protocol-agent-turn-blank-' })
108
+
109
+ assert.match(String(output.error || ''), /no visible output/i)
110
+ })
111
+
112
+ test('defaultExecuteAgentTurn uses direct provider runtime for CLI providers', () => {
113
+ const output = runWithTempDataDir<{ text: string | null }>(`
114
+ const storageMod = await import('./src/lib/server/storage')
115
+ const protocolsMod = await import('./src/lib/server/protocols/protocol-service')
116
+ const protocolTurnMod = await import('./src/lib/server/protocols/protocol-agent-turn')
117
+ const providersMod = await import('./src/lib/providers/index')
118
+
119
+ const storage = storageMod.default || storageMod
120
+ const protocols = protocolsMod.default || protocolsMod
121
+ const { defaultExecuteAgentTurn } = protocolTurnMod.default || protocolTurnMod
122
+ const providers = providersMod.default || providersMod
123
+
124
+ storage.upsertStoredItem('agents', 'agentA', {
125
+ id: 'agentA',
126
+ name: 'Agent A',
127
+ provider: 'copilot-cli',
128
+ model: 'gpt-5.4',
129
+ systemPrompt: 'test',
130
+ createdAt: 1,
131
+ updatedAt: 1,
132
+ })
133
+
134
+ const originalHandler = providers.PROVIDERS['copilot-cli'].handler
135
+ providers.PROVIDERS['copilot-cli'].handler = {
136
+ streamChat: async (opts) => {
137
+ opts.write('data: ' + JSON.stringify({ t: 'd', text: 'Copilot CLI structured response.' }) + '\\n\\n')
138
+ return 'Copilot CLI structured response.'
139
+ },
140
+ }
141
+
142
+ const run = protocols.createProtocolRun({
143
+ title: 'CLI structured run',
144
+ templateId: 'single_agent_structured_run',
145
+ participantAgentIds: ['agentA'],
146
+ facilitatorAgentId: 'agentA',
147
+ autoStart: false,
148
+ }, { now: () => 1000 })
149
+
150
+ try {
151
+ const result = await defaultExecuteAgentTurn({
152
+ run,
153
+ phase: { id: 'respond', label: 'Respond', kind: 'round_robin' },
154
+ agentId: 'agentA',
155
+ prompt: 'Say something useful.',
156
+ })
157
+ console.log(JSON.stringify({ text: result.text }))
158
+ } finally {
159
+ providers.PROVIDERS['copilot-cli'].handler = originalHandler
160
+ }
161
+ `, { prefix: 'swarmclaw-protocol-agent-turn-cli-' })
162
+
163
+ assert.equal(output.text, 'Copilot CLI structured response.')
164
+ })
@@ -6,6 +6,8 @@ import { HumanMessage } from '@langchain/core/messages'
6
6
  import { z } from 'zod'
7
7
  import { log } from '@/lib/server/logger'
8
8
  import { genId } from '@/lib/id'
9
+ import { getProvider } from '@/lib/providers'
10
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
9
11
 
10
12
  const TAG = 'protocol-agent-turn'
11
13
  import type {
@@ -49,6 +51,7 @@ import type { ProtocolAgentTurnResult, ProtocolRunDeps } from '@/lib/server/prot
49
51
  import { normalizeProtocolRun } from '@/lib/server/protocols/protocol-normalization'
50
52
  import { persistChatroomInteractionMemory } from '@/lib/server/chatrooms/chatroom-memory-bridge'
51
53
  import { selectKnowledgeCitations } from '@/lib/server/knowledge-sources'
54
+ import { queryLogs } from '@/lib/server/execution-log'
52
55
 
53
56
  // ---- Zod schema ----
54
57
 
@@ -248,24 +251,26 @@ export async function defaultExecuteAgentTurn(params: {
248
251
  await new Promise((resolve) => setTimeout(resolve, delay))
249
252
  }
250
253
  try {
251
- const result = await Promise.race([
252
- streamAgentChat({
253
- session: syntheticSession,
254
- message: params.prompt,
255
- apiKey,
256
- systemPrompt: fullSystemPrompt,
257
- write: () => {},
258
- history: buildHistoryForAgent(chatroom, agent.id),
259
- }),
260
- new Promise<never>((_, reject) =>
261
- setTimeout(() => reject(new Error(`Agent turn timed out after ${AGENT_TURN_TIMEOUT_MS / 1000}s (agent: ${params.agentId})`)), AGENT_TURN_TIMEOUT_MS),
262
- ),
263
- ])
264
- const rawText = result.finalResponse || result.fullText || ''
265
- const text = stripHiddenControlTokens(rawText)
254
+ const turnStartedAt = Date.now()
255
+ const history = buildHistoryForAgent(chatroom, agent.id)
256
+ const result = await executeStructuredSessionTurnWithTimeout({
257
+ session: syntheticSession,
258
+ message: params.prompt,
259
+ apiKey,
260
+ systemPrompt: fullSystemPrompt,
261
+ history,
262
+ agentId: params.agentId,
263
+ })
264
+ const rawText = result.text || ''
265
+ const text = resolveStructuredSessionTurnText({
266
+ rawText,
267
+ sessionId: syntheticSession.id,
268
+ turnStartedAt,
269
+ agentId: params.agentId,
270
+ })
266
271
  const grounding = selectKnowledgeCitations({
267
272
  responseText: text,
268
- retrievalTrace: result.knowledgeRetrievalTrace || null,
273
+ retrievalTrace: result.retrievalTrace || null,
269
274
  })
270
275
  if (text.trim() && !shouldSuppressHiddenControlText(rawText)) {
271
276
  appendSyntheticSessionMessage(syntheticSession.id, 'assistant', text)
@@ -297,6 +302,104 @@ export async function defaultExecuteAgentTurn(params: {
297
302
  throw lastError
298
303
  }
299
304
 
305
+ async function executeStructuredSessionTurn(params: {
306
+ session: ReturnType<typeof ensureSyntheticSession>
307
+ message: string
308
+ apiKey: string | null
309
+ systemPrompt: string
310
+ history: ReturnType<typeof buildHistoryForAgent>
311
+ }): Promise<ProtocolAgentTurnResult> {
312
+ if (NON_LANGGRAPH_PROVIDER_IDS.has(params.session.provider)) {
313
+ const provider = getProvider(params.session.provider)
314
+ if (!provider) throw new Error(`Unknown provider: ${params.session.provider}`)
315
+ let streamedText = ''
316
+ const rawResponseText = await provider.handler.streamChat({
317
+ session: params.session,
318
+ message: params.message,
319
+ apiKey: params.apiKey,
320
+ systemPrompt: params.systemPrompt,
321
+ write: (raw: string) => {
322
+ for (const line of raw.split('\n')) {
323
+ if (!line.startsWith('data: ')) continue
324
+ try {
325
+ const parsed = JSON.parse(line.slice(6).trim()) as { t?: string; text?: string }
326
+ if ((parsed.t === 'd' || parsed.t === 'r') && typeof parsed.text === 'string') {
327
+ streamedText += parsed.text
328
+ }
329
+ } catch {
330
+ // Ignore malformed provider event payloads here and fall back to the final response text.
331
+ }
332
+ }
333
+ },
334
+ active: new Map<string, unknown>(),
335
+ loadHistory: () => params.history,
336
+ })
337
+ return {
338
+ text: rawResponseText || streamedText,
339
+ toolEvents: [],
340
+ retrievalTrace: null,
341
+ citations: [],
342
+ }
343
+ }
344
+
345
+ const streamed = await streamAgentChat({
346
+ session: params.session,
347
+ message: params.message,
348
+ apiKey: params.apiKey,
349
+ systemPrompt: params.systemPrompt,
350
+ write: () => {},
351
+ history: params.history,
352
+ })
353
+ return {
354
+ text: streamed.finalResponse || streamed.fullText || '',
355
+ toolEvents: streamed.toolEvents || [],
356
+ retrievalTrace: streamed.knowledgeRetrievalTrace || null,
357
+ citations: [],
358
+ }
359
+ }
360
+
361
+ async function executeStructuredSessionTurnWithTimeout(params: {
362
+ session: ReturnType<typeof ensureSyntheticSession>
363
+ message: string
364
+ apiKey: string | null
365
+ systemPrompt: string
366
+ history: ReturnType<typeof buildHistoryForAgent>
367
+ agentId: string
368
+ }): Promise<ProtocolAgentTurnResult> {
369
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
370
+ try {
371
+ return await Promise.race([
372
+ executeStructuredSessionTurn(params),
373
+ new Promise<never>((_, reject) => {
374
+ timeoutId = setTimeout(
375
+ () => reject(new Error(`Agent turn timed out after ${AGENT_TURN_TIMEOUT_MS / 1000}s (agent: ${params.agentId})`)),
376
+ AGENT_TURN_TIMEOUT_MS,
377
+ )
378
+ }),
379
+ ])
380
+ } finally {
381
+ if (timeoutId) clearTimeout(timeoutId)
382
+ }
383
+ }
384
+
385
+ export function resolveStructuredSessionTurnText(params: {
386
+ rawText: string
387
+ sessionId: string
388
+ turnStartedAt: number
389
+ agentId: string
390
+ }): string {
391
+ const text = stripHiddenControlTokens(params.rawText)
392
+ if (text.trim()) return text
393
+ const recentError = queryLogs({
394
+ sessionId: params.sessionId,
395
+ category: 'error',
396
+ since: params.turnStartedAt,
397
+ limit: 1,
398
+ })[0]
399
+ if (recentError?.summary) throw new Error(recentError.summary)
400
+ throw new Error(`Structured session turn produced no visible output for agent ${params.agentId}.`)
401
+ }
402
+
300
403
  export function extractFirstJsonObject(text: string): string | null {
301
404
  const source = String(text || '')
302
405
  let start = -1
@@ -52,9 +52,6 @@ export function resolveProviderCredentialId(input: {
52
52
  })[0]?.[0] || normalizedId
53
53
  }
54
54
 
55
- const matchingIds = matchingEntries.map(([id]) => id)
56
-
57
- if (matchingIds.length === 1) return matchingIds[0]
58
55
  return normalizedId
59
56
  }
60
57
 
@@ -0,0 +1,91 @@
1
+ import type { ProtocolRun, ProtocolRunEvent, RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types'
2
+
3
+ function mapProtocolStatus(status: ProtocolRun['status']): SessionRunStatus {
4
+ switch (status) {
5
+ case 'draft':
6
+ return 'queued'
7
+ case 'running':
8
+ case 'waiting':
9
+ case 'paused':
10
+ return 'running'
11
+ case 'completed':
12
+ return 'completed'
13
+ case 'failed':
14
+ return 'failed'
15
+ case 'cancelled':
16
+ case 'archived':
17
+ return 'cancelled'
18
+ default:
19
+ return 'queued'
20
+ }
21
+ }
22
+
23
+ function buildProtocolMessagePreview(run: ProtocolRun): string {
24
+ return (
25
+ run.title
26
+ || run.config?.goal
27
+ || run.config?.kickoffMessage
28
+ || run.templateName
29
+ || 'Structured session run'
30
+ )
31
+ }
32
+
33
+ function buildProtocolResultPreview(run: ProtocolRun): string {
34
+ const summary = typeof run.summary === 'string' ? run.summary.trim() : ''
35
+ if (summary) return summary
36
+ const latestArtifact = Array.isArray(run.artifacts)
37
+ ? [...run.artifacts].sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))[0]
38
+ : null
39
+ const artifactContent = typeof latestArtifact?.content === 'string' ? latestArtifact.content.trim() : ''
40
+ if (artifactContent) return artifactContent
41
+ return ''
42
+ }
43
+
44
+ export function protocolRunToSessionRunRecord(run: ProtocolRun): SessionRunRecord {
45
+ return {
46
+ id: run.id,
47
+ sessionId: run.sessionId || run.transcriptChatroomId || `protocol-run:${run.id}`,
48
+ kind: 'protocol_step',
49
+ ownerType: 'protocol_run',
50
+ ownerId: run.id,
51
+ source: run.sourceRef.kind === 'schedule' ? 'structured schedule' : 'structured session',
52
+ internal: run.systemOwned === true,
53
+ mode: run.templateId,
54
+ status: mapProtocolStatus(run.status),
55
+ messagePreview: buildProtocolMessagePreview(run),
56
+ queuedAt: run.createdAt,
57
+ startedAt: run.startedAt || undefined,
58
+ endedAt: run.endedAt || run.archivedAt || undefined,
59
+ error: run.lastError || undefined,
60
+ resultPreview: buildProtocolResultPreview(run) || undefined,
61
+ }
62
+ }
63
+
64
+ export function protocolEventToRunEventRecord(run: ProtocolRun, event: ProtocolRunEvent): RunEventRecord {
65
+ const status = event.type === 'failed'
66
+ ? 'failed'
67
+ : event.type === 'completed'
68
+ ? 'completed'
69
+ : event.type === 'cancelled'
70
+ ? 'cancelled'
71
+ : event.type === 'created'
72
+ ? 'queued'
73
+ : undefined
74
+ return {
75
+ id: event.id,
76
+ runId: run.id,
77
+ sessionId: run.sessionId || run.transcriptChatroomId || `protocol-run:${run.id}`,
78
+ kind: 'protocol_step',
79
+ ownerType: 'protocol_run',
80
+ ownerId: run.id,
81
+ timestamp: event.createdAt,
82
+ phase: status ? 'status' : 'event',
83
+ status,
84
+ summary: event.summary,
85
+ event: {
86
+ t: status === 'failed' ? 'err' : status ? 'status' : 'md',
87
+ text: event.summary,
88
+ },
89
+ citations: event.citations,
90
+ }
91
+ }
@@ -319,7 +319,10 @@ export function normalizeSchedulePayload(payload: SchedulePayload, opts: Normali
319
319
  const cronTimezone = trimString(normalized.timezone)
320
320
  const interval = CronExpressionParser.parse(
321
321
  normalized.cron as string,
322
- cronTimezone ? { tz: cronTimezone } : undefined,
322
+ {
323
+ ...(cronTimezone ? { tz: cronTimezone } : {}),
324
+ currentDate: new Date(now),
325
+ },
323
326
  )
324
327
  normalized.nextRunAt = applyStagger(interval.next().getTime(), normalized.staggerSec as number | null)
325
328
  } catch {
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { prepareScheduleUpdate } from '@/lib/server/schedules/schedule-service'
5
+ import type { Schedule } from '@/types'
6
+
7
+ function readNextRunAt(value: object): number | undefined {
8
+ if (!('nextRunAt' in value)) return undefined
9
+ const nextRunAt = value.nextRunAt
10
+ return typeof nextRunAt === 'number' ? nextRunAt : undefined
11
+ }
12
+
13
+ function makeSchedule(overrides: Partial<Schedule> = {}): Schedule {
14
+ return {
15
+ id: 'sched-1',
16
+ name: 'Morning run',
17
+ agentId: 'agent-1',
18
+ taskPrompt: 'Do the thing',
19
+ scheduleType: 'cron',
20
+ cron: '40 10 * * *',
21
+ timezone: 'UTC',
22
+ status: 'active',
23
+ createdAt: 0,
24
+ updatedAt: 0,
25
+ nextRunAt: Date.parse('2026-01-01T10:40:00.000Z'),
26
+ ...overrides,
27
+ }
28
+ }
29
+
30
+ describe('prepareScheduleUpdate', () => {
31
+ it('recomputes nextRunAt when cron timing changes', () => {
32
+ const current = makeSchedule()
33
+ const now = Date.parse('2026-01-01T10:30:00.000Z')
34
+ const result = prepareScheduleUpdate({
35
+ id: current.id,
36
+ current,
37
+ patch: { cron: '45 10 * * *' },
38
+ schedules: { [current.id]: current },
39
+ now,
40
+ })
41
+
42
+ assert.equal(result.ok, true)
43
+ if (result.ok) {
44
+ const nextRunAt = readNextRunAt(result.schedule)
45
+ assert.notEqual(nextRunAt, current.nextRunAt)
46
+ assert.equal(typeof nextRunAt, 'number')
47
+ assert.equal(nextRunAt, Date.parse('2026-01-01T10:45:00.000Z'))
48
+ }
49
+ })
50
+
51
+ it('recomputes nextRunAt when reactivating a paused interval schedule', () => {
52
+ const current = makeSchedule({
53
+ scheduleType: 'interval',
54
+ cron: undefined,
55
+ intervalMs: 300_000,
56
+ status: 'paused',
57
+ nextRunAt: 123,
58
+ })
59
+ const now = 1_000_000
60
+ const result = prepareScheduleUpdate({
61
+ id: current.id,
62
+ current,
63
+ patch: { status: 'active' },
64
+ schedules: { [current.id]: current },
65
+ now,
66
+ })
67
+
68
+ assert.equal(result.ok, true)
69
+ if (result.ok) {
70
+ assert.equal(readNextRunAt(result.schedule), 1_300_000)
71
+ }
72
+ })
73
+ })
@@ -211,10 +211,17 @@ export type PrepareScheduleUpdateResult =
211
211
  }
212
212
 
213
213
  export function prepareScheduleUpdate(options: PrepareScheduleUpdateOptions): PrepareScheduleUpdateResult {
214
+ const nextStatus = normalizeScheduleStatus(options.patch.status)
215
+ const shouldRecomputeNextRunAt = !('nextRunAt' in options.patch) && (
216
+ ['scheduleType', 'cron', 'intervalMs', 'runAt', 'timezone', 'staggerSec']
217
+ .some((key) => key in options.patch)
218
+ || (nextStatus === 'active' && options.current.status !== 'active')
219
+ )
214
220
  const normalized = normalizeSchedulePayload({
215
221
  ...options.current,
216
222
  ...options.patch,
217
223
  id: options.id,
224
+ ...(shouldRecomputeNextRunAt ? { nextRunAt: undefined } : {}),
218
225
  }, {
219
226
  cwd: options.cwd,
220
227
  now: options.now,
@@ -237,8 +244,8 @@ export function prepareScheduleUpdate(options: PrepareScheduleUpdateOptions): Pr
237
244
  })
238
245
 
239
246
  const entries: Array<[string, ScheduleLike]> = [[options.id, nextSchedule]]
240
- const nextStatus = normalizeScheduleStatus(nextSchedule.status)
241
- if (options.propagateEquivalentStatuses && (nextStatus === 'paused' || nextStatus === 'completed' || nextStatus === 'failed' || nextStatus === 'archived')) {
247
+ const normalizedStatus = normalizeScheduleStatus(nextSchedule.status)
248
+ if (options.propagateEquivalentStatuses && (normalizedStatus === 'paused' || normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'archived')) {
242
249
  const relatedIds = findRelatedScheduleIds(
243
250
  options.schedules,
244
251
  options.propagationSource || options.current,
@@ -249,7 +256,7 @@ export function prepareScheduleUpdate(options: PrepareScheduleUpdateOptions): Pr
249
256
  if (!related) continue
250
257
  entries.push([relatedId, {
251
258
  ...related,
252
- status: nextStatus,
259
+ status: normalizedStatus,
253
260
  updatedAt: options.now,
254
261
  }])
255
262
  }
@@ -13,6 +13,7 @@ export function runWithTempDataDir<T = unknown>(
13
13
  dataDir?: string
14
14
  workspaceDir?: string
15
15
  browserProfilesDir?: string
16
+ timeoutMs?: number
16
17
  } = {},
17
18
  ): T {
18
19
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), options.prefix || 'swarmclaw-test-'))
@@ -38,9 +39,10 @@ export function runWithTempDataDir<T = unknown>(
38
39
  ...(browserProfilesDir ? { BROWSER_PROFILES_DIR: browserProfilesDir } : {}),
39
40
  },
40
41
  encoding: 'utf-8',
42
+ timeout: options.timeoutMs ?? 120_000,
41
43
  })
42
44
 
43
- assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
45
+ assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout || 'subprocess failed')
44
46
 
45
47
  const lines = (result.stdout || '')
46
48
  .trim()