@goodandready/dsh-cron 0.3.1 → 0.3.3

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.
@@ -1,3 +1,4 @@
1
+ import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
1
2
  import { approvalManager } from './approval.js';
2
3
  import { globalBudgetManager } from './global-budgets.js';
3
4
  import { validateRunResult } from './validators.js';
@@ -10,6 +11,7 @@ import fs from 'node:fs';
10
11
  import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
11
12
  import { applySilentRule } from './silent-rule.js';
12
13
  import { inspectFailure } from './failure-inspector.js';
14
+ import { parseChainTargets, sanitizeDependsOn } from './dag.js';
13
15
 
14
16
  /** Task types that talk to a model and can therefore use a fallback model. */
15
17
  export const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
@@ -511,6 +513,28 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
511
513
  export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount, meta = {}) {
512
514
  scheduler.countRun(status);
513
515
 
516
+ // Adaptive polling backoff (#329)
517
+ if (taskSnapshot && taskSnapshot.adaptive && taskSnapshot.adaptive.enabled && !taskSnapshot.oneShot && taskSnapshot.status === 'active' && !scheduler.isStopped) {
518
+ const history = scheduler.store.getHistory ? scheduler.store.getHistory(taskId, 2) : [];
519
+ const prevRun = history.length > 1 ? history[1] : null;
520
+ const outcomeForEval = meta?.outcome || { status };
521
+ const runInfoForEval = meta?.runInfo || {};
522
+ const evalRes = evaluateAdaptiveRun(taskSnapshot, outcomeForEval, runInfoForEval, prevRun);
523
+ const nextAdaptiveState = calculateNextAdaptiveState(taskSnapshot, evalRes.isIdle);
524
+ taskSnapshot.adaptiveState = nextAdaptiveState;
525
+ scheduler.store.set(taskSnapshot);
526
+
527
+ if (nextAdaptiveState.backedOff) {
528
+ logger.info(`[dsh-cron] adaptive backoff active for "${taskSnapshot.title}" (${taskSnapshot.id}): interval scaled to ${nextAdaptiveState.effectiveIntervalStr} (idle count: ${nextAdaptiveState.idleCount})`);
529
+ scheduler.armAdaptiveTimer(taskId, nextAdaptiveState.currentIntervalMs);
530
+ } else {
531
+ scheduler.clearAdaptiveTimer(taskId);
532
+ if (!scheduler.jobs.has(taskId)) {
533
+ scheduler.scheduleTask(taskSnapshot);
534
+ }
535
+ }
536
+ }
537
+
514
538
  // If this run was superseded by a newer run or scheduler is stopped, skip updating task
515
539
  if (meta.isCurrentRun === false || scheduler.isStopped) {
516
540
  return;
@@ -735,15 +759,24 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
735
759
  runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
736
760
  }
737
761
 
738
- // Chain Checkpoints (#319)
739
- const chainedTarget = outcome.status === 'success' ? task.onSuccess : task.onFailure;
740
- const chainRunId = options.chainRunId || (chainedTarget ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
762
+ // Chain Checkpoints (#319, #328)
763
+ const chainedTargets = parseChainTargets(outcome.status === 'success' ? task.onSuccess : task.onFailure);
764
+ let hasDependents = false;
765
+ if (scheduler.store?.tasks) {
766
+ for (const t of Object.values(scheduler.store.tasks)) {
767
+ if (t && sanitizeDependsOn(t.dependsOn).includes(taskId)) {
768
+ hasDependents = true;
769
+ break;
770
+ }
771
+ }
772
+ }
773
+ const chainRunId = options.chainRunId || ((chainedTargets.length > 0 || hasDependents) ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
741
774
  if (chainRunId) {
742
775
  runInfo.chainRunId = chainRunId;
743
776
  if (scheduler.store.chainCheckpointManager) {
744
777
  scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, taskId, {
745
778
  status: outcome.status,
746
- input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '' },
779
+ input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '', prevOutputs: options.prevOutputs || {} },
747
780
  output: outcome.output || outcome.error || '',
748
781
  durationMs: executionMs,
749
782
  });
@@ -773,6 +806,9 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
773
806
  isCurrentRun,
774
807
  runId: currentRun?.id,
775
808
  queuedRuns,
809
+ outcome,
810
+ runInfo,
811
+ silent,
776
812
  });
777
813
 
778
814
  if (scheduler.isStopped) return;
@@ -842,14 +878,42 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
842
878
  }
843
879
  }
844
880
 
