@goodandready/dsh-cron 0.2.34 → 0.2.35
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.js +20 -4
- package/lib/index.js +1 -0
- package/lib/scheduler-execution.js +57 -32
- package/lib/scheduler.js +17 -2
- package/lib/store.js +47 -8
- package/lib/task-patch.js +8 -4
- package/package.json +1 -1
package/lib/api.js
CHANGED
|
@@ -218,7 +218,13 @@ async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
|
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
-
|
|
221
|
+
let task;
|
|
222
|
+
try {
|
|
223
|
+
task = store.set(buildTaskRecord(store, body, taskType, parsed));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
sendJson(res, 500, { ok: false, error: 'Failed to persist task: ' + err.message });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
222
228
|
if (task.status === 'active') {
|
|
223
229
|
scheduler.scheduleTask(task);
|
|
224
230
|
} else {
|
|
@@ -345,7 +351,12 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
|
|
|
345
351
|
return true;
|
|
346
352
|
}
|
|
347
353
|
const copy = buildDuplicateTask(source, { id: randomUUID() });
|
|
348
|
-
|
|
354
|
+
try {
|
|
355
|
+
store.set(copy);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
sendJson(res, 500, { ok: false, error: 'Failed to persist duplicate task: ' + err.message });
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
349
360
|
// A copy starts paused: it must never fire on its own before the user
|
|
350
361
|
// reviews the schedule (a duplicated one-shot may point at a past time).
|
|
351
362
|
scheduler.pauseTask(copy.id);
|
|
@@ -402,7 +413,7 @@ async function patchTask({ store, scheduler, req, res, id }) {
|
|
|
402
413
|
allowCodeSwitch: req.headers[SCRIPT_CONFIRM_HEADER] === 'script',
|
|
403
414
|
});
|
|
404
415
|
if (!result.ok) {
|
|
405
|
-
const status = result.notFound ? 404 : (result.needsConfirmation ? 403 : 400);
|
|
416
|
+
const status = result.status || (result.notFound ? 404 : (result.needsConfirmation ? 403 : 400));
|
|
406
417
|
const error = result.needsConfirmation
|
|
407
418
|
? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
|
|
408
419
|
: result.error;
|
|
@@ -428,7 +439,12 @@ function deleteTask({ store, scheduler, res, id }) {
|
|
|
428
439
|
} else if (scheduler) {
|
|
429
440
|
scheduler.pauseTask(id);
|
|
430
441
|
}
|
|
431
|
-
|
|
442
|
+
try {
|
|
443
|
+
store.delete(id);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
sendJson(res, 500, { ok: false, error: 'Failed to delete task: ' + err.message });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
432
448
|
}
|
|
433
449
|
sendJson(res, 200, { ok: true });
|
|
434
450
|
}
|
package/lib/index.js
CHANGED
|
@@ -198,6 +198,7 @@ export function apply(ctx, rawConfig) {
|
|
|
198
198
|
return () => {
|
|
199
199
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
200
200
|
scheduler.stopAll();
|
|
201
|
+
bestEffort('lifecycle-store-flush', () => store.flushSync(), logger);
|
|
201
202
|
};
|
|
202
203
|
}, 'dsh-cron: lifecycle');
|
|
203
204
|
|
|
@@ -142,6 +142,7 @@ export async function executeWithFallback(scheduler, task, signal, options = {})
|
|
|
142
142
|
* Begin a task run with concurrency and overlap policy checks.
|
|
143
143
|
*/
|
|
144
144
|
export function beginRun(scheduler, task, taskId) {
|
|
145
|
+
if (scheduler.isStopped) return null;
|
|
145
146
|
if (!scheduler.running.has(taskId) && scheduler.maxConcurrent > 0 && scheduler.running.size >= scheduler.maxConcurrent) {
|
|
146
147
|
if (task.overlapPolicy === 'queue') {
|
|
147
148
|
logger.info(`[dsh-cron] Task "${task.title}" (${taskId}) queued by concurrency limit (${scheduler.maxConcurrent})`);
|
|
@@ -174,7 +175,8 @@ export function beginRun(scheduler, task, taskId) {
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
const controller = new AbortController();
|
|
177
|
-
const
|
|
178
|
+
const runId = 'run_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now();
|
|
179
|
+
const currentRun = { id: runId, controller, startedAt: Date.now(), queueCount: 0 };
|
|
178
180
|
scheduler.running.set(taskId, currentRun);
|
|
179
181
|
return currentRun;
|
|
180
182
|
}
|
|
@@ -183,6 +185,7 @@ export function beginRun(scheduler, task, taskId) {
|
|
|
183
185
|
* Run task by ID.
|
|
184
186
|
*/
|
|
185
187
|
export async function executeTask(scheduler, taskId, options = {}) {
|
|
188
|
+
if (scheduler.isStopped) return null;
|
|
186
189
|
const task = scheduler.store.get(taskId);
|
|
187
190
|
if (!task) return null;
|
|
188
191
|
|
|
@@ -328,19 +331,32 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
|
|
|
328
331
|
/**
|
|
329
332
|
* Post-run bookkeeping: one-shot completion, next-run timestamp, retry backoff, draining.
|
|
330
333
|
*/
|
|
331
|
-
export function finishRun(scheduler,
|
|
334
|
+
export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount, meta = {}) {
|
|
332
335
|
scheduler.countRun(status);
|
|
336
|
+
|
|
337
|
+
// If this run was superseded by a newer run or scheduler is stopped, skip updating task
|
|
338
|
+
if (meta.isCurrentRun === false || scheduler.isStopped) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Refetch live task from store to avoid resurrecting deleted task or overwriting edits (#215)
|
|
343
|
+
const current = scheduler.store.get(taskId);
|
|
344
|
+
if (!current) {
|
|
345
|
+
logger.info(`[dsh-cron] finishRun: task "${taskId}" no longer exists in store; skipping completion write`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
333
349
|
try {
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
scheduler.store.set(
|
|
350
|
+
if (current.oneShot) {
|
|
351
|
+
current.status = 'completed';
|
|
352
|
+
current.nextRunAt = null;
|
|
353
|
+
scheduler.store.set(current);
|
|
338
354
|
} else {
|
|
339
355
|
const job = scheduler.jobs.get(taskId);
|
|
340
356
|
if (job) {
|
|
341
357
|
const next = job.nextRun();
|
|
342
|
-
|
|
343
|
-
scheduler.store.set(
|
|
358
|
+
current.nextRunAt = next ? next.getTime() : null;
|
|
359
|
+
scheduler.store.set(current);
|
|
344
360
|
}
|
|
345
361
|
}
|
|
346
362
|
} catch (err) {
|
|
@@ -349,32 +365,36 @@ export function finishRun(scheduler, task, taskId, status, queuedCount) {
|
|
|
349
365
|
|
|
350
366
|
try {
|
|
351
367
|
const failed = status === 'error' || status === 'timeout';
|
|
352
|
-
const maxRetries = Number(
|
|
353
|
-
if (failed && maxRetries > 0 && (
|
|
354
|
-
|
|
355
|
-
const backoffMs = (Number(
|
|
356
|
-
const attempt =
|
|
357
|
-
scheduler.store.set(
|
|
368
|
+
const maxRetries = Number(current.maxRetries) > 0 ? Number(current.maxRetries) : 0;
|
|
369
|
+
if (failed && maxRetries > 0 && (current.attempts || 0) < maxRetries && current.status !== 'paused' && !scheduler.isStopped) {
|
|
370
|
+
current.attempts = (current.attempts || 0) + 1;
|
|
371
|
+
const backoffMs = (Number(current.retryBackoffMs) > 0 ? Number(current.retryBackoffMs) : 30000) * Math.pow(2, current.attempts - 1);
|
|
372
|
+
const attempt = current.attempts;
|
|
373
|
+
scheduler.store.set(current);
|
|
358
374
|
scheduler.clearRetryTimer(taskId);
|
|
359
375
|
scheduler.retryTimers.set(taskId, setTimeout(() => {
|
|
360
376
|
scheduler.retryTimers.delete(taskId);
|
|
361
|
-
scheduler.
|
|
377
|
+
if (!scheduler.isStopped) {
|
|
378
|
+
scheduler.runTask(taskId, { isRetry: true });
|
|
379
|
+
}
|
|
362
380
|
}, backoffMs));
|
|
363
|
-
logger.info(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${
|
|
364
|
-
} else if (!failed &&
|
|
365
|
-
|
|
366
|
-
scheduler.store.set(
|
|
367
|
-
} else if (failed && maxRetries > 0 && (
|
|
368
|
-
|
|
369
|
-
scheduler.store.set(
|
|
381
|
+
logger.info(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${current.title}" in ${backoffMs}ms`);
|
|
382
|
+
} else if (!failed && current.attempts) {
|
|
383
|
+
current.attempts = 0;
|
|
384
|
+
scheduler.store.set(current);
|
|
385
|
+
} else if (failed && maxRetries > 0 && (current.attempts || 0) >= maxRetries) {
|
|
386
|
+
current.attempts = 0;
|
|
387
|
+
scheduler.store.set(current);
|
|
370
388
|
}
|
|
371
389
|
} catch (err) {
|
|
372
390
|
logger.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
|
|
373
391
|
}
|
|
374
392
|
|
|
375
|
-
if (queuedCount > 0) {
|
|
393
|
+
if (queuedCount > 0 && !scheduler.isStopped) {
|
|
376
394
|
setImmediate(() => {
|
|
377
|
-
scheduler.
|
|
395
|
+
if (!scheduler.isStopped) {
|
|
396
|
+
scheduler.runTask(taskId);
|
|
397
|
+
}
|
|
378
398
|
});
|
|
379
399
|
}
|
|
380
400
|
}
|
|
@@ -440,24 +460,29 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
440
460
|
}
|
|
441
461
|
|
|
442
462
|
const finishedRun = scheduler.running.get(taskId);
|
|
443
|
-
|
|
444
|
-
|
|
463
|
+
const isCurrentRun = (finishedRun === currentRun);
|
|
464
|
+
if (isCurrentRun) scheduler.running.delete(taskId);
|
|
465
|
+
const queuedCount = isCurrentRun ? (finishedRun?.queueCount || 0) : 0;
|
|
445
466
|
|
|
446
467
|
if (!silent.skipped) await scheduler.deliverNotifications(task, runInfo);
|
|
447
|
-
scheduler.finishRun(task, taskId, outcome.status, queuedCount);
|
|
468
|
+
scheduler.finishRun(task, taskId, outcome.status, queuedCount, { isCurrentRun, runId: currentRun?.id });
|
|
469
|
+
|
|
470
|
+
if (scheduler.isStopped) return;
|
|
448
471
|
|
|
449
472
|
const guard = await checkAndApplyBurnGuard(scheduler, taskId, outcome);
|
|
450
473
|
if (guard && guard.exceeded) return;
|
|
451
474
|
|
|
452
|
-
while (scheduler.queue.length > 0 && scheduler.running.size < scheduler.maxConcurrent) {
|
|
475
|
+
while (!scheduler.isStopped && scheduler.queue.length > 0 && scheduler.running.size < scheduler.maxConcurrent) {
|
|
453
476
|
const nextItem = scheduler.queue.shift();
|
|
454
477
|
if (!nextItem) break;
|
|
455
478
|
const queuedTask = scheduler.store.get(nextItem.taskId);
|
|
456
|
-
if (queuedTask && queuedTask.status === 'active') {
|
|
479
|
+
if (queuedTask && queuedTask.status === 'active' && !scheduler.isStopped) {
|
|
457
480
|
setImmediate(() => {
|
|
458
|
-
scheduler.
|
|
459
|
-
|
|
460
|
-
|
|
481
|
+
if (!scheduler.isStopped) {
|
|
482
|
+
scheduler.runTask(nextItem.taskId).catch((runErr) => {
|
|
483
|
+
bestEffort('drain-queued-task', () => {}, scheduler.logger);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
461
486
|
});
|
|
462
487
|
break;
|
|
463
488
|
}
|
package/lib/scheduler.js
CHANGED
|
@@ -254,6 +254,8 @@ export class TaskScheduler {
|
|
|
254
254
|
this.queue = [];
|
|
255
255
|
this.heartbeatTimer = null;
|
|
256
256
|
this.logger = opts.logger || null;
|
|
257
|
+
this.isStopped = false;
|
|
258
|
+
this.generation = 0;
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
isRunning(taskId) {
|
|
@@ -290,6 +292,8 @@ export class TaskScheduler {
|
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
start() {
|
|
295
|
+
this.isStopped = false;
|
|
296
|
+
this.generation = (this.generation || 0) + 1;
|
|
293
297
|
const tasks = this.store.list();
|
|
294
298
|
const now = Date.now();
|
|
295
299
|
for (const task of tasks) {
|
|
@@ -328,7 +332,13 @@ export class TaskScheduler {
|
|
|
328
332
|
}
|
|
329
333
|
|
|
330
334
|
stopAll() {
|
|
335
|
+
this.isStopped = true;
|
|
336
|
+
this.generation = (this.generation || 0) + 1;
|
|
331
337
|
this.stopHeartbeatWatcher();
|
|
338
|
+
|
|
339
|
+
// Clear concurrency queue (#217)
|
|
340
|
+
this.queue = [];
|
|
341
|
+
|
|
332
342
|
for (const [, job] of this.jobs.entries()) {
|
|
333
343
|
bestEffort('stop-job', () => job.stop(), this.logger);
|
|
334
344
|
}
|
|
@@ -348,6 +358,10 @@ export class TaskScheduler {
|
|
|
348
358
|
bestEffort('abort-running', () => run.controller.abort(new Error('Scheduler stopped')), this.logger);
|
|
349
359
|
}
|
|
350
360
|
this.running.clear();
|
|
361
|
+
|
|
362
|
+
if (this.store && typeof this.store.flushSync === 'function') {
|
|
363
|
+
bestEffort('store-flush-on-stop', () => this.store.flushSync(), this.logger);
|
|
364
|
+
}
|
|
351
365
|
}
|
|
352
366
|
|
|
353
367
|
clearScheduled(taskId) {
|
|
@@ -451,6 +465,7 @@ export class TaskScheduler {
|
|
|
451
465
|
}
|
|
452
466
|
|
|
453
467
|
async runTask(taskId, options = {}) {
|
|
468
|
+
if (this.isStopped) return null;
|
|
454
469
|
return executeTask(this, taskId, options);
|
|
455
470
|
}
|
|
456
471
|
|
|
@@ -500,8 +515,8 @@ export class TaskScheduler {
|
|
|
500
515
|
});
|
|
501
516
|
}
|
|
502
517
|
|
|
503
|
-
finishRun(task, taskId, status, queuedCount) {
|
|
504
|
-
return finishRun(this, task, taskId, status, queuedCount);
|
|
518
|
+
finishRun(task, taskId, status, queuedCount, meta) {
|
|
519
|
+
return finishRun(this, task, taskId, status, queuedCount, meta);
|
|
505
520
|
}
|
|
506
521
|
|
|
507
522
|
pauseTask(taskId, reason) {
|
package/lib/store.js
CHANGED
|
@@ -112,7 +112,11 @@ export class TaskStore {
|
|
|
112
112
|
if (this._saveTimer) return;
|
|
113
113
|
this._saveTimer = setTimeout(() => {
|
|
114
114
|
this._saveTimer = null;
|
|
115
|
-
|
|
115
|
+
try {
|
|
116
|
+
this.save();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.error('[dsh-cron] debounced save failed:', err.message);
|
|
119
|
+
}
|
|
116
120
|
}, delay);
|
|
117
121
|
if (typeof this._saveTimer.unref === 'function') {
|
|
118
122
|
this._saveTimer.unref();
|
|
@@ -160,6 +164,7 @@ export class TaskStore {
|
|
|
160
164
|
}
|
|
161
165
|
} catch (err) {
|
|
162
166
|
logger.error('[dsh-cron] store save error:', err.message);
|
|
167
|
+
throw err;
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -254,6 +259,7 @@ export class TaskStore {
|
|
|
254
259
|
else if (typeof value === 'string') patch[key] = value.trim();
|
|
255
260
|
}
|
|
256
261
|
|
|
262
|
+
const prevSettings = this.settings;
|
|
257
263
|
this.settings = {
|
|
258
264
|
...this.settings,
|
|
259
265
|
...patch,
|
|
@@ -265,7 +271,12 @@ export class TaskStore {
|
|
|
265
271
|
onlyOnFailure: patch.onlyOnFailure !== undefined ? Boolean(patch.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
|
|
266
272
|
llmActionsEnabled: patch.llmActionsEnabled !== undefined ? Boolean(patch.llmActionsEnabled) : Boolean(this.settings.llmActionsEnabled),
|
|
267
273
|
};
|
|
268
|
-
|
|
274
|
+
try {
|
|
275
|
+
this.save();
|
|
276
|
+
} catch (err) {
|
|
277
|
+
this.settings = prevSettings;
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
269
280
|
return this.getSettings();
|
|
270
281
|
}
|
|
271
282
|
|
|
@@ -292,7 +303,8 @@ export class TaskStore {
|
|
|
292
303
|
set(task) {
|
|
293
304
|
const now = Date.now();
|
|
294
305
|
const id = task.id || ('cron_' + Math.random().toString(36).slice(2, 9));
|
|
295
|
-
const
|
|
306
|
+
const existing = this.tasks.get(id);
|
|
307
|
+
const prev = existing || {};
|
|
296
308
|
const record = {
|
|
297
309
|
...prev,
|
|
298
310
|
...task,
|
|
@@ -337,7 +349,13 @@ export class TaskStore {
|
|
|
337
349
|
nextRunAt: task.nextRunAt !== undefined ? task.nextRunAt : (prev.nextRunAt || null),
|
|
338
350
|
};
|
|
339
351
|
this.tasks.set(id, record);
|
|
340
|
-
|
|
352
|
+
try {
|
|
353
|
+
this.save();
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (existing) this.tasks.set(id, existing);
|
|
356
|
+
else this.tasks.delete(id);
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
341
359
|
return record;
|
|
342
360
|
}
|
|
343
361
|
|
|
@@ -345,13 +363,25 @@ export class TaskStore {
|
|
|
345
363
|
const task = this.tasks.get(id);
|
|
346
364
|
if (!task) return null;
|
|
347
365
|
const now = Date.now();
|
|
366
|
+
const prevPing = task.lastPingAt;
|
|
367
|
+
const prevAlerted = task.heartbeatAlerted;
|
|
368
|
+
const prevUpdated = task.updatedAt;
|
|
369
|
+
const prevStatus = task.lastStatus;
|
|
348
370
|
task.lastPingAt = now;
|
|
349
371
|
task.heartbeatAlerted = false;
|
|
350
372
|
task.updatedAt = now;
|
|
351
373
|
if (task.lastStatus === 'missed') {
|
|
352
374
|
task.lastStatus = 'active';
|
|
353
375
|
}
|
|
354
|
-
|
|
376
|
+
try {
|
|
377
|
+
this.save();
|
|
378
|
+
} catch (err) {
|
|
379
|
+
task.lastPingAt = prevPing;
|
|
380
|
+
task.heartbeatAlerted = prevAlerted;
|
|
381
|
+
task.updatedAt = prevUpdated;
|
|
382
|
+
task.lastStatus = prevStatus;
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
355
385
|
const interval = (task.heartbeatIntervalSeconds || 0) + (task.gracePeriodSeconds || 300);
|
|
356
386
|
return {
|
|
357
387
|
ok: true,
|
|
@@ -363,10 +393,19 @@ export class TaskStore {
|
|
|
363
393
|
}
|
|
364
394
|
|
|
365
395
|
delete(id) {
|
|
366
|
-
const
|
|
396
|
+
const existing = this.tasks.get(id);
|
|
397
|
+
if (!existing) return false;
|
|
398
|
+
const existingHistory = this.history.get(id);
|
|
399
|
+
this.tasks.delete(id);
|
|
367
400
|
this.history.delete(id);
|
|
368
|
-
|
|
369
|
-
|
|
401
|
+
try {
|
|
402
|
+
this.save();
|
|
403
|
+
} catch (err) {
|
|
404
|
+
this.tasks.set(id, existing);
|
|
405
|
+
if (existingHistory) this.history.set(id, existingHistory);
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
return true;
|
|
370
409
|
}
|
|
371
410
|
|
|
372
411
|
recordRun(id, runInfo) {
|
package/lib/task-patch.js
CHANGED
|
@@ -114,10 +114,14 @@ export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = f
|
|
|
114
114
|
}
|
|
115
115
|
const built = buildTaskPatch(current, body);
|
|
116
116
|
if (!built.ok) return built;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
try {
|
|
118
|
+
const task = store.set({ ...current, ...built.patch, id });
|
|
119
|
+
if (task.status === 'active') scheduler.scheduleTask(task);
|
|
120
|
+
else scheduler.pauseTask(id);
|
|
121
|
+
return { ok: true, task, patch: built.patch };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ok: false, error: 'Failed to persist task patch: ' + err.message, status: 500 };
|
|
124
|
+
}
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
/** Human-readable summary of what changed, for tool output and logs. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.35",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|