@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.
- package/README.md +23 -0
- package/lib/adaptive.js +151 -0
- package/lib/api.js +8 -0
- package/lib/chain-checkpoints.js +109 -11
- package/lib/client.js +4505 -4301
- package/lib/dag.js +330 -0
- package/lib/log-streamer.js +153 -0
- package/lib/prompt-interpolation.js +29 -3
- package/lib/routes.js +22 -0
- package/lib/runner-external.js +10 -0
- package/lib/runner.js +18 -1
- package/lib/scheduler-execution.js +108 -15
- package/lib/scheduler.js +38 -0
- package/lib/task-create.js +13 -1
- package/lib/task-patch.js +17 -0
- package/lib/task-transfer.js +4 -0
- package/package.json +1 -1
|
@@ -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
|
|
740
|
-
|
|
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
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
}
|
package/lib/task-create.js
CHANGED
|
@@ -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);
|
package/lib/task-transfer.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
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",
|