@goodandready/dsh-cron 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,6 +116,15 @@ Direct integration with the Telegram Bot API delivers execution reports and erro
116
116
  * **Markdown Formatting**: Messages are formatted with status badges (✅ / ❌), execution duration in milliseconds, schedule descriptions, and monospace code blocks.
117
117
  * **Test Dispatch Button**: Verify Telegram connectivity on the spot before scheduling critical production jobs.
118
118
 
119
+ ### 7. Overlap Policies & Execution Timeout Control (Issues #11, #17)
120
+ Prevent rogue processes from consuming server resources or stacking concurrent duplicate executions:
121
+
122
+ * **Execution Timeout (`timeoutSeconds`)**: Automatically cancels agent sessions or kills shell subprocesses when the configured run time limit is reached (default: 1800s / 30m). Prevents hung tasks and records a descriptive timeout failure in run logs.
123
+ * **Overlap Policy (`overlapPolicy`)**: Controls scheduler behavior when a scheduled tick fires while the previous execution is still running:
124
+ * **`skip`** (default): Drops the overlapping run and records a `skipped` status entry in the run history without spamming.
125
+ * **`queue`**: Queues the next execution and starts it automatically as soon as the active job completes.
126
+ * **`replace`**: Aborts the stuck/active run immediately via `AbortController` and launches the fresh execution.
127
+
119
128
  ### 5. Schedule Expression Syntax
120
129
  Powered by `croner`, supporting both standard 5-part/6-part cron expressions and user-friendly interval aliases:
121
130
 
