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