@goodandready/dsh-cron 0.2.18 → 0.2.20

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/lib/scheduler.js CHANGED
@@ -1,21 +1,54 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  import { Cron } from 'croner';
2
- import { exec } from 'node:child_process';
3
- import fs from 'node:fs';
4
- import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
5
- import { applySilentRule } from './silent-rule.js';
6
- import { inspectFailure } from './failure-inspector.js';
7
-
8
- /** Task types that talk to a model and can therefore use a fallback model. */
9
- const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
10
-
11
- /** Sum two usage records, so a fallback attempt is not lost in accounting. */
12
- function addUsage(a, b) {
13
- return {
14
- inputTokens: (a?.inputTokens || 0) + (b?.inputTokens || 0),
15
- outputTokens: (a?.outputTokens || 0) + (b?.outputTokens || 0),
16
- cacheReadTokens: (a?.cacheReadTokens || 0) + (b?.cacheReadTokens || 0),
17
- };
18
- }
3
+ import {
4
+ AGENT_TASK_TYPES,
5
+ addUsage,
6
+ executePreflight,
7
+ shouldUseFallback,
8
+ executeOnce,
9
+ executeWithFallback,
10
+ beginRun,
11
+ executeTask,
12
+ inspectFailureRun,
13
+ applySilentRuleToRun,
14
+ applyModelAssists,
15
+ deliverNotifications,
16
+ finishRun,
17
+ handleCompleteRun,
18
+ } from './scheduler-execution.js';
19
+ import {
20
+ startHeartbeatWatcher,
21
+ stopHeartbeatWatcher,
22
+ checkHeartbeats,
23
+ pauseTask,
24
+ removeTask,
25
+ resumeTask,
26
+ toggleTask,
27
+ } from './scheduler-lifecycle.js';
28
+
29
+ export {
30
+ executePreflight,
31
+ AGENT_TASK_TYPES,
32
+ addUsage,
33
+ shouldUseFallback,
34
+ executeOnce,
35
+ executeWithFallback,
36
+ beginRun,
37
+ executeTask,
38
+ inspectFailureRun,
39
+ applySilentRuleToRun,
40
+ applyModelAssists,
41
+ deliverNotifications,
42
+ finishRun,
43
+ handleCompleteRun,
44
+ startHeartbeatWatcher,
45
+ stopHeartbeatWatcher,
46
+ checkHeartbeats,
47
+ pauseTask,
48
+ removeTask,
49
+ resumeTask,
50
+ toggleTask,
51
+ };
19
52
 
