@goodandready/dsh-cron 0.3.24 → 0.3.26

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.
Files changed (84) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -190
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. package/package.json +6 -3
@@ -0,0 +1,751 @@
1
+ import { logger } from '../logger.js';
2
+ import { bestEffort } from '../best-effort.js';
3
+ import { Cron } from 'croner';
4
+ import { FileSystemWatcherManager } from '../watcher.js';
5
+ import { parseNaturalSchedule } from '../nl-schedule.js';
6
+
7
+ const MAX_TIMEOUT_MS = 2147483647; // 2^31 - 1 ms (~24.85 days)
8
+ import {
9
+ AGENT_TASK_TYPES,
10
+ addUsage,
11
+ executePreflight,
12
+ shouldUseFallback,
13
+ executeOnce,
14
+ executeWithFallback,
15
+ beginRun,
16
+ executeTask,
17
+ inspectFailureRun,
18
+ applySilentRuleToRun,
19
+ applyModelAssists,
20
+ deliverNotifications,
21
+ finishRun,
22
+ handleCompleteRun,
23
+ } from './scheduler-execution.js';
24
+ import {
25
+ startHeartbeatWatcher,
26
+ stopHeartbeatWatcher,
27
+ checkHeartbeats,
28
+ pauseTask,
29
+ removeTask,
30
+ resumeTask,
31
+ toggleTask,
32
+ } from './scheduler-lifecycle.js';
33
+
34
+ export {
35
+ parseNaturalSchedule,
36
+ executePreflight,
37
+ AGENT_TASK_TYPES,
38
+ addUsage,
39
+ shouldUseFallback,
40
+ executeOnce,
41
+ executeWithFallback,
42
+ beginRun,
43
+ executeTask,
44
+ inspectFailureRun,
45
+ applySilentRuleToRun,
46
+ applyModelAssists,
47
+ deliverNotifications,
48
+ finishRun,
49
+ handleCompleteRun,
50
+ startHeartbeatWatcher,
51
+ stopHeartbeatWatcher,
52
+ checkHeartbeats,
53
+ pauseTask,
54
+ removeTask,
55
+ resumeTask,
56
+ toggleTask,
57
+ FileSystemWatcherManager,
58
+ };
59
+
60
+ function pad(v) {
61
+ return String(v).padStart(2, '0');
62
+ }
63
+
64
+ export function describeCron(cronPattern) {
65
+ const parts = cronPattern.trim().split(/\s+/);
66
+ if (parts.length === 5) {
67
+ const [m, h, dom, mon, dow] = parts;
68
+ if (m.startsWith('*/')) return `Every ${m.slice(2)} minutes`;
69
+ if (dow === '1-5' && dom === '*' && mon === '*') {
70
+ return `Weekdays at ${pad(h)}:${pad(m)}`;
71
+ }
72
+ if (dow === '*' && dom === '*' && mon === '*') {
73
+ return `Every day at ${pad(h)}:${pad(m)}`;
74
+ }
75
+ if (dow === '5' && dom === '*' && mon === '*') {
76
+ return `Fridays at ${pad(h)}:${pad(m)}`;
77
+ }
78
+ }
79
+ return cronPattern;
80
+ }
81
+
82
+ /** One-shot from an ISO timestamp or an explicit "at:" prefix, else null. */
83
+ function parseAtExpression(str) {
84
+ const atPrefixMatch = str.match(/^(?:at:\s*|at\s+)(.+)$/i);
85
+ const candidateIso = atPrefixMatch ? atPrefixMatch[1].trim() : str;
86
+ const parsedDate = new Date(candidateIso);
87
+ if (isNaN(parsedDate.getTime()) || !(candidateIso.includes('-') || candidateIso.includes('T') || atPrefixMatch)) {
88
+ return null;
89
+ }
90
+ const targetMs = parsedDate.getTime();
91
+ return {
92
+ cronPattern: null,
93
+ isOneShot: true,
94
+ targetTimestamp: targetMs,
95
+ humanText: `One-shot at ${parsedDate.toISOString()}`,
96
+ nextRun: targetMs
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Relative one-shot: "in 20m", "in 2h", "in 30s", "in 1d" and the accepted
102
+ * Russian input aliases ("через 15 минут").
103
+ */
104
+ function parseRelativeOneShot(str) {
105
+ const match = str.match(/^(?:in\s+|через\s+)(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|мин|минут|часа?|часов)?$/i);
106
+ if (!match) return null;
107
+ const num = parseInt(match[1], 10);
108
+ const unit = (match[2] || 'm').toLowerCase();
109
+ let delayMs = num * 60 * 1000;
110
+ let unitText = `${num} min`;
111
+ if (unit.startsWith('s')) {
112
+ delayMs = num * 1000;
113
+ unitText = `${num} sec`;
114
+ } else if (unit.startsWith('h') || unit.startsWith('час')) {
115
+ delayMs = num * 3600 * 1000;
116
+ unitText = `${num} h`;
117
+ } else if (unit.startsWith('d')) {
118
+ delayMs = num * 86400 * 1000;
119
+ unitText = `${num} d`;
120
+ }
121
+ const targetMs = Date.now() + delayMs;
122
+ return {
123
+ cronPattern: null,
124
+ isOneShot: true,
125
+ targetTimestamp: targetMs,
126
+ humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
127
+ nextRun: targetMs
128
+ };
129
+ }
130
+
131
+ /** Repeating interval: "every 5m", "every 2h", "every 1d". */
132
+ function parseIntervalExpression(str) {
133
+ const match = str.match(/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i);
134
+ if (!match) return null;
135
+ const num = parseInt(match[1], 10);
136
+ const unit = (match[2] || 'm').toLowerCase();
137
+ let pattern = null;
138
+ let humanText = null;
139
+
140
+ if (unit.startsWith('m')) {
141
+ if (num < 1) {
142
+ throw new Error('Invalid interval: minute interval must be at least 1 minute');
143
+ }
144
+ if (num >= 60) {
145
+ if (num % 60 === 0) {
146
+ const hours = num / 60;
147
+ if (hours >= 24) {
148
+ if (hours % 24 === 0) {
149
+ const days = hours / 24;
150
+ if (days > 31) {
151
+ throw new Error(`Invalid interval: minute interval "${num}m" (${days} days) exceeds 31 days`);
152
+ }
153
+ pattern = `0 0 */${days} * *`;
154
+ humanText = `Every ${days} days`;
155
+ } else {
156
+ throw new Error(`Invalid interval: minute interval "${num}m" (${hours} hours) cannot be expressed as a cron step`);
157
+ }
158
+ } else {
159
+ pattern = `0 */${hours} * * *`;
160
+ humanText = `Every ${hours} hours`;
161
+ }
162
+ } else {
163
+ throw new Error(`Invalid interval: minute interval "${num}m" exceeds 59 minutes and is not a multiple of 60. Use hours (e.g. every 2h) or a multiple of 60m`);
164
+ }
165
+ } else {
166
+ pattern = `*/${num} * * * *`;
167
+ humanText = `Every ${num} minutes`;
168
+ }
169
+ } else if (unit.startsWith('h')) {
170
+ if (num < 1) {
171
+ throw new Error('Invalid interval: hour interval must be at least 1 hour');
172
+ }
173
+ if (num >= 24) {
174
+ if (num % 24 === 0) {
175
+ const days = num / 24;
176
+ if (days > 31) {
177
+ throw new Error(`Invalid interval: hour interval "${num}h" (${days} days) exceeds 31 days`);
178
+ }
179
+ pattern = `0 0 */${days} * *`;
180
+ humanText = `Every ${days} days`;
181
+ } else {
182
+ throw new Error(`Invalid interval: hour interval "${num}h" exceeds 23 hours and is not a multiple of 24. Use days (e.g. every 2d) or a multiple of 24h`);
183
+ }
184
+ } else {
185
+ pattern = `0 */${num} * * *`;
186
+ humanText = `Every ${num} hours`;
187
+ }
188
+ } else if (unit.startsWith('d')) {
189
+ if (num < 1 || num > 31) {
190
+ throw new Error(`Invalid interval: day interval "${num}d" must be between 1 and 31 days`);
191
+ }
192
+ pattern = `0 0 */${num} * *`;
193
+ humanText = `Every ${num} days`;
194
+ }
195
+
196
+ if (pattern) {
197
+ try {
198
+ const testJob = new Cron(pattern, { sloppyRanges: true });
199
+ const next = testJob.nextRun();
200
+ return {
201
+ cronPattern: pattern,
202
+ humanText,
203
+ isInterval: true,
204
+ nextRun: next ? next.getTime() : null,
205
+ };
206
+ } catch (err) {
207
+ throw new Error(`Invalid interval pattern "${pattern}": ${err.message}`);
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * @-shorthands (#15) and the friendly aliases, including the Russian input
215
+ * aliases. Russian keywords are accepted input, not display strings.
216
+ */
217
+ function makeAliasResult(cronPattern, humanText) {
218
+ try {
219
+ const job = new Cron(cronPattern, { sloppyRanges: true });
220
+ const next = job.nextRun();
221
+ return { cronPattern, humanText, nextRun: next ? next.getTime() : null };
222
+ } catch {
223
+ return { cronPattern, humanText };
224
+ }
225
+ }
226
+
227
+ function parseAliasExpression(str) {
228
+ const lower = str.toLowerCase();
229
+ if (lower === '@hourly') return makeAliasResult('0 * * * *', 'Every hour');
230
+ if (lower === '@daily' || lower === '@midnight') return makeAliasResult('0 0 * * *', 'Every day at 00:00');
231
+ if (lower === '@weekly') return makeAliasResult('0 0 * * 0', 'Every week on Sunday at 00:00');
232
+ if (lower === '@monthly') return makeAliasResult('0 0 1 * *', 'Every month on the 1st at 00:00');
233
+ if (lower === '@yearly' || lower === '@annually') return makeAliasResult('0 0 1 1 *', 'Every year on Jan 1 at 00:00');
234
+ const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
235
+ if (everyAlias) {
236
+ return parseIntervalExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
237
+ }
238
+ if (lower === 'daily' || lower === 'каждый день') {
239
+ return makeAliasResult('0 9 * * *', 'Every day at 09:00');
240
+ }
241
+ if (lower === 'weekdays' || lower === 'в будние дни' || lower === 'по будням') {
242
+ return makeAliasResult('0 9 * * 1-5', 'Weekdays at 09:00');
243
+ }
244
+ if (lower === 'hourly' || lower === 'каждый час') {
245
+ return makeAliasResult('0 * * * *', 'Every hour');
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /** Validate a 5-field cron expression; the only branch that throws. */
251
+ function parseCronExpression(str) {
252
+ try {
253
+ const testJob = new Cron(str, { sloppyRanges: true });
254
+ const next = testJob.nextRun();
255
+ return {
256
+ cronPattern: str,
257
+ humanText: describeCron(str),
258
+ nextRun: next ? next.getTime() : null,
259
+ };
260
+ } catch (err) {
261
+ throw new Error('Invalid cron schedule: ' + err.message);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Turn a typed schedule into a cron pattern or a one-shot target.
267
+ */
268
+ export function parseScheduleExpression(input) {
269
+ const str = String(input || '').trim();
270
+ if (!str) throw new Error('Schedule expression must not be empty');
271
+
272
+ return parseAtExpression(str)
273
+ || parseRelativeOneShot(str)
274
+ || parseIntervalExpression(str)
275
+ || parseAliasExpression(str)
276
+ || parseNaturalSchedule(str)
277
+ || parseCronExpression(str);
278
+ }
279
+
280
+ /**
281
+ * Preview future execution timestamps for a cron or interval schedule.
282
+ */
283
+ export function previewSchedule(scheduleStr, options = {}) {
284
+ const str = String(scheduleStr || '').trim();
285
+ if (!str) throw new Error('Schedule expression must not be empty');
286
+ const parsed = parseScheduleExpression(str);
287
+ const count = Math.min(Math.max(Number(options.count) || 5, 1), 10);
288
+ const timezone = options.timezone || undefined;
289
+
290
+ if (parsed.isOneShot) {
291
+ return {
292
+ isOneShot: true,
293
+ humanText: parsed.humanText,
294
+ runs: [new Date(parsed.nextRun).toISOString()],
295
+ };
296
+ }
297
+
298
+ const job = new Cron(parsed.cronPattern, {
299
+ timezone,
300
+ sloppyRanges: true,
301
+ });
302
+ const runs = [];
303
+ let current = new Date();
304
+ for (let i = 0; i < count; i++) {
305
+ const next = job.nextRun(current);
306
+ if (!next) break;
307
+ runs.push(next.toISOString());
308
+ current = new Date(next.getTime() + 1000);
309
+ }
310
+ return {
311
+ isOneShot: false,
312
+ cronPattern: parsed.cronPattern,
313
+ humanText: describeCron(parsed.cronPattern),
314
+ runs,
315
+ };
316
+ }
317
+
318
+ export class TaskScheduler {
319
+ constructor(store, executeFn, options = {}) {
320
+ const opts = (typeof executeFn === 'object' && executeFn !== null && typeof executeFn !== 'function') ? executeFn : (options || {});
321
+ this.store = store;
322
+ this.executeFn = typeof executeFn === 'function' ? executeFn : (opts.executeFn || null);
323
+ this.maxConcurrent = opts.maxConcurrent !== undefined && Number(opts.maxConcurrent) >= 0 ? Number(opts.maxConcurrent) : 2;
324
+ this.defaultTimezone = opts.defaultTimezone || '';
325
+ this.resolveSecrets = typeof opts.resolveSecrets === 'function' ? opts.resolveSecrets : null;
326
+ this.deliver = typeof opts.deliver === 'function' ? opts.deliver : null;
327
+ this.askModel = typeof opts.askModel === 'function' ? opts.askModel : null;
328
+ this.inspectTimeoutMs = opts.inspectTimeoutMs;
329
+ this.silentRuleTimeoutMs = opts.silentRuleTimeoutMs;
330
+ this.jobs = new Map();
331
+ this.timers = new Map();
332
+ this.retryTimers = new Map();
333
+ this.adaptiveTimers = new Map();
334
+ this.running = new Map();
335
+ this.runCounters = {};
336
+ this.queue = [];
337
+ this.groupLimits = opts.groupLimits instanceof Map ? opts.groupLimits : new Map(Object.entries(opts.groupLimits || {}));
338
+ this.heartbeatTimer = null;
339
+ this.watcherManager = new FileSystemWatcherManager(this);
340
+ this.logger = opts.logger || null;
341
+ this.isStopped = false;
342
+ this.generation = 0;
343
+ }
344
+
345
+ isRunning(taskId) {
346
+ return this.running.has(taskId);
347
+ }
348
+
349
+ runningCount() {
350
+ return this.running.size;
351
+ }
352
+
353
+ runningSince(taskId) {
354
+ const r = this.running.get(taskId);
355
+ return r ? r.startedAt : null;
356
+ }
357
+
358
+ clearRetryTimer(taskId) {
359
+ const t = this.retryTimers.get(taskId);
360
+ if (t) {
361
+ clearTimeout(t);
362
+ this.retryTimers.delete(taskId);
363
+ }
364
+ }
365
+
366
+ clearAdaptiveTimer(taskId) {
367
+ const t = this.adaptiveTimers.get(taskId);
368
+ if (t) {
369
+ clearTimeout(t);
370
+ this.adaptiveTimers.delete(taskId);
371
+ }
372
+ }
373
+
374
+ armAdaptiveTimer(taskId, delayMs) {
375
+ this.clearAdaptiveTimer(taskId);
376
+ if (this.jobs.has(taskId)) {
377
+ this.jobs.get(taskId).stop();
378
+ this.jobs.delete(taskId);
379
+ }
380
+ const task = this.store.get(taskId);
381
+ if (task) {
382
+ task.nextRunAt = Date.now() + delayMs;
383
+ this.store.set(task);
384
+ }
385
+ const timer = setTimeout(() => {
386
+ this.adaptiveTimers.delete(taskId);
387
+ if (!this.isStopped) {
388
+ this.runTask(taskId, { isAdaptive: true }).catch((err) => {
389
+ logger.error(`[dsh-cron] adaptive run of task ${taskId} failed:`, (err && err.message) || err);
390
+ });
391
+ }
392
+ }, delayMs);
393
+ if (timer.unref) timer.unref();
394
+ this.adaptiveTimers.set(taskId, timer);
395
+ }
396
+
397
+ startHeartbeatWatcher(intervalMs = 30000) {
398
+ return startHeartbeatWatcher(this, intervalMs);
399
+ }
400
+
401
+ stopHeartbeatWatcher() {
402
+ return stopHeartbeatWatcher(this);
403
+ }
404
+
405
+ checkHeartbeats() {
406
+ return checkHeartbeats(this);
407
+ }
408
+
409
+ start() {
410
+ this.isStopped = false;
411
+ this.generation = (this.generation || 0) + 1;
412
+
413
+ // Recovery & cleanup on restart (#317, #321)
414
+ if (this.store && this.store.durableJournal) {
415
+ try {
416
+ const recovered = this.store.durableJournal.recoverPendingRuns(this);
417
+ if (recovered.uncertain.length > 0) {
418
+ logger.warn(`[dsh-cron] Recovered ${recovered.uncertain.length} in-flight run(s) interrupted by restart -> status marked uncertain`);
419
+ }
420
+ } catch (recErr) {
421
+ logger.error('[dsh-cron] restart recovery failed:', recErr.message);
422
+ }
423
+ }
424
+ if (this.store && this.store.artifactManager) {
425
+ try {
426
+ this.store.artifactManager.cleanupArtifacts(this.store);
427
+ } catch (cleanErr) {
428
+ logger.warn('[dsh-cron] artifact cleanup failed:', cleanErr.message);
429
+ }
430
+ }
431
+
432
+ const tasks = this.store.list();
433
+ const now = Date.now();
434
+ for (const task of tasks) {
435
+ if (task.status !== 'active') continue;
436
+ const missed = task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000;
437
+ if (missed) {
438
+ const missedAt = task.nextRunAt;
439
+ const policy = task.misfirePolicy || 'skip';
440
+ logger.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()} (misfire: ${policy})`);
441
+ this.countRun('missed');
442
+ this.store.recordRun(task.id, {
443
+ at: missedAt,
444
+ status: 'missed',
445
+ durationMs: 0,
446
+ output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
447
+ error: null,
448
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
449
+ costUsd: 0,
450
+ });
451
+ if (policy === 'skip') {
452
+ if (task.oneShot) {
453
+ task.status = 'completed';
454
+ this.store.set(task);
455
+ continue;
456
+ }
457
+ this.scheduleTask(task);
458
+ continue;
459
+ }
460
+ this.scheduleTask(task);
461
+ setImmediate(() => this.runTask(task.id));
462
+ continue;
463
+ }
464
+ this.scheduleTask(task);
465
+ }
466
+ this.startHeartbeatWatcher();
467
+ if (this.watcherManager) {
468
+ this.watcherManager.syncAll(tasks);
469
+ }
470
+ }
471
+
472
+ stopAll() {
473
+ this.isStopped = true;
474
+ this.generation = (this.generation || 0) + 1;
475
+ this.stopHeartbeatWatcher();
476
+ if (this.watcherManager) {
477
+ this.watcherManager.stopAll();
478
+ }
479
+
480
+ // Clear concurrency queue (#217)
481
+ this.queue = [];
482
+
483
+ for (const [, job] of this.jobs.entries()) {
484
+ bestEffort('stop-job', () => job.stop(), this.logger);
485
+ }
486
+ this.jobs.clear();
487
+
488
+ for (const [, timer] of this.timers.entries()) {
489
+ bestEffort('clear-timer', () => clearTimeout(timer), this.logger);
490
+ }
491
+ this.timers.clear();
492
+
493
+ for (const [, timer] of this.retryTimers.entries()) {
494
+ bestEffort('clear-retry-timer', () => clearTimeout(timer), this.logger);
495
+ }
496
+ this.retryTimers.clear();
497
+
498
+ for (const [, timer] of this.adaptiveTimers.entries()) {
499
+ bestEffort('clear-adaptive-timer', () => clearTimeout(timer), this.logger);
500
+ }
501
+ this.adaptiveTimers.clear();
502
+
503
+ for (const [, run] of this.running.entries()) {
504
+ bestEffort('abort-running', () => run.controller.abort(new Error('Scheduler stopped')), this.logger);
505
+ }
506
+ this.running.clear();
507
+
508
+ if (this.store && typeof this.store.flushSync === 'function') {
509
+ bestEffort('store-flush-on-stop', () => this.store.flushSync(), this.logger);
510
+ }
511
+ }
512
+
513
+ clearScheduled(taskId) {
514
+ this.clearAdaptiveTimer(taskId);
515
+ if (this.watcherManager) {
516
+ this.watcherManager.unwatchTask(taskId);
517
+ }
518
+ if (this.jobs.has(taskId)) {
519
+ this.jobs.get(taskId).stop();
520
+ this.jobs.delete(taskId);
521
+ }
522
+ if (this.timers.has(taskId)) {
523
+ clearTimeout(this.timers.get(taskId));
524
+ this.timers.delete(taskId);
525
+ }
526
+ }
527
+
528
+ scheduleOneShot(task, parsed) {
529
+ task.oneShot = true;
530
+ task.targetTimestamp = parsed.targetTimestamp;
531
+ task.nextRunAt = parsed.targetTimestamp;
532
+ this.store.set(task);
533
+
534
+ const armTimer = () => {
535
+ const remaining = parsed.targetTimestamp - Date.now();
536
+ if (remaining <= 0) {
537
+ this.timers.delete(task.id);
538
+ this.runTask(task.id).catch((err) => {
539
+ logger.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
540
+ });
541
+ return;
542
+ }
543
+ const delay = Math.min(remaining, MAX_TIMEOUT_MS);
544
+ const timer = setTimeout(() => {
545
+ if (Date.now() >= parsed.targetTimestamp) {
546
+ this.timers.delete(task.id);
547
+ this.runTask(task.id).catch((err) => {
548
+ logger.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
549
+ });
550
+ } else {
551
+ armTimer();
552
+ }
553
+ }, delay);
554
+ this.timers.set(task.id, timer);
555
+ };
556
+
557
+ armTimer();
558
+ }
559
+
560
+ scheduleCron(task, parsed) {
561
+ const timezone = task.timezone || this.defaultTimezone || undefined;
562
+ const job = new Cron(parsed.cronPattern, {
563
+ sloppyRanges: true,
564
+ catch: (err) => logger.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
565
+ ...(timezone ? { timezone } : {}),
566
+ }, async () => {
567
+ await this.runTask(task.id);
568
+ });
569
+
570
+ this.jobs.set(task.id, job);
571
+ const next = job.nextRun();
572
+ if (next) {
573
+ task.nextRunAt = next.getTime();
574
+ this.store.set(task);
575
+ }
576
+ }
577
+
578
+ scheduleTask(task) {
579
+ this.clearScheduled(task.id);
580
+ if (task.status !== 'active') return;
581
+ this.clearRetryTimer(task.id);
582
+ if (this.watcherManager) {
583
+ this.watcherManager.watchTask(task);
584
+ }
585
+
586
+ try {
587
+ const parsed = parseScheduleExpression(task.schedule);
588
+ if (parsed.isOneShot) {
589
+ const existingTarget = task.targetTimestamp || task.nextRunAt;
590
+ if (existingTarget && typeof existingTarget === 'number' && !isNaN(existingTarget)) {
591
+ parsed.targetTimestamp = existingTarget;
592
+ parsed.nextRun = existingTarget;
593
+ }
594
+ this.scheduleOneShot(task, parsed);
595
+ return;
596
+ }
597
+ this.scheduleCron(task, parsed);
598
+ } catch (err) {
599
+ logger.error(`[dsh-cron] failed to schedule task ${task.id}:`, err.message);
600
+ }
601
+ }
602
+
603
+ getGroupLimit(group) {
604
+ if (!group || group === 'default') return 0;
605
+ if (this.groupLimits && this.groupLimits.has(group)) {
606
+ return Number(this.groupLimits.get(group)) || 0;
607
+ }
608
+ return 1;
609
+ }
610
+
611
+ getRunningGroupCount(group) {
612
+ if (!group || group === 'default') return 0;
613
+ let count = 0;
614
+ for (const [, run] of this.running.entries()) {
615
+ if (run.concurrencyGroup === group) {
616
+ count++;
617
+ }
618
+ }
619
+ return count;
620
+ }
621
+
622
+ beginRun(task, taskId, options = {}) {
623
+ return beginRun(this, task, taskId, options);
624
+ }
625
+
626
+ countRun(status) {
627
+ const key = status || 'unknown';
628
+ this.runCounters[key] = (this.runCounters[key] || 0) + 1;
629
+ }
630
+
631
+ getRunCounters() {
632
+ return { ...this.runCounters };
633
+ }
634
+
635
+ recordSkipped(taskId, reason) {
636
+ this.countRun('skipped');
637
+ const runId = 'run_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now();
638
+ this.store.recordRun(taskId, {
639
+ runId,
640
+ at: Date.now(),
641
+ status: 'skipped',
642
+ durationMs: 0,
643
+ output: reason,
644
+ error: null,
645
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
646
+ costUsd: 0,
647
+ stoppedReason: reason,
648
+ stages: [
649
+ { name: 'queue', status: 'skipped', durationMs: 0 },
650
+ { name: 'preflight', status: 'skipped', durationMs: 0, error: reason },
651
+ { name: 'execution', status: 'skipped', durationMs: 0 },
652
+ { name: 'validation', status: 'skipped', durationMs: 0 },
653
+ { name: 'delivery', status: 'skipped', durationMs: 0 },
654
+ ],
655
+ cost: { amount: 0, currency: 'USD', kind: 'exact' },
656
+ delivery: { status: 'skipped', delivered: [], failures: [] },
657
+ });
658
+ }
659
+
660
+ async executeOnce(task, signal, options = {}) {
661
+ return executeOnce(this, task, signal, options);
662
+ }
663
+
664
+ async executeWithFallback(task, signal, options = {}) {
665
+ return executeWithFallback(this, task, signal, options);
666
+ }
667
+
668
+ shouldUseFallback(task, outcome) {
669
+ return shouldUseFallback(task, outcome);
670
+ }
671
+
672
+ async runTask(taskId, options = {}) {
673
+ if (this.isStopped) return null;
674
+ return executeTask(this, taskId, options);
675
+ }
676
+
677
+ async completeRun(task, taskId, currentRun, outcome, start, options = {}) {
678
+ return handleCompleteRun(this, task, taskId, currentRun, outcome, start, options);
679
+ }
680
+
681
+ async applyModelAssists(task, runInfo) {
682
+ return applyModelAssists(task, runInfo, {
683
+ askModel: this.askModel,
684
+ readSettingsFn: () => this.readSettings(),
685
+ inspectTimeoutMs: this.inspectTimeoutMs,
686
+ silentRuleTimeoutMs: this.silentRuleTimeoutMs,
687
+ });
688
+ }
689
+
690
+ async inspectFailureRun(task, runInfo) {
691
+ return inspectFailureRun(task, runInfo, {
692
+ askModel: this.askModel,
693
+ readSettingsFn: () => this.readSettings(),
694
+ inspectTimeoutMs: this.inspectTimeoutMs || 25000,
695
+ });
696
+ }
697
+
698
+ readSettings() {
699
+ try {
700
+ return typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
701
+ } catch (err) {
702
+ bestEffort('readSettings', () => {}, this.logger);
703
+ return {};
704
+ }
705
+ }
706
+
707
+ async applySilentRuleToRun(task, runInfo) {
708
+ return applySilentRuleToRun(task, runInfo, {
709
+ askModel: this.askModel,
710
+ readSettingsFn: () => this.readSettings(),
711
+ silentRuleTimeoutMs: this.silentRuleTimeoutMs || 20000,
712
+ });
713
+ }
714
+
715
+ async deliverNotifications(task, runInfo) {
716
+ return deliverNotifications(task, runInfo, {
717
+ store: this.store,
718
+ resolveSecrets: this.resolveSecrets,
719
+ deliver: this.deliver,
720
+ });
721
+ }
722
+
723
+ finishRun(task, taskId, status, queuedCount, meta) {
724
+ return finishRun(this, task, taskId, status, queuedCount, meta);
725
+ }
726
+
727
+ pauseTask(taskId, reason) {
728
+ return pauseTask(this, taskId, reason);
729
+ }
730
+
731
+ removeTask(taskId) {
732
+ return removeTask(this, taskId);
733
+ }
734
+
735
+ resumeTask(taskId) {
736
+ return resumeTask(this, taskId);
737
+ }
738
+
739
+ toggleTask(taskId) {
740
+ return toggleTask(this, taskId);
741
+ }
742
+
743
+ triggerManualRun(taskId, options = {}) {
744
+ return this.runTask(taskId, options);
745
+ }
746
+
747
+ runNow(taskId, options = {}) {
748
+ return this.runTask(taskId, options);
749
+ }
750
+ }
751
+