@goodandready/dsh-cron 0.2.19 → 0.2.21

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