20
53
  function pad(v) {
21
54
  return String(v).padStart(2, '0');
@@ -119,7 +152,6 @@ function parseAliasExpression(str) {
119
152
  if (lower === '@yearly' || lower === '@annually') return { cronPattern: '0 0 1 1 *', humanText: 'Every year on Jan 1 at 00:00' };
120
153
  const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
121
154
  if (everyAlias) {
122
- // "@every N unit" is another spelling of the interval branch.
123
155
  return parseIntervalExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
124
156
  }
125
157
  if (lower === 'daily' || lower === 'каждый день') {
@@ -137,9 +169,6 @@ function parseAliasExpression(str) {
137
169
  /** Validate a 5-field cron expression; the only branch that throws. */
138
170
  function parseCronExpression(str) {
139
171
  try {
140
- // #117: croner 10 made numeric-prefix steps (0/10, 30/30) a parse error,
141
- // but stored schedules created under croner 9 use them. sloppyRanges
142
- // restores the croner 9 range behaviour so existing tasks keep firing.
143
172
  const testJob = new Cron(str, { sloppyRanges: true });
144
173
  const next = testJob.nextRun();
145
174
  return {
@@ -153,9 +182,7 @@ function parseCronExpression(str) {
153
182
  }
154
183
 
155
184
  /**
156
- * Turn a typed schedule into a cron pattern or a one-shot target. The branch
157
- * order is the contract: a timestamp, a relative one-shot, an interval and an
158
- * alias are all more specific than a raw cron expression.
185
+ * Turn a typed schedule into a cron pattern or a one-shot target.
159
186
  */
160
187
  export function parseScheduleExpression(input) {
161
188
  const str = String(input || '').trim();
@@ -206,75 +233,26 @@ export function previewSchedule(scheduleStr, options = {}) {
206
233
  };
207
234
  }
208
235
 
209
- /**
210
- * Execute pre-flight check gate before running task body.
211
- */
212
- export async function executePreflight(task) {
213
- const type = String(task.preflightType || 'none').toLowerCase();
214
- const target = String(task.preflightTarget || '').trim();
215
- if (!type || type === 'none' || !target) {
216
- return { ok: true };
217
- }
218
-
219
- if (type === 'http') {
220
- try {
221
- const controller = new AbortController();
222
- const timeout = setTimeout(() => controller.abort(), 5000);
223
- const res = await fetch(target, { signal: controller.signal });
224
- clearTimeout(timeout);
225
- if (res.ok) return { ok: true };
226
- return { ok: false, reason: `HTTP check returned status ${res.status}` };
227
- } catch (err) {
228
- return { ok: false, reason: `HTTP check failed: ${err.message}` };
229
- }
230
- }
231
-
232
- if (type === 'command') {
233
- return new Promise((resolve) => {
234
- exec(target, { timeout: 5000 }, (err, stdout, stderr) => {
235
- if (err) {
236
- resolve({ ok: false, reason: `Preflight command failed (code ${err.code || 1}): ${(stderr || stdout || err.message).trim().slice(0, 150)}` });
237
- } else {
238
- resolve({ ok: true });
239
- }
240
- });
241
- });
242
- }
243
-
244
- if (type === 'disk') {
245
- const requiredMb = Number(target) || 100;
246
- try {
247
- if (typeof fs.statfsSync === 'function') {
248
- const stats = fs.statfsSync(process.cwd());
249
- const freeMb = Math.round((stats.bavail * stats.bsize) / (1024 * 1024));
250
- if (freeMb < requiredMb) {
251
- return { ok: false, reason: `Free disk space (${freeMb} MB) is below required ${requiredMb} MB` };
252
- }
253
- }
254
- return { ok: true };
255
- } catch {
256
- return { ok: true }; // Fail-open
257
- }
258
- }
259
-
260
- return { ok: true };
261
- }
262
-
263
236
  export class TaskScheduler {
264
237
  constructor(store, executeFn, options = {}) {
238
+ const opts = (typeof executeFn === 'object' && executeFn !== null && typeof executeFn !== 'function') ? executeFn : (options || {});
265
239
  this.store = store;
266
- this.executeFn = executeFn;
267
- this.maxConcurrent = options.maxConcurrent !== undefined && Number(options.maxConcurrent) >= 0 ? Number(options.maxConcurrent) : 2; // default 2 parallel runs (#134, #52)
268
- this.defaultTimezone = options.defaultTimezone || ''; // #13
269
- this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
270
- this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
271
- this.jobs = new Map(); // taskId -> Cron instance
272
- this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
273
- this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
274
- this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
275
- this.runCounters = {}; // status -> finished runs since start (#53, /dsh-cron/metrics)
276
- this.queue = []; // [{ taskId, priority, queuedAt, options }]
240
+ this.executeFn = typeof executeFn === 'function' ? executeFn : (opts.executeFn || null);
241
+ this.maxConcurrent = opts.maxConcurrent !== undefined && Number(opts.maxConcurrent) >= 0 ? Number(opts.maxConcurrent) : 2;
242
+ this.defaultTimezone = opts.defaultTimezone || '';
243
+ this.resolveSecrets = typeof opts.resolveSecrets === 'function' ? opts.resolveSecrets : null;
244
+ this.deliver = typeof opts.deliver === 'function' ? opts.deliver : null;
245
+ this.askModel = typeof opts.askModel === 'function' ? opts.askModel : null;
246
+ this.inspectTimeoutMs = opts.inspectTimeoutMs;
247
+ this.silentRuleTimeoutMs = opts.silentRuleTimeoutMs;
248
+ this.jobs = new Map();
249
+ this.timers = new Map();
250
+ this.retryTimers = new Map();
251
+ this.running = new Map();
252
+ this.runCounters = {};
253
+ this.queue = [];
277
254
  this.heartbeatTimer = null;
255
+ this.logger = opts.logger || null;
278
256
  }
279
257
 
280
258
  isRunning(taskId) {
@@ -286,77 +264,28 @@ export class TaskScheduler {
286
264
  }
287
265
 
288
266
  runningSince(taskId) {
289
- const run = this.running.get(taskId);
290
- return run ? run.startedAt : null;
267
+ const r = this.running.get(taskId);
268
+ return r ? r.startedAt : null;
291
269
  }
292
270
 
293
271
  clearRetryTimer(taskId) {
294
- const timer = this.retryTimers.get(taskId);
295
- if (timer !== undefined) {
296
- clearTimeout(timer);
272
+ const t = this.retryTimers.get(taskId);
273
+ if (t) {
274
+ clearTimeout(t);
297
275
  this.retryTimers.delete(taskId);
298
276
  }
299
277
  }
300
278
 
301
279
  startHeartbeatWatcher(intervalMs = 30000) {
302
- this.stopHeartbeatWatcher();
303
- this.heartbeatTimer = setInterval(() => this.checkHeartbeats(), intervalMs);
304
- if (this.heartbeatTimer && typeof this.heartbeatTimer.unref === 'function') {
305
- this.heartbeatTimer.unref();
306
- }
280
+ return startHeartbeatWatcher(this, intervalMs);
307
281
  }
308
282
 
309
283
  stopHeartbeatWatcher() {
310
- if (this.heartbeatTimer) {
311
- clearInterval(this.heartbeatTimer);
312
- this.heartbeatTimer = null;
313
- }
284
+ return stopHeartbeatWatcher(this);
314
285
  }
315
286
 
316
- async checkHeartbeats() {
317
- const now = Date.now();
318
- const tasks = typeof this.store.list === 'function' ? this.store.list({ status: 'active' }) : Array.from(this.store.tasks.values());
319
- for (const task of tasks) {
320
- if (task.status !== 'active' || !task.heartbeatIntervalSeconds || task.heartbeatIntervalSeconds <= 0) {
321
- continue;
322
- }
323
- const grace = task.gracePeriodSeconds !== undefined ? task.gracePeriodSeconds : 300;
324
- const last = task.lastPingAt || task.createdAt || now;
325
- const deadline = last + (task.heartbeatIntervalSeconds + grace) * 1000;
326
-
327
- if (now > deadline && !task.heartbeatAlerted) {
328
- task.heartbeatAlerted = true;
329
- task.lastStatus = 'missed';
330
- task.updatedAt = now;
331
- this.store.save();
332
-
333
- console.warn(`[dsh-cron] Heartbeat missed for "${task.title}" (${task.id})!`);
334
- const runInfo = {
335
- at: now,
336
- status: 'missed',
337
- durationMs: 0,
338
- output: `Heartbeat missed: expected ping within ${task.heartbeatIntervalSeconds + grace}s, last ping was at ${new Date(last).toISOString()}`,
339
- error: 'Heartbeat deadline exceeded',
340
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
341
- costUsd: 0,
342
- };
343
-
344
- try {
345
- this.store.recordRun(task.id, runInfo);
346
- } catch {}
347
-
348
- await this.deliverNotifications(task, runInfo);
349
-
350
- if (task.onFailure && String(task.onFailure).trim()) {
351
- const targetId = task.onFailure.trim();
352
- this.runNow(targetId, {
353
- chainDepth: 1,
354
- prevOutput: runInfo.output,
355
- prevTaskId: task.id,
356
- }).catch((err) => console.warn(`[dsh-cron] heartbeat onFailure trigger error:`, err.message));
357
- }
358
- }
359
- }
287
+ checkHeartbeats() {
288
+ return checkHeartbeats(this);
360
289
  }
361
290
 
362
291
  start() {
@@ -364,11 +293,10 @@ export class TaskScheduler {
364
293
  const now = Date.now();
365
294
  for (const task of tasks) {
366
295
  if (task.status !== 'active') continue;
367
- // Detect missed execution if daemon was offline (threshold: > 5 minutes)
368
296
  const missed = task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000;
369
297
  if (missed) {
370
298
  const missedAt = task.nextRunAt;
371
- const policy = task.misfirePolicy || 'skip'; // #12: skip | runOnce | catchUpAll
299
+ const policy = task.misfirePolicy || 'skip';
372
300
  console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()} (misfire: ${policy})`);
373
301
  this.countRun('missed');
374
302
  this.store.recordRun(task.id, {
@@ -381,8 +309,6 @@ export class TaskScheduler {
381
309
  costUsd: 0,
382
310
  });
383
311
  if (policy === 'skip') {
384
- // A missed one-shot has no future slot left: retire it instead of
385
- // arming a timer in the past.
386
312
  if (task.oneShot) {
387
313
  task.status = 'completed';
388
314
  this.store.set(task);
@@ -391,7 +317,6 @@ export class TaskScheduler {
391
317
  this.scheduleTask(task);
392
318
  continue;
393
319
  }
394
- // runOnce / catchUpAll: run late exactly once, then resume the schedule.
395
320
  this.scheduleTask(task);
396
321
  setImmediate(() => this.runTask(task.id));
397
322
  continue;
@@ -403,36 +328,27 @@ export class TaskScheduler {
403
328
 
404
329
  stopAll() {
405
330
  this.stopHeartbeatWatcher();
406
- for (const [id, job] of this.jobs.entries()) {
407
- try {
408
- job.stop();
409
- } catch {}
331
+ for (const [, job] of this.jobs.entries()) {
332
+ bestEffort('stop-job', () => job.stop(), this.logger);
410
333
  }
411
334
  this.jobs.clear();
412
335
 
413
- for (const [id, timer] of this.timers.entries()) {
414
- try {
415
- clearTimeout(timer);
416
- } catch {}
336
+ for (const [, timer] of this.timers.entries()) {
337
+ bestEffort('clear-timer', () => clearTimeout(timer), this.logger);
417
338
  }
418
339
  this.timers.clear();
419
340
 
420
- for (const [id, timer] of this.retryTimers.entries()) {
421
- try {
422
- clearTimeout(timer);
423
- } catch {}
341
+ for (const [, timer] of this.retryTimers.entries()) {
342
+ bestEffort('clear-retry-timer', () => clearTimeout(timer), this.logger);
424
343
  }
425
344
  this.retryTimers.clear();
426
345
 
427
346
  for (const [, run] of this.running.entries()) {
428
- try {
429
- run.controller.abort(new Error('Scheduler stopped'));
430
- } catch {}
347
+ bestEffort('abort-running', () => run.controller.abort(new Error('Scheduler stopped')), this.logger);
431
348
  }
432
349
  this.running.clear();
433
350
  }
434
351
 
435
- /** Drop an armed job or one-shot timer before re-scheduling a task. */
436
352
  clearScheduled(taskId) {
437
353
  if (this.jobs.has(taskId)) {
438
354
  this.jobs.get(taskId).stop();
@@ -444,7 +360,6 @@ export class TaskScheduler {
444
360
  }
445
361
  }
446
362
 
447
- /** Arm a one-shot task; a target already in the past fires immediately. */
448
363
  scheduleOneShot(task, parsed) {
449
364
  task.oneShot = true;
450
365
  task.nextRunAt = parsed.targetTimestamp;
@@ -460,12 +375,11 @@ export class TaskScheduler {
460
375
  this.timers.set(task.id, timer);
461
376
  }
462
377
 
463
- /** Arm a recurring cron job and persist the next run timestamp. */
464
378
  scheduleCron(task, parsed) {
465
379
  const timezone = task.timezone || this.defaultTimezone || undefined;
466
380
  const job = new Cron(parsed.cronPattern, {
467
381
  protect: true,
468
- sloppyRanges: true, // #117: keep croner 9 range syntax working
382
+ sloppyRanges: true,
469
383
  catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
470
384
  ...(timezone ? { timezone } : {}),
471
385
  }, async () => {
@@ -497,64 +411,19 @@ export class TaskScheduler {
497
411
  }
498
412
  }
499
413
 
500
- /**
501
- * Pre-flight guards for a run: the global concurrency throttle (#52) and the
502
- * per-task overlap policy. Returns the run token to continue with, or null
503
- * when this tick must not execute (the reason is already recorded).
504
- */
505
414
  beginRun(task, taskId) {
506
- // Global concurrency throttle (#52): a run that would exceed the limit is
507
- // skipped and recorded; already-running tasks are never throttled against
508
- // themselves.
509
- if (!this.running.has(taskId) && this.maxConcurrent > 0 && this.running.size >= this.maxConcurrent) {
510
- if (task.overlapPolicy === 'queue') {
511
- console.log(`[dsh-cron] Task "${task.title}" (${taskId}) queued by concurrency limit (${this.maxConcurrent})`);
512
- this.queue.push({ taskId, priority: task.priority !== undefined ? task.priority : 5, queuedAt: Date.now() });
513
- this.queue.sort((a, b) => (a.priority - b.priority) || (a.queuedAt - b.queuedAt));
514
- return null;
515
- }
516
- console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
517
- this.recordSkipped(taskId, `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`);
518
- return null;
519
- }
520
-
521
- const overlapPolicy = task.overlapPolicy || 'skip';
522
- const active = this.running.get(taskId);
523
- if (active) {
524
- if (overlapPolicy === 'skip') {
525
- console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, skipping run (overlapPolicy: skip)`);
526
- this.recordSkipped(taskId, 'Skipped: the previous run is still in progress (overlapPolicy: skip)');
527
- return null;
528
- }
529
- if (overlapPolicy === 'replace') {
530
- console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
531
- active.controller.abort(new Error('Aborted by overlap policy: replace'));
532
- }
533
- if (overlapPolicy === 'queue') {
534
- console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
535
- active.queueCount = (active.queueCount || 0) + 1;
536
- return null;
537
- }
538
- }
539
-
540
- const controller = new AbortController();
541
- const currentRun = { controller, startedAt: Date.now(), queueCount: 0 };
542
- this.running.set(taskId, currentRun);
543
- return currentRun;
415
+ return beginRun(this, task, taskId);
544
416
  }
545
417
 
546
- /** Count a finished run for the metrics endpoint (#53). */
547
418
  countRun(status) {
548
419
  const key = status || 'unknown';
549
420
  this.runCounters[key] = (this.runCounters[key] || 0) + 1;
550
421
  }
551
422
 
552
- /** Snapshot of the run counters; the caller must not mutate it. */
553
423
  getRunCounters() {
554
424
  return { ...this.runCounters };
555
425
  }
556
426
 
557
- /** Record a run that never started, with the reason in the history entry. */
558
427
  recordSkipped(taskId, reason) {
559
428
  this.countRun('skipped');
560
429
  this.store.recordRun(taskId, {
@@ -568,479 +437,86 @@ export class TaskScheduler {
568
437
  });
569
438
  }
570
439
 
571
- /** Run the task body once and normalise whatever the executor returned. */
572
440
  async executeOnce(task, signal, options = {}) {
573
- const result = {
574
- status: 'success',
575
- output: '',
576
- error: null,
577
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
578
- costUsd: 0,
579
- sessionId: null,
580
- model: task.model || '',
581
- fallback: false,
582
- };
583
- if (typeof this.executeFn !== 'function') return result;
584
- try {
585
- const res = await this.executeFn(task, { signal, ...options });
586
- if (typeof res === 'object' && res !== null) {
587
- result.output = res.output || '';
588
- result.usage = res.usage || result.usage;
589
- result.costUsd = res.costUsd || 0;
590
- result.sessionId = res.sessionId || null;
591
- if (res.model) result.model = res.model;
592
- } else {
593
- result.output = String(res || '');
594
- }
595
- } catch (err) {
596
- result.error = err.message || String(err);
597
- const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
598
- result.status = timedOut ? 'timeout' : 'error';
599
- }
600
- return result;
441
+ return executeOnce(this, task, signal, options);
601
442
  }
602
443
 
603
- /**
604
- * Run the task, retrying once on the configured fallback model when the
605
- * primary attempt failed (#45).
606
- *
607
- * The point is cost: a task can default to the cheap model and still finish
608
- * on the strong one when the cheap model cannot do the job. Exactly one
609
- * fallback attempt is made — the ordinary retry backoff still applies
610
- * afterwards — and usage and cost of BOTH attempts are summed, because both
611
- * were really spent.
612
- */
613
444
  async executeWithFallback(task, signal, options = {}) {
614
- const primary = await this.executeOnce(task, signal, options);
615
- if (!this.shouldUseFallback(task, primary)) return primary;
616
-
617
- const fallbackTask = {
618
- ...task,
619
- provider: task.fallbackProvider || task.provider,
620
- model: task.fallbackModel,
621
- };
622
- console.log(`[dsh-cron] task "${task.title}" failed on ${task.model || 'the default model'}, retrying once on ${task.fallbackModel}`);
623
- const secondary = await this.executeOnce(fallbackTask, signal, options);
624
- return {
625
- ...secondary,
626
- fallback: true,
627
- usage: addUsage(primary.usage, secondary.usage),
628
- costUsd: Number((primary.costUsd + secondary.costUsd).toFixed(6)),
629
- primaryModel: task.model || '',
630
- };
445
+ return executeWithFallback(this, task, signal, options);
631
446
  }
632
447
 
633
- /** Only agent-mediated types use a model, and only a failed run falls back. */
634
448
  shouldUseFallback(task, outcome) {
635
- if (!task || !task.fallbackModel) return false;
636
- if (!AGENT_TASK_TYPES.includes(task.type || 'llm')) return false;
637
- return outcome.status === 'error' || outcome.status === 'timeout';
449
+ return shouldUseFallback(task, outcome);
638
450
  }
639
451
 
640
-
641
452
  async runTask(taskId, options = {}) {
642
- const task = this.store.get(taskId);
643
- if (!task) return null;
644
-
645
- if (!options.isRetry && task.attempts) {
646
- task.attempts = 0;
647
- this.store.set(task);
648
- }
649
-
650
- if (options.dryRun) {
651
- const start = Date.now();
652
- const outcome = await this.executeWithFallback(task, AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)), options);
653
- return {
654
- ok: true,
655
- dryRun: true,
656
- status: outcome.status,
657
- output: outcome.output,
658
- error: outcome.error,
659
- durationMs: Date.now() - start,
660
- usage: outcome.usage,
661
- costUsd: outcome.costUsd,
662
- model: outcome.model || task.model || '',
663
- };
664
- }
665
-
666
- const currentRun = this.beginRun(task, taskId);
667
- if (currentRun === null) return null;
668
-
669
- const preflight = await executePreflight(task);
670
- if (!preflight.ok) {
671
- console.log(`[dsh-cron] task "${task.title}" (${taskId}) skipped: ${preflight.reason}`);
672
- this.running.delete(taskId);
673
- this.recordSkipped(taskId, `Preflight check skipped: ${preflight.reason}`);
674
- return null;
675
- }
676
-
677
- const start = Date.now();
678
- const outcome = await this.executeWithFallback(task, currentRun.controller.signal, options);
679
- await this.completeRun(task, taskId, currentRun, outcome, start, options);
680
- return outcome;
453
+ return executeTask(this, taskId, options);
681
454
  }
682
455
 
683
- /**
684
- * Record the run, release the run slot and hand the result to delivery and
685
- * bookkeeping. Kept separate from runTask so the run flow reads in steps.
686
- */
687
456
  async completeRun(task, taskId, currentRun, outcome, start, options = {}) {
688
- const isSuccess = outcome.status === 'success';
689
- const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
690
- const runInfo = {
691
- at: start,
692
- status: outcome.status,
693
- durationMs: Date.now() - start,
694
- output: outcome.output,
695
- error: outcome.error,
696
- usage: outcome.usage,
697
- costUsd: outcome.costUsd,
698
- sessionId: outcome.sessionId,
699
- // #45: which model actually produced this result, and whether the run
700
- // only finished thanks to the configured fallback.
701
- model: outcome.model || task.model || '',
702
- fallback: Boolean(outcome.fallback),
703
- };
704
-
705
- // #43/#44 decide before the run is recorded, so the single history entry
706
- // already carries the diagnosis and the silence verdict.
707
- // 3. Self-healing & Auto-diagnose (#139)
708
- if (isFailure && task.selfHealingCommand) {
709
- console.log(`[dsh-cron] running self-healing command for task "${task.title}": ${task.selfHealingCommand}`);
710
- try {
711
- await new Promise((resolve) => {
712
- exec(task.selfHealingCommand, { timeout: 30000 }, (shErr, stdout, stderr) => {
713
- runInfo.selfHealing = {
714
- command: task.selfHealingCommand,
715
- success: !shErr,
716
- output: (stdout + (stderr ? '\n' + stderr : '')).trim().slice(0, 500),
717
- };
718
- resolve();
719
- });
720
- });
721
- } catch (shErr) {
722
- runInfo.selfHealing = { command: task.selfHealingCommand, success: false, error: shErr.message };
723
- }
724
- }
725
-
726
- if (isFailure && task.autoDiagnose && typeof this.executeFn === 'function') {
727
- try {
728
- const diagRes = await this.executeFn({
729
- type: 'llm',
730
- prompt: `Diagnose this task failure in 1-2 concise sentences. Error: ${outcome.error || 'unknown'}. Recent output: ${(outcome.output || '').slice(-300)}`,
731
- model: task.fallbackModel || task.model,
732
- }, { signal: AbortSignal.timeout(10000) });
733
- if (diagRes && diagRes.output) {
734
- runInfo.autoDiagnosis = diagRes.output.trim();
735
- }
736
- } catch {
737
- // Fail-open
738
- }
739
- }
740
-
741
- const silent = await this.applyModelAssists(task, runInfo);
742
-
743
- try {
744
- this.store.recordRun(taskId, runInfo);
745
- } catch (recordErr) {
746
- console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
747
- }
748
-
749
- // Release the slot before delivery so a long notification never blocks the
750
- // next tick of this task.
751
- const finishedRun = this.running.get(taskId);
752
- if (finishedRun === currentRun) this.running.delete(taskId);
753
- const queuedCount = finishedRun?.queueCount || 0;
754
-
755
- if (!silent.skipped) await this.deliverNotifications(task, runInfo);
756
- this.finishRun(task, taskId, outcome.status, queuedCount);
757
-
758
- while (this.queue.length > 0 && this.running.size < this.maxConcurrent) {
759
- const nextItem = this.queue.shift();
760
- if (!nextItem) break;
761
- const queuedTask = this.store.get(nextItem.taskId);
762
- if (queuedTask && queuedTask.status === 'active') {
763
- setImmediate(() => {
764
- this.runTask(nextItem.taskId).catch(() => {});
765
- });
766
- break;
767
- }
768
- }
769
-
770
- // 1. Structured LLM Actions (#137)
771
- if (task.type === 'llm' && outcome.status === 'success' && outcome.output) {
772
- try {
773
- const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
774
- if (settings.llmActionsEnabled) {
775
- const directives = parseLlmActionDirectives(outcome.output);
776
- if (directives.length > 0) {
777
- await executeLlmActionDirectives({
778
- directives,
779
- task,
780
- runInfo,
781
- scheduler: this,
782
- store: this.store,
783
- settings,
784
- });
785
- }
786
- }
787
- } catch (actErr) {
788
- console.warn(`[dsh-cron] structured action error for ${taskId}:`, actErr.message);
789
- }
790
- }
791
-
792
-
793
-
794
- // 2. Task Chaining (#137)
795
- const nextTaskId = isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null);
796
-
797
- if (nextTaskId && typeof nextTaskId === 'string' && nextTaskId.trim()) {
798
- const currentDepth = options.chainDepth || 0;
799
- if (currentDepth < 4) {
800
- const targetId = nextTaskId.trim();
801
- const nextTask = this.store.get(targetId);
802
- if (nextTask) {
803
- console.log(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
804
- this.runNow(targetId, {
805
- chainDepth: currentDepth + 1,
806
- prevOutput: outcome.output || outcome.error || '',
807
- prevTaskId: task.id,
808
- }).catch(err => {
809
- console.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
810
- });
811
- } else {
812
- console.warn(`[dsh-cron] chained task target not found: ${targetId}`);
813
- }
814
- } else {
815
- console.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id} -> ${nextTaskId}. Loop prevented.`);
816
- }
817
- }
457
+ return handleCompleteRun(this, task, taskId, currentRun, outcome, start, options);
818
458
  }
819
459
 
820
- /**
821
- * Run the model-assisted steps for a finished run: diagnose a failure (#43)
822
- * and decide whether a successful run should stay silent (#44). Both are
823
- * fail-open and write their outcome into `runInfo`.
824
- */
825
460
  async applyModelAssists(task, runInfo) {
826
- const inspection = await this.inspectFailureRun(task, runInfo);
827
- if (inspection.inspected) {
828
- runInfo.diagnosis = inspection.diagnosis;
829
- runInfo.suggestion = inspection.suggestion;
830
- runInfo.confidence = inspection.confidence;
831
- console.log(`[dsh-cron] task "${task.title}" failure diagnosed (${inspection.confidence}): ${inspection.diagnosis}`);
832
- } else if (inspection.error && task.inspectOnFailure) {
833
- console.warn(`[dsh-cron] failure inspection for "${task.title}" not applied: ${inspection.error}`);
834
- }
835
-
836
- const silent = await this.applySilentRuleToRun(task, runInfo);
837
- if (silent.skipped) {
838
- runInfo.silentSkip = true;
839
- runInfo.silentReason = silent.reason;
840
- console.log(`[dsh-cron] task "${task.title}" stayed silent by rule: ${silent.reason || 'no reason given'}`);
841
- } else if (silent.verdictError) {
842
- console.warn(`[dsh-cron] silent rule for "${task.title}" not applied: ${silent.verdictError}`);
843
- }
844
- return silent;
461
+ return applyModelAssists(task, runInfo, {
462
+ askModel: this.askModel,
463
+ readSettingsFn: () => this.readSettings(),
464
+ inspectTimeoutMs: this.inspectTimeoutMs,
465
+ silentRuleTimeoutMs: this.silentRuleTimeoutMs,
466
+ });
845
467
  }
846
468
 
847
- /** Ask the model to diagnose a failed run (#43). Never throws. */
848
469
  async inspectFailureRun(task, runInfo) {
849
- if (typeof this.askModel !== 'function') return { inspected: false, error: 'no model is available' };
850
- const settings = this.readSettings();
851
- try {
852
- return await inspectFailure({
853
- task,
854
- runInfo,
855
- ask: this.askModel,
856
- preferredModel: String(settings.inspectorModel || ''),
857
- timeoutMs: this.inspectTimeoutMs || 25000,
858
- });
859
- } catch (err) {
860
- return { inspected: false, error: err.message };
861
- }
470
+ return inspectFailureRun(task, runInfo, {
471
+ askModel: this.askModel,
472
+ readSettingsFn: () => this.readSettings(),
473
+ inspectTimeoutMs: this.inspectTimeoutMs || 25000,
474
+ });
862
475
  }
863
476
 
864
- /** Settings snapshot, or an empty object when the store has none. */
865
477
  readSettings() {
866
478
  try {
867
479
  return typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
868
- } catch {
480
+ } catch (err) {
481
+ bestEffort('readSettings', () => {}, this.logger);
869
482
  return {};
870
483
  }
871
484
  }
872
485
 
873
- /**
874
- * Ask the silent rule whether this run should be delivered (#44).
875
- * Never throws: any problem means "deliver".
876
- */
877
486
  async applySilentRuleToRun(task, runInfo) {
878
- if (typeof this.askModel !== 'function') return { skipped: false, reason: '' };
879
- const preferredModel = String(this.readSettings().silentRuleModel || '');
880
- try {
881
- return await applySilentRule({
882
- task,
883
- runInfo,
884
- ask: this.askModel,
885
- preferredModel,
886
- timeoutMs: this.silentRuleTimeoutMs || 20000,
887
- });
888
- } catch (err) {
889
- return { skipped: false, reason: '', verdictError: err.message };
890
- }
487
+ return applySilentRuleToRun(task, runInfo, {
488
+ askModel: this.askModel,
489
+ readSettingsFn: () => this.readSettings(),
490
+ silentRuleTimeoutMs: this.silentRuleTimeoutMs || 20000,
491
+ });
891
492
  }
892
493
 
893
- /**
894
- * Deliver a finished run to every configured channel (#26).
895
- * Never throws: a broken or slow channel is reported, not propagated, so the
896
- * cron callback (and therefore the next tick) is never blocked by delivery.
897
- */
898
494
  async deliverNotifications(task, runInfo) {
899
- try {
900
- let settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
901
- // #51: resolve delivery credentials through the credentials service
902
- // (credential reference → legacy token → gateway file → env).
903
- if (this.resolveSecrets) {
904
- try {
905
- settings = { ...settings, ...(await this.resolveSecrets(settings)) };
906
- } catch (secErr) {
907
- console.warn('[dsh-cron] credential resolution failed:', secErr.message);
908
- }
909
- }
910
- if (typeof this.deliver !== 'function') return;
911
- const result = await this.deliver(task, runInfo, settings);
912
- if (result && Array.isArray(result.delivered) && result.delivered.length) {
913
- console.log(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
914
- }
915
- if (result && Array.isArray(result.failures) && result.failures.length) {
916
- console.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
917
- }
918
- } catch (notifyErr) {
919
- console.error('[dsh-cron] notification error:', notifyErr.message);
920
- }
495
+ return deliverNotifications(task, runInfo, {
496
+ store: this.store,
497
+ resolveSecrets: this.resolveSecrets,
498
+ deliver: this.deliver,
499
+ });
921
500
  }
922
501
 
923
- /**
924
- * Post-run bookkeeping: one-shot completion, next-run timestamp, retry
925
- * backoff and draining a queued run. Each part is guarded so a store error
926
- * cannot reject the cron callback.
927
- */
928
502
  finishRun(task, taskId, status, queuedCount) {
929
- this.countRun(status);
930
- try {
931
- if (task.oneShot) {
932
- task.status = 'completed';
933
- task.nextRunAt = null;
934
- this.store.set(task);
935
- } else {
936
- const job = this.jobs.get(taskId);
937
- if (job) {
938
- const next = job.nextRun();
939
- task.nextRunAt = next ? next.getTime() : null;
940
- this.store.set(task);
941
- }
942
- }
943
- } catch (err) {
944
- console.error('[dsh-cron] failed to persist the next run of task', taskId + ':', err.message);
945
- }
946
-
947
- try {
948
- // Retry with exponential backoff on failure (#18)
949
- const failed = status === 'error' || status === 'timeout';
950
- const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
951
- if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
952
- task.attempts = (task.attempts || 0) + 1;
953
- const backoffMs = (Number(task.retryBackoffMs) > 0 ? Number(task.retryBackoffMs) : 30000) * Math.pow(2, task.attempts - 1);
954
- const attempt = task.attempts;
955
- this.store.set(task);
956
- this.clearRetryTimer(taskId);
957
- this.retryTimers.set(taskId, setTimeout(() => {
958
- this.retryTimers.delete(taskId);
959
- this.runTask(taskId, { isRetry: true });
960
- }, backoffMs));
961
- console.log(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${task.title}" in ${backoffMs}ms`);
962
- } else if (!failed && task.attempts) {
963
- task.attempts = 0;
964
- this.store.set(task);
965
- } else if (failed && maxRetries > 0 && (task.attempts || 0) >= maxRetries) {
966
- // Retry budget exhausted: reset attempts so the next scheduled run starts fresh (#145)
967
- task.attempts = 0;
968
- this.store.set(task);
969
- }
970
- } catch (err) {
971
- console.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
972
- }
973
-
974
- if (queuedCount > 0) {
975
- setImmediate(() => {
976
- this.runTask(taskId);
977
- });
978
- }
503
+ return finishRun(this, task, taskId, status, queuedCount);
979
504
  }
980
505
 
981
506
  pauseTask(taskId) {
982
- const task = this.store.get(taskId);
983
- if (!task) return null;
984
- task.status = 'paused';
985
- task.nextRunAt = null;
986
- this.clearRetryTimer(taskId);
987
- if (this.jobs.has(taskId)) {
988
- this.jobs.get(taskId).stop();
989
- this.jobs.delete(taskId);
990
- }
991
- if (this.timers.has(taskId)) {
992
- clearTimeout(this.timers.get(taskId));
993
- this.timers.delete(taskId);
994
- }
995
- const running = this.running.get(taskId);
996
- if (running) {
997
- try {
998
- running.controller.abort(new Error('Task paused'));
999
- } catch {}
1000
- this.running.delete(taskId);
1001
- }
1002
- this.queue = this.queue.filter((item) => item.taskId !== taskId);
1003
- this.store.set(task);
1004
- return task;
507
+ return pauseTask(this, taskId);
1005
508
  }
1006
509
 
1007
510
  removeTask(taskId) {
1008
- this.clearRetryTimer(taskId);
1009
- if (this.jobs.has(taskId)) {
1010
- this.jobs.get(taskId).stop();
1011
- this.jobs.delete(taskId);
1012
- }
1013
- if (this.timers.has(taskId)) {
1014
- clearTimeout(this.timers.get(taskId));
1015
- this.timers.delete(taskId);
1016
- }
1017
- const running = this.running.get(taskId);
1018
- if (running) {
1019
- try {
1020
- running.controller.abort(new Error('Task deleted'));
1021
- } catch {}
1022
- this.running.delete(taskId);
1023
- }
1024
- this.queue = this.queue.filter((item) => item.taskId !== taskId);
511
+ return removeTask(this, taskId);
1025
512
  }
1026
513
 
1027
514
  resumeTask(taskId) {
1028
- const task = this.store.get(taskId);
1029
- if (!task) return null;
1030
- task.status = 'active';
1031
- this.scheduleTask(task);
1032
- this.store.set(task);
1033
- return task;
515
+ return resumeTask(this, taskId);
1034
516
  }
1035
517
 
1036
518
  toggleTask(taskId) {
1037
- const task = this.store.get(taskId);
1038
- if (!task) return null;
1039
- if (task.status === 'active') {
1040
- return this.pauseTask(taskId);
1041
- } else {
1042
- return this.resumeTask(taskId);
1043
- }
519
+ return toggleTask(this, taskId);
1044
520
  }
1045
521
 
1046
522
  triggerManualRun(taskId, options = {}) {
@@ -1051,3 +527,4 @@ export class TaskScheduler {
1051
527
  return this.runTask(taskId, options);
1052
528
  }
1053
529
  }
530
+