@goodandready/dsh-cron 0.2.33 → 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 CHANGED
@@ -218,7 +218,13 @@ async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
218
218
  return;
219
219
  }
220
220
  }
221
- const task = store.set(buildTaskRecord(store, body, taskType, parsed));
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
- store.set(copy);
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
- store.delete(id);
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 currentRun = { controller, startedAt: Date.now(), queueCount: 0 };
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, task, taskId, status, queuedCount) {
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 (task.oneShot) {
335
- task.status = 'completed';
336
- task.nextRunAt = null;
337
- scheduler.store.set(task);
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
- task.nextRunAt = next ? next.getTime() : null;
343
- scheduler.store.set(task);
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(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
353
- if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
354
- task.attempts = (task.attempts || 0) + 1;
355
- const backoffMs = (Number(task.retryBackoffMs) > 0 ? Number(task.retryBackoffMs) : 30000) * Math.pow(2, task.attempts - 1);
356
- const attempt = task.attempts;
357
- scheduler.store.set(task);
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.runTask(taskId, { isRetry: true });
377
+ if (!scheduler.isStopped) {
378
+ scheduler.runTask(taskId, { isRetry: true });
379
+ }
362
380
  }, backoffMs));
363
- logger.info(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${task.title}" in ${backoffMs}ms`);
364
- } else if (!failed && task.attempts) {
365
- task.attempts = 0;
366
- scheduler.store.set(task);
367
- } else if (failed && maxRetries > 0 && (task.attempts || 0) >= maxRetries) {
368
- task.attempts = 0;
369
- scheduler.store.set(task);
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.runTask(taskId);
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
- if (finishedRun === currentRun) scheduler.running.delete(taskId);
444
- const queuedCount = finishedRun?.queueCount || 0;
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.runTask(nextItem.taskId).catch((runErr) => {
459
- bestEffort('drain-queued-task', () => {}, scheduler.logger);
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
- this.save();
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
- this.save();
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 prev = this.tasks.get(id) || {};
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
- this.save();
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
- this.save();
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 existed = this.tasks.delete(id);
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
- if (existed) this.save();
369
- return existed;
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
- const task = store.set({ ...current, ...built.patch, id });
118
- if (task.status === 'active') scheduler.scheduleTask(task);
119
- else scheduler.pauseTask(id);
120
- return { ok: true, task, patch: built.patch };
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.33",
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",
@@ -16,6 +16,12 @@
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs",
21
+ "build:client": "node scripts/build-client.mjs",
22
+ "pretest": "node scripts/build-client.mjs",
23
+ "preflight": "node scripts/ci-preflight.mjs"
24
+ },
19
25
  "keywords": [
20
26
  "dsh",
21
27
  "dsh-plugin",
@@ -83,11 +89,5 @@
83
89
  "publishConfig": {
84
90
  "registry": "https://registry.npmjs.org",
85
91
  "access": "public"
86
- },
87
- "scripts": {
88
- "test": "node --test test/*.test.mjs",
89
- "build:client": "node scripts/build-client.mjs",
90
- "pretest": "node scripts/build-client.mjs",
91
- "preflight": "node scripts/ci-preflight.mjs"
92
92
  }
93
- }
93
+ }