@goodandready/dsh-cron 0.3.15 → 0.3.16

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,211 +1,39 @@
1
- import { executeSwarmTask } from './swarm.js';
2
- import { captureGitDiff } from './git-diff.js';
3
- import { resolveBranchTargets } from './branching.js';
4
- import { shouldAutoHeal, applyDoctorFix } from './doctor.js';
5
1
  import { recordPromptRunOutcome } from './prompt-evolver.js';
6
2
  import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
7
- import { approvalManager } from './approval.js';
8
3
  import { globalBudgetManager } from './global-budgets.js';
9
- import { validateRunResult } from './validators.js';
10
4
  import { logger } from './logger.js';
11
5
  import { checkAndApplyBurnGuard, checkTaskBudgetLimits } from './burn-guard.js';
12
6
  import { resolveTaskForExecution } from './prompt-interpolation.js';
13
- import { bestEffort } from './best-effort.js';
14
- import { exec } from 'node:child_process';
15
- import fs from 'node:fs';
16
- import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
17
- import { applySilentRule } from './silent-rule.js';
18
- import { inspectFailure } from './failure-inspector.js';
19
- import { parseChainTargets, sanitizeDependsOn } from './dag.js';
20
-
21
- /** Task types that talk to a model and can therefore use a fallback model. */
22
- export const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
23
-
24
- /** Sum two usage records, so a fallback attempt is not lost in accounting. */
25
- export function addUsage(a, b) {
26
- return {
27
- inputTokens: (a?.inputTokens || 0) + (b?.inputTokens || 0),
28
- outputTokens: (a?.outputTokens || 0) + (b?.outputTokens || 0),
29
- cacheReadTokens: (a?.cacheReadTokens || 0) + (b?.cacheReadTokens || 0),
30
- };
31
- }
32
-
33
- /**
34
- * Execute pre-flight check gate before running task body.
35
- */
36
- export async function executePreflight(task) {
37
- const type = String(task.preflightType || 'none').trim().toLowerCase();
38
- const target = String(task.preflightTarget || '').trim();
39
- if (!type || type === 'none') {
40
- return { ok: true };
41
- }
42
-
43
- const KNOWN_PREFLIGHT_TYPES = ['http', 'command', 'shell', 'disk'];
44
- if (!KNOWN_PREFLIGHT_TYPES.includes(type)) {
45
- return { ok: false, reason: `Unknown preflight type "${type}"` };
46
- }
47
-
48
- if (!target) {
49
- return { ok: false, reason: `Preflight target is required for type "${type}"` };
50
- }
51
-
52
- if (type === 'http') {
53
- let timeout;
54
- try {
55
- const controller = new AbortController();
56
- timeout = setTimeout(() => controller.abort(), 5000);
57
- const res = await fetch(target, { signal: controller.signal });
58
- if (res.ok) return { ok: true };
59
- return { ok: false, reason: `HTTP check returned status ${res.status}` };
60
- } catch (err) {
61
- return { ok: false, reason: `HTTP check failed: ${err.message}` };
62
- } finally {
63
- if (timeout) clearTimeout(timeout);
64
- }
65
- }
66
-
67
- if (type === 'command' || type === 'shell') {
68
- return new Promise((resolve) => {
69
- exec(target, { timeout: 5000 }, (err, stdout, stderr) => {
70
- if (err) {
71
- resolve({ ok: false, reason: `Preflight command failed (code ${err.code || 1}): ${(stderr || stdout || err.message).trim().slice(0, 150)}` });
72
- } else {
73
- resolve({ ok: true });
74
- }
75
- });
76
- });
77
- }
78
-
79
- if (type === 'disk') {
80
- let diskPath = task.cwd || process.cwd();
81
- let requirement = target;
82
-
83
- // Check for "path:requirement", taking care not to split Windows drive letters (e.g. C:\:10%)
84
- const lastColon = target.lastIndexOf(':');
85
- if (lastColon > 0) {
86
- const candidatePath = target.slice(0, lastColon).trim();
87
- const candidateReq = target.slice(lastColon + 1).trim();
88
- if (candidateReq) {
89
- diskPath = candidatePath;
90
- requirement = candidateReq;
91
- }
92
- }
93
-
94
- const isPercent = requirement.endsWith('%');
95
- const numericVal = parseFloat(requirement);
96
- if (isNaN(numericVal) || numericVal <= 0) {
97
- return { ok: false, reason: `Invalid preflight disk target requirement: "${requirement}"` };
98
- }
99
-
100
- try {
101
- if (typeof fs.statfsSync !== 'function') {
102
- return { ok: false, reason: 'statfsSync is not available on this platform' };
103
- }
104
- const stats = fs.statfsSync(diskPath);
105
- if (isPercent) {
106
- const freePercent = stats.blocks > 0 ? (stats.bavail / stats.blocks) * 100 : 0;
107
- if (freePercent < numericVal) {
108
- return { ok: false, reason: `Free disk space on "${diskPath}" (${freePercent.toFixed(1)}%) is below required ${numericVal}%` };
109
- }
110
- } else {
111
- const freeMb = Math.round((stats.bavail * stats.bsize) / (1024 * 1024));
112
- if (freeMb < numericVal) {
113
- return { ok: false, reason: `Free disk space on "${diskPath}" (${freeMb} MB) is below required ${numericVal} MB` };
114
- }
115
- }
116
- return { ok: true };
117
- } catch (err) {
118
- return { ok: false, reason: `Preflight disk check failed: filesystem "${diskPath}" not accessible (${err.message})` };
119
- }
120
- }
121
-
122
- return { ok: false, reason: `Unsupported preflight type "${type}"` };
123
- }
124
-
125
- /** Only agent-mediated types use a model, and only a failed run falls back. */
126
- export function shouldUseFallback(task, outcome) {
127
- if (!task || !task.fallbackModel) return false;
128
- if (!AGENT_TASK_TYPES.includes(task.type || 'llm')) return false;
129
- return outcome.status === 'error' || outcome.status === 'timeout';
130
- }
131
-
132
- /** Run the task body once and normalise whatever the executor returned. */
133
- export async function executeOnce(scheduler, task, signal, options = {}) {
134
- const result = {
135
- status: 'success',
136
- output: '',
137
- error: null,
138
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
139
- costUsd: 0,
140
- sessionId: null,
141
- model: task.model || '',
142
- fallback: false,
143
- };
144
- if (task.type === 'swarm') {
145
- try {
146
- const swarmRes = await executeSwarmTask(task, {
147
- executeFn: (subtask) => scheduler.executeFn(subtask, { signal, ...options }),
148
- askModel: scheduler.askModel,
149
- signal,
150
- logger,
151
- });
152
- result.output = swarmRes.output || '';
153
- result.status = swarmRes.status;
154
- result.subtaskRuns = swarmRes.subtaskRuns || [];
155
- result.swarmStats = swarmRes.stats;
156
- return result;
157
- } catch (err) {
158
- result.error = err.message || String(err);
159
- result.status = 'error';
160
- return result;
161
- }
162
- }
163
- if (typeof scheduler.executeFn !== 'function') return result;
164
- const executableTask = resolveTaskForExecution(task, options);
165
- try {
166
- const res = await scheduler.executeFn(executableTask, { signal, ...options });
167
- if (typeof res === 'object' && res !== null) {
168
- result.output = res.output || '';
169
- result.usage = res.usage || result.usage;
170
- result.costUsd = res.costUsd || 0;
171
- result.sessionId = res.sessionId || null;
172
- if (res.model) result.model = res.model;
173
- } else {
174
- result.output = String(res || '');
175
- }
176
- } catch (err) {
177
- result.error = err.message || String(err);
178
- const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
179
- result.status = timedOut ? 'timeout' : 'error';
180
- if (err.usage) result.usage = err.usage;
181
- if (typeof err.costUsd === 'number') result.costUsd = err.costUsd;
182
- if (err.sessionId) result.sessionId = err.sessionId;
183
- }
184
- return result;
185
- }
186
-
187
- /**
188
- * Run the task, retrying once on the configured fallback model when the primary attempt failed (#45).
189
- */
190
- export async function executeWithFallback(scheduler, task, signal, options = {}) {
191
- const primary = await scheduler.executeOnce(task, signal, options);
192
- if (!scheduler.shouldUseFallback(task, primary)) return primary;
193
-
194
- const fallbackTask = {
195
- ...task,
196
- provider: task.fallbackProvider || task.provider,
197
- model: task.fallbackModel,
198
- };
199
- logger.info(`[dsh-cron] task "${task.title}" failed on ${task.model || 'the default model'}, retrying once on ${task.fallbackModel}`);
200
- const secondary = await scheduler.executeOnce(fallbackTask, signal, options);
201
- return {
202
- ...secondary,
203
- fallback: true,
204
- usage: addUsage(primary.usage, secondary.usage),
205
- costUsd: Number((primary.costUsd + secondary.costUsd).toFixed(6)),
206
- primaryModel: task.model || '',
207
- };
208
- }
7
+ import { approvalManager } from './approval.js';
8
+ import {
9
+ AGENT_TASK_TYPES,
10
+ addUsage,
11
+ executePreflight,
12
+ shouldUseFallback,
13
+ executeOnce,
14
+ executeWithFallback,
15
+ } from './scheduler-preflight.js';
16
+ import {
17
+ inspectFailureRun,
18
+ applySilentRuleToRun,
19
+ applyModelAssists,
20
+ deliverNotifications,
21
+ } from './scheduler-assists.js';
22
+ import { handleCompleteRun } from './scheduler-completion.js';
23
+
24
+ export {
25
+ AGENT_TASK_TYPES,
26
+ addUsage,
27
+ executePreflight,
28
+ shouldUseFallback,
29
+ executeOnce,
30
+ executeWithFallback,
31
+ inspectFailureRun,
32
+ applySilentRuleToRun,
33
+ applyModelAssists,
34
+ deliverNotifications,
35
+ handleCompleteRun,
36
+ };
209
37
 
