@swarmclawai/swarmclaw 1.9.22 → 1.9.23

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.
package/README.md CHANGED
@@ -151,13 +151,13 @@ clawhub install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
153
153
 
154
- ## v1.9.17 Highlights
154
+ ## v1.9.23 Highlights
155
155
 
156
- Agent configuration history is now visible in the agent editor, so operators can review recent saved versions and restore prior settings without leaving the agent workflow.
156
+ Schedule reliability is now more deterministic for recurring autonomous work, especially after restarts or stale stored timing state.
157
157
 
158
- - **Agent sheet history.** Advanced agent settings list recent saved versions with timestamp, actor, and provider/model snapshot.
159
- - **One-click restore.** Restoring a prior version uses the existing config-version restore API, refreshes agent state, and closes the sheet to avoid stale form data.
160
- - **Regression coverage.** New tests cover config-version list/restore routes and UI summary formatting.
158
+ - **Cron drift repair.** Active schedules repair stale future cron slots before they skip the nearest run.
159
+ - **Stable stagger.** Staggered schedules keep a deterministic per-schedule offset.
160
+ - **Mission continuity.** Schedule-created board tasks keep a persistent mission link across recurring runs.
161
161
 
162
162
  ## Hosted Deploys
163
163
 
@@ -409,6 +409,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
409
409
 
410
410
  ## Releases
411
411
 
412
+ ### v1.9.23 Highlights
413
+
414
+ Schedule reliability release: recurring work now repairs stale timing state before it can skip the nearest run, and scheduled board tasks keep mission context across repeat launches.
415
+
416
+ - **Cron drift repair.** Active cron schedules repair missing or invalid `nextRunAt` values and stale future cron slots before the scheduler decides whether work is due.
417
+ - **Tick-time advancement.** Cron and interval schedules now advance from the scheduler tick time instead of the process wall clock, making restart and catch-up behavior deterministic.
418
+ - **Stable stagger.** Schedule stagger offsets are deterministic per schedule, avoiding thundering-herd launches without moving a saved next-run target on every recompute.
419
+ - **Mission continuity.** Schedule-created board tasks attach to a persistent mission link, so recurring runs share the same operational context.
420
+
412
421
  ### v1.9.22 Highlights
413
422
 
414
423
  Research tools release: agents now get direct `web_extract` and `web_crawl` tools alongside `web_search`, `web_fetch`, and the unified `web` tool.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.22",
3
+ "version": "1.9.23",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -88,7 +88,7 @@
88
88
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/electron-signing-config.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
89
89
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
90
90
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
91
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
92
92
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
93
93
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
94
94
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -162,6 +162,8 @@ function historyActionLabel(action: ScheduleHistoryEntry['action']): string {
162
162
  return 'Skipped'
163
163
  case 'failed':
164
164
  return 'Failed'
165
+ case 'repaired':
166
+ return 'Repaired'
165
167
  default:
166
168
  return action
167
169
  }
@@ -171,6 +173,7 @@ function historyActionBadge(action: ScheduleHistoryEntry['action']): string {
171
173
  if (action === 'created' || action === 'restored' || action === 'run_started') return badgeClass('completed')
172
174
  if (action === 'failed') return badgeClass('failed')
173
175
  if (action === 'skipped' || action === 'archived') return badgeClass('paused')
176
+ if (action === 'repaired') return badgeClass('running')
174
177
  return badgeClass('running')
175
178
  }
176
179
 
@@ -202,6 +202,135 @@ describe('scheduler wake targeting', () => {
202
202
  assert.deepEqual(output.deliveryModes, ['silent'])
203
203
  })
204
204
 
