@goodandready/dsh-cron 0.1.22 → 0.1.24
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/docs/design/DESIGN.md +1 -0
- package/lib/client.js +47 -8
- package/lib/runner.js +22 -5
- package/lib/scheduler.js +15 -0
- package/lib/store.js +4 -1
- package/package.json +1 -1
package/docs/design/DESIGN.md
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
4. Выполнение по крону: срабатывает таймер croner -> запуск сессии агента или команды -> запись в историю -> доставка результата.
|
|
57
57
|
|
|
58
58
|
## Locked Design Decisions
|
|
59
|
+
- 2026-09-08 — Пакет надежности ядра (v0.1.24): буфер shell-задач 10МБ, bash/env, атомарное сохранение с PID, автоочистка сессий агента ephemeral/internal, аудит пропущенных запусков при рестарте (missed), фоновый поллинг UI (8с).
|
|
59
60
|
- 2026-09-03 — Публичный скоуп @goodandready/dsh-cron.
|
|
60
61
|
- 2026-09-03 — Оверлей открывается через mountSidebarEntry и mountScreen аналогично dsh-kanban.
|
|
61
62
|
- 2026-09-03 — Двойная кнопка «Создать ⌄» («Создать с DSH» и «Настроить вручную»).
|
package/lib/client.js
CHANGED
|
@@ -239,6 +239,8 @@ const React = window.React || require('react');
|
|
|
239
239
|
const [query, setQuery] = React.useState('');
|
|
240
240
|
const [tasks, setTasks] = React.useState([]);
|
|
241
241
|
const [recs, setRecs] = React.useState([]);
|
|
242
|
+
const [loading, setLoading] = React.useState(false);
|
|
243
|
+
const [fetchError, setFetchError] = React.useState(null);
|
|
242
244
|
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
|
243
245
|
|
|
244
246
|
// Manual / Edit modal state
|
|
@@ -300,16 +302,20 @@ const React = window.React || require('react');
|
|
|
300
302
|
|
|
301
303
|
const fetchTasks = async () => {
|
|
302
304
|
setLoading(true);
|
|
305
|
+
setFetchError(null);
|
|
303
306
|
try {
|
|
304
307
|
const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query));
|
|
305
308
|
const data = await res.json();
|
|
306
309
|
if (data && data.ok) {
|
|
307
310
|
setTasks(data.tasks || []);
|
|
308
|
-
|
|
311
|
+
setRecs(data.recommendations || []);
|
|
309
312
|
setStats(data.stats || null);
|
|
313
|
+
} else {
|
|
314
|
+
setFetchError(data && data.error ? data.error : 'Не удалось загрузить задачи');
|
|
310
315
|
}
|
|
311
316
|
} catch (err) {
|
|
312
317
|
console.error('[dsh-cron] fetch tasks error:', err);
|
|
318
|
+
setFetchError(err.message || 'Ошибка сети при загрузке задач');
|
|
313
319
|
} finally {
|
|
314
320
|
setLoading(false);
|
|
315
321
|
}
|
|
@@ -336,7 +342,23 @@ const React = window.React || require('react');
|
|
|
336
342
|
}
|
|
337
343
|
};
|
|
338
344
|
|
|
339
|
-
React.useEffect(() => {
|
|
345
|
+
React.useEffect(() => {
|
|
346
|
+
fetchTasks();
|
|
347
|
+
const pollInterval = setInterval(() => {
|
|
348
|
+
// Background polling without resetting loading spinner
|
|
349
|
+
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
|
|
350
|
+
.then(res => res.json())
|
|
351
|
+
.then(data => {
|
|
352
|
+
if (data && data.ok) {
|
|
353
|
+
setTasks(data.tasks || []);
|
|
354
|
+
setRecs(data.recommendations || []);
|
|
355
|
+
setStats(data.stats || null);
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
.catch(() => {});
|
|
359
|
+
}, 8000);
|
|
360
|
+
return () => clearInterval(pollInterval);
|
|
361
|
+
}, [tab, query]);
|
|
340
362
|
React.useEffect(() => { loadModels(); }, []);
|
|
341
363
|
|
|
342
364
|
const handleToggleTask = async (id) => {
|
|
@@ -348,10 +370,12 @@ const React = window.React || require('react');
|
|
|
348
370
|
|
|
349
371
|
const handleRunNow = async (id) => {
|
|
350
372
|
try {
|
|
373
|
+
setTasks(prev => prev.map(t => t.id === id ? { ...t, lastStatus: 'running' } : t));
|
|
351
374
|
await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
375
|
+
setTimeout(() => fetchTasks(), 1500);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error('[dsh-cron] run now error:', err);
|
|
378
|
+
}
|
|
355
379
|
};
|
|
356
380
|
|
|
357
381
|
const handleDelete = async (id) => {
|
|
@@ -649,10 +673,25 @@ const React = window.React || require('react');
|
|
|
649
673
|
}, t === 'all' ? 'Все' : t === 'active' ? 'Активные' : t === 'paused' ? 'На паузе' : 'Завершенные')
|
|
650
674
|
)
|
|
651
675
|
),
|
|
676
|
+
fetchError ? React.createElement('div', {
|
|
677
|
+
className: 'dsh-cron-status-banner',
|
|
678
|
+
'data-type': 'error',
|
|
679
|
+
style: { margin: '8px 0', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }
|
|
680
|
+
},
|
|
681
|
+
React.createElement('span', null, 'Ошибка загрузки задач: ' + fetchError),
|
|
682
|
+
React.createElement('button', {
|
|
683
|
+
type: 'button',
|
|
684
|
+
className: 'dsh-cron-btn',
|
|
685
|
+
style: { marginLeft: '8px', padding: '2px 8px', fontSize: '12px' },
|
|
686
|
+
onClick: () => fetchTasks()
|
|
687
|
+
}, 'Повторить')
|
|
688
|
+
) : null,
|
|
652
689
|
React.createElement('div', { className: 'dsh-cron-task-list' },
|
|
653
|
-
tasks.length === 0
|
|
654
|
-
? React.createElement('div', { style: { color: '#
|
|
655
|
-
: tasks.
|
|
690
|
+
loading && tasks.length === 0
|
|
691
|
+
? React.createElement('div', { style: { color: '#888', padding: '16px 0', fontSize: '14px' } }, 'Загрузка запланированных задач...')
|
|
692
|
+
: tasks.length === 0
|
|
693
|
+
? React.createElement('div', { style: { color: '#777', padding: '16px 0', fontSize: '14px' } }, 'Нет запланированных задач')
|
|
694
|
+
: tasks.map((task) =>
|
|
656
695
|
React.createElement('div', {
|
|
657
696
|
key: task.id,
|
|
658
697
|
className: 'dsh-cron-task-item',
|
package/lib/runner.js
CHANGED
|
@@ -50,9 +50,13 @@ export class SessionRunner {
|
|
|
50
50
|
if (task.type === 'script' || task.mode === 'no-llm') {
|
|
51
51
|
const { exec } = await import('node:child_process');
|
|
52
52
|
return new Promise((resolve, reject) => {
|
|
53
|
+
const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
|
|
53
54
|
const child = exec(task.prompt, {
|
|
54
55
|
cwd: process.cwd(),
|
|
55
56
|
signal,
|
|
57
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
58
|
+
shell: shellPath,
|
|
59
|
+
env: { ...process.env },
|
|
56
60
|
}, (err, stdout, stderr) => {
|
|
57
61
|
if (err) {
|
|
58
62
|
if (signal && signal.aborted) {
|
|
@@ -100,11 +104,13 @@ export class SessionRunner {
|
|
|
100
104
|
const model = task.model || defaultSel?.model || 'deepseek-v4-flash';
|
|
101
105
|
|
|
102
106
|
const cwd = process.cwd();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
let handle = null;
|
|
108
|
+
try {
|
|
109
|
+
handle = await this.ctx.agents.create({
|
|
110
|
+
sessionId: mintSessionId(),
|
|
111
|
+
meta: { cwd, ephemeral: true, internal: true },
|
|
112
|
+
agentOptions: provider && model ? { provider, model } : undefined,
|
|
113
|
+
});
|
|
108
114
|
|
|
109
115
|
if (signal && signal.aborted) {
|
|
110
116
|
throw (signal.reason || new Error('Aborted'));
|
|
@@ -141,6 +147,17 @@ export class SessionRunner {
|
|
|
141
147
|
usage,
|
|
142
148
|
costUsd
|
|
143
149
|
};
|
|
150
|
+
} finally {
|
|
151
|
+
try {
|
|
152
|
+
if (handle && typeof handle.dispose === 'function') {
|
|
153
|
+
await handle.dispose();
|
|
154
|
+
} else if (handle && handle.agent && typeof handle.agent.destroy === 'function') {
|
|
155
|
+
await handle.agent.destroy();
|
|
156
|
+
}
|
|
157
|
+
} catch (disposeErr) {
|
|
158
|
+
console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
144
161
|
} catch (err) {
|
|
145
162
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|
|
146
163
|
throw err;
|
package/lib/scheduler.js
CHANGED
|
@@ -135,8 +135,23 @@ export class TaskScheduler {
|
|
|
135
135
|
|
|
136
136
|
start() {
|
|
137
137
|
const tasks = this.store.list();
|
|
138
|
+
const now = Date.now();
|
|
138
139
|
for (const task of tasks) {
|
|
139
140
|
if (task.status === 'active') {
|
|
141
|
+
// Detect missed execution if daemon was offline (threshold: > 5 minutes)
|
|
142
|
+
if (task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000) {
|
|
143
|
+
const missedAt = task.nextRunAt;
|
|
144
|
+
console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()}`);
|
|
145
|
+
this.store.recordRun(task.id, {
|
|
146
|
+
at: missedAt,
|
|
147
|
+
status: 'missed',
|
|
148
|
+
durationMs: 0,
|
|
149
|
+
output: `Запуск пропущен: сервис был выключен или перезагружался (плановый запуск: ${new Date(missedAt).toLocaleString('ru-RU')})`,
|
|
150
|
+
error: null,
|
|
151
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
152
|
+
costUsd: 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
140
155
|
this.scheduleTask(task);
|
|
141
156
|
}
|
|
142
157
|
}
|
package/lib/store.js
CHANGED
|
@@ -13,6 +13,7 @@ export class TaskStore {
|
|
|
13
13
|
this.tasks = new Map();
|
|
14
14
|
this.history = new Map();
|
|
15
15
|
this.settings = {};
|
|
16
|
+
this._savePromise = Promise.resolve();
|
|
16
17
|
this.init();
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -57,7 +58,9 @@ export class TaskStore {
|
|
|
57
58
|
history: Object.fromEntries(this.history.entries()),
|
|
58
59
|
settings: this.settings || {},
|
|
59
60
|
};
|
|
60
|
-
|
|
61
|
+
// Unique tmp file per save call with process PID and random token to eliminate file collision
|
|
62
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
63
|
+
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
61
64
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
62
65
|
fs.renameSync(tmp, this.filePath);
|
|
63
66
|
} catch (err) {
|