845
- // 2. Task Chaining (#137)
846
- const nextTaskId = isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null);
847
- if (nextTaskId && typeof nextTaskId === 'string' && nextTaskId.trim()) {
848
- const currentDepth = options.chainDepth || 0;
849
- if (currentDepth < 4) {
850
- const targetId = nextTaskId.trim();
881
+ // 2. Task Chaining (#137, #328 Fan-Out / Fan-In)
882
+ const targets = parseChainTargets(isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null));
883
+ const currentDepth = options.chainDepth || 0;
884
+
885
+ if (currentDepth < 4) {
886
+ // 2.1 Trigger explicit targets (Fan-Out)
887
+ for (const targetId of targets) {
851
888
  const nextTask = scheduler.store.get(targetId);
852
- if (nextTask) {
889
+ if (!nextTask) {
890
+ logger.warn(`[dsh-cron] chained task target not found: ${targetId}`);
891
+ continue;
892
+ }
893
+ const deps = sanitizeDependsOn(nextTask.dependsOn);
894
+ if (deps.length > 0 && scheduler.store.chainCheckpointManager) {
895
+ const barrier = scheduler.store.chainCheckpointManager.checkJoinBarrier(chainRunId, nextTask);
896
+ if (barrier.ready && scheduler.store.chainCheckpointManager.canTriggerBarrier(chainRunId, nextTask.id)) {
897
+ scheduler.store.chainCheckpointManager.markBarrierTriggered(chainRunId, nextTask.id);
898
+ logger.info(`[dsh-cron] triggering joined task "${nextTask.title}" (${targetId}) (barrier satisfied)`);
899
+ scheduler.runNow(targetId, {
900
+ chainRunId,
901
+ chainDepth: currentDepth + 1,
902
+ prevOutputs: barrier.outputs,
903
+ prevOutput: barrier.latestOutput,
904
+ prevTaskId: task.id,
905
+ prevStatus: outcome.status,
906
+ prevDurationMs: Date.now() - start,
907
+ prevCostUsd: outcome.costUsd || 0,
908
+ }).catch((err) => {
909
+ logger.warn(`[dsh-cron] chained joined task ${targetId} failed to trigger:`, err.message);
910
+ });
911
+ } else if (barrier.pending) {
912
+ logger.info(`[dsh-cron] task "${nextTask.title}" (${targetId}) waiting for dependencies: ${barrier.waitingFor?.join(', ')}`);
913
+ } else if (barrier.failed) {
914
+ logger.warn(`[dsh-cron] task "${nextTask.title}" (${targetId}) barrier failed due to dependency: ${barrier.failedDep}`);
915
+ }
916
+ } else {
853
917
  logger.info(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
854
918
  scheduler.runNow(targetId, {
855
919
  chainRunId,
@@ -862,12 +926,41 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
862
926
  }).catch((err) => {
863
927
  logger.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
864
928
  });
865
- } else {
866
- logger.warn(`[dsh-cron] chained task target not found: ${targetId}`);
867
929
  }
868
- } else {
869
- logger.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id} -> ${nextTaskId}. Loop prevented.`);
870
930
  }
931
+
932
+ // 2.2 Trigger any other tasks in store that depend on this task via dependsOn (Fan-In join step)
933
+ if (chainRunId && scheduler.store?.tasks && scheduler.store.chainCheckpointManager) {
934
+ for (const candidate of Object.values(scheduler.store.tasks)) {
935
+ if (!candidate || candidate.id === taskId || targets.includes(candidate.id)) continue;
936
+ const deps = sanitizeDependsOn(candidate.dependsOn);
937
+ if (deps.includes(taskId)) {
938
+ const barrier = scheduler.store.chainCheckpointManager.checkJoinBarrier(chainRunId, candidate);
939
+ if (barrier.ready && scheduler.store.chainCheckpointManager.canTriggerBarrier(chainRunId, candidate.id)) {
940
+ scheduler.store.chainCheckpointManager.markBarrierTriggered(chainRunId, candidate.id);
941
+ logger.info(`[dsh-cron] triggering dependent joined task "${candidate.title}" (${candidate.id}) (barrier satisfied)`);
942
+ scheduler.runNow(candidate.id, {
943
+ chainRunId,
944
+ chainDepth: currentDepth + 1,
945
+ prevOutputs: barrier.outputs,
946
+ prevOutput: barrier.latestOutput,
947
+ prevTaskId: task.id,
948
+ prevStatus: outcome.status,
949
+ prevDurationMs: Date.now() - start,
950
+ prevCostUsd: outcome.costUsd || 0,
951
+ }).catch((err) => {
952
+ logger.warn(`[dsh-cron] dependent joined task ${candidate.id} failed to trigger:`, err.message);
953
+ });
954
+ } else if (barrier.pending) {
955
+ logger.info(`[dsh-cron] task "${candidate.title}" (${candidate.id}) waiting for dependencies: ${barrier.waitingFor?.join(', ')}`);
956
+ } else if (barrier.failed) {
957
+ logger.warn(`[dsh-cron] task "${candidate.title}" (${candidate.id}) barrier failed due to dependency: ${barrier.failedDep}`);
958
+ }
959
+ }
960
+ }
961
+ }
962
+ } else {
963
+ logger.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id}. Loop prevented.`);
871
964
  }
872
965
  }
873
966
 
package/lib/scheduler.js CHANGED
@@ -317,6 +317,7 @@ export class TaskScheduler {
317
317
  this.jobs = new Map();
318
318
  this.timers = new Map();
319
319
  this.retryTimers = new Map();
320
+ this.adaptiveTimers = new Map();
320
321
  this.running = new Map();
321
322
  this.runCounters = {};
322
323
  this.queue = [];
@@ -349,6 +350,37 @@ export class TaskScheduler {
349
350
  }
350
351
  }
351
352
 
353
+ clearAdaptiveTimer(taskId) {
354
+ const t = this.adaptiveTimers.get(taskId);
355
+ if (t) {
356
+ clearTimeout(t);
357
+ this.adaptiveTimers.delete(taskId);
358
+ }
359
+ }
360
+
361
+ armAdaptiveTimer(taskId, delayMs) {
362
+ this.clearAdaptiveTimer(taskId);
363
+ if (this.jobs.has(taskId)) {
364
+ this.jobs.get(taskId).stop();
365
+ this.jobs.delete(taskId);
366
+ }
367
+ const task = this.store.get(taskId);
368
+ if (task) {
369
+ task.nextRunAt = Date.now() + delayMs;
370
+ this.store.set(task);
371
+ }
372
+ const timer = setTimeout(() => {
373
+ this.adaptiveTimers.delete(taskId);
374
+ if (!this.isStopped) {
375
+ this.runTask(taskId, { isAdaptive: true }).catch((err) => {
376
+ logger.error(`[dsh-cron] adaptive run of task ${taskId} failed:`, (err && err.message) || err);
377
+ });
378
+ }
379
+ }, delayMs);
380
+ if (timer.unref) timer.unref();
381
+ this.adaptiveTimers.set(taskId, timer);
382
+ }
383
+
352
384
  startHeartbeatWatcher(intervalMs = 30000) {
353
385
  return startHeartbeatWatcher(this, intervalMs);
354
386
  }
@@ -450,6 +482,11 @@ export class TaskScheduler {
450
482
  }
451
483
  this.retryTimers.clear();
452
484
 
485
+ for (const [, timer] of this.adaptiveTimers.entries()) {
486
+ bestEffort('clear-adaptive-timer', () => clearTimeout(timer), this.logger);
487
+ }
488
+ this.adaptiveTimers.clear();
489
+
453
490
  for (const [, run] of this.running.entries()) {
454
491
  bestEffort('abort-running', () => run.controller.abort(new Error('Scheduler stopped')), this.logger);
455
492
  }
@@ -461,6 +498,7 @@ export class TaskScheduler {
461
498
  }
462
499
 
463
500
  clearScheduled(taskId) {
501
+ this.clearAdaptiveTimer(taskId);
464
502
  if (this.watcherManager) {
465
503
  this.watcherManager.unwatchTask(taskId);
466
504
  }
@@ -1,9 +1,11 @@
1
+ import { sanitizeAdaptiveConfig } from './adaptive.js';
1
2
  import { sanitizeValidators } from './validators.js';
2
3
  import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
3
4
  import { validateTaskType } from './task-transfer.js';
4
5
  import { parseScheduleExpression } from './scheduler.js';
5
6
  import { CHANNEL_IDS } from './channels.js';
6
7
  import { redactTaskSecrets } from './http-utils.js';
8
+ import { sanitizeDependsOn, sanitizeJoinPolicy, validateDAG } from './dag.js';
7
9
 
8
10
  export function sanitizeFileWatch(fw) {
9
11
  if (!fw || typeof fw !== 'object') return undefined;
@@ -45,6 +47,9 @@ export function executeCreateTask(store, scheduler, args) {
45
47
  targetSessionReset: args.targetSessionReset ? String(args.targetSessionReset).trim() : 'never',
46
48
  onSuccess: args.onSuccess ? String(args.onSuccess).trim() : '',
47
49
  onFailure: args.onFailure ? String(args.onFailure).trim() : '',
50
+ dependsOn: args.dependsOn !== undefined ? sanitizeDependsOn(args.dependsOn) : undefined,
51
+ joinPolicy: args.joinPolicy !== undefined ? sanitizeJoinPolicy(args.joinPolicy) : undefined,
52
+ adaptive: args.adaptive !== undefined ? sanitizeAdaptiveConfig(args.adaptive) : undefined,
48
53
  heartbeatIntervalSeconds: Number(args.heartbeatIntervalSeconds) || 0,
49
54
  gracePeriodSeconds: Number(args.gracePeriodSeconds) || 300,
50
55
  preflightType: args.preflightType ? String(args.preflightType).trim().toLowerCase() : 'none',
@@ -93,12 +98,20 @@ export function executeCreateTask(store, scheduler, args) {
93
98
  requireApproval: Boolean(args.requireApproval),
94
99
  fileWatch: args.fileWatch && typeof args.fileWatch === 'object' ? sanitizeFileWatch(args.fileWatch) : undefined,
95
100
  };
101
+
96
102
  // Issue #310 / GH #5: strip any undefined properties before store.set()
97
103
  for (const k of Object.keys(taskData)) {
98
104
  if (taskData[k] === undefined) {
99
105
  delete taskData[k];
100
106
  }
101
107
  }
108
+
109
+ // DAG cycle validation (#328)
110
+ const dagCheck = validateDAG(store?.tasks || {}, taskData);
111
+ if (!dagCheck.ok) {
112
+ throw new Error(dagCheck.error);
113
+ }
114
+
102
115
  const task = store.set(taskData);
103
116
  scheduler.scheduleTask(task);
104
117
  return {
@@ -107,4 +120,3 @@ export function executeCreateTask(store, scheduler, args) {
107
120
  task: redactTaskSecrets(task),
108
121
  };
109
122
  }
110
-
package/lib/task-patch.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { sanitizeAdaptiveConfig } from './adaptive.js';
1
2
  import { sanitizeValidators } from './validators.js';
2
3
  import { sanitizeFileWatch } from './task-create.js';
4
+ import { sanitizeDependsOn, sanitizeJoinPolicy, validateDAG } from './dag.js';
3
5
  /**
4
6
  * Partial task updates, shared by the HTTP PATCH route and the cron tool (action: 'update')
5
7
  * tool (#49).
@@ -91,9 +93,18 @@ export function buildTaskPatch(current, body) {
91
93
  if (patch.requireApproval !== undefined) {
92
94
  patch.requireApproval = Boolean(patch.requireApproval);
93
95
  }
96
+ if (patch.dependsOn !== undefined) {
97
+ patch.dependsOn = sanitizeDependsOn(patch.dependsOn);
98
+ }
99
+ if (patch.joinPolicy !== undefined) {
100
+ patch.joinPolicy = sanitizeJoinPolicy(patch.joinPolicy);
101
+ }
94
102
  if (patch.fileWatch !== undefined) {
95
103
  patch.fileWatch = patch.fileWatch && typeof patch.fileWatch === 'object' ? sanitizeFileWatch(patch.fileWatch) : null;
96
104
  }
105
+ if (patch.adaptive !== undefined) {
106
+ patch.adaptive = patch.adaptive ? sanitizeAdaptiveConfig(patch.adaptive) : null;
107
+ }
97
108
  if (patch.validators !== undefined) {
98
109
  patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
99
110
  }
@@ -149,6 +160,12 @@ export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = f
149
160
  }
150
161
  const built = buildTaskPatch(current, body);
151
162
  if (!built.ok) return built;
163
+ if (built.patch.dependsOn !== undefined || built.patch.onSuccess !== undefined) {
164
+ const dagCheck = validateDAG(store?.tasks || {}, { ...current, ...built.patch, id });
165
+ if (!dagCheck.ok) {
166
+ return { ok: false, error: dagCheck.error };
167
+ }
168
+ }
152
169
  try {
153
170
  const task = store.set({ ...current, ...built.patch, id });
154
171
  if (task.status === 'active') scheduler.scheduleTask(task);
@@ -77,6 +77,10 @@ export const PATCHABLE_TASK_FIELDS = [
77
77
  'validators',
78
78
  'requireApproval',
79
79
  'fileWatch',
80
+ 'dependsOn',
81
+ 'joinPolicy',
82
+ 'adaptive',
83
+ 'adaptiveState',
80
84
  ];
81
85
 
82
86
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",