205
+ it('repairs stale future cron next-run slots without launching a run', () => {
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.parse('2026-05-06T07:30:00.000Z')
213
+ const staleFuture = Date.parse('2026-05-12T08:00:00.000Z')
214
+
215
+ storage.saveSchedules({
216
+ 'sched-cron': {
217
+ id: 'sched-cron',
218
+ name: 'Daily status',
219
+ agentId: 'agent-1',
220
+ taskPrompt: 'Send the daily status.',
221
+ scheduleType: 'cron',
222
+ cron: '0 8 * * *',
223
+ timezone: 'UTC',
224
+ status: 'active',
225
+ nextRunAt: staleFuture,
226
+ createdAt: now - 10_000,
227
+ updatedAt: now - 10_000,
228
+ },
229
+ })
230
+
231
+ await scheduler.runSchedulerTickForTests(now)
232
+ const schedule = storage.loadSchedules()['sched-cron']
233
+
234
+ console.log(JSON.stringify({
235
+ status: schedule.status,
236
+ nextRunAt: schedule.nextRunAt,
237
+ taskCount: Object.keys(storage.loadTasks()).length,
238
+ historyAction: schedule.history?.[0]?.action || null,
239
+ historyReason: schedule.history?.[0]?.metadata?.reason || null,
240
+ }))
241
+ `)
242
+
243
+ assert.equal(output.status, 'active')
244
+ assert.equal(output.nextRunAt, Date.parse('2026-05-06T08:00:00.000Z'))
245
+ assert.equal(output.taskCount, 0)
246
+ assert.equal(output.historyAction, 'repaired')
247
+ assert.equal(output.historyReason, 'stale_future')
248
+ })
249
+
250
+ it('advances cron schedules from the scheduler tick time after firing', () => {
251
+ const output = runSchedulerWithTempDataDir(`
252
+ const storageMod = await import('@/lib/server/storage')
253
+ const schedulerMod = await import('@/lib/server/runtime/scheduler')
254
+ const heartbeatWakeMod = await import('@/lib/server/runtime/heartbeat-wake')
255
+ const storage = storageMod.default || storageMod
256
+ const scheduler = schedulerMod.default || schedulerMod
257
+ const heartbeatWake = heartbeatWakeMod.default || heartbeatWakeMod
258
+
259
+ const now = Date.parse('2030-01-01T08:00:30.000Z')
260
+ const dueAt = Date.parse('2030-01-01T08:00:00.000Z')
261
+
262
+ storage.saveAgents({
263
+ 'agent-1': {
264
+ id: 'agent-1',
265
+ name: 'Daily Agent',
266
+ description: '',
267
+ systemPrompt: '',
268
+ provider: 'openai',
269
+ model: 'gpt-test',
270
+ threadSessionId: 'thread-main',
271
+ createdAt: now - 10_000,
272
+ updatedAt: now - 10_000,
273
+ },
274
+ })
275
+
276
+ storage.saveSessions({
277
+ 'thread-main': {
278
+ id: 'thread-main',
279
+ name: 'Daily Agent',
280
+ cwd: process.env.WORKSPACE_DIR,
281
+ user: 'tester',
282
+ provider: 'openai',
283
+ model: 'gpt-test',
284
+ claudeSessionId: null,
285
+ messages: [],
286
+ createdAt: now - 10_000,
287
+ lastActiveAt: now - 5_000,
288
+ active: true,
289
+ currentRunId: null,
290
+ agentId: 'agent-1',
291
+ shortcutForAgentId: 'agent-1',
292
+ },
293
+ })
294
+
295
+ storage.saveSchedules({
296
+ 'sched-cron': {
297
+ id: 'sched-cron',
298
+ name: 'Daily wake',
299
+ agentId: 'agent-1',
300
+ taskPrompt: 'Wake for the daily status.',
301
+ taskMode: 'wake_only',
302
+ message: 'Run the daily status.',
303
+ scheduleType: 'cron',
304
+ cron: '0 8 * * *',
305
+ timezone: 'UTC',
306
+ status: 'active',
307
+ nextRunAt: dueAt,
308
+ createdInSessionId: 'thread-main',
309
+ createdAt: now - 10_000,
310
+ updatedAt: now - 10_000,
311
+ },
312
+ })
313
+
314
+ await scheduler.runSchedulerTickForTests(now)
315
+ const schedule = storage.loadSchedules()['sched-cron']
316
+ const wakes = heartbeatWake.snapshotPendingHeartbeatWakesForTests()
317
+
318
+ console.log(JSON.stringify({
319
+ status: schedule.status,
320
+ nextRunAt: schedule.nextRunAt,
321
+ runNumber: schedule.runNumber,
322
+ historyAction: schedule.history?.[0]?.action || null,
323
+ wakeCount: wakes.length,
324
+ }))
325
+ `)
326
+
327
+ assert.equal(output.status, 'active')
328
+ assert.equal(output.nextRunAt, Date.parse('2030-01-02T08:00:00.000Z'))
329
+ assert.equal(output.runNumber, 1)
330
+ assert.equal(output.historyAction, 'run_started')
331
+ assert.equal(output.wakeCount, 1)
332
+ })
333
+
205
334
  it('reuses a persistent mission for scheduled task runs', () => {
206
335
  const output = runSchedulerWithTempDataDir(`
207
336
  const storageMod = await import('@/lib/server/storage')
@@ -2,7 +2,6 @@ import { listAgents } from '@/lib/server/agents/agent-repository'
2
2
  import { loadSchedules, upsertSchedule, upsertSchedules } from '@/lib/server/schedules/schedule-repository'
3
3
  import { loadTasks, upsertTask } from '@/lib/server/tasks/task-repository'
4
4
  import { enqueueTask } from '@/lib/server/runtime/queue'
5
- import { CronExpressionParser } from 'cron-parser'
6
5
  import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-loop'
7
6
  import { getScheduleSignatureKey } from '@/lib/schedules/schedule-dedupe'
8
7
  import { dispatchWake } from '@/lib/server/runtime/wake-dispatcher'
@@ -14,6 +13,7 @@ import { hasActiveProtocolRunForSchedule, launchProtocolRunForSchedule } from '@
14
13
  import { hmrSingleton } from '@/lib/shared-utils'
15
14
  import { log } from '@/lib/server/logger'
16
15
  import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
16
+ import { assessScheduleNextRunRepair, computeScheduleNextRunAt } from '@/lib/server/schedules/schedule-timing'
17
17
  import type { Schedule } from '@/types'
18
18
 
19
19
  const TAG = 'scheduler'
@@ -52,7 +52,7 @@ export function startScheduler() {
52
52
  if (schedulerState.intervalId) return
53
53
  log.info(TAG, 'Starting scheduler engine (60s tick)')
54
54
 
55
- // Compute initial nextRunAt for cron schedules missing it
55
+ // Compute initial timing and repair stale nextRunAt values before the first tick.
56
56
  computeNextRuns()
57
57
 
58
58
  schedulerState.intervalId = setInterval(tick, TICK_INTERVAL)
@@ -66,32 +66,64 @@ export function stopScheduler() {
66
66
  }
67
67
  }
68
68
 
69
- function computeNextRuns() {
69
+ function computeNextRuns(now = Date.now()): Record<string, Schedule> {
70
70
  const schedules = loadSchedules()
71
71
  const changedEntries: Array<[string, Schedule]> = []
72
72
  for (const schedule of Object.values(schedules)) {
73
73
  if (schedule.status !== 'active') continue
74
- if (schedule.scheduleType === 'cron' && schedule.cron && !schedule.nextRunAt) {
75
- try {
76
- const interval = CronExpressionParser.parse(
77
- schedule.cron,
78
- schedule.timezone ? { tz: schedule.timezone } : undefined,
79
- )
80
- schedule.nextRunAt = interval.next().getTime()
81
- changedEntries.push([schedule.id, schedule])
82
- } catch (err) {
83
- log.error(TAG, `Invalid cron for ${schedule.id}:`, err)
84
- schedule.status = 'failed'
85
- changedEntries.push([schedule.id, schedule])
86
- }
74
+ const assessment = assessScheduleNextRunRepair(schedule, now)
75
+ if (!assessment.ok) {
76
+ log.error(TAG, `Invalid cron for ${schedule.id}`)
77
+ const failedSchedule = appendScheduleHistoryEntry({
78
+ ...schedule,
79
+ status: 'failed',
80
+ updatedAt: now,
81
+ }, {
82
+ now,
83
+ actor: 'system',
84
+ action: 'failed',
85
+ summary: `Schedule failed because cron could not be parsed: "${schedule.name}"`,
86
+ changes: [{
87
+ field: 'status',
88
+ label: 'Status',
89
+ before: 'active',
90
+ after: 'failed',
91
+ }],
92
+ metadata: { reason: 'invalid_cron' },
93
+ })
94
+ schedules[schedule.id] = failedSchedule
95
+ changedEntries.push([schedule.id, failedSchedule])
96
+ continue
97
+ }
98
+ if (assessment.repair) {
99
+ const repairedSchedule = appendScheduleHistoryEntry({
100
+ ...schedule,
101
+ nextRunAt: assessment.nextRunAt,
102
+ updatedAt: now,
103
+ }, {
104
+ now,
105
+ actor: 'system',
106
+ action: 'repaired',
107
+ summary: `Schedule timing repaired: "${schedule.name}"`,
108
+ changes: [{
109
+ field: 'nextRunAt',
110
+ label: 'Next run',
111
+ before: assessment.previousNextRunAt == null ? null : String(assessment.previousNextRunAt),
112
+ after: String(assessment.nextRunAt),
113
+ }],
114
+ metadata: { reason: assessment.reason },
115
+ })
116
+ schedules[schedule.id] = repairedSchedule
117
+ changedEntries.push([schedule.id, repairedSchedule])
87
118
  }
88
119
  }
89
120
  if (changedEntries.length > 0) upsertSchedules(changedEntries)
121
+ return schedules
90
122
  }
91
123
 
92
124
  async function tick(now = Date.now()) {
93
125
  await processDueWatchJobs(now)
94
- const schedules = loadSchedules()
126
+ const schedules = computeNextRuns(now)
95
127
  const agents = listAgents()
96
128
  const tasks = loadTasks()
97
129
  const inFlightScheduleKeys = new Set<string>(
@@ -101,27 +133,22 @@ async function tick(now = Date.now()) {
101
133
  .filter((value: string) => value.length > 0),
102
134
  )
103
135
 
104
- const applyStagger = (ts: number, staggerSec: number | null | undefined): number => {
105
- if (!staggerSec || staggerSec <= 0) return ts
106
- return ts + Math.floor(Math.random() * staggerSec * 1000)
107
- }
108
-
109
136
  const advanceSchedule = (schedule: Schedule): void => {
110
- if (schedule.scheduleType === 'cron' && schedule.cron) {
111
- try {
112
- const interval = CronExpressionParser.parse(
113
- schedule.cron,
114
- schedule.timezone ? { tz: schedule.timezone } : undefined,
115
- )
116
- schedule.nextRunAt = applyStagger(interval.next().getTime(), schedule.staggerSec)
117
- } catch {
118
- schedule.status = 'failed'
119
- }
120
- } else if (schedule.scheduleType === 'interval' && schedule.intervalMs) {
121
- schedule.nextRunAt = applyStagger(now + schedule.intervalMs, schedule.staggerSec)
122
- } else if (schedule.scheduleType === 'once') {
137
+ if (schedule.scheduleType === 'once') {
123
138
  schedule.status = 'completed'
124
139
  schedule.nextRunAt = undefined
140
+ return
141
+ }
142
+
143
+ try {
144
+ const nextRunAt = computeScheduleNextRunAt(schedule, now)
145
+ if (nextRunAt == null) {
146
+ schedule.status = 'failed'
147
+ } else {
148
+ schedule.nextRunAt = nextRunAt
149
+ }
150
+ } catch {
151
+ schedule.status = 'failed'
125
152
  }
126
153
  }
127
154
 
@@ -118,4 +118,18 @@ describe('schedule history', () => {
118
118
  assert.equal(history[24].id, 'hist-5')
119
119
  assert.equal(schedule.revision, 30)
120
120
  })
121
+
122
+ it('retains scheduler repair history entries', () => {
123
+ const history = normalizeScheduleHistory([{
124
+ id: 'hist-repair',
125
+ at: 1_000,
126
+ actor: 'system',
127
+ action: 'repaired',
128
+ revision: 1,
129
+ summary: 'Schedule timing repaired',
130
+ }])
131
+
132
+ assert.equal(history.length, 1)
133
+ assert.equal(history[0].action, 'repaired')
134
+ })
121
135
  })
@@ -57,6 +57,7 @@ const HISTORY_ACTIONS = new Set<ScheduleHistoryAction>([
57
57
  'run_started',
58
58
  'skipped',
59
59
  'failed',
60
+ 'repaired',
60
61
  ])
61
62
 
62
63
  function cleanActor(value: string): string {
@@ -1,5 +1,3 @@
1
- import { CronExpressionParser } from 'cron-parser'
2
-
3
1
  import { genId } from '@/lib/id'
4
2
  import type { BoardTask, Schedule, ScheduleStatus, Session } from '@/types'
5
3
  import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-loop'
@@ -19,6 +17,7 @@ import {
19
17
  import { notify } from '@/lib/server/ws-hub'
20
18
  import { getScheduleClusterIds } from '@/lib/server/schedules/schedule-service'
21
19
  import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
20
+ import { computeScheduleNextRunAt } from '@/lib/server/schedules/schedule-timing'
22
21
 
23
22
  type RestorableScheduleStatus = Exclude<ScheduleStatus, 'archived'>
24
23
 
@@ -50,33 +49,11 @@ export interface SchedulePurgeResult {
50
49
  }
51
50
 
52
51
  function computeNextRunAt(schedule: Pick<Schedule, 'scheduleType' | 'cron' | 'intervalMs' | 'runAt' | 'timezone' | 'staggerSec'>, now: number): number | undefined {
53
- const applyStagger = (timestamp: number): number => {
54
- if (!schedule.staggerSec || schedule.staggerSec <= 0) return timestamp
55
- return timestamp + Math.floor(Math.random() * schedule.staggerSec * 1000)
56
- }
57
-
58
- if (schedule.scheduleType === 'once') {
59
- return typeof schedule.runAt === 'number' && Number.isFinite(schedule.runAt)
60
- ? applyStagger(schedule.runAt)
61
- : undefined
62
- }
63
- if (schedule.scheduleType === 'interval') {
64
- return typeof schedule.intervalMs === 'number' && Number.isFinite(schedule.intervalMs)
65
- ? applyStagger(now + schedule.intervalMs)
66
- : undefined
67
- }
68
- if (schedule.scheduleType === 'cron' && typeof schedule.cron === 'string' && schedule.cron.trim()) {
69
- try {
70
- const interval = CronExpressionParser.parse(
71
- schedule.cron,
72
- schedule.timezone ? { tz: schedule.timezone } : undefined,
73
- )
74
- return applyStagger(interval.next().getTime())
75
- } catch {
76
- return undefined
77
- }
52
+ try {
53
+ return computeScheduleNextRunAt(schedule, now)
54
+ } catch {
55
+ return undefined
78
56
  }
79
- return undefined
80
57
  }
81
58
 
82
59
  function cloneSchedule(schedule: Schedule): Schedule {
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { CronExpressionParser } from 'cron-parser'
4
3
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
4
+ import { computeScheduleNextRunAt } from '@/lib/server/schedules/schedule-timing'
5
5
 
6
6
  type SchedulePayload = Record<string, unknown>
7
7
 
@@ -86,15 +86,6 @@ function parseAtTimeToCron(atTime: string): string | null {
86
86
  return `${minutes} ${hours} * * *`
87
87
  }
88
88
 
89
- /**
90
- * Apply a random stagger offset (in seconds) to a timestamp.
91
- */
92
- function applyStagger(timestamp: number, staggerSec: number | null | undefined): number {
93
- if (!staggerSec || staggerSec <= 0) return timestamp
94
- const offset = Math.floor(Math.random() * staggerSec * 1000)
95
- return timestamp + offset
96
- }
97
-
98
89
  function normalizePositiveInt(value: unknown): number | null {
99
90
  const parsed = typeof value === 'number'
100
91
  ? value
@@ -326,24 +317,11 @@ export function normalizeSchedulePayload(payload: SchedulePayload, opts: Normali
326
317
  }
327
318
 
328
319
  if (normalized.status !== 'archived' && normalized.nextRunAt == null) {
329
- if (normalized.scheduleType === 'once') {
330
- if (runAt != null) normalized.nextRunAt = applyStagger(runAt, normalized.staggerSec as number | null)
331
- } else if (normalized.scheduleType === 'interval') {
332
- if (intervalMs != null) normalized.nextRunAt = applyStagger(now + intervalMs, normalized.staggerSec as number | null)
333
- } else if (normalized.scheduleType === 'cron' && normalized.cron) {
334
- try {
335
- const cronTimezone = trimString(normalized.timezone)
336
- const interval = CronExpressionParser.parse(
337
- normalized.cron as string,
338
- {
339
- ...(cronTimezone ? { tz: cronTimezone } : {}),
340
- currentDate: new Date(now),
341
- },
342
- )
343
- normalized.nextRunAt = applyStagger(interval.next().getTime(), normalized.staggerSec as number | null)
344
- } catch {
345
- return { ok: false, error: 'Error: invalid cron expression.' }
346
- }
320
+ try {
321
+ const computedNextRunAt = computeScheduleNextRunAt(normalized, now)
322
+ if (computedNextRunAt != null) normalized.nextRunAt = computedNextRunAt
323
+ } catch {
324
+ return { ok: false, error: 'Error: invalid cron expression.' }
347
325
  }
348
326
  }
349
327
 
@@ -0,0 +1,80 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import {
5
+ assessScheduleNextRunRepair,
6
+ computeScheduleNextRunAt,
7
+ stableScheduleStaggerMs,
8
+ } from '@/lib/server/schedules/schedule-timing'
9
+
10
+ describe('schedule timing', () => {
11
+ it('computes cron next runs from the provided scheduler time', () => {
12
+ const nextRunAt = computeScheduleNextRunAt({
13
+ id: 'sched-daily',
14
+ name: 'Daily status',
15
+ agentId: 'agent-1',
16
+ scheduleType: 'cron',
17
+ cron: '0 8 * * *',
18
+ timezone: 'UTC',
19
+ }, Date.parse('2030-01-01T08:00:30.000Z'))
20
+
21
+ assert.equal(nextRunAt, Date.parse('2030-01-02T08:00:00.000Z'))
22
+ })
23
+
24
+ it('uses deterministic schedule stagger inside the configured window', () => {
25
+ const schedule = {
26
+ id: 'sched-staggered',
27
+ name: 'Staggered status',
28
+ agentId: 'agent-1',
29
+ scheduleType: 'cron',
30
+ cron: '0 8 * * *',
31
+ timezone: 'UTC',
32
+ staggerSec: 30,
33
+ }
34
+
35
+ const first = stableScheduleStaggerMs(schedule)
36
+ const second = stableScheduleStaggerMs(schedule)
37
+
38
+ assert.equal(first, second)
39
+ assert.ok(first >= 0)
40
+ assert.ok(first < 30_000)
41
+ })
42
+
43
+ it('repairs stale future cron slots to the earliest upcoming slot', () => {
44
+ const assessment = assessScheduleNextRunRepair({
45
+ id: 'sched-stale',
46
+ name: 'Daily status',
47
+ agentId: 'agent-1',
48
+ scheduleType: 'cron',
49
+ cron: '0 8 * * *',
50
+ timezone: 'UTC',
51
+ status: 'active',
52
+ nextRunAt: Date.parse('2026-05-12T08:00:00.000Z'),
53
+ }, Date.parse('2026-05-06T07:30:00.000Z'))
54
+
55
+ assert.equal(assessment.ok, true)
56
+ assert.equal(assessment.repair, true)
57
+ if (assessment.ok && assessment.repair) {
58
+ assert.equal(assessment.reason, 'stale_future')
59
+ assert.equal(assessment.nextRunAt, Date.parse('2026-05-06T08:00:00.000Z'))
60
+ }
61
+ })
62
+
63
+ it('flags invalid due cron schedules before they launch', () => {
64
+ const assessment = assessScheduleNextRunRepair({
65
+ id: 'sched-invalid',
66
+ name: 'Broken cron',
67
+ agentId: 'agent-1',
68
+ scheduleType: 'cron',
69
+ cron: 'not a cron',
70
+ status: 'active',
71
+ nextRunAt: Date.parse('2026-05-06T07:00:00.000Z'),
72
+ }, Date.parse('2026-05-06T07:30:00.000Z'))
73
+
74
+ assert.equal(assessment.ok, false)
75
+ if (!assessment.ok) {
76
+ assert.equal(assessment.reason, 'invalid_cron')
77
+ assert.equal(assessment.previousNextRunAt, Date.parse('2026-05-06T07:00:00.000Z'))
78
+ }
79
+ })
80
+ })
@@ -0,0 +1,179 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { CronExpressionParser } from 'cron-parser'
4
+
5
+ import type { ScheduleType } from '@/types'
6
+
7
+ export type ScheduleTimingRepairReason = 'missing' | 'invalid' | 'stale_future'
8
+
9
+ export type ScheduleTimingInput = {
10
+ id?: string | null
11
+ name?: string | null
12
+ agentId?: string | null
13
+ taskPrompt?: string | null
14
+ scheduleType?: ScheduleType | string | null
15
+ cron?: string | null
16
+ intervalMs?: number | null
17
+ runAt?: number | null
18
+ timezone?: string | null
19
+ staggerSec?: number | null
20
+ nextRunAt?: number | null
21
+ status?: string | null
22
+ }
23
+
24
+ export type ScheduleNextRunRepairAssessment =
25
+ | { ok: true; repair: false }
26
+ | {
27
+ ok: true
28
+ repair: true
29
+ reason: ScheduleTimingRepairReason
30
+ nextRunAt: number
31
+ previousNextRunAt: number | null
32
+ }
33
+ | {
34
+ ok: false
35
+ reason: 'invalid_cron'
36
+ previousNextRunAt: number | null
37
+ }
38
+
39
+ const CRON_REPAIR_TOLERANCE_MS = 1_000
40
+
41
+ function trimString(value: unknown): string {
42
+ return typeof value === 'string' ? value.trim() : ''
43
+ }
44
+
45
+ function normalizeTimestamp(value: unknown): number | null {
46
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
47
+ const normalized = Math.trunc(value)
48
+ return normalized > 0 ? normalized : null
49
+ }
50
+
51
+ function normalizeNow(value: number): number {
52
+ return Number.isFinite(value) ? Math.trunc(value) : Date.now()
53
+ }
54
+
55
+ function normalizeStaggerWindowMs(staggerSec: unknown): number {
56
+ if (typeof staggerSec !== 'number' || !Number.isFinite(staggerSec) || staggerSec <= 0) return 0
57
+ return Math.min(Math.trunc(staggerSec * 1000), Number.MAX_SAFE_INTEGER)
58
+ }
59
+
60
+ function stableScheduleKey(schedule: ScheduleTimingInput): string {
61
+ return [
62
+ trimString(schedule.id),
63
+ trimString(schedule.agentId),
64
+ trimString(schedule.name),
65
+ trimString(schedule.taskPrompt),
66
+ trimString(schedule.scheduleType),
67
+ trimString(schedule.cron),
68
+ typeof schedule.intervalMs === 'number' && Number.isFinite(schedule.intervalMs) ? Math.trunc(schedule.intervalMs) : '',
69
+ typeof schedule.runAt === 'number' && Number.isFinite(schedule.runAt) ? Math.trunc(schedule.runAt) : '',
70
+ trimString(schedule.timezone),
71
+ ].join('\0')
72
+ }
73
+
74
+ export function stableScheduleStaggerMs(schedule: ScheduleTimingInput): number {
75
+ const windowMs = normalizeStaggerWindowMs(schedule.staggerSec)
76
+ if (windowMs <= 0) return 0
77
+ const digest = createHash('sha256').update(stableScheduleKey(schedule)).digest()
78
+ const value = digest.readBigUInt64BE(0)
79
+ return Number(value % BigInt(windowMs))
80
+ }
81
+
82
+ function applyStableStagger(timestamp: number, schedule: ScheduleTimingInput): number {
83
+ return Math.trunc(timestamp + stableScheduleStaggerMs(schedule))
84
+ }
85
+
86
+ function parseCron(schedule: ScheduleTimingInput, now: number) {
87
+ const cron = trimString(schedule.cron)
88
+ if (!cron) return null
89
+ const timezone = trimString(schedule.timezone)
90
+ return CronExpressionParser.parse(cron, {
91
+ ...(timezone ? { tz: timezone } : {}),
92
+ currentDate: new Date(normalizeNow(now)),
93
+ })
94
+ }
95
+
96
+ export function computeScheduleNextRunAt(schedule: ScheduleTimingInput, now: number): number | undefined {
97
+ const scheduleType = trimString(schedule.scheduleType)
98
+ if (scheduleType === 'once') {
99
+ const runAt = normalizeTimestamp(schedule.runAt)
100
+ return runAt == null ? undefined : applyStableStagger(runAt, schedule)
101
+ }
102
+ if (scheduleType === 'interval') {
103
+ const intervalMs = normalizeTimestamp(schedule.intervalMs)
104
+ return intervalMs == null ? undefined : applyStableStagger(normalizeNow(now) + intervalMs, schedule)
105
+ }
106
+ if (scheduleType === 'cron') {
107
+ const interval = parseCron(schedule, now)
108
+ if (!interval) return undefined
109
+ return applyStableStagger(interval.next().getTime(), schedule)
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ function computeCronWindow(schedule: ScheduleTimingInput, now: number): { earliest: number; latest: number; nextRunAt: number } | null {
115
+ const interval = parseCron(schedule, now)
116
+ if (!interval) return null
117
+ const earliest = interval.next().getTime()
118
+ const latest = earliest + normalizeStaggerWindowMs(schedule.staggerSec)
119
+ return {
120
+ earliest,
121
+ latest,
122
+ nextRunAt: applyStableStagger(earliest, schedule),
123
+ }
124
+ }
125
+
126
+ export function assessScheduleNextRunRepair(
127
+ schedule: ScheduleTimingInput,
128
+ now: number,
129
+ ): ScheduleNextRunRepairAssessment {
130
+ if (trimString(schedule.status) && trimString(schedule.status) !== 'active') return { ok: true, repair: false }
131
+
132
+ const previousNextRunAt = normalizeTimestamp(schedule.nextRunAt)
133
+ const hasNextRunAt = schedule.nextRunAt != null
134
+ if (previousNextRunAt != null && previousNextRunAt <= normalizeNow(now)) {
135
+ if (trimString(schedule.scheduleType) === 'cron') {
136
+ try {
137
+ if (!parseCron(schedule, now)) return { ok: false, reason: 'invalid_cron', previousNextRunAt }
138
+ } catch {
139
+ return { ok: false, reason: 'invalid_cron', previousNextRunAt }
140
+ }
141
+ }
142
+ return { ok: true, repair: false }
143
+ }
144
+
145
+ if (previousNextRunAt == null) {
146
+ try {
147
+ const nextRunAt = computeScheduleNextRunAt(schedule, now)
148
+ if (nextRunAt == null) return { ok: true, repair: false }
149
+ return {
150
+ ok: true,
151
+ repair: true,
152
+ reason: hasNextRunAt ? 'invalid' : 'missing',
153
+ nextRunAt,
154
+ previousNextRunAt: null,
155
+ }
156
+ } catch {
157
+ return { ok: false, reason: 'invalid_cron', previousNextRunAt: null }
158
+ }
159
+ }
160
+
161
+ if (trimString(schedule.scheduleType) !== 'cron') return { ok: true, repair: false }
162
+
163
+ try {
164
+ const window = computeCronWindow(schedule, now)
165
+ if (!window) return { ok: true, repair: false }
166
+ const tooEarly = previousNextRunAt < window.earliest - CRON_REPAIR_TOLERANCE_MS
167
+ const tooLate = previousNextRunAt > window.latest + CRON_REPAIR_TOLERANCE_MS
168
+ if (!tooEarly && !tooLate) return { ok: true, repair: false }
169
+ return {
170
+ ok: true,
171
+ repair: true,
172
+ reason: 'stale_future',
173
+ nextRunAt: window.nextRunAt,
174
+ previousNextRunAt,
175
+ }
176
+ } catch {
177
+ return { ok: false, reason: 'invalid_cron', previousNextRunAt }
178
+ }
179
+ }
@@ -8,6 +8,9 @@ import {
8
8
  type TaskCompletionValidation,
9
9
  } from '@/lib/server/tasks/task-validation'
10
10
  import { syncTaskExecutionPolicyState } from '@/lib/server/tasks/task-execution-policy'
11
+ import { createMission, startMission } from '@/lib/server/missions/mission-service'
12
+ import { getMission } from '@/lib/server/missions/mission-repository'
13
+ import { loadSessions } from '@/lib/server/storage'
11
14
 
12
15
  export interface BuildBoardTaskInput {
13
16
  id?: string
@@ -84,6 +87,7 @@ export interface PrepareScheduledTaskRunOptions {
84
87
  | 'agentId'
85
88
  | 'taskPrompt'
86
89
  | 'linkedTaskId'
90
+ | 'linkedMissionId'
87
91
  | 'runNumber'
88
92
  | 'createdInSessionId'
89
93
  | 'createdByAgentId'
@@ -98,20 +102,45 @@ export interface PrepareScheduledTaskRunOptions {
98
102
  scheduleSignature?: string | null
99
103
  }
100
104
 
105
+ function ensureScheduleMission(schedule: PrepareScheduledTaskRunOptions['schedule']): string | null {
106
+ const existingMissionId = typeof schedule.linkedMissionId === 'string' ? schedule.linkedMissionId.trim() : ''
107
+ if (existingMissionId && getMission(existingMissionId)) return existingMissionId
108
+
109
+ const rootSessionId = typeof schedule.createdInSessionId === 'string' ? schedule.createdInSessionId.trim() : ''
110
+ if (!rootSessionId) return existingMissionId || null
111
+ const sessions = loadSessions()
112
+ if (!sessions[rootSessionId]) return existingMissionId || null
113
+
114
+ const mission = createMission({
115
+ title: `Scheduled task: ${schedule.name}`,
116
+ goal: schedule.taskPrompt || schedule.name,
117
+ successCriteria: ['Scheduled run is queued, executed, and reported back to the task board.'],
118
+ rootSessionId,
119
+ agentIds: [schedule.agentId].filter(Boolean),
120
+ reportSchedule: null,
121
+ })
122
+ startMission(mission.id)
123
+ schedule.linkedMissionId = mission.id
124
+ return mission.id
125
+ }
126
+
101
127
  export function prepareScheduledTaskRun(params: PrepareScheduledTaskRunOptions): { taskId: string; task: BoardTask } {
102
128
  const { schedule, tasks, now, scheduleSignature } = params
103
129
  const title = `[Sched] ${schedule.name} (run #${schedule.runNumber})`
104
130
  const existingTaskId = typeof schedule.linkedTaskId === 'string' ? schedule.linkedTaskId : ''
105
131
  const existingTask = existingTaskId ? tasks[existingTaskId] : null
132
+ const missionId = ensureScheduleMission(schedule)
106
133
 
107
134
  if (existingTask && existingTask.status !== 'queued' && existingTask.status !== 'running') {
135
+ const task = resetTaskForRerun(existingTask, {
136
+ title,
137
+ now,
138
+ runNumber: schedule.runNumber,
139
+ })
140
+ task.missionId = missionId
108
141
  return {
109
142
  taskId: existingTaskId,
110
- task: resetTaskForRerun(existingTask, {
111
- title,
112
- now,
113
- runNumber: schedule.runNumber,
114
- }),
143
+ task,
115
144
  }
116
145
  }
117
146
 
@@ -125,6 +154,7 @@ export function prepareScheduledTaskRun(params: PrepareScheduledTaskRunOptions):
125
154
  sourceScheduleId: schedule.id,
126
155
  sourceScheduleName: schedule.name,
127
156
  sourceScheduleKey: scheduleSignature || null,
157
+ missionId,
128
158
  createdInSessionId: schedule.createdInSessionId || null,
129
159
  createdByAgentId: schedule.createdByAgentId || null,
130
160
  followupConnectorId: schedule.followupConnectorId || null,
@@ -3,7 +3,7 @@ import type { ExtensionManagedResourceMarker } from './extension'
3
3
  export type ScheduleType = 'cron' | 'interval' | 'once'
4
4
  export type ScheduleStatus = 'active' | 'paused' | 'completed' | 'failed' | 'archived'
5
5
  export type ScheduleTaskMode = 'task' | 'wake_only' | 'protocol'
6
- export type ScheduleHistoryAction = 'created' | 'updated' | 'archived' | 'restored' | 'run_started' | 'skipped' | 'failed'
6
+ export type ScheduleHistoryAction = 'created' | 'updated' | 'archived' | 'restored' | 'run_started' | 'skipped' | 'failed' | 'repaired'
7
7
 
8
8
  export interface ScheduleHistoryChange {
9
9
  field: string
@@ -55,7 +55,7 @@ export interface Schedule {
55
55
  nextRunAt?: number
56
56
  /** IANA timezone for schedule evaluation (default: system local) */
57
57
  timezone?: string | null
58
- /** Random stagger window in seconds added to nextRunAt to avoid thundering herd */
58
+ /** Deterministic stagger window in seconds added to nextRunAt to avoid thundering herd */
59
59
  staggerSec?: number | null
60
60
  /** Last delivery status for this schedule */
61
61
  lastDeliveryStatus?: 'ok' | 'error' | null
package/src/types/task.ts CHANGED
@@ -182,6 +182,7 @@ export interface BoardTask {
182
182
  cwd?: string | null
183
183
  file?: string | null
184
184
  sessionId?: string | null
185
+ missionId?: string | null
185
186
  completionReportPath?: string | null
186
187
  result?: string | null
187
188
  error?: string | null