@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 +9 -0
- package/lib/client.js +31 -1
- package/lib/index.js +9 -3
- package/lib/runner.js +55 -14
- package/lib/scheduler.js +68 -1
- package/lib/store.js +2 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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),
|