@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 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` (`weekdays` → `0 9 * * 1-5`)
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
- * Задачи выполняются в **локальном часовом поясе сервера**; cron-выражения вычисляет `croner` по часам хоста.
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. Friendly recurring aliases (English plus accepted Russian input aliases)
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 === 'active') {
142
- // Detect missed execution if daemon was offline (threshold: > 5 minutes)
143
- if (task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000) {
144
- const missedAt = task.nextRunAt;
145
- console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()}`);
146
- this.store.recordRun(task.id, {
147
- at: missedAt,
148
- status: 'missed',
149
- durationMs: 0,
150
- output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
151
- error: null,
152
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
153
- costUsd: 0,
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
@@ -212,6 +212,7 @@ export class TaskStore {
212
212
  error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
213
213
  usage,
214
214
  costUsd,
215
+ sessionId: runInfo.sessionId || null,
215
216
  });
216
217
  if (runs.length > 50) runs.length = 50;
217
218
  this.history.set(id, runs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",