210
38
  /**
211
39
  * Begin a task run with concurrency and overlap policy checks.
@@ -422,143 +250,6 @@ export async function executeTask(scheduler, taskId, options = {}) {
422
250
  return outcome;
423
251
  }
424
252
 
425
- /**
426
- * Ask the model to diagnose a failed run (#43). Never throws.
427
- */
428
- export async function inspectFailureRun(task, runInfo, { askModel, readSettingsFn, inspectTimeoutMs }) {
429
- if (typeof askModel !== 'function') return { inspected: false, error: 'no model is available' };
430
- const settings = readSettingsFn ? readSettingsFn() : {};
431
- try {
432
- return await inspectFailure({
433
- task,
434
- runInfo,
435
- ask: askModel,
436
- preferredModel: String(settings.inspectorModel || ''),
437
- timeoutMs: inspectTimeoutMs || 25000,
438
- });
439
- } catch (err) {
440
- return { inspected: false, error: err.message };
441
- }
442
- }
443
-
444
- /**
445
- * Ask the silent rule whether this run should be delivered (#44).
446
- * Never throws: any problem means "deliver".
447
- */
448
- export async function applySilentRuleToRun(task, runInfo, { askModel, readSettingsFn, silentRuleTimeoutMs }) {
449
- if (typeof askModel !== 'function') return { skipped: false, reason: '' };
450
- const preferredModel = String((readSettingsFn ? readSettingsFn() : {}).silentRuleModel || '');
451
- try {
452
- return await applySilentRule({
453
- task,
454
- runInfo,
455
- ask: askModel,
456
- preferredModel,
457
- timeoutMs: silentRuleTimeoutMs || 20000,
458
- });
459
- } catch (err) {
460
- return { skipped: false, reason: '', verdictError: err.message };
461
- }
462
- }
463
-
464
- /**
465
- * Run the model-assisted steps for a finished run.
466
- */
467
- export async function applyModelAssists(task, runInfo, options = {}) {
468
- const inspection = await inspectFailureRun(task, runInfo, options);
469
- if (inspection.inspected) {
470
- runInfo.diagnosis = inspection.diagnosis;
471
- runInfo.suggestion = inspection.suggestion;
472
- runInfo.confidence = inspection.confidence;
473
- runInfo.doctorReport = inspection.doctorReport || {
474
- category: inspection.category || 'runtime_error',
475
- cause: inspection.diagnosis,
476
- suggestedFix: inspection.suggestion,
477
- confidence: inspection.confidence,
478
- diff: inspection.diff || '',
479
- timestamp: Date.now(),
480
- applied: false,
481
- autoHealed: false,
482
- };
483
- logger.info(`[dsh-cron] task "${task.title}" failure diagnosed (${inspection.confidence}): ${inspection.diagnosis}`);
484
-
485
- // Safe Auto-Heal Check (#353)
486
- if (shouldAutoHeal(task, runInfo.doctorReport)) {
487
- try {
488
- const healResult = await applyDoctorFix(task, runInfo.doctorReport, {
489
- store: options.store,
490
- user: 'scheduler:auto-heal',
491
- isAutoHeal: true,
492
- });
493
- if (healResult.ok) {
494
- logger.info(`[dsh-cron] task "${task.title}" auto-healed successfully with doctor fix!`);
495
- runInfo.autoHealed = true;
496
- if (runInfo.doctorReport) runInfo.doctorReport.autoHealed = true;
497
- }
498
- } catch (autoHealErr) {
499
- logger.warn(`[dsh-cron] failed to auto-heal task "${task.title}":`, autoHealErr.message);
500
- }
501
- }
502
- } else if (inspection.error && (task.inspectOnFailure || task.autoDiagnose || task.autoHeal)) {
503
- logger.warn(`[dsh-cron] failure inspection for "${task.title}" not applied: ${inspection.error}`);
504
- }
505
-
506
- const silent = await applySilentRuleToRun(task, runInfo, options);
507
- if (silent.skipped) {
508
- runInfo.silentSkip = true;
509
- runInfo.silentReason = silent.reason;
510
- logger.info(`[dsh-cron] task "${task.title}" stayed silent by rule: ${silent.reason || 'no reason given'}`);
511
- } else if (silent.verdictError) {
512
- logger.warn(`[dsh-cron] silent rule for "${task.title}" not applied: ${silent.verdictError}`);
513
- }
514
- return silent;
515
- }
516
-
517
- /**
518
- * Deliver a finished run to every configured channel (#26).
519
- */
520
- export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver, secretTimeoutMs = 5000 }) {
521
- try {
522
- let settings = typeof store?.getSettings === 'function' ? store.getSettings() : {};
523
- if (resolveSecrets) {
524
- try {
525
- let timer;
526
- const deadline = new Promise((_, reject) => {
527
- timer = setTimeout(() => reject(new Error(`credential resolution timed out after ${secretTimeoutMs}ms`)), secretTimeoutMs);
528
- if (typeof timer.unref === 'function') timer.unref();
529
- });
530
- const resolved = await Promise.race([
531
- Promise.resolve(resolveSecrets(settings)).finally(() => {
532
- if (timer) clearTimeout(timer);
533
- }),
534
- deadline,
535
- ]);
536
- settings = { ...settings, ...(resolved || {}) };
537
- } catch (secErr) {
538
- logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
539
- }
540
- }
541
- if (typeof deliver !== 'function') return { status: 'skipped', delivered: [], failures: [] };
542
- const result = await deliver(task, runInfo, settings);
543
- if (result && Array.isArray(result.delivered) && result.delivered.length) {
544
- logger.info(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
545
- }
546
- if (result && Array.isArray(result.failures) && result.failures.length) {
547
- logger.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
548
- }
549
- const delivered = Array.isArray(result?.delivered) ? result.delivered : [];
550
- const failures = Array.isArray(result?.failures) ? result.failures : [];
551
- let status = 'skipped';
552
- if (delivered.length > 0 && failures.length === 0) status = 'delivered';
553
- else if (delivered.length > 0 && failures.length > 0) status = 'partial';
554
- else if (delivered.length === 0 && failures.length > 0) status = 'failed';
555
- return { status, delivered, failures };
556
- } catch (notifyErr) {
557
- logger.error('[dsh-cron] notification error:', notifyErr.message);
558
- return { status: 'failed', delivered: [], failures: [{ channel: 'all', error: notifyErr.message }] };
559
- }
560
- }
561
-
562
253
  /**
563
254
  * Post-run bookkeeping: one-shot completion, next-run timestamp, retry backoff, draining.
564
255
  */
@@ -667,402 +358,3 @@ export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount,
667
358
  }
