@goodandready/dsh-cron 0.2.2 → 0.2.3
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 +27 -2
- package/docs/README.ru.md +24 -3
- package/docs/README.zh.md +22 -2
- package/lib/client.js +103 -3
- package/lib/index.js +68 -3
- package/lib/runner.js +29 -1
- package/lib/scheduler.js +117 -17
- package/lib/store.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -122,9 +122,21 @@ Powered by `croner`, supporting standard 5-field cron expressions plus user-frie
|
|
|
122
122
|
* `*/15 * * * *` — every 15 minutes
|
|
123
123
|
* `0 0 * * 0` — every Sunday at midnight
|
|
124
124
|
* `every 10m` / `every 2h` / `every 30s` — natural duration intervals
|
|
125
|
-
* `daily` / `hourly` / `weekdays` shortcuts
|
|
125
|
+
* `daily` / `hourly` / `weekdays` shortcuts, plus standard `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` and `@every 30m`
|
|
126
|
+
* **Per-task time zones** — set an IANA zone (e.g. `Europe/Berlin`) on a task; without it the schedule follows the server's local time
|
|
126
127
|
* **One-shot tasks**: `at: 2026-09-05T15:00:00Z` (exact ISO timestamp) or relative delays `in 20m` / `in 2h` (Russian aliases such as `через 15 минут` are accepted too). One-shot tasks flip to `completed` automatically after their single run and are listed under the **Completed** tab.
|
|
127
128
|
|
|
129
|
+
### 5. Execution Reliability
|
|
130
|
+
* **Automatic retries** — set `maxRetries` and a base `retryBackoffMs` per task; failed runs (`error`/`timeout`) are retried with exponential backoff, and the attempt counter resets on success.
|
|
131
|
+
* **Misfire policies** — choose per task what happens when the daemon was offline at a scheduled time: `skip` (default — record the gap), `runOnce` (execute once, late), or `catchUpAll` (run late and record the gap). A missed one-shot under `skip` is retired as `completed` instead of firing stale.
|
|
132
|
+
* **Concurrency limit** — `maxConcurrent` (plugin setting) caps parallel runs; extra runs are recorded as `skipped` with a reason.
|
|
133
|
+
* **Live execution indicator** — the task list shows a pulsing status icon and a running timer for the task in flight.
|
|
134
|
+
|
|
135
|
+
### 6. Session Integration & Permissions
|
|
136
|
+
* **Per-task permission presets** — `default`, `read-only`, `workspace-write`, or `full` are applied to the task's agent session before the prompt runs.
|
|
137
|
+
* **Session auto-archive** — isolated cron sessions are archived after each run (best-effort) so they do not clutter the chat list.
|
|
138
|
+
* **History → session navigation** — every LLM run records its session; open it straight from the run history entry.
|
|
139
|
+
|
|
128
140
|
### 5. Telegram Notifications & Delivery Routing
|
|
129
141
|
Direct integration with the Telegram Bot API delivers execution reports and error traces straight to your messenger:
|
|
130
142
|
|
|
@@ -148,6 +160,10 @@ Prevent rogue processes from stacking concurrent duplicate executions:
|
|
|
148
160
|
|
|
149
161
|
If the daemon was offline at a scheduled time, the run is recorded as `missed` on startup, so gaps in the history stay visible.
|
|
150
162
|
|
|
163
|
+
### 8. Heartbeat Monitoring (#16-style dead man's switch)
|
|
164
|
+
* Set `heartbeatUrl` and `heartbeatIntervalSec` in the plugin settings and the scheduler pings that URL on schedule — an external monitor alerts when the pings stop.
|
|
165
|
+
* A built-in `GET /dsh-cron/heartbeat` endpoint reports liveness, active task count and the last run time for your own watchdogs.
|
|
166
|
+
|
|
151
167
|
---
|
|
152
168
|
|
|
153
169
|
## 📦 Installation
|
|
@@ -174,6 +190,10 @@ dsh-cron:
|
|
|
174
190
|
notifyTelegram: false # deliver reports for every task globally
|
|
175
191
|
onlyOnFailure: false # deliver reports only for failed runs
|
|
176
192
|
kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API base URL
|
|
193
|
+
defaultTimezone: "" # default IANA time zone for schedules (empty = server local)
|
|
194
|
+
maxConcurrent: 0 # max parallel task runs (0 = unlimited)
|
|
195
|
+
heartbeatUrl: "" # dead man's snitch URL pinged on the heartbeat interval
|
|
196
|
+
heartbeatIntervalSec: 0 # heartbeat ping interval in seconds (0 = off)
|
|
177
197
|
```
|
|
178
198
|
|
|
179
199
|
### Configuration Parameters
|
|
@@ -185,6 +205,10 @@ dsh-cron:
|
|
|
185
205
|
| `notifyTelegram` | `boolean` | `false` | Global switch: deliver run reports to Telegram |
|
|
186
206
|
| `onlyOnFailure` | `boolean` | `false` | Global switch: deliver reports only for `error`/`timeout` runs |
|
|
187
207
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Base URL of the `dsh-kanban` HTTP API used for automatic card creation |
|
|
208
|
+
| `defaultTimezone` | `string` | `""` | Default IANA time zone for task schedules; empty = server local time |
|
|
209
|
+
| `maxConcurrent` | `number` | `0` | Cap on parallel task runs; extra runs are recorded as `skipped` (0 = unlimited) |
|
|
210
|
+
| `heartbeatUrl` | `string` | `""` | Dead man's snitch URL pinged every `heartbeatIntervalSec` while the scheduler is alive |
|
|
211
|
+
| `heartbeatIntervalSec` | `number` | `0` | Heartbeat ping interval in seconds (0 = disabled) |
|
|
188
212
|
|
|
189
213
|
Notes:
|
|
190
214
|
|
|
@@ -212,7 +236,8 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
|
|
|
212
236
|
| `GET` | `/dsh-cron/models` | List LLM providers; `?provider=<id>` lists models |
|
|
213
237
|
| `POST` | `/dsh-cron/chat/start` | Start a "Create with DSH" agent session with the task-setup instructions |
|
|
214
238
|
| `GET` | `/dsh-cron/settings` | Client-safe settings (token masked) |
|
|
215
|
-
| `POST` | `/dsh-cron/settings` | Update integration settings |
|
|
239
|
+
| `POST` | `/dsh-cron/settings` | Update integration settings (applied through the settings service) |
|
|
240
|
+
| `GET` | `/dsh-cron/heartbeat` | Liveness probe: active task count, last run time, server time |
|
|
216
241
|
| `POST` | `/dsh-cron/telegram/test` | Send a Telegram test message |
|
|
217
242
|
| `POST` | `/dsh-cron/kanban/test` | Create a Kanban connectivity-test card |
|
|
218
243
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy alias for the task action routes (`run`, `toggle`, `delete`, `history`) |
|
package/docs/README.ru.md
CHANGED
|
@@ -121,9 +121,21 @@ cron_create_task({
|
|
|
121
121
|
* `*/15 * * * *` — каждые 15 минут
|
|
122
122
|
* `0 0 * * 0` — каждое воскресенье в полночь
|
|
123
123
|
* `every 10m` / `every 2h` / `every 30s` — естественные интервалы
|
|
124
|
-
* алиасы `daily` / `hourly` / `weekdays`
|
|
124
|
+
* алиасы `daily` / `hourly` / `weekdays`, а также стандартные `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` и `@every 30m`
|
|
125
|
+
* **Часовые пояса** — для задачи можно указать IANA-зону (например, `Europe/Berlin`); без неё расписание живёт в серверном времени
|
|
125
126
|
* **Разовые задачи**: `at: 2026-09-05T15:00:00Z` (точный ISO-таймстемп) или относительные задержки `in 20m` / `in 2h` (принимаются и русские варианты вроде `через 15 минут`). После единственного запуска задача автоматически переходит в `completed` и отображается на табе **Завершённые**.
|
|
126
127
|
|
|
128
|
+
### 5. Надёжность исполнения
|
|
129
|
+
* **Автоповторы** — `maxRetries` и база `retryBackoffMs` на задачу: упавшие запуски (error/timeout) повторяются с экспоненциальной задержкой, счётчик сбрасывается после успеха.
|
|
130
|
+
* **Misfire-политики** — что делать с пропущенным за время простоя запуском: `skip` (по умолчанию — записать пропуск), `runOnce` (выполнить один раз с опозданием) или `catchUpAll` (выполнить и зафиксировать пропуск). Пропущенный one-shot при `skip` уходит в `completed` без выполнения.
|
|
131
|
+
* **Лимит параллельности** — настройка `maxConcurrent` ограничивает число одновременных запусков; лишние помечаются `skipped` с причиной.
|
|
132
|
+
* **Живой индикатор** — в списке задач пульсирует статус и идёт таймер текущего запуска.
|
|
133
|
+
|
|
134
|
+
### 6. Интеграция сессий и права
|
|
135
|
+
* **Permission-пресеты на задачу** — `default`, `read-only`, `workspace-write` или `full` применяются к сессии агента перед запуском промпта.
|
|
136
|
+
* **Автоархивация сессий** — изолированные cron-сессии архивируются после запуска (best-effort), не засоряя список чатов.
|
|
137
|
+
* **История → сессия** — каждый LLM-запуск хранит свою сессию; открыть диалог можно прямо из записи истории.
|
|
138
|
+
|
|
127
139
|
### 5. Уведомления Telegram
|
|
128
140
|
Прямая интеграция с Telegram Bot API доставляет отчёты о запусках и трейсы ошибок в мессенджер:
|
|
129
141
|
|
|
@@ -170,6 +182,10 @@ dsh-cron:
|
|
|
170
182
|
notifyTelegram: false # глобально отправлять отчёты о всех задачах
|
|
171
183
|
onlyOnFailure: false # отправлять отчёты только при сбоях
|
|
172
184
|
kanbanBaseUrl: "http://127.0.0.1:3000" # базовый URL HTTP API dsh-kanban
|
|
185
|
+
defaultTimezone: "" # IANA-зона по умолчанию (пусто = серверное время)
|
|
186
|
+
maxConcurrent: 0 # максимум параллельных запусков (0 = без лимита)
|
|
187
|
+
heartbeatUrl: "" # URL dead man's snitch, пингуется по интервалу
|
|
188
|
+
heartbeatIntervalSec: 0 # интервал heartbeat-пинга в секундах (0 = выключено)
|
|
173
189
|
```
|
|
174
190
|
|
|
175
191
|
### Параметры
|
|
@@ -181,11 +197,15 @@ dsh-cron:
|
|
|
181
197
|
| `notifyTelegram` | `boolean` | `false` | Глобальный выключатель доставки отчётов в Telegram |
|
|
182
198
|
| `onlyOnFailure` | `boolean` | `false` | Глобальный режим «только при сбоях» (`error`/`timeout`) |
|
|
183
199
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Базовый URL HTTP API `dsh-kanban` для автоматических карточек |
|
|
200
|
+
| `defaultTimezone` | `string` | `""` | IANA-зона по умолчанию для расписаний; пусто = серверное время |
|
|
201
|
+
| `maxConcurrent` | `number` | `0` | Лимит параллельных запусков; лишние помечаются `skipped` (0 = без лимита) |
|
|
202
|
+
| `heartbeatUrl` | `string` | `""` | URL dead man's snitch, пингуемый каждый `heartbeatIntervalSec`, пока жив планировщик |
|
|
203
|
+
| `heartbeatIntervalSec` | `number` | `0` | Интервал heartbeat-пинга в секундах (0 = выключено) |
|
|
184
204
|
|
|
185
205
|
Примечания:
|
|
186
206
|
|
|
187
207
|
* История запусков ограничена **50 записями на задачу** (фиксировано); в записи хранится до 4000 символов вывода.
|
|
188
|
-
* Задачи выполняются в **локальном часовом поясе
|
|
208
|
+
* Задачи выполняются в **локальном часовом поясе сервера**, если для задачи не указана своя IANA-зона; cron-выражения вычисляет `croner` по часам хоста.
|
|
189
209
|
* Задачи сохраняются в каталоге данных DSH (`cron/tasks.json`) и переживают перезапуск; пропущенные one-shot запуски обнаруживаются при старте.
|
|
190
210
|
|
|
191
211
|
---
|
|
@@ -208,7 +228,8 @@ dsh-cron:
|
|
|
208
228
|
| `GET` | `/dsh-cron/models` | Список LLM-провайдеров; `?provider=<id>` — модели |
|
|
209
229
|
| `POST` | `/dsh-cron/chat/start` | Старт агентской сессии «Создать с DSH» с инструкциями планировщика |
|
|
210
230
|
| `GET` | `/dsh-cron/settings` | Настройки для клиента (токен замаскирован) |
|
|
211
|
-
| `POST` | `/dsh-cron/settings` | Обновление настроек интеграций |
|
|
231
|
+
| `POST` | `/dsh-cron/settings` | Обновление настроек интеграций (через службу настроек) |
|
|
232
|
+
| `GET` | `/dsh-cron/heartbeat` | Probe живости: число активных задач, время последнего запуска |
|
|
212
233
|
| `POST` | `/dsh-cron/telegram/test` | Тестовое сообщение в Telegram |
|
|
213
234
|
| `POST` | `/dsh-cron/kanban/test` | Тестовая карточка в Kanban |
|
|
214
235
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy-алиас действий над задачей (`run`, `toggle`, `delete`, `history`) |
|
package/docs/README.zh.md
CHANGED
|
@@ -121,9 +121,16 @@ cron_create_task({
|
|
|
121
121
|
* `*/15 * * * *` —— 每 15 分钟
|
|
122
122
|
* `0 0 * * 0` —— 每周日午夜
|
|
123
123
|
* `every 10m` / `every 2h` / `every 30s` —— 自然语言间隔
|
|
124
|
-
* `daily` / `hourly` / `weekdays`
|
|
124
|
+
* `daily` / `hourly` / `weekdays` 快捷方式,以及标准 `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` 与 `@every 30m`
|
|
125
|
+
* **任务级时区** —— 可为任务设置 IANA 时区(如 `Europe/Berlin`);未设置时按服务器本地时间调度
|
|
125
126
|
* **一次性任务**:`at: 2026-09-05T15:00:00Z`(精确 ISO 时间戳)或相对延时 `in 20m` / `in 2h`(也接受 `через 15 минут` 之类的俄语输入)。一次性任务在单次运行后自动转为 `completed`,显示在 **已完成** 标签下。
|
|
126
127
|
|
|
128
|
+
### 5. 执行可靠性
|
|
129
|
+
* **自动重试** —— 按任务设置 `maxRetries` 与基础 `retryBackoffMs`:失败(`error`/`timeout`)的运行按指数退避自动重试,成功后计数归零。
|
|
130
|
+
* **Misfire 策略** —— 选择守护进程离线期间错过的运行如何处理:`skip`(默认 —— 记录缺口)、`runOnce`(迟执行一次)或 `catchUpAll`(迟执行并记录缺口)。`skip` 下错过的一次性任务直接转为 `completed`,不再过期触发。
|
|
131
|
+
* **并发上限** —— 插件设置 `maxConcurrent` 限制并行运行数;超出的运行记录为 `skipped` 并附原因。
|
|
132
|
+
* **实时执行指示** —— 任务列表中的脉冲状态图标与运行计时器。
|
|
133
|
+
|
|
127
134
|
### 5. Telegram 通知与投递路由
|
|
128
135
|
通过与 Telegram Bot API 的直接集成,将执行报告与错误跟踪推送到你的即时通讯工具:
|
|
129
136
|
|
|
@@ -146,6 +153,10 @@ cron_create_task({
|
|
|
146
153
|
|
|
147
154
|
如果守护进程在计划时刻处于离线状态,启动时该次运行会被记录为 `missed`,历史空档始终可见。
|
|
148
155
|
|
|
156
|
+
### 8. 心跳监控(Dead man's switch)
|
|
157
|
+
* 在插件设置中配置 `heartbeatUrl` 与 `heartbeatIntervalSec`,调度器会按间隔 GET 该地址 —— 外部监控可在心跳停止时告警。
|
|
158
|
+
* 内置 `GET /dsh-cron/heartbeat` 端点返回存活状态、活跃任务数与最近运行时间,便于自建看门狗。
|
|
159
|
+
|
|
149
160
|
---
|
|
150
161
|
|
|
151
162
|
## 📦 安装
|
|
@@ -170,6 +181,10 @@ dsh-cron:
|
|
|
170
181
|
notifyTelegram: false # 全局投递所有任务的报告
|
|
171
182
|
onlyOnFailure: false # 仅失败时投递报告
|
|
172
183
|
kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API 基础地址
|
|
184
|
+
defaultTimezone: "" # 默认 IANA 时区(空 = 服务器本地)
|
|
185
|
+
maxConcurrent: 0 # 最大并行运行数(0 = 不限)
|
|
186
|
+
heartbeatUrl: "" # 心跳上报 URL(dead man's snitch)
|
|
187
|
+
heartbeatIntervalSec: 0 # 心跳间隔秒数(0 = 关闭)
|
|
173
188
|
```
|
|
174
189
|
|
|
175
190
|
### 配置参数
|
|
@@ -181,6 +196,10 @@ dsh-cron:
|
|
|
181
196
|
| `notifyTelegram` | `boolean` | `false` | 全局开关:向 Telegram 投递运行报告 |
|
|
182
197
|
| `onlyOnFailure` | `boolean` | `false` | 全局开关:仅对 `error`/`timeout` 运行投递报告 |
|
|
183
198
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | 用于自动卡片的 `dsh-kanban` HTTP API 基础地址 |
|
|
199
|
+
| `defaultTimezone` | `string` | `""` | 任务调度的默认 IANA 时区;空 = 服务器本地时间 |
|
|
200
|
+
| `maxConcurrent` | `number` | `0` | 并行运行上限;超出的运行记录为 `skipped`(0 = 不限) |
|
|
201
|
+
| `heartbeatUrl` | `string` | `""` | 心跳上报 URL,调度器存活期间按 `heartbeatIntervalSec` 间隔 GET |
|
|
202
|
+
| `heartbeatIntervalSec` | `number` | `0` | 心跳间隔秒数(0 = 关闭) |
|
|
184
203
|
|
|
185
204
|
说明:
|
|
186
205
|
|
|
@@ -208,7 +227,8 @@ dsh-cron:
|
|
|
208
227
|
| `GET` | `/dsh-cron/models` | 列出 LLM 提供方;`?provider=<id>` 列出模型 |
|
|
209
228
|
| `POST` | `/dsh-cron/chat/start` | 启动带任务配置指令的“由 DSH 创建”智能体会话 |
|
|
210
229
|
| `GET` | `/dsh-cron/settings` | 客户端安全设置(令牌掩码显示) |
|
|
211
|
-
| `POST` | `/dsh-cron/settings` |
|
|
230
|
+
| `POST` | `/dsh-cron/settings` | 更新集成设置(通过设置服务应用) |
|
|
231
|
+
| `GET` | `/dsh-cron/heartbeat` | 存活探针:活跃任务数与最近运行时间 |
|
|
212
232
|
| `POST` | `/dsh-cron/telegram/test` | 发送 Telegram 测试消息 |
|
|
213
233
|
| `POST` | `/dsh-cron/kanban/test` | 创建 Kanban 连通性测试卡片 |
|
|
214
234
|
| `*` | `/dsh-cron/action/:id/:action` | 任务操作路由的兼容别名(`run`、`toggle`、`delete`、`history`) |
|
package/lib/client.js
CHANGED
|
@@ -114,6 +114,20 @@ window.__ModuleLoader__.load({
|
|
|
114
114
|
'form.promptLabelLlm': 'Instruction / Prompt for the agent',
|
|
115
115
|
'form.promptPlaceholderScript': 'curl -fsSL https://... || exit 1',
|
|
116
116
|
'form.promptPlaceholderLlm': 'Describe exactly what the agent should do...',
|
|
117
|
+
'form.timezoneLabel': 'Time zone (IANA, optional)',
|
|
118
|
+
'form.timezonePlaceholder': 'e.g. Europe/Berlin (empty = server local)',
|
|
119
|
+
'form.misfireLabel': 'On missed run (after downtime)',
|
|
120
|
+
'form.misfireSkip': 'Skip the missed run (skip)',
|
|
121
|
+
'form.misfireRunOnce': 'Run once late (runOnce)',
|
|
122
|
+
'form.misfireCatchUp': 'Run late once and record the gap (catchUpAll)',
|
|
123
|
+
'form.retriesLabel': 'Automatic retries on failure (0 = off)',
|
|
124
|
+
'form.permissionLabel': 'Session permissions',
|
|
125
|
+
'form.permissionDefault': 'Default (as configured in DSH)',
|
|
126
|
+
'form.permissionReadonly': 'Read-only',
|
|
127
|
+
'form.permissionWrite': 'Workspace write',
|
|
128
|
+
'form.permissionFull': 'Full',
|
|
129
|
+
'list.runningNow': 'Running now',
|
|
130
|
+
'history.openSession': 'Open session',
|
|
117
131
|
'form.notifyTitle': 'Telegram notifications',
|
|
118
132
|
'form.notifyTelegram': 'Send the run report to Telegram',
|
|
119
133
|
'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
|
|
@@ -363,6 +377,8 @@ window.__ModuleLoader__.load({
|
|
|
363
377
|
.dsh-cron-status-banner[data-type="success"] { background: var(--dsh-cron-success-bg); border: 1px solid var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
364
378
|
.dsh-cron-status-banner[data-type="error"] { background: var(--dsh-cron-danger-bg); border: 1px solid var(--dsh-cron-danger); color: var(--dsh-cron-danger); }
|
|
365
379
|
.dsh-cron-status-banner[data-type="info"] { background: var(--dsh-cron-info-bg); border: 1px solid var(--dsh-cron-info); color: var(--dsh-cron-info); }
|
|
380
|
+
@keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); } 70% { box-shadow: 0 0 0 9px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } }
|
|
381
|
+
.dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
366
382
|
`;
|
|
367
383
|
|
|
368
384
|
function ensureStyles() {
|
|
@@ -484,8 +500,26 @@ window.__ModuleLoader__.load({
|
|
|
484
500
|
const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
|
|
485
501
|
const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
|
|
486
502
|
const [formKanbanMode, setFormKanbanMode] = React.useState('none');
|
|
503
|
+
const [formTimezone, setFormTimezone] = React.useState('');
|
|
504
|
+
const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
|
|
505
|
+
const [formMaxRetries, setFormMaxRetries] = React.useState(0);
|
|
506
|
+
const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
|
|
487
507
|
const [stats, setStats] = React.useState(null);
|
|
488
508
|
|
|
509
|
+
// Live execution indicator (#35): a light 1s tick only while some task
|
|
510
|
+
// is actually running.
|
|
511
|
+
const tasksRef = React.useRef([]);
|
|
512
|
+
const [nowTick, setNowTick] = React.useState(0);
|
|
513
|
+
React.useEffect(() => {
|
|
514
|
+
tasksRef.current = tasks;
|
|
515
|
+
}, [tasks]);
|
|
516
|
+
React.useEffect(() => {
|
|
517
|
+
const tick = setInterval(() => {
|
|
518
|
+
if (tasksRef.current.some((t2) => t2.running)) setNowTick((v) => v + 1);
|
|
519
|
+
}, 1000);
|
|
520
|
+
return () => clearInterval(tick);
|
|
521
|
+
}, []);
|
|
522
|
+
|
|
489
523
|
// Settings modal state
|
|
490
524
|
const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
|
|
491
525
|
const [settingsBotToken, setSettingsBotToken] = React.useState('');
|
|
@@ -684,6 +718,10 @@ window.__ModuleLoader__.load({
|
|
|
684
718
|
setFormTimeoutSeconds(1800);
|
|
685
719
|
setFormOverlapPolicy('skip');
|
|
686
720
|
setFormKanbanMode('none');
|
|
721
|
+
setFormTimezone('');
|
|
722
|
+
setFormMisfirePolicy('skip');
|
|
723
|
+
setFormMaxRetries(0);
|
|
724
|
+
setFormPermissionPreset('default');
|
|
687
725
|
if (preset) {
|
|
688
726
|
setFormTitle(preset.title || '');
|
|
689
727
|
setFormSchedule(preset.schedule || '');
|
|
@@ -734,6 +772,10 @@ window.__ModuleLoader__.load({
|
|
|
734
772
|
setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
|
|
735
773
|
setFormOverlapPolicy(task.overlapPolicy || 'skip');
|
|
736
774
|
setFormKanbanMode(task.kanbanMode || 'none');
|
|
775
|
+
setFormTimezone(task.timezone || '');
|
|
776
|
+
setFormMisfirePolicy(task.misfirePolicy || 'skip');
|
|
777
|
+
setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
|
|
778
|
+
setFormPermissionPreset(task.permissionPreset || 'default');
|
|
737
779
|
if (task.provider) loadModels(task.provider);
|
|
738
780
|
else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
|
|
739
781
|
loadTaskHistory(task.id);
|
|
@@ -764,6 +806,10 @@ window.__ModuleLoader__.load({
|
|
|
764
806
|
onlyOnFailure: formOnlyOnFailure,
|
|
765
807
|
timeoutSeconds: Number(formTimeoutSeconds) || 1800,
|
|
766
808
|
overlapPolicy: formOverlapPolicy,
|
|
809
|
+
timezone: formTimezone.trim(),
|
|
810
|
+
misfirePolicy: formMisfirePolicy,
|
|
811
|
+
maxRetries: Number(formMaxRetries) || 0,
|
|
812
|
+
permissionPreset: formPermissionPreset,
|
|
767
813
|
kanbanMode: formKanbanMode,
|
|
768
814
|
};
|
|
769
815
|
// Cross-origin hardening: script tasks need the explicit confirm
|
|
@@ -940,7 +986,7 @@ window.__ModuleLoader__.load({
|
|
|
940
986
|
},
|
|
941
987
|
React.createElement('button', {
|
|
942
988
|
type: 'button',
|
|
943
|
-
className: 'dsh-cron-task-status-btn',
|
|
989
|
+
className: 'dsh-cron-task-status-btn' + (task.running ? ' dsh-cron-pulse' : ''),
|
|
944
990
|
'data-active': task.status === 'active' ? 'true' : undefined,
|
|
945
991
|
title: task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
|
|
946
992
|
'aria-label': task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
|
|
@@ -962,7 +1008,10 @@ window.__ModuleLoader__.load({
|
|
|
962
1008
|
),
|
|
963
1009
|
React.createElement('div', { className: 'dsh-cron-task-sched' },
|
|
964
1010
|
task.scheduleText || task.schedule,
|
|
965
|
-
task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : ''
|
|
1011
|
+
task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : '',
|
|
1012
|
+
task.running && task.runningSince
|
|
1013
|
+
? ' · ' + T_KEY(t, 'list.runningNow') + ' ' + Math.max(0, Math.round((Date.now() - task.runningSince) / 1000)) + 's'
|
|
1014
|
+
: ''
|
|
966
1015
|
),
|
|
967
1016
|
task.prompt ? React.createElement('div', {
|
|
968
1017
|
className: 'dsh-cron-task-prompt-preview',
|
|
@@ -1085,7 +1134,13 @@ window.__ModuleLoader__.load({
|
|
|
1085
1134
|
)
|
|
1086
1135
|
),
|
|
1087
1136
|
run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
|
|
1088
|
-
run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error)
|
|
1137
|
+
run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error),
|
|
1138
|
+
run.sessionId && React.createElement('button', {
|
|
1139
|
+
type: 'button',
|
|
1140
|
+
className: 'dsh-cron-btn-secondary',
|
|
1141
|
+
style: { marginTop: '8px', fontSize: '12px', padding: '4px 10px' },
|
|
1142
|
+
onClick: () => openSession(ctx, run.sessionId)
|
|
1143
|
+
}, T_KEY(t, 'history.openSession'))
|
|
1089
1144
|
))
|
|
1090
1145
|
),
|
|
1091
1146
|
React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '18px' } },
|
|
@@ -1157,7 +1212,52 @@ window.__ModuleLoader__.load({
|
|
|
1157
1212
|
)
|
|
1158
1213
|
)
|
|
1159
1214
|
),
|
|
1215
|
+
React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1216
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1217
|
+
React.createElement('label', null, T_KEY(t, 'form.timezoneLabel')),
|
|
1218
|
+
React.createElement('input', {
|
|
1219
|
+
value: formTimezone,
|
|
1220
|
+
placeholder: T_KEY(t, 'form.timezonePlaceholder'),
|
|
1221
|
+
onChange: (e) => setFormTimezone(e.target.value)
|
|
1222
|
+
})
|
|
1223
|
+
),
|
|
1160
1224
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1225
|
+
React.createElement('label', null, T_KEY(t, 'form.misfireLabel')),
|
|
1226
|
+
React.createElement('select', {
|
|
1227
|
+
value: formMisfirePolicy,
|
|
1228
|
+
onChange: (e) => setFormMisfirePolicy(e.target.value)
|
|
1229
|
+
},
|
|
1230
|
+
React.createElement('option', { value: 'skip' }, T_KEY(t, 'form.misfireSkip')),
|
|
1231
|
+
React.createElement('option', { value: 'runOnce' }, T_KEY(t, 'form.misfireRunOnce')),
|
|
1232
|
+
React.createElement('option', { value: 'catchUpAll' }, T_KEY(t, 'form.misfireCatchUp'))
|
|
1233
|
+
)
|
|
1234
|
+
)
|
|
1235
|
+
),
|
|
1236
|
+
React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1237
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1238
|
+
React.createElement('label', null, T_KEY(t, 'form.retriesLabel')),
|
|
1239
|
+
React.createElement('input', {
|
|
1240
|
+
type: 'number',
|
|
1241
|
+
min: 0,
|
|
1242
|
+
value: formMaxRetries,
|
|
1243
|
+
placeholder: '0',
|
|
1244
|
+
onChange: (e) => setFormMaxRetries(e.target.value)
|
|
1245
|
+
})
|
|
1246
|
+
),
|
|
1247
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1248
|
+
React.createElement('label', null, T_KEY(t, 'form.permissionLabel')),
|
|
1249
|
+
React.createElement('select', {
|
|
1250
|
+
value: formPermissionPreset,
|
|
1251
|
+
onChange: (e) => setFormPermissionPreset(e.target.value)
|
|
1252
|
+
},
|
|
1253
|
+
React.createElement('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
|
|
1254
|
+
React.createElement('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
|
|
1255
|
+
React.createElement('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
|
|
1256
|
+
React.createElement('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
|
|
1257
|
+
)
|
|
1258
|
+
)
|
|
1259
|
+
),
|
|
1260
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1161
1261
|
React.createElement('label', null, T_KEY(t, 'form.kanbanLabel')),
|
|
1162
1262
|
React.createElement('select', {
|
|
1163
1263
|
value: formKanbanMode,
|
package/lib/index.js
CHANGED
|
@@ -24,6 +24,10 @@ export const Config = z.object({
|
|
|
24
24
|
notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
|
|
25
25
|
onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
|
|
26
26
|
kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
|
|
27
|
+
defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
|
|
28
|
+
maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
|
|
29
|
+
heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
|
|
30
|
+
heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
|
|
27
31
|
});
|
|
28
32
|
|
|
29
33
|
const PATCHABLE_TASK_FIELDS = [
|
|
@@ -35,6 +39,11 @@ const PATCHABLE_TASK_FIELDS = [
|
|
|
35
39
|
'delivery',
|
|
36
40
|
'provider',
|
|
37
41
|
'model',
|
|
42
|
+
'timezone',
|
|
43
|
+
'misfirePolicy',
|
|
44
|
+
'maxRetries',
|
|
45
|
+
'retryBackoffMs',
|
|
46
|
+
'permissionPreset',
|
|
38
47
|
'notifyTelegram',
|
|
39
48
|
'onlyOnFailure',
|
|
40
49
|
'timeoutSeconds',
|
|
@@ -124,7 +133,11 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
124
133
|
if (req.method === 'GET') {
|
|
125
134
|
const status = url.searchParams.get('status') || 'all';
|
|
126
135
|
const query = url.searchParams.get('query') || '';
|
|
127
|
-
const list = store.list({ status, query })
|
|
136
|
+
const list = store.list({ status, query }).map((t) => ({
|
|
137
|
+
...t,
|
|
138
|
+
running: scheduler.isRunning(t.id),
|
|
139
|
+
runningSince: scheduler.runningSince(t.id),
|
|
140
|
+
}));
|
|
128
141
|
const stats = store.getAggregatedStats();
|
|
129
142
|
sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
|
|
130
143
|
return;
|
|
@@ -162,6 +175,11 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
162
175
|
timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
|
|
163
176
|
overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
|
|
164
177
|
kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
|
|
178
|
+
timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
|
|
179
|
+
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
|
|
180
|
+
maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
|
|
181
|
+
retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
|
|
182
|
+
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
|
|
165
183
|
oneShot: Boolean(parsed.isOneShot || body.oneShot),
|
|
166
184
|
});
|
|
167
185
|
if (task.status === 'active') {
|
|
@@ -294,6 +312,11 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
294
312
|
onlyOnFailure: Boolean(args.onlyOnFailure),
|
|
295
313
|
timeoutSeconds: Number(args.timeoutSeconds) || 1800,
|
|
296
314
|
overlapPolicy: args.overlapPolicy || 'skip',
|
|
315
|
+
timezone: args.timezone ? String(args.timezone).trim() : '',
|
|
316
|
+
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
|
|
317
|
+
maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
|
|
318
|
+
retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
|
|
319
|
+
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
|
|
297
320
|
kanbanMode: args.kanbanMode || 'none',
|
|
298
321
|
oneShot: Boolean(parsed.isOneShot),
|
|
299
322
|
});
|
|
@@ -307,16 +330,21 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
307
330
|
|
|
308
331
|
const createTaskParameters = {
|
|
309
332
|
title: { type: 'string', description: 'Short task name', required: true },
|
|
310
|
-
schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
|
|
333
|
+
schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), shorthand ("@daily", "@every 30m"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
|
|
311
334
|
prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
|
|
312
335
|
type: { type: 'string', enum: ['prompt', 'script'], description: 'Task type' },
|
|
313
336
|
delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
|
|
314
337
|
provider: { type: 'string', description: 'Model provider (optional)' },
|
|
315
338
|
model: { type: 'string', description: 'Model (optional)' },
|
|
339
|
+
timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
|
|
316
340
|
notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
|
|
317
341
|
onlyOnFailure: { type: 'boolean', description: 'Send reports only for failures' },
|
|
318
342
|
timeoutSeconds: { type: 'number', description: 'Execution time limit in seconds' },
|
|
319
343
|
overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Overlap policy' },
|
|
344
|
+
misfirePolicy: { type: 'string', enum: ['skip', 'runOnce', 'catchUpAll'], description: 'What to do with a run missed during downtime' },
|
|
345
|
+
maxRetries: { type: 'number', description: 'Automatic retry attempts on failure (0 = off)' },
|
|
346
|
+
retryBackoffMs: { type: 'number', description: 'Base backoff between retries in ms, doubled per attempt' },
|
|
347
|
+
permissionPreset: { type: 'string', enum: ['default', 'read-only', 'workspace-write', 'full'], description: 'Permission preset applied to the task session' },
|
|
320
348
|
kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
|
|
321
349
|
};
|
|
322
350
|
|
|
@@ -336,7 +364,10 @@ const createTaskOutput = {
|
|
|
336
364
|
export function apply(ctx, config) {
|
|
337
365
|
const store = new TaskStore();
|
|
338
366
|
const runner = new SessionRunner(ctx);
|
|
339
|
-
const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts)
|
|
367
|
+
const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
|
|
368
|
+
maxConcurrent: config && config.maxConcurrent,
|
|
369
|
+
defaultTimezone: config && config.defaultTimezone,
|
|
370
|
+
});
|
|
340
371
|
|
|
341
372
|
// Register settings in the Harness settings service (#71, #102): the scope
|
|
342
373
|
// is the source of truth for the core, scope.watch mirrors values into the
|
|
@@ -365,8 +396,21 @@ export function apply(ctx, config) {
|
|
|
365
396
|
|
|
366
397
|
scheduler.start();
|
|
367
398
|
|
|
399
|
+
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
400
|
+
// external monitor can alert when this scheduler stops responding.
|
|
401
|
+
let heartbeatTimer = null;
|
|
402
|
+
if (config && config.heartbeatUrl && Number(config.heartbeatIntervalSec) > 0) {
|
|
403
|
+
const intervalMs = Number(config.heartbeatIntervalSec) * 1000;
|
|
404
|
+
heartbeatTimer = setInterval(() => {
|
|
405
|
+
fetch(config.heartbeatUrl, { method: 'GET' }).catch((err) => {
|
|
406
|
+
console.warn('[dsh-cron] heartbeat ping failed:', err.message);
|
|
407
|
+
});
|
|
408
|
+
}, intervalMs);
|
|
409
|
+
}
|
|
410
|
+
|
|
368
411
|
ctx.effect(() => {
|
|
369
412
|
return () => {
|
|
413
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
370
414
|
scheduler.stopAll();
|
|
371
415
|
};
|
|
372
416
|
}, 'dsh-cron: lifecycle');
|
|
@@ -466,6 +510,27 @@ export function apply(ctx, config) {
|
|
|
466
510
|
}
|
|
467
511
|
}), 'dsh-cron: /settings');
|
|
468
512
|
|
|
513
|
+
// GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
|
|
514
|
+
ctx.effect(() => ctx.webServer.register({
|
|
515
|
+
kind: 'exact',
|
|
516
|
+
path: '/dsh-cron/heartbeat',
|
|
517
|
+
handler: async (req, res) => {
|
|
518
|
+
try {
|
|
519
|
+
const tasks = store.list({ status: 'active' });
|
|
520
|
+
const lastRuns = tasks.map((t) => t.lastRunAt || 0);
|
|
521
|
+
sendJson(res, 200, {
|
|
522
|
+
ok: true,
|
|
523
|
+
activeTasks: tasks.length,
|
|
524
|
+
running: scheduler.runningCount(),
|
|
525
|
+
lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
|
|
526
|
+
serverTime: Date.now(),
|
|
527
|
+
});
|
|
528
|
+
} catch (err) {
|
|
529
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}), 'dsh-cron: /heartbeat');
|
|
533
|
+
|
|
469
534
|
// POST /dsh-cron/telegram/test
|
|
470
535
|
ctx.effect(() => ctx.webServer.register({
|
|
471
536
|
kind: 'exact',
|
package/lib/runner.js
CHANGED
|
@@ -130,6 +130,18 @@ export class SessionRunner {
|
|
|
130
130
|
agentOptions: provider && model ? { provider, model } : undefined,
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
// Per-job permission preset (#32): read-only | workspace-write | full.
|
|
134
|
+
if (task.permissionPreset && task.permissionPreset !== 'default') {
|
|
135
|
+
try {
|
|
136
|
+
const presets = this.ctx.get && this.ctx.get('permissionPresets');
|
|
137
|
+
if (presets && typeof presets.set === 'function') {
|
|
138
|
+
presets.set(handle.agent.session, task.permissionPreset);
|
|
139
|
+
}
|
|
140
|
+
} catch (ppErr) {
|
|
141
|
+
console.warn('[dsh-cron] permission preset not applied:', ppErr.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
await raceAbort(handle.agent.whenIdle());
|
|
134
146
|
handle.agent.followup(createUserMessage({
|
|
135
147
|
content: [{ type: 'text', text: task.prompt }],
|
|
@@ -159,9 +171,14 @@ export class SessionRunner {
|
|
|
159
171
|
return {
|
|
160
172
|
output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
|
|
161
173
|
usage,
|
|
162
|
-
costUsd
|
|
174
|
+
costUsd,
|
|
175
|
+
sessionId: handle.agent.session?.id || null
|
|
163
176
|
};
|
|
164
177
|
} finally {
|
|
178
|
+
let archivedSessionId = null;
|
|
179
|
+
try {
|
|
180
|
+
archivedSessionId = (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
|
|
181
|
+
} catch {}
|
|
165
182
|
try {
|
|
166
183
|
if (handle && typeof handle.dispose === 'function') {
|
|
167
184
|
await handle.dispose();
|
|
@@ -171,6 +188,17 @@ export class SessionRunner {
|
|
|
171
188
|
} catch (disposeErr) {
|
|
172
189
|
console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
|
|
173
190
|
}
|
|
191
|
+
// Best-effort: keep ephemeral cron sessions out of the main chat
|
|
192
|
+
// list (#30). The archive call degrades silently when the core
|
|
193
|
+
// does not expose it.
|
|
194
|
+
if (archivedSessionId && this.ctx && this.ctx.get) {
|
|
195
|
+
try {
|
|
196
|
+
const sessions = this.ctx.get('sessions');
|
|
197
|
+
if (sessions && typeof sessions.archive === 'function') {
|
|
198
|
+
sessions.archive(archivedSessionId);
|
|
199
|
+
}
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
174
202
|
}
|
|
175
203
|
} catch (err) {
|
|
176
204
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|
package/lib/scheduler.js
CHANGED
|
@@ -99,8 +99,19 @@ export function parseScheduleExpression(input) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// 4.
|
|
102
|
+
// 4. Standard @-shorthands (#15)
|
|
103
103
|
const lower = str.toLowerCase();
|
|
104
|
+
if (lower === '@hourly') return { cronPattern: '0 * * * *', humanText: 'Every hour' };
|
|
105
|
+
if (lower === '@daily' || lower === '@midnight') return { cronPattern: '0 0 * * *', humanText: 'Every day at 00:00' };
|
|
106
|
+
if (lower === '@weekly') return { cronPattern: '0 0 * * 0', humanText: 'Every week on Sunday at 00:00' };
|
|
107
|
+
if (lower === '@monthly') return { cronPattern: '0 0 1 * *', humanText: 'Every month on the 1st at 00:00' };
|
|
108
|
+
if (lower === '@yearly' || lower === '@annually') return { cronPattern: '0 0 1 1 *', humanText: 'Every year on Jan 1 at 00:00' };
|
|
109
|
+
const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
|
|
110
|
+
if (everyAlias) {
|
|
111
|
+
return parseScheduleExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 5. Friendly recurring aliases (English plus accepted Russian input aliases)
|
|
104
115
|
if (lower === 'daily' || lower === 'каждый день') {
|
|
105
116
|
return { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
|
|
106
117
|
}
|
|
@@ -126,35 +137,75 @@ export function parseScheduleExpression(input) {
|
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
export class TaskScheduler {
|
|
129
|
-
constructor(store, executeFn) {
|
|
140
|
+
constructor(store, executeFn, options = {}) {
|
|
130
141
|
this.store = store;
|
|
131
142
|
this.executeFn = executeFn;
|
|
143
|
+
this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
|
|
144
|
+
this.defaultTimezone = options.defaultTimezone || ''; // #13
|
|
132
145
|
this.jobs = new Map(); // taskId -> Cron instance
|
|
133
146
|
this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
|
|
147
|
+
this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
|
|
134
148
|
this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
|
|
135
149
|
}
|
|
136
150
|
|
|
151
|
+
isRunning(taskId) {
|
|
152
|
+
return this.running.has(taskId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
runningCount() {
|
|
156
|
+
return this.running.size;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
runningSince(taskId) {
|
|
160
|
+
const run = this.running.get(taskId);
|
|
161
|
+
return run ? run.startedAt : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clearRetryTimer(taskId) {
|
|
165
|
+
const timer = this.retryTimers.get(taskId);
|
|
166
|
+
if (timer !== undefined) {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
this.retryTimers.delete(taskId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
start() {
|
|
138
173
|
const tasks = this.store.list();
|
|
139
174
|
const now = Date.now();
|
|
140
175
|
for (const task of tasks) {
|
|
141
|
-
if (task.status
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
176
|
+
if (task.status !== 'active') continue;
|
|
177
|
+
// Detect missed execution if daemon was offline (threshold: > 5 minutes)
|
|
178
|
+
const missed = task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000;
|
|
179
|
+
if (missed) {
|
|
180
|
+
const missedAt = task.nextRunAt;
|
|
181
|
+
const policy = task.misfirePolicy || 'skip'; // #12: skip | runOnce | catchUpAll
|
|
182
|
+
console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()} (misfire: ${policy})`);
|
|
183
|
+
this.store.recordRun(task.id, {
|
|
184
|
+
at: missedAt,
|
|
185
|
+
status: 'missed',
|
|
186
|
+
durationMs: 0,
|
|
187
|
+
output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
|
|
188
|
+
error: null,
|
|
189
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
190
|
+
costUsd: 0,
|
|
191
|
+
});
|
|
192
|
+
if (policy === 'skip') {
|
|
193
|
+
// A missed one-shot has no future slot left: retire it instead of
|
|
194
|
+
// arming a timer in the past.
|
|
195
|
+
if (task.oneShot) {
|
|
196
|
+
task.status = 'completed';
|
|
197
|
+
this.store.set(task);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
this.scheduleTask(task);
|
|
201
|
+
continue;
|
|
155
202
|
}
|
|
203
|
+
// runOnce / catchUpAll: run late exactly once, then resume the schedule.
|
|
156
204
|
this.scheduleTask(task);
|
|
205
|
+
setImmediate(() => this.runTask(task.id));
|
|
206
|
+
continue;
|
|
157
207
|
}
|
|
208
|
+
this.scheduleTask(task);
|
|
158
209
|
}
|
|
159
210
|
}
|
|
160
211
|
|
|
@@ -173,6 +224,13 @@ export class TaskScheduler {
|
|
|
173
224
|
}
|
|
174
225
|
this.timers.clear();
|
|
175
226
|
|
|
227
|
+
for (const [id, timer] of this.retryTimers.entries()) {
|
|
228
|
+
try {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
this.retryTimers.clear();
|
|
233
|
+
|
|
176
234
|
for (const [, run] of this.running.entries()) {
|
|
177
235
|
try {
|
|
178
236
|
run.controller.abort(new Error('Scheduler stopped'));
|
|
@@ -193,9 +251,11 @@ export class TaskScheduler {
|
|
|
193
251
|
}
|
|
194
252
|
|
|
195
253
|
if (task.status !== 'active') return;
|
|
254
|
+
this.clearRetryTimer(task.id);
|
|
196
255
|
|
|
197
256
|
try {
|
|
198
257
|
const parsed = parseScheduleExpression(task.schedule);
|
|
258
|
+
const timezone = task.timezone || this.defaultTimezone || undefined;
|
|
199
259
|
|
|
200
260
|
// Handle One-shot task
|
|
201
261
|
if (parsed.isOneShot) {
|
|
@@ -216,7 +276,7 @@ export class TaskScheduler {
|
|
|
216
276
|
}
|
|
217
277
|
|
|
218
278
|
// Handle Recurring Cron job
|
|
219
|
-
const job = new Cron(parsed.cronPattern, { protect: true }, async () => {
|
|
279
|
+
const job = new Cron(parsed.cronPattern, { protect: true, ...(timezone ? { timezone } : {}) }, async () => {
|
|
220
280
|
await this.runTask(task.id);
|
|
221
281
|
});
|
|
222
282
|
|
|
@@ -235,6 +295,23 @@ export class TaskScheduler {
|
|
|
235
295
|
const task = this.store.get(taskId);
|
|
236
296
|
if (!task) return;
|
|
237
297
|
|
|
298
|
+
// Global concurrency throttle (#52): a run that would exceed the limit is
|
|
299
|
+
// skipped and recorded; already-running tasks are never throttled against
|
|
300
|
+
// themselves.
|
|
301
|
+
if (!this.running.has(taskId) && this.maxConcurrent > 0 && this.running.size >= this.maxConcurrent) {
|
|
302
|
+
console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
|
|
303
|
+
this.store.recordRun(taskId, {
|
|
304
|
+
at: Date.now(),
|
|
305
|
+
status: 'skipped',
|
|
306
|
+
durationMs: 0,
|
|
307
|
+
output: `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`,
|
|
308
|
+
error: null,
|
|
309
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
310
|
+
costUsd: 0,
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
238
315
|
const overlapPolicy = task.overlapPolicy || 'skip';
|
|
239
316
|
|
|
240
317
|
// Check overlap with currently running task
|
|
@@ -278,6 +355,7 @@ export class TaskScheduler {
|
|
|
278
355
|
let error = null;
|
|
279
356
|
let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
|
|
280
357
|
let costUsd = 0;
|
|
358
|
+
let sessionId = null;
|
|
281
359
|
|
|
282
360
|
try {
|
|
283
361
|
if (typeof this.executeFn === 'function') {
|
|
@@ -286,6 +364,7 @@ export class TaskScheduler {
|
|
|
286
364
|
output = res.output || '';
|
|
287
365
|
usage = res.usage || usage;
|
|
288
366
|
costUsd = res.costUsd || 0;
|
|
367
|
+
sessionId = res.sessionId || null;
|
|
289
368
|
} else {
|
|
290
369
|
output = String(res || '');
|
|
291
370
|
}
|
|
@@ -307,6 +386,7 @@ export class TaskScheduler {
|
|
|
307
386
|
error,
|
|
308
387
|
usage,
|
|
309
388
|
costUsd,
|
|
389
|
+
sessionId,
|
|
310
390
|
};
|
|
311
391
|
this.store.recordRun(taskId, runInfo);
|
|
312
392
|
|
|
@@ -383,6 +463,25 @@ export class TaskScheduler {
|
|
|
383
463
|
}
|
|
384
464
|
}
|
|
385
465
|
|
|
466
|
+
// 4. Retry with exponential backoff on failure (#18)
|
|
467
|
+
const failed = status === 'error' || status === 'timeout';
|
|
468
|
+
const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
|
|
469
|
+
if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
|
|
470
|
+
task.attempts = (task.attempts || 0) + 1;
|
|
471
|
+
const backoffMs = (Number(task.retryBackoffMs) > 0 ? Number(task.retryBackoffMs) : 30000) * Math.pow(2, task.attempts - 1);
|
|
472
|
+
const attempt = task.attempts;
|
|
473
|
+
this.store.set(task);
|
|
474
|
+
this.clearRetryTimer(taskId);
|
|
475
|
+
this.retryTimers.set(taskId, setTimeout(() => {
|
|
476
|
+
this.retryTimers.delete(taskId);
|
|
477
|
+
this.runTask(taskId);
|
|
478
|
+
}, backoffMs));
|
|
479
|
+
console.log(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${task.title}" in ${backoffMs}ms`);
|
|
480
|
+
} else if (!failed && task.attempts) {
|
|
481
|
+
task.attempts = 0;
|
|
482
|
+
this.store.set(task);
|
|
483
|
+
}
|
|
484
|
+
|
|
386
485
|
if (queuedCount > 0) {
|
|
387
486
|
setImmediate(() => {
|
|
388
487
|
this.runTask(taskId);
|
|
@@ -396,6 +495,7 @@ export class TaskScheduler {
|
|
|
396
495
|
if (!task) return null;
|
|
397
496
|
task.status = 'paused';
|
|
398
497
|
task.nextRunAt = null;
|
|
498
|
+
this.clearRetryTimer(taskId);
|
|
399
499
|
if (this.jobs.has(taskId)) {
|
|
400
500
|
this.jobs.get(taskId).stop();
|
|
401
501
|
this.jobs.delete(taskId);
|
package/lib/store.js
CHANGED