@goodandready/dsh-cron 0.2.19 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ import { bestEffort } from './best-effort.js';
2
+ import { exec } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
5
+ import { applySilentRule } from './silent-rule.js';
6
+ import { inspectFailure } from './failure-inspector.js';
7
+
8
+ /** Task types that talk to a model and can therefore use a fallback model. */
9
+ export const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
10
+
11
+ /** Sum two usage records, so a fallback attempt is not lost in accounting. */
12
+ export function addUsage(a, b) {
13
+ return {
14
+ inputTokens: (a?.inputTokens || 0) + (b?.inputTokens || 0),
15
+ outputTokens: (a?.outputTokens || 0) + (b?.outputTokens || 0),
16
+ cacheReadTokens: (a?.cacheReadTokens || 0) + (b?.cacheReadTokens || 0),
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Execute pre-flight check gate before running task body.
22
+ */
23
+ export async function executePreflight(task) {
24
+ const type = String(task.preflightType || 'none').toLowerCase();
25
+ const target = String(task.preflightTarget || '').trim();
26
+ if (!type || type === 'none' || !target) {
27
+ return { ok: true };
28
+ }
29
+
30
+ if (type === 'http') {
31
+ try {
32
+ const controller = new AbortController();
33
+ const timeout = setTimeout(() => controller.abort(), 5000);
34
+ const res = await fetch(target, { signal: controller.signal });
35
+ clearTimeout(timeout);
36
+ if (res.ok) return { ok: true };
37
+ return { ok: false, reason: `HTTP check returned status ${res.status}` };
38
+ } catch (err) {
39
+ return { ok: false, reason: `HTTP check failed: ${err.message}` };
40
+ }
41
+ }
42
+
43
+ if (type === 'command') {
44
+ return new Promise((resolve) => {
45
+ exec(target, { timeout: 5000 }, (err, stdout, stderr) => {
46
+ if (err) {
47
+ resolve({ ok: false, reason: `Preflight command failed (code ${err.code || 1}): ${(stderr || stdout || err.message).trim().slice(0, 150)}` });
48
+ } else {
49
+ resolve({ ok: true });
50
+ }
51
+ });
52
+ });
53
+ }
54
+
55
+ if (type === 'disk') {
56
+ const requiredMb = Number(target) || 100;
57
+ try {
58
+ if (typeof fs.statfsSync === 'function') {
59
+ const stats = fs.statfsSync(process.cwd());
60
+ const freeMb = Math.round((stats.bavail * stats.bsize) / (1024 * 1024));
61
+ if (freeMb < requiredMb) {
62
+ return { ok: false, reason: `Free disk space (${freeMb} MB) is below required ${requiredMb} MB` };
63
+ }
64
+ }
65
+ return { ok: true };
66
+ } catch (err) {
67
+ bestEffort('statfsSync', () => {}, null);
68
+ return { ok: true }; // Fail-open
69
+ }
70
+ }
71
+
72
+ return { ok: true };
73
+ }
74
+
75
+ /** Only agent-mediated types use a model, and only a failed run falls back. */
76
+ export function shouldUseFallback(task, outcome) {
77
+ if (!task || !task.fallbackModel) return false;
78
+ if (!AGENT_TASK_TYPES.includes(task.type || 'llm')) return false;
79
+ return outcome.status === 'error' || outcome.status === 'timeout';
80
+ }
81
+
82
+ /** Run the task body once and normalise whatever the executor returned. */
83
+ export async function executeOnce(scheduler, task, signal, options = {}) {
84
+ const result = {
85
+ status: 'success',
86
+ output: '',
87
+ error: null,
88
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
89
+ costUsd: 0,
90
+ sessionId: null,
91
+ model: task.model || '',
92
+ fallback: false,
93
+ };
94
+ if (typeof scheduler.executeFn !== 'function') return result;
95
+ try {
96
+ const res = await scheduler.executeFn(task, { signal, ...options });
97
+ if (typeof res === 'object' && res !== null) {
98
+ result.output = res.output || '';
99
+ result.usage = res.usage || result.usage;
100
+ result.costUsd = res.costUsd || 0;
101
+ result.sessionId = res.sessionId || null;
102
+ if (res.model) result.model = res.model;
103
+ } else {
104
+ result.output = String(res || '');
105
+ }
106
+ } catch (err) {
107
+ result.error = err.message || String(err);
108
+ const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
109
+ result.status = timedOut ? 'timeout' : 'error';
110
+ }
111
+ return result;
112
+ }
113
+
114
+ /**
115
+ * Run the task, retrying once on the configured fallback model when the primary attempt failed (#45).
116
+ */
117
+ export async function executeWithFallback(scheduler, task, signal, options = {}) {
118
+ const primary = await scheduler.executeOnce(task, signal, options);
119
+ if (!scheduler.shouldUseFallback(task, primary)) return primary;
120
+
121
+ const fallbackTask = {
122
+ ...task,
123
+ provider: task.fallbackProvider || task.provider,
124
+ model: task.fallbackModel,
125
+ };
126
+ console.log(`[dsh-cron] task "${task.title}" failed on ${task.model || 'the default model'}, retrying once on ${task.fallbackModel}`);
127
+ const secondary = await scheduler.executeOnce(fallbackTask, signal, options);
128
+ return {
129
+ ...secondary,
130
+ fallback: true,
131
+ usage: addUsage(primary.usage, secondary.usage),
132
+ costUsd: Number((primary.costUsd + secondary.costUsd).toFixed(6)),
133
+ primaryModel: task.model || '',
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Begin a task run with concurrency and overlap policy checks.
139
+ */
140
+ export function beginRun(scheduler, task, taskId) {
141
+ if (!scheduler.running.has(taskId) && scheduler.maxConcurrent > 0 && scheduler.running.size >= scheduler.maxConcurrent) {
142
+ if (task.overlapPolicy === 'queue') {
143
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) queued by concurrency limit (${scheduler.maxConcurrent})`);
144
+ scheduler.queue.push({ taskId, priority: task.priority !== undefined ? task.priority : 5, queuedAt: Date.now() });
145
+ scheduler.queue.sort((a, b) => (a.priority - b.priority) || (a.queuedAt - b.queuedAt));
146
+ return null;
147
+ }
148
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${scheduler.maxConcurrent})`);
149
+ scheduler.recordSkipped(taskId, `Skipped: concurrency limit reached (${scheduler.maxConcurrent} parallel runs)`);
150
+ return null;
151
+ }
152
+
153
+ const overlapPolicy = task.overlapPolicy || 'skip';
154
+ const active = scheduler.running.get(taskId);
155
+ if (active) {
156
+ if (overlapPolicy === 'skip') {
157
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, skipping run (overlapPolicy: skip)`);
158
+ scheduler.recordSkipped(taskId, 'Skipped: the previous run is still in progress (overlapPolicy: skip)');
159
+ return null;
160
+ }
161
+ if (overlapPolicy === 'replace') {
162
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
163
+ active.controller.abort(new Error('Aborted by overlap policy: replace'));
164
+ }
165
+ if (overlapPolicy === 'queue') {
166
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
167
+ active.queueCount = (active.queueCount || 0) + 1;
168
+ return null;
169
+ }
170
+ }
171
+
172
+ const controller = new AbortController();
173
+ const currentRun = { controller, startedAt: Date.now(), queueCount: 0 };
174
+ scheduler.running.set(taskId, currentRun);
175
+ return currentRun;
176
+ }
177
+
178
+ /**
179
+ * Run task by ID.
180
+ */
181
+ export async function executeTask(scheduler, taskId, options = {}) {
182
+ const task = scheduler.store.get(taskId);
183
+ if (!task) return null;
184
+
185
+ if (!options.isRetry && task.attempts) {
186
+ task.attempts = 0;
187
+ scheduler.store.set(task);
188
+ }
189
+
190
+ if (options.dryRun) {
191
+ const start = Date.now();
192
+ const outcome = await scheduler.executeWithFallback(task, AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)), options);
193
+ return {
194
+ ok: true,
195
+ dryRun: true,
196
+ status: outcome.status,
197
+ output: outcome.output,
198
+ error: outcome.error,
199
+ durationMs: Date.now() - start,
200
+ usage: outcome.usage,
201
+ costUsd: outcome.costUsd,
202
+ model: outcome.model || task.model || '',
203
+ };
204
+ }
205
+
206
+ const currentRun = scheduler.beginRun(task, taskId);
207
+ if (currentRun === null) return null;
208
+
209
+ const preflight = await executePreflight(task);
210
+ if (!preflight.ok) {
211
+ console.log(`[dsh-cron] task "${task.title}" (${taskId}) skipped: ${preflight.reason}`);
212
+ scheduler.running.delete(taskId);
213
+ scheduler.recordSkipped(taskId, `Preflight check skipped: ${preflight.reason}`);
214
+ return null;
215
+ }
216
+
217
+ const start = Date.now();
218
+ const outcome = await scheduler.executeWithFallback(task, currentRun.controller.signal, options);
219
+ await scheduler.completeRun(task, taskId, currentRun, outcome, start, options);
220
+ return outcome;
221
+ }
222
+
223
+ /**
224
+ * Ask the model to diagnose a failed run (#43). Never throws.
225
+ */
226
+ export async function inspectFailureRun(task, runInfo, { askModel, readSettingsFn, inspectTimeoutMs }) {
227
+ if (typeof askModel !== 'function') return { inspected: false, error: 'no model is available' };
228
+ const settings = readSettingsFn ? readSettingsFn() : {};
229
+ try {
230
+ return await inspectFailure({
231
+ task,
232
+ runInfo,
233
+ ask: askModel,
234
+ preferredModel: String(settings.inspectorModel || ''),
235
+ timeoutMs: inspectTimeoutMs || 25000,
236
+ });
237
+ } catch (err) {
238
+ return { inspected: false, error: err.message };
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Ask the silent rule whether this run should be delivered (#44).
244
+ * Never throws: any problem means "deliver".
245
+ */
246
+ export async function applySilentRuleToRun(task, runInfo, { askModel, readSettingsFn, silentRuleTimeoutMs }) {
247
+ if (typeof askModel !== 'function') return { skipped: false, reason: '' };
248
+ const preferredModel = String((readSettingsFn ? readSettingsFn() : {}).silentRuleModel || '');
249
+ try {
250
+ return await applySilentRule({
251
+ task,
252
+ runInfo,
253
+ ask: askModel,
254
+ preferredModel,
255
+ timeoutMs: silentRuleTimeoutMs || 20000,
256
+ });
257
+ } catch (err) {
258
+ return { skipped: false, reason: '', verdictError: err.message };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Run the model-assisted steps for a finished run.
264
+ */
265
+ export async function applyModelAssists(task, runInfo, options) {
266
+ const inspection = await inspectFailureRun(task, runInfo, options);
267
+ if (inspection.inspected) {
268
+ runInfo.diagnosis = inspection.diagnosis;
269
+ runInfo.suggestion = inspection.suggestion;
270
+ runInfo.confidence = inspection.confidence;
271
+ console.log(`[dsh-cron] task "${task.title}" failure diagnosed (${inspection.confidence}): ${inspection.diagnosis}`);
272
+ } else if (inspection.error && task.inspectOnFailure) {
273
+ console.warn(`[dsh-cron] failure inspection for "${task.title}" not applied: ${inspection.error}`);
274
+ }
275
+
276
+ const silent = await applySilentRuleToRun(task, runInfo, options);
277
+ if (silent.skipped) {
278
+ runInfo.silentSkip = true;
279
+ runInfo.silentReason = silent.reason;
280
+ console.log(`[dsh-cron] task "${task.title}" stayed silent by rule: ${silent.reason || 'no reason given'}`);
281
+ } else if (silent.verdictError) {
282
+ console.warn(`[dsh-cron] silent rule for "${task.title}" not applied: ${silent.verdictError}`);
283
+ }
284
+ return silent;
285
+ }
286
+
287
+ /**
288
+ * Deliver a finished run to every configured channel (#26).
289
+ */
290
+ export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver }) {
291
+ try {
292
+ let settings = typeof store?.getSettings === 'function' ? store.getSettings() : {};
293
+ if (resolveSecrets) {
294
+ try {
295
+ settings = { ...settings, ...(await resolveSecrets(settings)) };
296
+ } catch (secErr) {
297
+ console.warn('[dsh-cron] credential resolution failed:', secErr.message);
298
+ }
299
+ }
300
+ if (typeof deliver !== 'function') return;
301
+ const result = await deliver(task, runInfo, settings);
302
+ if (result && Array.isArray(result.delivered) && result.delivered.length) {
303
+ console.log(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
304
+ }
305
+ if (result && Array.isArray(result.failures) && result.failures.length) {
306
+ console.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
307
+ }
308
+ } catch (notifyErr) {
309
+ console.error('[dsh-cron] notification error:', notifyErr.message);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Post-run bookkeeping: one-shot completion, next-run timestamp, retry backoff, draining.
315
+ */
316
+ export function finishRun(scheduler, task, taskId, status, queuedCount) {
317
+ scheduler.countRun(status);
318
+ try {
319
+ if (task.oneShot) {
320
+ task.status = 'completed';
321
+ task.nextRunAt = null;
322
+ scheduler.store.set(task);
323
+ } else {
324
+ const job = scheduler.jobs.get(taskId);
325
+ if (job) {
326
+ const next = job.nextRun();
327
+ task.nextRunAt = next ? next.getTime() : null;
328
+ scheduler.store.set(task);
329
+ }
330
+ }
331
+ } catch (err) {
332
+ console.error('[dsh-cron] failed to persist the next run of task', taskId + ':', err.message);
333
+ }
334
+
335
+ try {
336
+ const failed = status === 'error' || status === 'timeout';
337
+ const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
338
+ if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
339
+ task.attempts = (task.attempts || 0) + 1;
340
+ const backoffMs = (Number(task.retryBackoffMs) > 0 ? Number(task.retryBackoffMs) : 30000) * Math.pow(2, task.attempts - 1);
341
+ const attempt = task.attempts;
342
+ scheduler.store.set(task);
343
+ scheduler.clearRetryTimer(taskId);
344
+ scheduler.retryTimers.set(taskId, setTimeout(() => {
345
+ scheduler.retryTimers.delete(taskId);
346
+ scheduler.runTask(taskId, { isRetry: true });
347
+ }, backoffMs));
348
+ console.log(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${task.title}" in ${backoffMs}ms`);
349
+ } else if (!failed && task.attempts) {
350
+ task.attempts = 0;
351
+ scheduler.store.set(task);
352
+ } else if (failed && maxRetries > 0 && (task.attempts || 0) >= maxRetries) {
353
+ task.attempts = 0;
354
+ scheduler.store.set(task);
355
+ }
356
+ } catch (err) {
357
+ console.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
358
+ }
359
+
360
+ if (queuedCount > 0) {
361
+ setImmediate(() => {
362
+ scheduler.runTask(taskId);
363
+ });
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Handle complete run lifecycle: metrics, self-healing, diagnosis, silent rule, storage, delivery, chaining.
369
+ */
370
+ export async function handleCompleteRun(scheduler, task, taskId, currentRun, outcome, start, options = {}) {
371
+ const isSuccess = outcome.status === 'success';
372
+ const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
373
+ const runInfo = {
374
+ at: start,
375
+ status: outcome.status,
376
+ durationMs: Date.now() - start,
377
+ output: outcome.output,
378
+ error: outcome.error,
379
+ usage: outcome.usage,
380
+ costUsd: outcome.costUsd,
381
+ sessionId: outcome.sessionId,
382
+ model: outcome.model || task.model || '',
383
+ fallback: Boolean(outcome.fallback),
384
+ };
385
+
386
+ if (isFailure && task.selfHealingCommand) {
387
+ console.log(`[dsh-cron] running self-healing command for task "${task.title}": ${task.selfHealingCommand}`);
388
+ try {
389
+ await new Promise((resolve) => {
390
+ exec(task.selfHealingCommand, { timeout: 30000 }, (shErr, stdout, stderr) => {
391
+ runInfo.selfHealing = {
392
+ command: task.selfHealingCommand,
393
+ success: !shErr,
394
+ output: (stdout + (stderr ? '\n' + stderr : '')).trim().slice(0, 500),
395
+ };
396
+ resolve();
397
+ });
398
+ });
399
+ } catch (shErr) {
400
+ runInfo.selfHealing = { command: task.selfHealingCommand, success: false, error: shErr.message };
401
+ }
402
+ }
403
+
404
+ if (isFailure && task.autoDiagnose && typeof scheduler.executeFn === 'function') {
405
+ try {
406
+ const diagRes = await scheduler.executeFn({
407
+ type: 'llm',
408
+ prompt: `Diagnose this task failure in 1-2 concise sentences. Error: ${outcome.error || 'unknown'}. Recent output: ${(outcome.output || '').slice(-300)}`,
409
+ model: task.fallbackModel || task.model,
410
+ }, { signal: AbortSignal.timeout(10000) });
411
+ if (diagRes && diagRes.output) {
412
+ runInfo.autoDiagnosis = diagRes.output.trim();
413
+ }
414
+ } catch (diagErr) {
415
+ bestEffort('auto-diagnose', () => {}, scheduler.logger);
416
+ }
417
+ }
418
+
419
+ const silent = await scheduler.applyModelAssists(task, runInfo);
420
+
421
+ try {
422
+ scheduler.store.recordRun(taskId, runInfo);
423
+ } catch (recordErr) {
424
+ console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
425
+ }
426
+
427
+ const finishedRun = scheduler.running.get(taskId);
428
+ if (finishedRun === currentRun) scheduler.running.delete(taskId);
429
+ const queuedCount = finishedRun?.queueCount || 0;
430
+
431
+ if (!silent.skipped) await scheduler.deliverNotifications(task, runInfo);
432
+ scheduler.finishRun(task, taskId, outcome.status, queuedCount);
433
+
434
+ while (scheduler.queue.length > 0 && scheduler.running.size < scheduler.maxConcurrent) {
435
+ const nextItem = scheduler.queue.shift();
436
+ if (!nextItem) break;
437
+ const queuedTask = scheduler.store.get(nextItem.taskId);
438
+ if (queuedTask && queuedTask.status === 'active') {
439
+ setImmediate(() => {
440
+ scheduler.runTask(nextItem.taskId).catch((runErr) => {
441
+ bestEffort('drain-queued-task', () => {}, scheduler.logger);
442
+ });
443
+ });
444
+ break;
445
+ }
446
+ }
447
+
448
+ // 1. Structured LLM Actions (#137)
449
+ if (task.type === 'llm' && outcome.status === 'success' && outcome.output) {
450
+ try {
451
+ const settings = typeof scheduler.store.getSettings === 'function' ? scheduler.store.getSettings() : {};
452
+ if (settings.llmActionsEnabled) {
453
+ const directives = parseLlmActionDirectives(outcome.output);
454
+ if (directives.length > 0) {
455
+ await executeLlmActionDirectives({
456
+ directives,
457
+ task,
458
+ runInfo,
459
+ scheduler,
460
+ store: scheduler.store,
461
+ settings,
462
+ });
463
+ }
464
+ }
465
+ } catch (actErr) {
466
+ console.warn(`[dsh-cron] structured action error for ${taskId}:`, actErr.message);
467
+ }
468
+ }
469
+
470
+ // 2. Task Chaining (#137)
471
+ const nextTaskId = isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null);
472
+ if (nextTaskId && typeof nextTaskId === 'string' && nextTaskId.trim()) {
473
+ const currentDepth = options.chainDepth || 0;
474
+ if (currentDepth < 4) {
475
+ const targetId = nextTaskId.trim();
476
+ const nextTask = scheduler.store.get(targetId);
477
+ if (nextTask) {
478
+ console.log(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
479
+ scheduler.runNow(targetId, {
480
+ chainDepth: currentDepth + 1,
481
+ prevOutput: outcome.output || outcome.error || '',
482
+ prevTaskId: task.id,
483
+ }).catch((err) => {
484
+ console.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
485
+ });
486
+ } else {
487
+ console.warn(`[dsh-cron] chained task target not found: ${targetId}`);
488
+ }
489
+ } else {
490
+ console.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id} -> ${nextTaskId}. Loop prevented.`);
491
+ }
492
+ }
493
+ }
494
+
@@ -0,0 +1,143 @@
1
+ import { bestEffort } from './best-effort.js';
2
+
3
+ /**
4
+ * Start heartbeat watcher timer.
5
+ */
6
+ export function startHeartbeatWatcher(scheduler, intervalMs = 30000) {
7
+ stopHeartbeatWatcher(scheduler);
8
+ scheduler.heartbeatTimer = setInterval(() => scheduler.checkHeartbeats(), intervalMs);
9
+ if (scheduler.heartbeatTimer && typeof scheduler.heartbeatTimer.unref === 'function') {
10
+ scheduler.heartbeatTimer.unref();
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Stop heartbeat watcher timer.
16
+ */
17
+ export function stopHeartbeatWatcher(scheduler) {
18
+ if (scheduler.heartbeatTimer) {
19
+ clearInterval(scheduler.heartbeatTimer);
20
+ scheduler.heartbeatTimer = null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Check active tasks for missed heartbeats.
26
+ */
27
+ export async function checkHeartbeats(scheduler) {
28
+ const now = Date.now();
29
+ const tasks = typeof scheduler.store.list === 'function' ? scheduler.store.list({ status: 'active' }) : Array.from(scheduler.store.tasks.values());
30
+ for (const task of tasks) {
31
+ if (task.status !== 'active' || !task.heartbeatIntervalSeconds || task.heartbeatIntervalSeconds <= 0) {
32
+ continue;
33
+ }
34
+ const grace = task.gracePeriodSeconds !== undefined ? task.gracePeriodSeconds : 300;
35
+ const last = task.lastPingAt || task.createdAt || now;
36
+ const deadline = last + (task.heartbeatIntervalSeconds + grace) * 1000;
37
+
38
+ if (now > deadline && !task.heartbeatAlerted) {
39
+ task.heartbeatAlerted = true;
40
+ task.lastStatus = 'missed';
41
+ task.updatedAt = now;
42
+ scheduler.store.save();
43
+
44
+ console.warn(`[dsh-cron] Heartbeat missed for "${task.title}" (${task.id})!`);
45
+ const runInfo = {
46
+ at: now,
47
+ status: 'missed',
48
+ durationMs: 0,
49
+ output: `Heartbeat missed: expected ping within ${task.heartbeatIntervalSeconds + grace}s, last ping was at ${new Date(last).toISOString()}`,
50
+ error: 'Heartbeat deadline exceeded',
51
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
52
+ costUsd: 0,
53
+ };
54
+
55
+ bestEffort('record-run', () => scheduler.store.recordRun(task.id, runInfo), scheduler.logger);
56
+
57
+ await scheduler.deliverNotifications(task, runInfo);
58
+
59
+ if (task.onFailure && String(task.onFailure).trim()) {
60
+ const targetId = task.onFailure.trim();
61
+ scheduler.runNow(targetId, {
62
+ chainDepth: 1,
63
+ prevOutput: runInfo.output,
64
+ prevTaskId: task.id,
65
+ }).catch((err) => console.warn('[dsh-cron] heartbeat onFailure trigger error:', err.message));
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Pause a task and abort any active execution.
73
+ */
74
+ export function pauseTask(scheduler, taskId) {
75
+ const task = scheduler.store.get(taskId);
76
+ if (!task) return null;
77
+ task.status = 'paused';
78
+ task.nextRunAt = null;
79
+ scheduler.clearRetryTimer(taskId);
80
+ if (scheduler.jobs.has(taskId)) {
81
+ scheduler.jobs.get(taskId).stop();
82
+ scheduler.jobs.delete(taskId);
83
+ }
84
+ if (scheduler.timers.has(taskId)) {
85
+ clearTimeout(scheduler.timers.get(taskId));
86
+ scheduler.timers.delete(taskId);
87
+ }
88
+ const running = scheduler.running.get(taskId);
89
+ if (running) {
90
+ bestEffort('abort-paused-task', () => running.controller.abort(new Error('Task paused')), scheduler.logger);
91
+ scheduler.running.delete(taskId);
92
+ }
93
+ scheduler.queue = scheduler.queue.filter((item) => item.taskId !== taskId);
94
+ scheduler.store.set(task);
95
+ return task;
96
+ }
97
+
98
+ /**
99
+ * Remove a scheduled task from timers, jobs, and run queue.
100
+ */
101
+ export function removeTask(scheduler, taskId) {
102
+ scheduler.clearRetryTimer(taskId);
103
+ if (scheduler.jobs.has(taskId)) {
104
+ scheduler.jobs.get(taskId).stop();
105
+ scheduler.jobs.delete(taskId);
106
+ }
107
+ if (scheduler.timers.has(taskId)) {
108
+ clearTimeout(scheduler.timers.get(taskId));
109
+ scheduler.timers.delete(taskId);
110
+ }
111
+ const running = scheduler.running.get(taskId);
112
+ if (running) {
113
+ bestEffort('abort-deleted-task', () => running.controller.abort(new Error('Task deleted')), scheduler.logger);
114
+ scheduler.running.delete(taskId);
115
+ }
116
+ scheduler.queue = scheduler.queue.filter((item) => item.taskId !== taskId);
117
+ }
118
+
119
+ /**
120
+ * Resume a paused task and re-arm schedule.
121
+ */
122
+ export function resumeTask(scheduler, taskId) {
123
+ const task = scheduler.store.get(taskId);
124
+ if (!task) return null;
125
+ task.status = 'active';
126
+ scheduler.scheduleTask(task);
127
+ scheduler.store.set(task);
128
+ return task;
129
+ }
130
+
131
+ /**
132
+ * Toggle task state between active and paused.
133
+ */
134
+ export function toggleTask(scheduler, taskId) {
135
+ const task = scheduler.store.get(taskId);
136
+ if (!task) return null;
137
+ if (task.status === 'active') {
138
+ return scheduler.pauseTask(taskId);
139
+ } else {
140
+ return scheduler.resumeTask(taskId);
141
+ }
142
+ }
143
+