@goodandready/dsh-cron 0.2.37 → 0.2.39

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
@@ -452,6 +452,7 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
452
452
  - **Modular Client Architecture (#150)**: Decomposed monolithic `lib/client.js` (~3950 lines) into 14 focused, single-responsibility fragments under `lib/client-src/` (none exceeding 580 lines). Integrated zero-dependency build script `scripts/build-client.mjs` wired into `package.json` (`build:client`, `pretest`). Development fragments are excluded from npm distribution via `"files": ["lib/*.js", ...]`.
453
453
  - **DSH Theme Tokens & Visual Standardization (#149)**: Replaced inline styles on task type badges (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) with dedicated `.dsh-cron-tag-*` CSS classes powered by semantic `--dsh-cron-*` theme variables. Modal overlay now adapts dynamically using `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`, and keyframe pulse animations use theme variables without hardcoded RGBA.
454
454
  - **Label Governance & Triage Audit (#95)**: Standardized 100% of repository issues and triage on the canonical repo-level label set.
455
+ - **Style Self-Healing on Dynamic DOM Sweeps (#263 / GH-4)**: Watches `<head>` and `<html>` via `MutationObserver` alongside a 2s backstop interval and `visibilitychange` listener (`startStyleSelfHeal`), automatically re-attaching or repairing the `<style id="dsh-cron-styles">` tag if swept by host head re-renders, theme switches, or neighbor cleanups.
455
456
 
456
457
  ---
457
458
 
@@ -487,7 +488,7 @@ dsh-cron:
487
488
  onlyOnFailure: false # deliver reports only for failed runs
488
489
  kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API base URL
489
490
  defaultTimezone: "" # default IANA time zone for schedules (empty = server local)
490
- maxConcurrent: 0 # max parallel task runs (0 = unlimited)
491
+ maxConcurrent: 2 # max parallel task runs (default 2, 0 = unlimited)
491
492
  heartbeatUrl: "" # dead man's snitch URL pinged on the heartbeat interval
492
493
  heartbeatIntervalSec: 0 # heartbeat ping interval in seconds (0 = off)
493
494
  # --- delivery channels ---
@@ -512,7 +513,15 @@ dsh-cron:
512
513
  apiToken: "" # bearer token for the external /dsh-cron/api/* surface (masked; empty = 503)
513
514
  ```
514
515
 
515
- ### Configuration Parameters
516
+ #### Timers, Intervals & Concurrency Groups Hardening (0.2.39)
517
+
518
+ - **32-Bit Timer Overflow Protection**: One-shot tasks scheduled >24.85 days in the future (exceeding Node's 32-bit signed integer `setTimeout` limit of 2,147,483,647 ms) are safely executed via bounded timer chunking, preventing immediate misfires.
519
+ - **Persistent Relative One-Shot Deadlines**: Relative one-shot tasks (`in 30m`, `in 2h`) maintain their absolute target timestamp across daemon restarts, store reloads, pause/resume, and metadata edits. Deadlines are only recalculated upon explicit schedule modifications.
520
+ - **Strict Interval Validation & Normalization**: Unsupported irregular intervals exceeding 59 minutes (e.g. `every 90m`) are validated and rejected upfront with HTTP 400 before persisting. Clean hourly multiples (e.g. `every 120m` -> `every 2h`, `every 24h` -> `every 1d`) are automatically converted to valid cron patterns.
521
+ - **Unified Safe Concurrency Defaults**: The Config schema and TaskScheduler now uniformly default `maxConcurrent` to safe cap `2` (0 explicitly denotes unlimited).
522
+ - **Concurrency Group Pool Isolation**: Tasks configured with `concurrencyGroup` have per-group execution limits enforced during admission and queue draining (default 1 concurrent run per named group). Tasks in separate groups run concurrently up to `maxConcurrent`, while queued tasks drain as soon as group capacity becomes available.
523
+
524
+ ## Configuration Parameters
516
525
 
517
526
  | Parameter | Type | Default | Description |
518
527
  |:---|:---|:---|:---|
@@ -522,7 +531,7 @@ dsh-cron:
522
531
  | `onlyOnFailure` | `boolean` | `false` | Global switch: deliver reports only for `error`/`timeout` runs |
523
532
  | `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Base URL of the `dsh-kanban` HTTP API used for automatic card creation |
524
533
  | `defaultTimezone` | `string` | `""` | Default IANA time zone for task schedules; empty = server local time |
525
- | `maxConcurrent` | `number` | `0` | Cap on parallel task runs; extra runs are recorded as `skipped` (0 = unlimited) |
534
+ | `maxConcurrent` | `number` | `2` | Cap on parallel task runs; extra runs are recorded as `skipped` (default 2, 0 = unlimited) |
526
535
  | `heartbeatUrl` | `string` | `""` | Dead man's snitch URL pinged every `heartbeatIntervalSec` while the scheduler is alive |
527
536
  | `heartbeatIntervalSec` | `number` | `0` | Heartbeat ping interval in seconds (0 = disabled) |
528
537
  | `botTokenRef` | `string` | `""` | Name of the DSH credential holding the Telegram bot token; resolved at send time (falls back to `botToken`, then the messenger-gateway settings, then the `CRON_TELEGRAM_BOT_TOKEN` environment variable) |
package/README.ru.md CHANGED
@@ -319,6 +319,7 @@ cron({
319
319
  - **Модульная архитектура клиентской части (#150)**: Монолитный файл `lib/client.js` (~3950 строк) декомпозирован на 14 независимых специализированных модулей в каталоге `lib/client-src/` (каждый строго <= 580 строк). Сборка `lib/client.js` выполняется легковесным скриптом `scripts/build-client.mjs` перед запуском тестов и сборки. Исходные фрагменты исключены из npm-дистрибутива через `"files": ["lib/*.js", ...]`.
320
320
  - **Семантические токены тем DSH (#149)**: Инлайн-стили тегов типа задач (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) переведены на выделенные CSS-классы `.dsh-cron-tag-*` на базе переменных темы `--dsh-cron-*`. Оверлей модальных окон переведен на адаптивную маску `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`, а анимация пульсации избавлена от захардкоженных значений RGBA.
321
321
  - **Аудит меток репозитория (#95)**: Подтверждена 100% консистентность использования канонического репозиторного набора меток и фильтрации задач.
322
+ - **Самовосстановление стилей при динамических перерисовках DOM (#263 / GH-4)**: Отслеживает `<head>` и `<html>` через `MutationObserver`, резервный интервал 2с и событие `visibilitychange` (`startStyleSelfHeal`), мгновенно восстанавливая узел `<style id="dsh-cron-styles">` при перерисовке заголовка хостом, смене темы или очистке соседними модулями.
322
323
 
323
324
  ---
324
325
 
@@ -352,7 +353,7 @@ dsh-cron:
352
353
  onlyOnFailure: false # отправлять отчёты только при сбоях
353
354
  kanbanBaseUrl: "http://127.0.0.1:3000" # базовый URL HTTP API dsh-kanban
354
355
  defaultTimezone: "" # IANA-зона по умолчанию (пусто = серверное время)
355
- maxConcurrent: 0 # максимум параллельных запусков (0 = без лимита)
356
+ maxConcurrent: 2 # максимум параллельных запусков (по умолчанию 2, 0 = без лимита)
356
357
  heartbeatUrl: "" # URL dead man's snitch, пингуется по интервалу
357
358
  heartbeatIntervalSec: 0 # интервал heartbeat-пинга в секундах (0 = выключено)
358
359
  # --- каналы доставки ---
@@ -512,7 +513,7 @@ bash deploy.sh verify [exact-version]
512
513
  | `onlyOnFailure` | `boolean` | `false` | Глобальный режим «только при сбоях» (`error`/`timeout`) |
513
514
  | `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Базовый URL HTTP API `dsh-kanban` для автоматических карточек |
514
515
  | `defaultTimezone` | `string` | `""` | IANA-зона по умолчанию для расписаний; пусто = серверное время |
515
- | `maxConcurrent` | `number` | `0` | Лимит параллельных запусков; лишние помечаются `skipped` (0 = без лимита) |
516
+ | `maxConcurrent` | `number` | `2` | Лимит параллельных запусков; лишние помечаются `skipped` (по умолчанию 2, 0 = без лимита) |
516
517
  | `heartbeatUrl` | `string` | `""` | URL dead man's snitch, пингуемый каждый `heartbeatIntervalSec`, пока жив планировщик |
517
518
  | `heartbeatIntervalSec` | `number` | `0` | Интервал heartbeat-пинга в секундах (0 = выключено) |
518
519
  | `botTokenRef` | `string` | `""` | Имя credential DSH с токеном Telegram-бота; резолвится при отправке (фолбэк: `botToken` → настройки messenger-gateway → переменная окружения `CRON_TELEGRAM_BOT_TOKEN`) |
@@ -590,3 +591,12 @@ node scripts/ci-preflight.mjs
590
591
  ## 📄 Лицензия
591
592
 
592
593
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
594
+
595
+ ### Надёжность таймеров, интервалов и групп параллелизма (0.2.39)
596
+
597
+ - **Защита от 32-битного переполнения таймера**: Одноразовые задачи, запланированные более чем на 24.85 дня вперёд (превышающие лимит 32-битного целого `setTimeout` в 2 147 483 647 мс в Node.js), выполняются через порционный таймер и не запускаются раньше времени.
598
+ - **Сохранение первоначального дедлайна относительных задач**: Задачи вида `in 30m` или `через 2 часа` сохраняют первоначальный целевой таймстемп при перезапусках сервера, перезагрузке хранилища, паузе/возобновлении и изменении полей задачи (title, tags, channels). Новый дедлайн вычисляется только при явном изменении поля расписания.
599
+ - **Строгая валидация и нормализация интервалов**: Недопустимые минутные интервалы свыше 59 минут (например, `every 90m`) отклоняются на входе с кодом HTTP 400 без создания неработающей активной задачи. Кратные интервалы (например, `every 120m` -> `every 2h`, `every 24h` -> `every 1d`) корректно приводятся к валидным cron-паттернам.
600
+ - **Единый безопасный лимит maxConcurrent**: Схема Config и планировщик TaskScheduler теперь согласованно используют лимит по умолчанию `2` (0 остаётся явным выбором безлимитного режима).
601
+ - **Изоляция пулов через concurrencyGroup**: Поле `concurrencyGroup` теперь строго контролируется при допуске задач и опустошении очереди (по умолчанию 1 параллельный запуск на именованную группу). Задачи из разных групп исполняются параллельно в пределах общего `maxConcurrent`, а задачи с политикой `queue` стартуют сразу при освобождении слота своей группы.
602
+
package/README.zh.md CHANGED
@@ -444,6 +444,7 @@ bash deploy.sh verify [exact-version]
444
444
  - **客户端模块化架构 (#150)**: 将庞大的单文件 `lib/client.js` (约 3950 行) 拆分为 `lib/client-src/` 下的 14 个高内聚模块文件 (各模块严格 <= 580 行)。集成零依赖构建脚本 `scripts/build-client.mjs` 并接入 `package.json` (`build:client`, `pretest`),通过 `"files": ["lib/*.js", ...]` 避免开发源码冗余打包进 npm 发布包。
445
445
  - **DSH 语义化主题变量对齐 (#149)**: 将任务类型标签 (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) 的内联样式全部替换为基于主题变量的 `.dsh-cron-tag-*` 类;模态框遮罩层接入自适应主题遮罩变量 `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`,移除脉冲动画关键帧中的硬编码 RGBA。
446
446
  - **标签治理与工单审计 (#95)**: 审计并确认全仓库 100% 统一规范使用仓库级标签集。
447
+ - **动态 DOM 清理下的样式自动恢复(#263 / GH-4)**: 通过 `MutationObserver`、2 秒兜底定时器与 `visibilitychange` 事件(`startStyleSelfHeal`)监听 `<head>` 与 `<html>`,当宿主重绘 `<head>`、切换主题或邻近插件清理样式时,自动重新注入并修复 `<style id="dsh-cron-styles">` 标签。
447
448
 
448
449
  ---
449
450
 
@@ -477,7 +478,7 @@ dsh-cron:
477
478
  onlyOnFailure: false # 仅失败时投递报告
478
479
  kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API 基础地址
479
480
  defaultTimezone: "" # 默认 IANA 时区(空 = 服务器本地)
480
- maxConcurrent: 0 # 最大并行运行数(0 = 不限)
481
+ maxConcurrent: 2 # 最大并行运行数(默认 2,0 = 不限)
481
482
  heartbeatUrl: "" # 心跳上报 URL(dead man's snitch)
482
483
  heartbeatIntervalSec: 0 # 心跳间隔秒数(0 = 关闭)
483
484
  # --- 投递渠道 ---
@@ -512,7 +513,7 @@ dsh-cron:
512
513
  | `onlyOnFailure` | `boolean` | `false` | 全局开关:仅对 `error`/`timeout` 运行投递报告 |
513
514
  | `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | 用于自动卡片的 `dsh-kanban` HTTP API 基础地址 |
514
515
  | `defaultTimezone` | `string` | `""` | 任务调度的默认 IANA 时区;空 = 服务器本地时间 |
515
- | `maxConcurrent` | `number` | `0` | 并行运行上限;超出的运行记录为 `skipped`(0 = 不限) |
516
+ | `maxConcurrent` | `number` | `2` | 并行运行上限;超出的运行记录为 `skipped`(默认 2,0 = 不限) |
516
517
  | `heartbeatUrl` | `string` | `""` | 心跳上报 URL,调度器存活期间按 `heartbeatIntervalSec` 间隔 GET |
517
518
  | `heartbeatIntervalSec` | `number` | `0` | 心跳间隔秒数(0 = 关闭) |
518
519
  | `botTokenRef` | `string` | `""` | 保存 Telegram bot token 的 DSH 凭据名称;发送时解析(回退顺序:`botToken` → messenger-gateway 设置 → 环境变量 `CRON_TELEGRAM_BOT_TOKEN`) |
@@ -590,3 +591,12 @@ node scripts/ci-preflight.mjs
590
591
  ## 📄 许可证
591
592
 
592
593
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
594
+
595
+ ### 定时器、间隔与并发分组精度强化 (0.2.39)
596
+
597
+ - **32位定时器溢出防护**:计划在 24.85 天以后的单次任务(超出 Node.js `setTimeout` 32位有符号整数上限 2,147,483,647 毫秒)通过分段定时器安全挂起,杜绝立即误触发。
598
+ - **相对单次任务持久截止时间**:相对单次任务(如 `in 30m`、`in 2h`)在服务重启、存储重载、暂停/恢复以及修改元数据时完整保留初始目标绝对时间戳,仅在显式修改调度表达式时重新计算。
599
+ - **严格的时间间隔校验与转换**:对于无法用标准 Cron 分钟步进表示的超限分钟间隔(如 `every 90m`),在持久化前返回 HTTP 400 拦截;规整整倍数(如 `every 120m` -> `every 2h`、`every 24h` -> `every 1d`)自动转为合法 Cron 表达式。
600
+ - **统一安全并发默认值**:配置规范(Config)与调度器(TaskScheduler)全面对齐默认并发上限 `maxConcurrent = 2`(0 仅作为显式无限制选项)。
601
+ - **并发分组隔离调度**:任务的 `concurrencyGroup` 属性全面接入调度仲裁与队列排队(同名分组默认限流 1 个并发执行),不同分组可并发运行直至 `maxConcurrent`,队列任务随所属分组资源释放立即触发。
602
+
package/lib/api.js CHANGED
@@ -200,7 +200,13 @@ async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
200
200
  sendJson(res, 400, { ok: false, error: typeError });
201
201
  return;
202
202
  }
203
- const parsed = parseScheduleExpression(body.schedule);
203
+ let parsed;
204
+ try {
205
+ parsed = parseScheduleExpression(body.schedule);
206
+ } catch (err) {
207
+ sendJson(res, 400, { ok: false, error: err.message });
208
+ return;
209
+ }
204
210
  // A client-supplied id must not shadow the collection routes under
205
211
  // /dsh-cron/tasks, otherwise that task could never be fetched or deleted
206
212
  // again (#42 review finding).
@@ -480,6 +486,9 @@ function mergeExecutionFields(current, body, parsed) {
480
486
  skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
481
487
  workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
482
488
  oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
489
+ targetTimestamp: parsed && parsed.isOneShot
490
+ ? (current && current.schedule === (parsed.cronPattern || body.schedule) ? (current.targetTimestamp || parsed.targetTimestamp) : parsed.targetTimestamp)
491
+ : (current ? current.targetTimestamp : undefined),
483
492
  costLimitUsd: body.costLimitUsd !== undefined ? (body.costLimitUsd === null ? null : (Number(body.costLimitUsd) > 0 ? Number(body.costLimitUsd) : null)) : (current ? current.costLimitUsd : undefined),
484
493
  dailyCostLimitUsd: body.dailyCostLimitUsd !== undefined ? (body.dailyCostLimitUsd === null ? null : (Number(body.dailyCostLimitUsd) > 0 ? Number(body.dailyCostLimitUsd) : null)) : (current ? current.dailyCostLimitUsd : undefined),
485
494
  tokenLimit: body.tokenLimit !== undefined ? (body.tokenLimit === null ? null : (Number(body.tokenLimit) > 0 ? Number(body.tokenLimit) : null)) : (current ? current.tokenLimit : undefined),
package/lib/client.js CHANGED
@@ -847,17 +847,63 @@ window.__ModuleLoader__.load({
847
847
  }
848
848
  `;
849
849
 
850
+ const STYLE_ID = 'dsh-cron-styles';
851
+
850
852
  function ensureStyles() {
851
- if (typeof document === 'undefined') return;
852
- let el = document.getElementById('dsh-cron-styles');
853
- if (!el) {
853
+ if (typeof document === 'undefined') return null;
854
+ let el = document.getElementById(STYLE_ID);
855
+ if (el === null) {
856
+ const head = document.head || document.documentElement;
857
+ if (!head) return null;
854
858
  el = document.createElement('style');
855
- el.id = 'dsh-cron-styles';
859
+ el.id = STYLE_ID;
856
860
  // Mark ownership before insertion so neighbor cleanups leave it alone (#91)
857
861
  el.dataset.dshPlugin = 'dsh-cron';
858
- document.head.appendChild(el);
862
+ el.textContent = STYLES;
863
+ head.appendChild(el);
864
+ return el;
859
865
  }
860
- el.textContent = STYLES;
866
+ if (el.textContent !== STYLES) el.textContent = STYLES;
867
+ return el;
868
+ }
869
+
870
+ // Registry is shared across hot reloads so the newest instance owns exactly
871
+ // one watcher instead of stacking one per module evaluation.
872
+ const STYLE_HEAL_KEY = '__dshCronStyleHeal';
873
+ const STYLE_HEAL_INTERVAL_MS = 2000;
874
+
875
+ /**
876
+ * Keep the stylesheet attached: watch <head> (and <html>, which catches a
877
+ * replaced <head>) and re-attach on demand, with a slow interval as the
878
+ * backstop. heal() is idempotent and writes nothing while healthy.
879
+ */
880
+ function startStyleSelfHeal() {
881
+ if (typeof document === 'undefined') return () => {};
882
+ if (typeof window !== 'undefined' && typeof window[STYLE_HEAL_KEY] === 'function') {
883
+ try { window[STYLE_HEAL_KEY](); } catch (_err) { void _err; }
884
+ }
885
+ const heal = () => { try { ensureStyles(); } catch (_err) { void _err; } };
886
+ heal();
887
+ let observer = null;
888
+ try {
889
+ if (typeof MutationObserver === 'function') {
890
+ observer = new MutationObserver(heal);
891
+ if (document.head) observer.observe(document.head, { childList: true });
892
+ if (document.documentElement) observer.observe(document.documentElement, { childList: true });
893
+ }
894
+ } catch (_err) { observer = null; }
895
+ let timer = null;
896
+ try { timer = setInterval(heal, STYLE_HEAL_INTERVAL_MS); } catch (_err) { timer = null; }
897
+ const onVisibility = () => { if (document.visibilityState !== 'hidden') heal(); };
898
+ try { document.addEventListener('visibilitychange', onVisibility); } catch (_err) { void _err; }
899
+ const stop = () => {
900
+ try { if (observer) observer.disconnect(); } catch (_err) { void _err; }
901
+ try { if (timer !== null) clearInterval(timer); } catch (_err) { void _err; }
902
+ try { document.removeEventListener('visibilitychange', onVisibility); } catch (_err) { void _err; }
903
+ if (typeof window !== 'undefined' && window[STYLE_HEAL_KEY] === stop) window[STYLE_HEAL_KEY] = undefined;
904
+ };
905
+ if (typeof window !== 'undefined') window[STYLE_HEAL_KEY] = stop;
906
+ return stop;
861
907
  }
862
908
 
863
909
  function createErrorBoundary() {
@@ -4014,6 +4060,9 @@ window.__ModuleLoader__.load({
4014
4060
 
4015
4061
  const applyActive = () => {
4016
4062
  if (toggle.isOpen()) {
4063
+ // Repair a dropped stylesheet before the panel paints, so opening the
4064
+ // screen never shows an unstyled page (see startStyleSelfHeal).
4065
+ ensureStyles();
4017
4066
  document.documentElement.setAttribute(ACTIVE_ATTR, '');
4018
4067
  document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }));
4019
4068
  } else {
@@ -4070,7 +4119,7 @@ window.__ModuleLoader__.load({
4070
4119
  }
4071
4120
 
4072
4121
  function apply(ctx) {
4073
- ensureStyles();
4122
+ const stopStyleSelfHeal = startStyleSelfHeal();
4074
4123
 
4075
4124
  // Register the English canonical strings; rebinding the translator lets
4076
4125
  // the translation plugin drive the active language at runtime (#87).
@@ -4158,7 +4207,7 @@ window.__ModuleLoader__.load({
4158
4207
  mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
4159
4208
  mountCronScreen(ctx, toggle)
4160
4209
  ];
4161
- return () => { for (const dispose of off) dispose(); };
4210
+ return () => { for (const dispose of off) dispose(); stopStyleSelfHeal(); };
4162
4211
  }, 'dsh-cron: client overlay');
4163
4212
 
4164
4213
  exports.slots = { card: cardSlot, chip: chipSlot };
@@ -195,6 +195,28 @@ export function beginRun(scheduler, task, taskId, options = {}) {
195
195
  return null;
196
196
  }
197
197
 
198
+ const group = task.concurrencyGroup && task.concurrencyGroup !== 'default' ? String(task.concurrencyGroup).trim() : null;
199
+ const groupLimit = scheduler.getGroupLimit ? scheduler.getGroupLimit(group) : (group ? 1 : 0);
200
+ if (group && groupLimit > 0 && !scheduler.running.has(taskId)) {
201
+ const runningInGroup = scheduler.getRunningGroupCount ? scheduler.getRunningGroupCount(group) : 0;
202
+ if (runningInGroup >= groupLimit) {
203
+ if (task.overlapPolicy === 'queue') {
204
+ logger.info(`[dsh-cron] Task "${task.title}" (${taskId}) queued by group "${group}" limit (${groupLimit})`);
205
+ scheduler.queue.push({
206
+ taskId,
207
+ options: { ...options },
208
+ priority: task.priority !== undefined ? task.priority : 5,
209
+ queuedAt: Date.now(),
210
+ });
211
+ scheduler.queue.sort((a, b) => (a.priority - b.priority) || (a.queuedAt - b.queuedAt));
212
+ return null;
213
+ }
214
+ logger.info(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency group "${group}" limit reached (${groupLimit})`);
215
+ scheduler.recordSkipped(taskId, `Skipped: concurrency group "${group}" limit reached (${groupLimit} parallel runs)`);
216
+ return null;
217
+ }
218
+ }
219
+
198
220
  const overlapPolicy = task.overlapPolicy || 'skip';
199
221
  const active = scheduler.running.get(taskId);
200
222
  if (active) {
@@ -225,6 +247,7 @@ export function beginRun(scheduler, task, taskId, options = {}) {
225
247
  startedAt: Date.now(),
226
248
  queuedRuns: pendingOverlap,
227
249
  queueCount: pendingOverlap.length,
250
+ concurrencyGroup: group || 'default',
228
251
  };
229
252
  scheduler.running.set(taskId, currentRun);
230
253
  return currentRun;
@@ -532,20 +555,41 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
532
555
  const guard = await checkAndApplyBurnGuard(scheduler, taskId, outcome);
533
556
  if (guard && guard.exceeded) return;
534
557
 
535
- while (!scheduler.isStopped && scheduler.queue.length > 0 && scheduler.running.size < scheduler.maxConcurrent) {
536
- const nextItem = scheduler.queue.shift();
537
- if (!nextItem) break;
538
- const queuedTask = scheduler.store.get(nextItem.taskId);
539
- if (queuedTask && queuedTask.status === 'active' && !scheduler.isStopped) {
540
- setImmediate(() => {
541
- if (!scheduler.isStopped) {
542
- scheduler.runTask(nextItem.taskId, nextItem.options || {}).catch((runErr) => {
543
- bestEffort('drain-queued-task', () => {}, scheduler.logger);
544
- });
558
+ while (!scheduler.isStopped && scheduler.queue.length > 0 && (scheduler.maxConcurrent === 0 || scheduler.running.size < scheduler.maxConcurrent)) {
559
+ let admittedIndex = -1;
560
+ for (let i = 0; i < scheduler.queue.length; i++) {
561
+ const candidate = scheduler.queue[i];
562
+ const queuedTask = scheduler.store.get(candidate.taskId);
563
+ if (!queuedTask || queuedTask.status !== 'active') {
564
+ scheduler.queue.splice(i, 1);
565
+ i--;
566
+ continue;
567
+ }
568
+ const qGroup = queuedTask.concurrencyGroup && queuedTask.concurrencyGroup !== 'default'
569
+ ? String(queuedTask.concurrencyGroup).trim()
570
+ : null;
571
+ const qLimit = scheduler.getGroupLimit ? scheduler.getGroupLimit(qGroup) : (qGroup ? 1 : 0);
572
+ if (qGroup && qLimit > 0) {
573
+ const inGroup = scheduler.getRunningGroupCount ? scheduler.getRunningGroupCount(qGroup) : 0;
574
+ if (inGroup >= qLimit) {
575
+ continue;
545
576
  }
546
- });
577
+ }
578
+ admittedIndex = i;
547
579
  break;
548
580
  }
581
+
582
+ if (admittedIndex === -1) break;
583
+
584
+ const nextItem = scheduler.queue.splice(admittedIndex, 1)[0];
585
+ setImmediate(() => {
586
+ if (!scheduler.isStopped) {
587
+ scheduler.runTask(nextItem.taskId, nextItem.options || {}).catch((runErr) => {
588
+ bestEffort('drain-queued-task', () => {}, scheduler.logger);
589
+ });
590
+ }
591
+ });
592
+ break;
549
593
  }
550
594
 
551
595
  // 1. Structured LLM Actions (#137)
@@ -76,6 +76,9 @@ export function pauseTask(scheduler, taskId, reason = null) {
76
76
  const task = scheduler.store.get(taskId);
77
77
  if (!task) return null;
78
78
  task.status = 'paused';
79
+ if (task.oneShot) {
80
+ task.targetTimestamp = task.targetTimestamp || task.nextRunAt;
81
+ }
79
82
  task.nextRunAt = null;
80
83
  if (reason) {
81
84
  task.pausedReason = reason;
package/lib/scheduler.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { logger } from './logger.js';
2
2
  import { bestEffort } from './best-effort.js';
3
3
  import { Cron } from 'croner';
4
+
5
+ const MAX_TIMEOUT_MS = 2147483647; // 2^31 - 1 ms (~24.85 days)
4
6
  import {
5
7
  AGENT_TASK_TYPES,
6
8
  addUsage,
@@ -128,14 +130,76 @@ function parseIntervalExpression(str) {
128
130
  if (!match) return null;
129
131
  const num = parseInt(match[1], 10);
130
132
  const unit = (match[2] || 'm').toLowerCase();
133
+ let pattern = null;
134
+ let humanText = null;
135
+
131
136
  if (unit.startsWith('m')) {
132
- return { cronPattern: `*/${num} * * * *`, humanText: `Every ${num} minutes`, isInterval: true };
133
- }
134
- if (unit.startsWith('h')) {
135
- return { cronPattern: `0 */${num} * * *`, humanText: `Every ${num} hours`, isInterval: true };
137
+ if (num < 1) {
138
+ throw new Error('Invalid interval: minute interval must be at least 1 minute');
139
+ }
140
+ if (num >= 60) {
141
+ if (num % 60 === 0) {
142
+ const hours = num / 60;
143
+ if (hours >= 24 && hours % 24 === 0) {
144
+ const days = hours / 24;
145
+ if (days > 31) {
146
+ throw new Error(`Invalid interval: minute interval "${num}m" (${days} days) exceeds 31 days`);
147
+ }
148
+ pattern = `0 0 */${days} * *`;
149
+ humanText = `Every ${days} days`;
150
+ } else if (hours > 23) {
151
+ throw new Error(`Invalid interval: minute interval "${num}m" (${hours} hours) cannot be expressed as a cron step`);
152
+ } else {
153
+ pattern = `0 */${hours} * * *`;
154
+ humanText = `Every ${hours} hours`;
155
+ }
156
+ } else {
157
+ throw new Error(`Invalid interval: minute interval "${num}m" exceeds 59 minutes and is not a multiple of 60. Use hours (e.g. every 2h) or a multiple of 60m`);
158
+ }
159
+ } else {
160
+ pattern = `*/${num} * * * *`;
161
+ humanText = `Every ${num} minutes`;
162
+ }
163
+ } else if (unit.startsWith('h')) {
164
+ if (num < 1) {
165
+ throw new Error('Invalid interval: hour interval must be at least 1 hour');
166
+ }
167
+ if (num >= 24) {
168
+ if (num % 24 === 0) {
169
+ const days = num / 24;
170
+ if (days > 31) {
171
+ throw new Error(`Invalid interval: hour interval "${num}h" (${days} days) exceeds 31 days`);
172
+ }
173
+ pattern = `0 0 */${days} * *`;
174
+ humanText = `Every ${days} days`;
175
+ } else {
176
+ throw new Error(`Invalid interval: hour interval "${num}h" exceeds 23 hours and is not a multiple of 24. Use days (e.g. every 2d) or a multiple of 24h`);
177
+ }
178
+ } else {
179
+ pattern = `0 */${num} * * *`;
180
+ humanText = `Every ${num} hours`;
181
+ }
182
+ } else if (unit.startsWith('d')) {
183
+ if (num < 1 || num > 31) {
184
+ throw new Error(`Invalid interval: day interval "${num}d" must be between 1 and 31 days`);
185
+ }
186
+ pattern = `0 0 */${num} * *`;
187
+ humanText = `Every ${num} days`;
136
188
  }
137
- if (unit.startsWith('d')) {
138
- return { cronPattern: `0 0 */${num} * *`, humanText: `Every ${num} days`, isInterval: true };
189
+
190
+ if (pattern) {
191
+ try {
192
+ const testJob = new Cron(pattern, { sloppyRanges: true });
193
+ const next = testJob.nextRun();
194
+ return {
195
+ cronPattern: pattern,
196
+ humanText,
197
+ isInterval: true,
198
+ nextRun: next ? next.getTime() : null,
199
+ };
200
+ } catch (err) {
201
+ throw new Error(`Invalid interval pattern "${pattern}": ${err.message}`);
202
+ }
139
203
  }
140
204
  return null;
141
205
  }
@@ -252,6 +316,7 @@ export class TaskScheduler {
252
316
  this.running = new Map();
253
317
  this.runCounters = {};
254
318
  this.queue = [];
319
+ this.groupLimits = opts.groupLimits instanceof Map ? opts.groupLimits : new Map(Object.entries(opts.groupLimits || {}));
255
320
  this.heartbeatTimer = null;
256
321
  this.logger = opts.logger || null;
257
322
  this.isStopped = false;
@@ -377,17 +442,34 @@ export class TaskScheduler {
377
442
 
378
443
  scheduleOneShot(task, parsed) {
379
444
  task.oneShot = true;
445
+ task.targetTimestamp = parsed.targetTimestamp;
380
446
  task.nextRunAt = parsed.targetTimestamp;
381
447
  this.store.set(task);
382
448
 
383
- const delay = Math.max(0, parsed.targetTimestamp - Date.now());
384
- const timer = setTimeout(() => {
385
- this.timers.delete(task.id);
386
- this.runTask(task.id).catch((err) => {
387
- logger.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
388
- });
389
- }, delay);
390
- this.timers.set(task.id, timer);
449
+ const armTimer = () => {
450
+ const remaining = parsed.targetTimestamp - Date.now();
451
+ if (remaining <= 0) {
452
+ this.timers.delete(task.id);
453
+ this.runTask(task.id).catch((err) => {
454
+ logger.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
455
+ });
456
+ return;
457
+ }
458
+ const delay = Math.min(remaining, MAX_TIMEOUT_MS);
459
+ const timer = setTimeout(() => {
460
+ if (Date.now() >= parsed.targetTimestamp) {
461
+ this.timers.delete(task.id);
462
+ this.runTask(task.id).catch((err) => {
463
+ logger.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
464
+ });
465
+ } else {
466
+ armTimer();
467
+ }
468
+ }, delay);
469
+ this.timers.set(task.id, timer);
470
+ };
471
+
472
+ armTimer();
391
473
  }
392
474
 
393
475
  scheduleCron(task, parsed) {
@@ -416,6 +498,11 @@ export class TaskScheduler {
416
498
  try {
417
499
  const parsed = parseScheduleExpression(task.schedule);
418
500
  if (parsed.isOneShot) {
501
+ const existingTarget = task.targetTimestamp || task.nextRunAt;
502
+ if (existingTarget && typeof existingTarget === 'number' && !isNaN(existingTarget)) {
503
+ parsed.targetTimestamp = existingTarget;
504
+ parsed.nextRun = existingTarget;
505
+ }
419
506
  this.scheduleOneShot(task, parsed);
420
507
  return;
421
508
  }
@@ -425,6 +512,25 @@ export class TaskScheduler {
425
512
  }
426
513
  }
427
514
 
515
+ getGroupLimit(group) {
516
+ if (!group || group === 'default') return 0;
517
+ if (this.groupLimits && this.groupLimits.has(group)) {
518
+ return Number(this.groupLimits.get(group)) || 0;
519
+ }
520
+ return 1;
521
+ }
522
+
523
+ getRunningGroupCount(group) {
524
+ if (!group || group === 'default') return 0;
525
+ let count = 0;
526
+ for (const [, run] of this.running.entries()) {
527
+ if (run.concurrencyGroup === group) {
528
+ count++;
529
+ }
530
+ }
531
+ return count;
532
+ }
533
+
428
534
  beginRun(task, taskId, options = {}) {
429
535
  return beginRun(this, task, taskId, options);
430
536
  }
package/lib/settings.js CHANGED
@@ -21,7 +21,7 @@ export const Config = z.object({
21
21
  onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs').volatile(),
22
22
  kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API').volatile(),
23
23
  defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)').volatile(),
24
- maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)').volatile(),
24
+ maxConcurrent: z.number().default(2).description('Max parallel task runs (0 = unlimited, default 2)').volatile(),
25
25
  heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval').volatile(),
26
26
  heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)').volatile(),
27
27
  // --- Delivery channels (#20-#23, #26, #28, #47) ---
@@ -66,6 +66,8 @@ export function executeCreateTask(store, scheduler, args) {
66
66
  skillName: args.skillName ? String(args.skillName).trim() : '',
67
67
  workflowName: args.workflowName ? String(args.workflowName).trim() : '',
68
68
  oneShot: Boolean(parsed.isOneShot),
69
+ targetTimestamp: parsed.isOneShot ? parsed.targetTimestamp : undefined,
70
+ nextRunAt: parsed.isOneShot ? parsed.targetTimestamp : (parsed.nextRun || undefined),
69
71
  costLimitUsd: Number(args.costLimitUsd) > 0 ? Number(args.costLimitUsd) : undefined,
70
72
  dailyCostLimitUsd: Number(args.dailyCostLimitUsd) > 0 ? Number(args.dailyCostLimitUsd) : undefined,
71
73
  tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
package/lib/task-patch.js CHANGED
@@ -67,10 +67,20 @@ export function buildTaskPatch(current, body) {
67
67
  if (patch.schedule !== undefined) {
68
68
  // An empty schedule would leave an armed task that can never fire.
69
69
  if (!String(patch.schedule).trim()) return { ok: false, error: 'Schedule cannot be empty' };
70
- const parsed = parseScheduleExpression(patch.schedule);
71
- patch.schedule = parsed.cronPattern || patch.schedule;
72
- patch.scheduleText = (body && body.scheduleText) || parsed.humanText;
73
- patch.oneShot = Boolean(parsed.isOneShot || (body && body.oneShot));
70
+ try {
71
+ const parsed = parseScheduleExpression(patch.schedule);
72
+ const newSchedule = parsed.cronPattern || patch.schedule;
73
+ const scheduleChanged = newSchedule !== current.schedule;
74
+ patch.schedule = newSchedule;
75
+ patch.scheduleText = (body && body.scheduleText) || parsed.humanText;
76
+ patch.oneShot = Boolean(parsed.isOneShot || (body && body.oneShot));
77
+ if (scheduleChanged) {
78
+ patch.nextRunAt = parsed.isOneShot ? parsed.targetTimestamp : (parsed.nextRun || null);
79
+ patch.targetTimestamp = parsed.isOneShot ? parsed.targetTimestamp : undefined;
80
+ }
81
+ } catch (err) {
82
+ return { ok: false, error: err.message };
83
+ }
74
84
  }
75
85
  if (patch.costLimitUsd !== undefined) {
76
86
  patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",