668
359
  }
669
360
 
670
- /**
671
- * Handle complete run lifecycle: metrics, self-healing, diagnosis, silent rule, storage, delivery, chaining.
672
- */
673
- function computeStoppedReason(status, valRes, error) {
674
- if (status === 'validation_failed') return `Validation failed: ${valRes?.error || 'Check failed'}`;
675
- if (status === 'validator_error') return `Validator configuration error: ${valRes?.error || 'Check failed'}`;
676
- if (status === 'timeout') return 'Execution timed out';
677
- if (status === 'error') return error ? `Execution error: ${error}` : 'Execution error';
678
- if (status === 'success') return 'Completed successfully';
679
- if (status === 'skipped') return error || 'Run skipped';
680
- return null;
681
- }
682
-
683
- export async function handleCompleteRun(scheduler, task, taskId, currentRun, outcome, start, options = {}) {
684
- // Post-execution declarative validation (#316)
685
- let valRes = { ok: true, skipped: true };
686
- let valDurationMs = 0;
687
- if (outcome.status === 'success' && Array.isArray(task.validators) && task.validators.length > 0) {
688
- const vStart = Date.now();
689
- valRes = validateRunResult(task, outcome, { cwd: task.cwd });
690
- valDurationMs = Date.now() - vStart;
691
- if (!valRes.ok) {
692
- outcome.status = valRes.status === 'error' ? 'validator_error' : 'validation_failed';
693
- outcome.error = valRes.error || 'Validation failed';
694
- }
695
- }
696
-
697
- const isSuccess = outcome.status === 'success';
698
- const isFailure = outcome.status === 'error' || outcome.status === 'timeout' || outcome.status === 'validation_failed' || outcome.status === 'validator_error';
699
- const executionMs = Date.now() - start;
700
- const queueWaitMs = (currentRun && currentRun.startedAt && currentRun.queuedAt) ? (currentRun.startedAt - currentRun.queuedAt) : 0;
701
- const preflightMs = options.preflightDurationMs || 0;
702
-
703
- const runInfo = {
704
- runId: currentRun?.id || ('run_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now()),
705
- at: start,
706
- status: outcome.status,
707
- durationMs: executionMs,
708
- output: outcome.output,
709
- error: outcome.error,
710
- usage: outcome.usage,
711
- costUsd: outcome.costUsd,
712
- sessionId: outcome.sessionId,
713
- model: outcome.model || task.model || '',
714
- fallback: Boolean(outcome.fallback),
715
- validation: valRes.skipped ? null : valRes,
716
- };
717
-
718
- if (isFailure && task.selfHealingCommand) {
719
- logger.info(`[dsh-cron] running self-healing command for task "${task.title}": ${task.selfHealingCommand}`);
720
- try {
721
- await new Promise((resolve) => {
722
- exec(task.selfHealingCommand, { timeout: 30000 }, (shErr, stdout, stderr) => {
723
- runInfo.selfHealing = {
724
- command: task.selfHealingCommand,
725
- success: !shErr,
726
- output: (stdout + (stderr ? '\n' + stderr : '')).trim().slice(0, 500),
727
- };
728
- resolve();
729
- });
730
- });
731
- } catch (shErr) {
732
- runInfo.selfHealing = { command: task.selfHealingCommand, success: false, error: shErr.message };
733
- }
734
- }
735
-
736
- if (isFailure && task.autoDiagnose && typeof scheduler.executeFn === 'function') {
737
- try {
738
- const diagRes = await scheduler.executeFn({
739
- type: 'llm',
740
- prompt: `Diagnose this task failure in 1-2 concise sentences. Error: ${outcome.error || 'unknown'}. Recent output: ${(outcome.output || '').slice(-300)}`,
741
- model: task.fallbackModel || task.model,
742
- }, { signal: AbortSignal.timeout(10000) });
743
- if (diagRes && diagRes.output) {
744
- runInfo.autoDiagnosis = diagRes.output.trim();
745
- }
746
- } catch (diagErr) {
747
- bestEffort('auto-diagnose', () => {}, scheduler.logger);
748
- }
749
- }
750
-
751
- const silent = await scheduler.applyModelAssists(task, runInfo);
752
-
753
- let deliveryResult = { status: 'skipped', delivered: [], failures: [] };
754
- let delivStart = Date.now();
755
- if (!silent.skipped) {
756
- deliveryResult = (await scheduler.deliverNotifications(task, runInfo)) || deliveryResult;
757
- }
758
- const delivDurationMs = Date.now() - delivStart;
759
-
760
- const stages = [
761
- {
762
- name: 'queue',
763
- status: 'success',
764
- durationMs: queueWaitMs,
765
- },
766
- {
767
- name: 'preflight',
768
- status: 'success',
769
- durationMs: preflightMs,
770
- error: null,
771
- },
772
- {
773
- name: 'execution',
774
- status: (outcome.status === 'error' || outcome.status === 'timeout') ? 'failed' : 'success',
775
- durationMs: executionMs,
776
- error: (outcome.status === 'error' || outcome.status === 'timeout') ? (outcome.error || null) : null,
777
- },
778
- {
779
- name: 'validation',
780
- status: valRes.skipped ? 'skipped' : (valRes.ok ? 'success' : 'failed'),
781
- durationMs: valDurationMs,
782
- error: valRes.ok ? null : (valRes.error || null),
783
- },
784
- {
785
- name: 'delivery',
786
- status: deliveryResult?.status || 'skipped',
787
- durationMs: delivDurationMs,
788
- error: (deliveryResult?.failures && deliveryResult.failures.length > 0)
789
- ? deliveryResult.failures.map(f => `${f.channel}: ${f.error}`).join('; ')
790
- : null,
791
- delivered: deliveryResult?.delivered || [],
792
- failures: deliveryResult?.failures || [],
793
- },
794
- ];
795
-
796
- if (outcome.subtaskRuns) {
797
- runInfo.subtaskRuns = outcome.subtaskRuns;
798
- runInfo.swarmStats = outcome.swarmStats;
799
- }
800
-
801
- // Capture git diff if task runs in a worktree or custom git cwd (#351)
802
- const diffDir = task.worktree ? (runInfo.worktreePath || task.cwd) : task.cwd;
803
- if (diffDir) {
804
- try {
805
- const diffResult = await captureGitDiff(diffDir);
806
- if (diffResult && diffResult.hasChanges) {
807
- runInfo.gitDiff = diffResult;
808
- }
809
- } catch {
810
- // Non-fatal if diff capture fails
811
- }
812
- }
813
-
814
- runInfo.stages = stages;
815
- runInfo.delivery = deliveryResult;
816
- runInfo.cost = {
817
- amount: Number(outcome.costUsd) || 0,
818
- currency: 'USD',
819
- kind: (Number(outcome.costUsd) > 0) ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact'),
820
- };
821
- runInfo.stoppedReason = computeStoppedReason(outcome.status, valRes, outcome.error);
822
- // Durable journal & Idempotency (#317)
823
- runInfo.idempotencyKey = options.idempotencyKey || currentRun?.idempotencyKey || null;
824
-
825
- // Artifacts capture (#321)
826
- if (Array.isArray(outcome.artifacts) && scheduler.store.artifactManager) {
827
- for (const art of outcome.artifacts) {
828
- if (art && art.name && (art.content || art.data)) {
829
- try {
830
- scheduler.store.artifactManager.saveArtifact(runInfo.runId, taskId, art.name, art.content || art.data);
831
- } catch (artErr) {
832
- logger.warn(`[dsh-cron] failed to save artifact ${art.name}:`, artErr.message);
833
- }
834
- }
835
- }
836
- }
837
- if (scheduler.store.getArtifactsForRun) {
838
- runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
839
- }
840
-
841
- // Chain Checkpoints & Decision Nodes (#319, #328, #352)
842
- const branchResult = resolveBranchTargets(task, outcome, runInfo);
843
- if (branchResult && branchResult.decision) {
844
- runInfo.branchDecision = branchResult.decision;
845
- }
846
- const chainedTargets = branchResult
847
- ? branchResult.targets
848
- : parseChainTargets(outcome.status === 'success' ? task.onSuccess : task.onFailure);
849
- let hasDependents = false;
850
- if (scheduler.store?.tasks) {
851
- for (const t of Object.values(scheduler.store.tasks)) {
852
- if (t && sanitizeDependsOn(t.dependsOn).includes(taskId)) {
853
- hasDependents = true;
854
- break;
855
- }
856
- }
857
- }
858
- const chainRunId = options.chainRunId || ((chainedTargets.length > 0 || hasDependents) ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
859
- if (chainRunId) {
860
- runInfo.chainRunId = chainRunId;
861
- if (scheduler.store.chainCheckpointManager) {
862
- scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, taskId, {
863
- status: outcome.status,
864
- input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '', prevOutputs: options.prevOutputs || {} },
865
- output: outcome.output || outcome.error || '',
866
- durationMs: executionMs,
867
- });
868
- }
869
- }
870
-
871
- // Update Durable Journal (#317)
872
- if (scheduler.store.durableJournal) {
873
- const journalState = outcome.status === 'success' ? 'completed' : (outcome.status === 'uncertain' ? 'uncertain' : 'failed');
874
- scheduler.store.durableJournal.transition(taskId, runInfo.runId, journalState, runInfo);
875
- }
876
-
877
-
878
- try {
879
- scheduler.store.recordRun(taskId, runInfo);
880
- } catch (recordErr) {
881
- logger.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
882
- }
883
-
884
- const finishedRun = scheduler.running.get(taskId);
885
- const isCurrentRun = (finishedRun === currentRun);
886
- if (isCurrentRun) scheduler.running.delete(taskId);
887
- const queuedRuns = isCurrentRun ? (finishedRun?.queuedRuns || []) : [];
888
- const queuedCount = isCurrentRun ? (finishedRun?.queueCount || 0) : 0;
889
-
890
- scheduler.finishRun(task, taskId, outcome.status, queuedCount, {
891
- isCurrentRun,
892
- runId: currentRun?.id,
893
- queuedRuns,
894
- outcome,
895
- runInfo,
896
- silent,
897
- });
898
-
899
- if (scheduler.isStopped) return;
900
-
901
- const guard = await checkAndApplyBurnGuard(scheduler, taskId, outcome);
902
- if (guard && guard.exceeded) return;
903
-
904
- if (!scheduler.isStopped && scheduler.queue.length > 0 && (scheduler.maxConcurrent === 0 || scheduler.running.size < scheduler.maxConcurrent)) {
905
- let admittedIndex = -1;
906
- for (let i = 0; i < scheduler.queue.length; i++) {
907
- const candidate = scheduler.queue[i];
908
- const queuedTask = scheduler.store.get(candidate.taskId);
909
- if (!queuedTask || queuedTask.status !== 'active') {
910
- scheduler.queue.splice(i, 1);
911
- i--;
912
- continue;
913
- }
914
- const qGroup = queuedTask.concurrencyGroup && queuedTask.concurrencyGroup !== 'default'
915
- ? String(queuedTask.concurrencyGroup).trim()
916
- : null;
917
- const qLimit = scheduler.getGroupLimit ? scheduler.getGroupLimit(qGroup) : (qGroup ? 1 : 0);
918
- if (qGroup && qLimit > 0) {
919
- const inGroup = scheduler.getRunningGroupCount ? scheduler.getRunningGroupCount(qGroup) : 0;
920
- if (inGroup >= qLimit) {
921
- continue;
922
- }
923
- }
924
- admittedIndex = i;
925
- break;
926
- }
927
-
928
- if (admittedIndex !== -1) {
929
- const nextItem = scheduler.queue.splice(admittedIndex, 1)[0];
930
- setImmediate(() => {
931
- if (!scheduler.isStopped) {
932
- scheduler.runTask(nextItem.taskId, nextItem.options || {}).catch((runErr) => {
933
- bestEffort('drain-queued-task', () => {}, scheduler.logger);
934
- });
935
- }
936
- });
937
- }
938
- }
939
-
940
- // 1. Structured LLM Actions (#137)
941
- if (task.type === 'llm' && outcome.status === 'success' && outcome.output) {
942
- try {
943
- const settings = typeof scheduler.store.getSettings === 'function' ? scheduler.store.getSettings() : {};
944
- if (settings.llmActionsEnabled) {
945
- const directives = parseLlmActionDirectives(outcome.output);
946
- if (directives.length > 0) {
947
- const actionResults = await executeLlmActionDirectives({
948
- directives,
949
- task,
950
- runInfo,
951
- scheduler,
952
- store: scheduler.store,
953
- settings,
954
- options,
955
- });
956
- if (actionResults && actionResults.length > 0) {
957
- runInfo.actionResults = actionResults;
958
- }
959
- }
960
- }
961
- } catch (actErr) {
962
- logger.warn(`[dsh-cron] structured action error for ${taskId}:`, actErr.message);
963
- }
964
- }
965
-
966
- // 2. Task Chaining & Decision Nodes (#137, #328, #352 Fan-Out / Fan-In)
967
- const targets = branchResult
968
- ? branchResult.targets
969
- : parseChainTargets(isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null));
970
- const currentDepth = options.chainDepth || 0;
971
-
972
- if (branchResult && branchResult.decision) {
973
- logger.info(`[dsh-cron] decision node in "${task.title}": routed to ${targets.join(', ') || 'none'} (${branchResult.decision.isElse ? 'fallback else' : (branchResult.decision.matched ? `rule #${branchResult.decision.matchedIndex + 1}` : 'unmatched')})`);
974
- if (chainRunId && scheduler.store.chainCheckpointManager && Array.isArray(task.branchRules)) {
975
- for (const rule of task.branchRules) {
976
- if (rule && rule.targetTask && !targets.includes(rule.targetTask)) {
977
- scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, rule.targetTask, {
978
- status: 'skipped',
979
- skippedReason: 'condition not met',
980
- durationMs: 0,
981
- });
982
- }
983
- }
984
- }
985
- }
986
-
987
- if (currentDepth < 4) {
988
- // 2.1 Trigger explicit targets (Fan-Out)
989
- for (const targetId of targets) {
990
- const nextTask = scheduler.store.get(targetId);
991
- if (!nextTask) {
992
- logger.warn(`[dsh-cron] chained task target not found: ${targetId}`);
993
- continue;
994
- }
995
- const deps = sanitizeDependsOn(nextTask.dependsOn);
996
- if (deps.length > 0 && scheduler.store.chainCheckpointManager) {
997
- const barrier = scheduler.store.chainCheckpointManager.checkJoinBarrier(chainRunId, nextTask);
998
- if (barrier.ready && scheduler.store.chainCheckpointManager.canTriggerBarrier(chainRunId, nextTask.id)) {
999
- scheduler.store.chainCheckpointManager.markBarrierTriggered(chainRunId, nextTask.id);
1000
- logger.info(`[dsh-cron] triggering joined task "${nextTask.title}" (${targetId}) (barrier satisfied)`);
1001
- scheduler.runNow(targetId, {
1002
- chainRunId,
1003
- chainDepth: currentDepth + 1,
1004
- prevOutputs: barrier.outputs,
1005
- prevOutput: barrier.latestOutput,
1006
- prevTaskId: task.id,
1007
- prevStatus: outcome.status,
1008
- prevDurationMs: Date.now() - start,
1009
- prevCostUsd: outcome.costUsd || 0,
1010
- }).catch((err) => {
1011
- logger.warn(`[dsh-cron] chained joined task ${targetId} failed to trigger:`, err.message);
1012
- });
1013
- } else if (barrier.pending) {
1014
- logger.info(`[dsh-cron] task "${nextTask.title}" (${targetId}) waiting for dependencies: ${barrier.waitingFor?.join(', ')}`);
1015
- } else if (barrier.failed) {
1016
- logger.warn(`[dsh-cron] task "${nextTask.title}" (${targetId}) barrier failed due to dependency: ${barrier.failedDep}`);
1017
- }
1018
- } else {
1019
- logger.info(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
1020
- scheduler.runNow(targetId, {
1021
- chainRunId,
1022
- chainDepth: currentDepth + 1,
1023
- prevOutput: outcome.output || outcome.error || '',
1024
- prevTaskId: task.id,
1025
- prevStatus: outcome.status,
1026
- prevDurationMs: Date.now() - start,
1027
- prevCostUsd: outcome.costUsd || 0,
1028
- }).catch((err) => {
1029
- logger.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
1030
- });
1031
- }
1032
- }
1033
-
1034
- // 2.2 Trigger any other tasks in store that depend on this task via dependsOn (Fan-In join step)
1035
- if (chainRunId && scheduler.store?.tasks && scheduler.store.chainCheckpointManager) {
1036
- for (const candidate of Object.values(scheduler.store.tasks)) {
1037
- if (!candidate || candidate.id === taskId || targets.includes(candidate.id)) continue;
1038
- const deps = sanitizeDependsOn(candidate.dependsOn);
1039
- if (deps.includes(taskId)) {
1040
- const barrier = scheduler.store.chainCheckpointManager.checkJoinBarrier(chainRunId, candidate);
1041
- if (barrier.ready && scheduler.store.chainCheckpointManager.canTriggerBarrier(chainRunId, candidate.id)) {
1042
- scheduler.store.chainCheckpointManager.markBarrierTriggered(chainRunId, candidate.id);
1043
- logger.info(`[dsh-cron] triggering dependent joined task "${candidate.title}" (${candidate.id}) (barrier satisfied)`);
1044
- scheduler.runNow(candidate.id, {
1045
- chainRunId,
1046
- chainDepth: currentDepth + 1,
1047
- prevOutputs: barrier.outputs,
1048
- prevOutput: barrier.latestOutput,
1049
- prevTaskId: task.id,
1050
- prevStatus: outcome.status,
1051
- prevDurationMs: Date.now() - start,
1052
- prevCostUsd: outcome.costUsd || 0,
1053
- }).catch((err) => {
1054
- logger.warn(`[dsh-cron] dependent joined task ${candidate.id} failed to trigger:`, err.message);
1055
- });
1056
- } else if (barrier.pending) {
1057
- logger.info(`[dsh-cron] task "${candidate.title}" (${candidate.id}) waiting for dependencies: ${barrier.waitingFor?.join(', ')}`);
1058
- } else if (barrier.failed) {
1059
- logger.warn(`[dsh-cron] task "${candidate.title}" (${candidate.id}) barrier failed due to dependency: ${barrier.failedDep}`);
1060
- }
1061
- }
1062
- }
1063
- }
1064
- } else {
1065
- logger.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id}. Loop prevented.`);
1066
- }
1067
- }
1068
-