@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 +14 -5
- package/package.json +2 -2
- package/src/components/schedules/schedule-console.tsx +3 -0
- package/src/lib/server/runtime/scheduler.test.ts +129 -0
- package/src/lib/server/runtime/scheduler.ts +62 -35
- package/src/lib/server/schedules/schedule-history.test.ts +14 -0
- package/src/lib/server/schedules/schedule-history.ts +1 -0
- package/src/lib/server/schedules/schedule-lifecycle.ts +5 -28
- package/src/lib/server/schedules/schedule-normalization.ts +6 -28
- package/src/lib/server/schedules/schedule-timing.test.ts +80 -0
- package/src/lib/server/schedules/schedule-timing.ts +179 -0
- package/src/lib/server/tasks/task-lifecycle.ts +35 -5
- package/src/types/schedule.ts +2 -2
- package/src/types/task.ts +1 -0
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.
|
|
154
|
+
## v1.9.23 Highlights
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
Schedule reliability is now more deterministic for recurring autonomous work, especially after restarts or stale stored timing state.
|
|
157
157
|
|
|
158
|
-
- **
|
|
159
|
-
- **
|
|
160
|
-
- **
|
|
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.
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 =
|
|
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 === '
|
|
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
|
})
|
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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,
|
package/src/types/schedule.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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