package/lib/client.js CHANGED
@@ -136,6 +136,7 @@ const React = window.React || require('react');
136
136
  .dsh-cron-history-status { font-weight: 600; font-size: 11.5px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
137
137
  .dsh-cron-history-status[data-status="success"] { background: rgba(16, 185, 129, 0.15); color: #10b981; }
138
138
  .dsh-cron-history-status[data-status="error"] { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
139
+ .dsh-cron-history-status[data-status="skipped"] { background: rgba(234, 179, 8, 0.15); color: #eab308; }
139
140
  .dsh-cron-history-time { color: #777; font-size: 12px; }
140
141
  .dsh-cron-history-output { background: #0c0c0c; border: 1px solid #222; border-radius: 6px; padding: 8px 10px; font-family: monospace; font-size: 12px; color: #bbb; max-height: 140px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 6px; }
141
142
  .dsh-cron-history-empty { text-align: center; padding: 36px 0; color: #666; font-size: 13.5px; }
@@ -248,6 +249,8 @@ const React = window.React || require('react');
248
249
  const [historyLoading, setHistoryLoading] = React.useState(false);
249
250
  const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
250
251
  const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
252
+ const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
253
+ const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
251
254
 
252
255
  // Settings modal state
253
256
  const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
@@ -401,6 +404,8 @@ const React = window.React || require('react');
401
404
  setFormDelivery('isolated');
402
405
  setFormNotifyTelegram(false);
403
406
  setFormOnlyOnFailure(false);
407
+ setFormTimeoutSeconds(1800);
408
+ setFormOverlapPolicy('skip');
404
409
  if (preset) {
405
410
  setFormTitle(preset.title || '');
406
411
  setFormSchedule(preset.schedule || '');
@@ -448,6 +453,8 @@ const React = window.React || require('react');
448
453
  setFormDelivery(task.delivery || 'isolated');
449
454
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
450
455
  setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
456
+ setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
457
+ setFormOverlapPolicy(task.overlapPolicy || 'skip');
451
458
  if (task.provider) loadModels(task.provider);
452
459
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
453
460
  loadTaskHistory(task.id);
@@ -718,7 +725,7 @@ const React = window.React || require('react');
718
725
  React.createElement('span', {
719
726
  className: 'dsh-cron-history-status',
720
727
  'data-status': run.status
721
- }, run.status === 'success' ? 'Успешно' : 'Ошибка'),
728
+ }, run.status === 'success' ? 'Успешно' : run.status === 'skipped' ? 'Пропущен' : 'Ошибка'),
722
729
  React.createElement('span', { className: 'dsh-cron-history-time' }, run.at ? new Date(run.at).toLocaleString() : '')
723
730
  ),
724
731
  React.createElement('span', { style: { color: '#888', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
@@ -772,6 +779,29 @@ const React = window.React || require('react');
772
779
  })
773
780
  )
774
781
  ),
782
+ React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
783
+ React.createElement('div', { className: 'dsh-cron-form-group' },
784
+ React.createElement('label', null, 'Таймаут выполнения (секунды)'),
785
+ React.createElement('input', {
786
+ type: 'number',
787
+ min: 1,
788
+ value: formTimeoutSeconds,
789
+ placeholder: '1800',
790
+ onChange: (e) => setFormTimeoutSeconds(e.target.value)
791
+ })
792
+ ),
793
+ React.createElement('div', { className: 'dsh-cron-form-group' },
794
+ React.createElement('label', null, 'Политика наложения (Overlap)'),
795
+ React.createElement('select', {
796
+ value: formOverlapPolicy,
797
+ onChange: (e) => setFormOverlapPolicy(e.target.value)
798
+ },
799
+ React.createElement('option', { value: 'skip' }, 'Пропустить новый запуск (skip)'),
800
+ React.createElement('option', { value: 'queue' }, 'Встать в очередь (queue)'),
801
+ React.createElement('option', { value: 'replace' }, 'Отменить текущий и перезапустить (replace)')
802
+ )
803
+ )
804
+ ),
775
805
  formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
776
806
  React.createElement('div', { className: 'dsh-cron-form-group' },
777
807
  React.createElement('label', null, 'Провайдер LLM'),
package/lib/index.js CHANGED
@@ -33,7 +33,7 @@ function sendJson(res, statusCode, data) {
33
33
  export function apply(ctx, config) {
34
34
  const store = new TaskStore();
35
35
  const runner = new SessionRunner(ctx);
36
- const scheduler = new TaskScheduler(store, (task) => runner.execute(task));
36
+ const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
37
37
 
38
38
  scheduler.start();
39
39
 
@@ -109,6 +109,8 @@ export function apply(ctx, config) {
109
109
  model: body.model || undefined,
110
110
  notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : false,
111
111
  onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : false,
112
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : 1800,
113
+ overlapPolicy: body.overlapPolicy || 'skip',
112
114
  });
113
115
  scheduler.scheduleTask(task);
114
116
  sendJson(res, 200, { ok: true, task });
@@ -270,7 +272,9 @@ export function apply(ctx, config) {
270
272
  provider: { type: 'string', description: 'ID провайдера LLM для выполнения задачи' },
271
273
  model: { type: 'string', description: 'ID модели LLM для выполнения задачи' },
272
274
  notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
273
- onlyOnFailure: { type: 'boolean', description: 'Отправлять в Telegram только при ошибках' }
275
+ onlyOnFailure: { type: 'boolean', description: 'Отправлять в Telegram только при ошибках' },
276
+ timeoutSeconds: { type: 'integer', description: 'Таймаут выполнения задачи в секундах (по умолчанию 1800 = 30 мин)' },
277
+ overlapPolicy: { type: 'string', description: 'Политика наложения: "skip" (по умолчанию), "queue", "replace"' }
274
278
  },
275
279
  output: {
276
280
  schema: {
@@ -285,7 +289,7 @@ export function apply(ctx, config) {
285
289
  return [{ type: 'text', text: value.message }];
286
290
  }
287
291
  },
288
- execute: async ({ title, schedule, prompt, delivery, provider, model, type, notifyTelegram, onlyOnFailure }) => {
292
+ execute: async ({ title, schedule, prompt, delivery, provider, model, type, notifyTelegram, onlyOnFailure, timeoutSeconds, overlapPolicy }) => {
289
293
  try {
290
294
  const parsed = parseScheduleExpression(schedule);
291
295
  const task = store.set({
@@ -299,6 +303,8 @@ export function apply(ctx, config) {
299
303
  model: model || undefined,
300
304
  notifyTelegram: Boolean(notifyTelegram),
301
305
  onlyOnFailure: Boolean(onlyOnFailure),
306
+ timeoutSeconds: timeoutSeconds !== undefined ? Number(timeoutSeconds) : 1800,
307
+ overlapPolicy: overlapPolicy || 'skip',
302
308
  status: 'active'
303
309
  });
304
310
  scheduler.scheduleTask(task);
package/lib/runner.js CHANGED
@@ -6,25 +6,62 @@ export class SessionRunner {
6
6
  this.ctx = ctx;
7
7
  }
8
8
 
9
- async execute(task) {
9
+ async execute(task, options = {}) {
10
+ const timeoutSeconds = Number(task.timeoutSeconds) > 0 ? Number(task.timeoutSeconds) : 1800;
11
+ const timeoutMs = timeoutSeconds * 1000;
12
+ const externalSignal = options.signal;
13
+
14
+ // Internal abort controller combining external signal + timeout
15
+ const controller = new AbortController();
16
+ let timeoutId = null;
17
+
18
+ if (externalSignal) {
19
+ if (externalSignal.aborted) {
20
+ throw new Error(externalSignal.reason?.message || 'Task aborted before execution');
21
+ }
22
+ externalSignal.addEventListener('abort', () => {
23
+ controller.abort(externalSignal.reason || new Error('Task aborted by overlap policy'));
24
+ }, { once: true });
25
+ }
26
+
27
+ const timeoutPromise = new Promise((_, reject) => {
28
+ timeoutId = setTimeout(() => {
29
+ const timeoutErr = new Error(`Task execution timed out after ${timeoutSeconds} seconds`);
30
+ controller.abort(timeoutErr);
31
+ reject(timeoutErr);
32
+ }, timeoutMs);
33
+ });
34
+
35
+ const executionPromise = this._run(task, controller.signal);
36
+
37
+ try {
38
+ return await Promise.race([executionPromise, timeoutPromise]);
39
+ } finally {
40
+ if (timeoutId) clearTimeout(timeoutId);
41
+ }
42
+ }
43
+
44
+ async _run(task, signal) {
10
45
  console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
11
46
 
12
47
  // 1. Direct shell / script task fallback (NO-LLM)
13
48
  if (task.type === 'script' || task.mode === 'no-llm') {
14
49
  const { exec } = await import('node:child_process');
15
- const { promisify } = await import('node:util');
16
- const execAsync = promisify(exec);
17
- try {
18
- const { stdout, stderr } = await execAsync(task.prompt, {
19
- timeout: 60000,
20
- cwd: process.cwd()
50
+ return new Promise((resolve, reject) => {
51
+ const child = exec(task.prompt, {
52
+ cwd: process.cwd(),
53
+ signal,
54
+ }, (err, stdout, stderr) => {
55
+ if (err) {
56
+ if (signal && signal.aborted) {
57
+ return reject(signal.reason || new Error('Command aborted'));
58
+ }
59
+ return reject(err);
60
+ }
61
+ const out = (stdout || stderr || '').trim();
62
+ resolve(out || 'Script completed with code 0');
21
63
  });
22
- const out = (stdout || stderr || '').trim();
23
- return out || 'Script completed with code 0';
24
- } catch (err) {
25
- console.error(`[dsh-cron] script task failed:`, err.message);
26
- throw err;
27
- }
64
+ });
28
65
  }
29
66
 
30
67
  // 2. If ctx.agents is available, run through agent session (LLM)
@@ -59,6 +96,10 @@ export class SessionRunner {
59
96
  agentOptions: provider && model ? { provider, model } : undefined,
60
97
  });
61
98
 
99
+ if (signal && signal.aborted) {
100
+ throw (signal.reason || new Error('Aborted'));
101
+ }
102
+
62
103
  await handle.agent.whenIdle();
63
104
  handle.agent.followup(createUserMessage({
64
105
  content: [{ type: 'text', text: task.prompt }],
@@ -92,4 +133,4 @@ export class SessionRunner {
92
133
 
93
134
  return `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`;
94
135
  }
95
- }
136
+ }
package/lib/scheduler.js CHANGED
@@ -83,6 +83,7 @@ export class TaskScheduler {
83
83
  this.store = store;
84
84
  this.executeFn = executeFn;
85
85
  this.jobs = new Map();
86
+ this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
86
87
  }
87
88
 
88
89
  start() {
@@ -101,6 +102,13 @@ export class TaskScheduler {
101
102
  } catch {}
102
103
  }
103
104
  this.jobs.clear();
105
+
106
+ for (const [, run] of this.running.entries()) {
107
+ try {
108
+ run.controller.abort(new Error('Планировщик остановлен'));
109
+ } catch {}
110
+ }
111
+ this.running.clear();
104
112
  }
105
113
 
106
114
  scheduleTask(task) {
@@ -132,6 +140,41 @@ export class TaskScheduler {
132
140
  const task = this.store.get(taskId);
133
141
  if (!task) return;
134
142
 
143
+ const overlapPolicy = task.overlapPolicy || 'skip';
144
+
145
+ // Check overlap with currently running task
146
+ const active = this.running.get(taskId);
147
+ if (active) {
148
+ if (overlapPolicy === 'skip') {
149
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, skipping run (overlapPolicy: skip)`);
150
+ this.store.recordRun(taskId, {
151
+ at: Date.now(),
152
+ status: 'skipped',
153
+ durationMs: 0,
154
+ output: 'Запуск пропущен: предыдущий процесс ещё не завершился (overlapPolicy: skip)',
155
+ error: null,
156
+ });
157
+ return;
158
+ }
159
+ if (overlapPolicy === 'replace') {
160
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
161
+ active.controller.abort(new Error('Прервано из-за политики overlap: replace'));
162
+ }
163
+ if (overlapPolicy === 'queue') {
164
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
165
+ active.queueCount = (active.queueCount || 0) + 1;
166
+ return;
167
+ }
168
+ }
169
+
170
+ const controller = new AbortController();
171
+ const currentRun = {
172
+ controller,
173
+ startedAt: Date.now(),
174
+ queueCount: 0,
175
+ };
176
+ this.running.set(taskId, currentRun);
177
+
135
178
  const start = Date.now();
136
179
  let status = 'success';
137
180
  let output = '';
@@ -139,7 +182,7 @@ export class TaskScheduler {
139
182
 
140
183
  try {
141
184
  if (typeof this.executeFn === 'function') {
142
- output = await this.executeFn(task);
185
+ output = await this.executeFn(task, { signal: controller.signal });
143
186
  }
144
187
  } catch (err) {
145
188
  status = 'error';
@@ -155,6 +198,14 @@ export class TaskScheduler {
155
198
  };
156
199
  this.store.recordRun(taskId, runInfo);
157
200
 
201
+ // Clean up running map
202
+ const finishedRun = this.running.get(taskId);
203
+ if (finishedRun === currentRun) {
204
+ this.running.delete(taskId);
205
+ }
206
+
207
+ const queuedCount = finishedRun?.queueCount || 0;
208
+
158
209
  // Telegram notification delivery
159
210
  try {
160
211
  const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
@@ -178,6 +229,12 @@ export class TaskScheduler {
178
229
  task.nextRunAt = next ? next.getTime() : null;
179
230
  this.store.set(task);
180
231
  }
232
+
233
+ if (queuedCount > 0) {
234
+ setImmediate(() => {
235
+ this.runTask(taskId);
236
+ });
237
+ }
181
238
  }
182
239
  }
183
240
 
@@ -190,6 +247,11 @@ export class TaskScheduler {
190
247
  this.jobs.get(taskId).stop();
191
248
  this.jobs.delete(taskId);
192
249
  }
250
+ const running = this.running.get(taskId);
251
+ if (running) {
252
+ running.controller.abort(new Error('Задача приостановлена'));
253
+ this.running.delete(taskId);
254
+ }
193
255
  this.store.set(task);
194
256
  return task;
195
257
  }
@@ -208,6 +270,11 @@ export class TaskScheduler {
208
270
  this.jobs.get(taskId).stop();
209
271
  this.jobs.delete(taskId);
210
272
  }
273
+ const running = this.running.get(taskId);
274
+ if (running) {
275
+ running.controller.abort(new Error('Задача удалена'));
276
+ this.running.delete(taskId);
277
+ }
211
278
  return this.store.delete(taskId);
212
279
  }
213
280
 
package/lib/store.js CHANGED
@@ -127,6 +127,8 @@ export class TaskStore {
127
127
  model: task.model !== undefined ? task.model : prev.model,
128
128
  notifyTelegram: task.notifyTelegram !== undefined ? Boolean(task.notifyTelegram) : (prev.notifyTelegram || false),
129
129
  onlyOnFailure: task.onlyOnFailure !== undefined ? Boolean(task.onlyOnFailure) : (prev.onlyOnFailure || false),
130
+ timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
131
+ overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
130
132
  createdAt: prev.createdAt || task.createdAt || now,
131
133
  updatedAt: now,
132
134
  lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",