@goodandready/dsh-cron 0.2.10 → 0.2.12

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/index.js CHANGED
@@ -393,6 +393,24 @@ export function apply(ctx, config) {
393
393
  handler: createCronApiHandler(store, scheduler, null)
394
394
  }), 'dsh-cron: /action/:id/:action');
395
395
 
396
+ ctx.effect(() => ctx.webServer.register({
397
+ kind: 'prefix',
398
+ path: '/dsh-cron/telegram',
399
+ handler: createCronApiHandler(store, scheduler, null)
400
+ }), 'dsh-cron: /telegram[...]');
401
+
402
+ ctx.effect(() => ctx.webServer.register({
403
+ kind: 'prefix',
404
+ path: '/dsh-cron/heartbeat-ping',
405
+ handler: createCronApiHandler(store, scheduler, null)
406
+ }), 'dsh-cron: /heartbeat-ping[...]');
407
+
408
+ ctx.effect(() => ctx.webServer.register({
409
+ kind: 'prefix',
410
+ path: '/dsh-cron/schedule',
411
+ handler: createCronApiHandler(store, scheduler, null)
412
+ }), 'dsh-cron: /schedule[...]');
413
+
396
414
  // GET /dsh-cron/metrics (#53) — Prometheus text exposition
397
415
  ctx.effect(() => ctx.webServer.register({
398
416
  kind: 'exact',
package/lib/runner.js CHANGED
@@ -457,10 +457,38 @@ ${prompt}`;
457
457
  async _executeAgentTurn(task, prep) {
458
458
  let handle = null;
459
459
  try {
460
+ // Resolve agent preset (#GH-1): scheduled llm sessions must join an agent preset
461
+ // (default or task-configured), otherwise tools resolve against the empty global layer.
462
+ const presets = this.ctx.agentPresets || (this.ctx.get && this.ctx.get('agentPresets'));
463
+ let preset = null;
464
+ if (presets && typeof presets.resolve === 'function') {
465
+ try {
466
+ preset = await presets.resolve(task.agentPreset || undefined);
467
+ if (preset && typeof presets.standingKeyFor === 'function') {
468
+ await presets.standingKeyFor(preset.id);
469
+ }
470
+ } catch (presetErr) {
471
+ console.warn('[dsh-cron] agent preset unavailable, running without one:', presetErr && presetErr.message);
472
+ preset = null;
473
+ }
474
+ }
475
+
460
476
  handle = await this.ctx.agents.create({
461
477
  sessionId: prep.mintSessionId(),
462
- meta: { cwd: prep.cwd, ephemeral: true, internal: true },
478
+ meta: {
479
+ cwd: prep.cwd,
480
+ ephemeral: true,
481
+ internal: true,
482
+ ...(preset ? { agentPreset: preset.id } : {})
483
+ },
463
484
  agentOptions: prep.provider && prep.model ? { provider: prep.provider, model: prep.model } : undefined,
485
+ ...(preset ? {
486
+ setup: async (agentCtx) => {
487
+ if (typeof presets.mount === 'function') {
488
+ await presets.mount(agentCtx, preset.id);
489
+ }
490
+ }
491
+ } : {}),
464
492
  });
465
493
  this._applyPermissionPreset(task, handle);
466
494
  await prep.raceAbort(handle.agent.whenIdle());
package/lib/scheduler.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Cron } from 'croner';
2
+ import { exec } from 'node:child_process';
3
+ import fs from 'node:fs';
2
4
  import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
3
5
  import { applySilentRule } from './silent-rule.js';
4
6
  import { inspectFailure } from './failure-inspector.js';
@@ -166,6 +168,98 @@ export function parseScheduleExpression(input) {
166
168
  || parseCronExpression(str);
167
169
  }
168
170
 
171
+ /**
172
+ * Preview future execution timestamps for a cron or interval schedule.
173
+ */
174
+ export function previewSchedule(scheduleStr, options = {}) {
175
+ const str = String(scheduleStr || '').trim();
176
+ if (!str) throw new Error('Schedule expression must not be empty');
177
+ const parsed = parseScheduleExpression(str);
178
+ const count = Math.min(Math.max(Number(options.count) || 5, 1), 10);
179
+ const timezone = options.timezone || undefined;
180
+
181
+ if (parsed.isOneShot) {
182
+ return {
183
+ isOneShot: true,
184
+ humanText: parsed.humanText,
185
+ runs: [new Date(parsed.nextRun).toISOString()],
186
+ };
187
+ }
188
+
189
+ const job = new Cron(parsed.cronPattern, {
190
+ timezone,
191
+ sloppyRanges: true,
192
+ });
193
+ const runs = [];
194
+ let current = new Date();
195
+ for (let i = 0; i < count; i++) {
196
+ const next = job.nextRun(current);
197
+ if (!next) break;
198
+ runs.push(next.toISOString());
199
+ current = new Date(next.getTime() + 1000);
200
+ }
201
+ return {
202
+ isOneShot: false,
203
+ cronPattern: parsed.cronPattern,
204
+ humanText: describeCron(parsed.cronPattern),
205
+ runs,
206
+ };
207
+ }
208
+
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
+
169
263
  export class TaskScheduler {
170
264
  constructor(store, executeFn, options = {}) {
171
265
  this.store = store;
@@ -179,6 +273,8 @@ export class TaskScheduler {
179
273
  this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
180
274
  this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
181
275
  this.runCounters = {}; // status -> finished runs since start (#53, /dsh-cron/metrics)
276
+ this.queue = []; // [{ taskId, priority, queuedAt, options }]
277
+ this.heartbeatTimer = null;
182
278
  }
183
279
 
184
280
  isRunning(taskId) {
@@ -202,6 +298,67 @@ export class TaskScheduler {
202
298
  }
203
299
  }
204
300
 
301
+ startHeartbeatWatcher(intervalMs = 30000) {
302
+ this.stopHeartbeatWatcher();
303
+ this.heartbeatTimer = setInterval(() => this.checkHeartbeats(), intervalMs);
304
+ if (this.heartbeatTimer && typeof this.heartbeatTimer.unref === 'function') {
305
+ this.heartbeatTimer.unref();
306
+ }
307
+ }
308
+
309
+ stopHeartbeatWatcher() {
310
+ if (this.heartbeatTimer) {
311
+ clearInterval(this.heartbeatTimer);
312
+ this.heartbeatTimer = null;
313
+ }
314
+ }
315
+
316
+ async checkHeartbeats() {
317
+ const now = Date.now();
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
+ }
360
+ }
361
+
205
362
  start() {
206
363
  const tasks = this.store.list();
207
364
  const now = Date.now();
@@ -241,9 +398,11 @@ export class TaskScheduler {
241
398
  }
242
399
  this.scheduleTask(task);
243
400
  }
401
+ this.startHeartbeatWatcher();
244
402
  }
245
403
 
246
404
  stopAll() {
405
+ this.stopHeartbeatWatcher();
247
406
  for (const [id, job] of this.jobs.entries()) {
248
407
  try {
249
408
  job.stop();
@@ -348,6 +507,12 @@ export class TaskScheduler {
348
507
  // skipped and recorded; already-running tasks are never throttled against
349
508
  // themselves.
350
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
+ }
351
516
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
352
517
  this.recordSkipped(taskId, `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`);
353
518
  return null;
@@ -475,14 +640,39 @@ export class TaskScheduler {
475
640
 
476
641
  async runTask(taskId, options = {}) {
477
642
  const task = this.store.get(taskId);
478
- if (!task) return;
643
+ if (!task) return null;
644
+
645
+ if (options.dryRun) {
646
+ const start = Date.now();
647
+ const outcome = await this.executeWithFallback(task, AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)), options);
648
+ return {
649
+ ok: true,
650
+ dryRun: true,
651
+ status: outcome.status,
652
+ output: outcome.output,
653
+ error: outcome.error,
654
+ durationMs: Date.now() - start,
655
+ usage: outcome.usage,
656
+ costUsd: outcome.costUsd,
657
+ model: outcome.model || task.model || '',
658
+ };
659
+ }
479
660
 
480
661
  const currentRun = this.beginRun(task, taskId);
481
- if (currentRun === null) return;
662
+ if (currentRun === null) return null;
663
+
664
+ const preflight = await executePreflight(task);
665
+ if (!preflight.ok) {
666
+ console.log(`[dsh-cron] task "${task.title}" (${taskId}) skipped: ${preflight.reason}`);
667
+ this.running.delete(taskId);
668
+ this.recordSkipped(taskId, `Preflight check skipped: ${preflight.reason}`);
669
+ return null;
670
+ }
482
671
 
483
672
  const start = Date.now();
484
673
  const outcome = await this.executeWithFallback(task, currentRun.controller.signal, options);
485
674
  await this.completeRun(task, taskId, currentRun, outcome, start, options);
675
+ return outcome;
486
676
  }
487
677
 
488
678
  /**
@@ -490,6 +680,8 @@ export class TaskScheduler {
490
680
  * bookkeeping. Kept separate from runTask so the run flow reads in steps.
491
681
  */
492
682
  async completeRun(task, taskId, currentRun, outcome, start, options = {}) {
683
+ const isSuccess = outcome.status === 'success';
684
+ const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
493
685
  const runInfo = {
494
686
  at: start,
495
687
  status: outcome.status,
@@ -507,6 +699,40 @@ export class TaskScheduler {
507
699
 
508
700
  // #43/#44 decide before the run is recorded, so the single history entry
509
701
  // already carries the diagnosis and the silence verdict.
702
+ // 3. Self-healing & Auto-diagnose (#139)
703
+ if (isFailure && task.selfHealingCommand) {
704
+ console.log(`[dsh-cron] running self-healing command for task "${task.title}": ${task.selfHealingCommand}`);
705
+ try {
706
+ await new Promise((resolve) => {
707
+ exec(task.selfHealingCommand, { timeout: 30000 }, (shErr, stdout, stderr) => {
708
+ runInfo.selfHealing = {
709
+ command: task.selfHealingCommand,
710
+ success: !shErr,
711
+ output: (stdout + (stderr ? '\n' + stderr : '')).trim().slice(0, 500),
712
+ };
713
+ resolve();
714
+ });
715
+ });
716
+ } catch (shErr) {
717
+ runInfo.selfHealing = { command: task.selfHealingCommand, success: false, error: shErr.message };
718
+ }
719
+ }
720
+
721
+ if (isFailure && task.autoDiagnose && typeof this.executeFn === 'function') {
722
+ try {
723
+ const diagRes = await this.executeFn({
724
+ type: 'llm',
725
+ prompt: `Diagnose this task failure in 1-2 concise sentences. Error: ${outcome.error || 'unknown'}. Recent output: ${(outcome.output || '').slice(-300)}`,
726
+ model: task.fallbackModel || task.model,
727
+ }, { signal: AbortSignal.timeout(10000) });
728
+ if (diagRes && diagRes.output) {
729
+ runInfo.autoDiagnosis = diagRes.output.trim();
730
+ }
731
+ } catch {
732
+ // Fail-open
733
+ }
734
+ }
735
+
510
736
  const silent = await this.applyModelAssists(task, runInfo);
511
737
 
512
738
  try {
@@ -524,6 +750,15 @@ export class TaskScheduler {
524
750
  if (!silent.skipped) await this.deliverNotifications(task, runInfo);
525
751
  this.finishRun(task, taskId, outcome.status, queuedCount);
526
752
 
753
+ if (this.queue.length > 0 && this.running.size < this.maxConcurrent) {
754
+ const nextItem = this.queue.shift();
755
+ if (nextItem) {
756
+ setImmediate(() => {
757
+ this.runTask(nextItem.taskId).catch(() => {});
758
+ });
759
+ }
760
+ }
761
+
527
762
  // 1. Structured LLM Actions (#137)
528
763
  if (task.type === 'llm' && outcome.status === 'success' && outcome.output) {
529
764
  try {
@@ -546,9 +781,9 @@ export class TaskScheduler {
546
781
  }
547
782
  }
548
783
 
784
+
785
+
549
786
  // 2. Task Chaining (#137)
550
- const isSuccess = outcome.status === 'success';
551
- const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
552
787
  const nextTaskId = isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null);
553
788
 
554
789
  if (nextTaskId && typeof nextTaskId === 'string' && nextTaskId.trim()) {
package/lib/store.js CHANGED
@@ -239,6 +239,17 @@ export class TaskStore {
239
239
  onSuccess: task.onSuccess !== undefined ? String(task.onSuccess).trim() : (prev.onSuccess || ''),
240
240
  onFailure: task.onFailure !== undefined ? String(task.onFailure).trim() : (prev.onFailure || ''),
241
241
  oneShot: task.oneShot !== undefined ? Boolean(task.oneShot) : Boolean(prev.oneShot),
242
+ heartbeatIntervalSeconds: task.heartbeatIntervalSeconds !== undefined ? Number(task.heartbeatIntervalSeconds) : (prev.heartbeatIntervalSeconds || 0),
243
+ gracePeriodSeconds: task.gracePeriodSeconds !== undefined ? Number(task.gracePeriodSeconds) : (prev.gracePeriodSeconds || 300),
244
+ lastPingAt: task.lastPingAt !== undefined ? task.lastPingAt : (prev.lastPingAt || null),
245
+ heartbeatAlerted: task.heartbeatAlerted !== undefined ? Boolean(task.heartbeatAlerted) : (prev.heartbeatAlerted || false),
246
+ preflightType: task.preflightType !== undefined ? String(task.preflightType) : (prev.preflightType || 'none'),
247
+ preflightTarget: task.preflightTarget !== undefined ? String(task.preflightTarget).trim() : (prev.preflightTarget || ''),
248
+ concurrencyGroup: task.concurrencyGroup !== undefined ? String(task.concurrencyGroup).trim() : (prev.concurrencyGroup || 'default'),
249
+ priority: task.priority !== undefined ? Number(task.priority) : (prev.priority || 5),
250
+ selfHealingCommand: task.selfHealingCommand !== undefined ? String(task.selfHealingCommand).trim() : (prev.selfHealingCommand || ''),
251
+ autoDiagnose: task.autoDiagnose !== undefined ? Boolean(task.autoDiagnose) : (prev.autoDiagnose || false),
252
+ agentPreset: task.agentPreset !== undefined ? String(task.agentPreset).trim() : (prev.agentPreset || ''),
242
253
  totalTokens: prev.totalTokens || 0,
243
254
  totalCostUsd: prev.totalCostUsd || 0,
244
255
  createdAt: prev.createdAt || task.createdAt || now,
@@ -253,6 +264,27 @@ export class TaskStore {
253
264
  return record;
254
265
  }
255
266
 
267
+ recordHeartbeat(id) {
268
+ const task = this.tasks.get(id);
269
+ if (!task) return null;
270
+ const now = Date.now();
271
+ task.lastPingAt = now;
272
+ task.heartbeatAlerted = false;
273
+ task.updatedAt = now;
274
+ if (task.lastStatus === 'missed') {
275
+ task.lastStatus = 'active';
276
+ }
277
+ this.save();
278
+ const interval = (task.heartbeatIntervalSeconds || 0) + (task.gracePeriodSeconds || 300);
279
+ return {
280
+ ok: true,
281
+ id,
282
+ taskId: id,
283
+ lastPingAt: now,
284
+ nextDeadline: now + interval * 1000,
285
+ };
286
+ }
287
+
256
288
  delete(id) {
257
289
  const existed = this.tasks.delete(id);
258
290
  this.history.delete(id);
@@ -297,6 +329,8 @@ export class TaskStore {
297
329
  // #43: the failure diagnosis and the prompt change it proposes.
298
330
  diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
299
331
  suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
332
+ selfHealing: runInfo.selfHealing || null,
333
+ autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
300
334
  confidence: runInfo.confidence || '',
301
335
  });
302
336
  // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
@@ -30,6 +30,7 @@ export const PATCHABLE_TASK_FIELDS = [
30
30
  'maxRetries',
31
31
  'retryBackoffMs',
32
32
  'permissionPreset',
33
+ 'agentPreset',
33
34
  'env',
34
35
  'cwd',
35
36
  'workspaceId',
@@ -55,6 +56,14 @@ export const PATCHABLE_TASK_FIELDS = [
55
56
  'kanbanMode',
56
57
  'onSuccess',
57
58
  'onFailure',
59
+ 'heartbeatIntervalSeconds',
60
+ 'gracePeriodSeconds',
61
+ 'preflightType',
62
+ 'preflightTarget',
63
+ 'concurrencyGroup',
64
+ 'priority',
65
+ 'selfHealingCommand',
66
+ 'autoDiagnose',
58
67
  'channels',
59
68
  'template',
60
69
  'status',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",