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