@h-ai/scheduler 0.1.0-alpha.53 → 0.1.0-alpha.55

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/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import { cache } from '@h-ai/cache';
3
- import { core, err, ok } from '@h-ai/core';
3
+ import { core, ok, err } from '@h-ai/core';
4
4
  import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
5
5
  import { createHash } from 'crypto';
6
6
  import { Script } from 'vm';
7
+ import { Worker } from 'worker_threads';
7
8
  import { Cron } from 'croner';
8
9
 
9
10
  // src/scheduler-config.ts
@@ -29,16 +30,22 @@ var en_US_default = {
29
30
  scheduler_initFailed: "Failed to initialize scheduler: {error}",
30
31
  scheduler_alreadyRunning: "Scheduler is already running",
31
32
  scheduler_notRunning: "Scheduler is not running",
33
+ scheduler_closing: "Scheduler is closing; task execution cancelled",
34
+ scheduler_definitionsChanged: "Task definitions changed during refresh; retry the operation",
35
+ scheduler_taskNoLongerDue: "Task {taskId} was removed, disabled, or rescheduled",
32
36
  scheduler_taskNotFound: "Task not found: {taskId}",
33
37
  scheduler_taskAlreadyExists: "Task already exists: {taskId}",
34
38
  scheduler_invalidCron: "Invalid cron expression: {expression}",
35
39
  scheduler_executionFailed: "Task execution failed: {error}",
36
40
  scheduler_jsExecutionFailed: "JS code execution failed: {error}",
41
+ scheduler_jsTimedOut: "JS task timed out after {timeout}ms",
37
42
  scheduler_apiExecutionFailed: "API call failed: {error}",
43
+ scheduler_apiResponseTooLarge: "API response exceeds the {maxResponseBytes} byte limit",
38
44
  scheduler_dbSaveFailed: "Failed to save execution log: {error}",
39
45
  scheduler_dbNotInitialized: "Database not initialized, cannot save execution logs",
40
46
  scheduler_configError: "Scheduler config validation failed: {error}",
41
47
  scheduler_taskIdEmpty: "Task ID cannot be empty",
48
+ scheduler_invalidPersistedTask: "Invalid persisted task {taskId}: {error}",
42
49
  scheduler_taskRunning: "Task is currently running: {taskId}",
43
50
  scheduler_invalidApiConfig: "Invalid api_config for task: {taskId}",
44
51
  scheduler_lockAcquireFailed: "Failed to acquire distributed lock, another node already holds it: {taskId}",
@@ -57,16 +64,22 @@ var zh_CN_default = {
57
64
  scheduler_initFailed: "\u5B9A\u65F6\u4EFB\u52A1\u6A21\u5757\u521D\u59CB\u5316\u5931\u8D25: {error}",
58
65
  scheduler_alreadyRunning: "\u5B9A\u65F6\u4EFB\u52A1\u8C03\u5EA6\u5668\u5DF2\u5728\u8FD0\u884C\u4E2D",
59
66
  scheduler_notRunning: "\u5B9A\u65F6\u4EFB\u52A1\u8C03\u5EA6\u5668\u672A\u8FD0\u884C",
67
+ scheduler_closing: "\u8C03\u5EA6\u5668\u6B63\u5728\u5173\u95ED\uFF0C\u4EFB\u52A1\u6267\u884C\u5DF2\u53D6\u6D88",
68
+ scheduler_definitionsChanged: "\u5237\u65B0\u671F\u95F4\u4EFB\u52A1\u5B9A\u4E49\u5DF2\u53D8\u5316\uFF0C\u8BF7\u91CD\u8BD5\u64CD\u4F5C",
69
+ scheduler_taskNoLongerDue: "\u4EFB\u52A1 {taskId} \u5DF2\u5220\u9664\u3001\u7981\u7528\u6216\u6539\u53D8\u8C03\u5EA6\u65F6\u95F4",
60
70
  scheduler_taskNotFound: "\u4EFB\u52A1\u672A\u627E\u5230: {taskId}",
61
71
  scheduler_taskAlreadyExists: "\u4EFB\u52A1\u5DF2\u5B58\u5728: {taskId}",
62
72
  scheduler_invalidCron: "\u65E0\u6548\u7684 cron \u8868\u8FBE\u5F0F: {expression}",
63
73
  scheduler_executionFailed: "\u4EFB\u52A1\u6267\u884C\u5931\u8D25: {error}",
64
74
  scheduler_jsExecutionFailed: "JS \u4EE3\u7801\u6267\u884C\u5931\u8D25: {error}",
75
+ scheduler_jsTimedOut: "JS \u4EFB\u52A1\u6267\u884C\u8D85\u8FC7 {timeout} \u6BEB\u79D2",
65
76
  scheduler_apiExecutionFailed: "API \u8C03\u7528\u5931\u8D25: {error}",
77
+ scheduler_apiResponseTooLarge: "API \u54CD\u5E94\u8D85\u8FC7 {maxResponseBytes} \u5B57\u8282\u4E0A\u9650",
66
78
  scheduler_dbSaveFailed: "\u4FDD\u5B58\u6267\u884C\u8BB0\u5F55\u5931\u8D25: {error}",
67
79
  scheduler_dbNotInitialized: "\u6570\u636E\u5E93\u672A\u521D\u59CB\u5316\uFF0C\u65E0\u6CD5\u4FDD\u5B58\u6267\u884C\u8BB0\u5F55",
68
80
  scheduler_configError: "\u8C03\u5EA6\u5668\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A{error}",
69
81
  scheduler_taskIdEmpty: "\u4EFB\u52A1 ID \u4E0D\u80FD\u4E3A\u7A7A",
82
+ scheduler_invalidPersistedTask: "\u6301\u4E45\u5316\u4EFB\u52A1 {taskId} \u65E0\u6548\uFF1A{error}",
70
83
  scheduler_taskRunning: "\u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D: {taskId}",
71
84
  scheduler_invalidApiConfig: "\u4EFB\u52A1 api_config \u683C\u5F0F\u65E0\u6548: {taskId}",
72
85
  scheduler_lockAcquireFailed: "\u83B7\u53D6\u5206\u5E03\u5F0F\u9501\u5931\u8D25\uFF0C\u5176\u4ED6\u8282\u70B9\u5DF2\u83B7\u9501: {taskId}",
@@ -451,18 +464,15 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
451
464
  for (const row of rows.data) {
452
465
  const paramsResult = TaskParamsSchema.safeParse(row.params ?? {});
453
466
  if (!paramsResult.success) {
454
- logger2.warn("Skipping task with invalid params", { taskId: row.taskId, error: paramsResult.error.message });
455
- continue;
467
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidPersistedTask", { params: { taskId: row.taskId, error: paramsResult.error.message } }));
456
468
  }
457
469
  const handlerResult = row.handler == null ? { success: true, data: void 0 } : TaskHandlerConfigSchema.safeParse(row.handler);
458
470
  if (!handlerResult.success) {
459
- logger2.warn("Skipping task with invalid handler config", { taskId: row.taskId, error: handlerResult.error.message });
460
- continue;
471
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidPersistedTask", { params: { taskId: row.taskId, error: handlerResult.error.message } }));
461
472
  }
462
473
  const retryResult = row.retry == null ? { success: true, data: void 0 } : TaskRetryPolicySchema.safeParse(row.retry);
463
474
  if (!retryResult.success) {
464
- logger2.warn("Skipping task with invalid retry policy", { taskId: row.taskId, error: retryResult.error.message });
465
- continue;
475
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidPersistedTask", { params: { taskId: row.taskId, error: retryResult.error.message } }));
466
476
  }
467
477
  tasks.push({
468
478
  id: row.taskId,
@@ -490,6 +500,18 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
490
500
  }
491
501
  }
492
502
  };
503
+
504
+ // src/scheduler-cancellation.ts
505
+ async function waitWithSignal(operation, signal) {
506
+ signal.throwIfAborted();
507
+ return new Promise((resolve, reject) => {
508
+ const aborted = () => reject(signal.reason);
509
+ signal.addEventListener("abort", aborted, { once: true });
510
+ Promise.resolve().then(operation).then(resolve, reject).finally(() => {
511
+ signal.removeEventListener("abort", aborted);
512
+ });
513
+ });
514
+ }
493
515
  var compiledHandlerCache = /* @__PURE__ */ new Map();
494
516
  function createCacheKey(config) {
495
517
  const keySource = `${config.code}:${config.timeout ?? 0}`;
@@ -504,32 +526,36 @@ function compileJsTaskHandler(config) {
504
526
  if (cachedHandler)
505
527
  return ok(cachedHandler);
506
528
  try {
507
- const script = new Script(`(${config.code})`);
508
- const candidate = script.runInNewContext({});
509
- if (typeof candidate !== "function") {
510
- return err(
511
- HaiSchedulerError.JS_COMPILE_FAILED,
512
- schedulerM("scheduler_jsCompileFailed", { params: { error: "Compiled result is not a function" } })
513
- );
514
- }
515
- const handler = async (context) => {
516
- const functionResult = candidate(context);
517
- if (!config.timeout || config.timeout <= 0)
518
- return functionResult;
529
+ void new Script(`(${config.code})`);
530
+ const handler = async (context, signal) => {
531
+ signal?.throwIfAborted();
519
532
  return await new Promise((resolve, reject) => {
520
- const timer = setTimeout(() => {
521
- reject(new Error(`JS task timed out after ${config.timeout}ms`));
522
- }, config.timeout);
523
- Promise.resolve(functionResult).then(
524
- (result) => {
525
- clearTimeout(timer);
526
- resolve(result);
527
- },
528
- (error) => {
529
- clearTimeout(timer);
530
- reject(error);
531
- }
532
- );
533
+ const timeout = config.timeout ?? 3e4;
534
+ const worker = new Worker(new URL("./scheduler-js-worker.js", import.meta.url), {
535
+ workerData: { code: config.code, context },
536
+ execArgv: []
537
+ });
538
+ let settled = false;
539
+ let timer;
540
+ const aborted = () => finish(new Error(schedulerM("scheduler_closing")));
541
+ function finish(error, result) {
542
+ if (settled)
543
+ return;
544
+ settled = true;
545
+ clearTimeout(timer);
546
+ signal?.removeEventListener("abort", aborted);
547
+ void worker.terminate().then(() => error ? reject(error) : resolve(result), reject);
548
+ }
549
+ timer = setTimeout(() => finish(new Error(schedulerM("scheduler_jsTimedOut", { params: { timeout } }))), timeout);
550
+ signal?.addEventListener("abort", aborted, { once: true });
551
+ worker.once("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
552
+ worker.once("exit", (code) => finish(new Error(`JS worker exited before returning a result (${code})`)));
553
+ worker.once("message", (message) => {
554
+ if (message.success)
555
+ finish(void 0, message.data);
556
+ else
557
+ finish(new Error(message.message));
558
+ });
533
559
  });
534
560
  };
535
561
  compiledHandlerCache.set(cacheKey, handler);
@@ -584,7 +610,7 @@ async function notifyTaskStart(hooks, event) {
584
610
  if (!hooks.onTaskStart)
585
611
  return;
586
612
  try {
587
- await hooks.onTaskStart(event);
613
+ await waitWithSignal(() => hooks.onTaskStart(event), event.signal);
588
614
  } catch (error) {
589
615
  logger3.warn("Task start hook failed", { taskId: event.task.id, error });
590
616
  }
@@ -593,7 +619,7 @@ async function notifyTaskInterrupted(hooks, event) {
593
619
  if (!hooks.onTaskInterrupted)
594
620
  return;
595
621
  try {
596
- await hooks.onTaskInterrupted(event);
622
+ await waitWithSignal(() => hooks.onTaskInterrupted(event), event.signal);
597
623
  } catch (error) {
598
624
  logger3.warn("Task interrupted hook failed", { taskId: event.task.id, error });
599
625
  }
@@ -602,7 +628,7 @@ async function notifyTaskFinish(hooks, event) {
602
628
  if (!hooks.onTaskFinish)
603
629
  return;
604
630
  try {
605
- await hooks.onTaskFinish(event);
631
+ await waitWithSignal(() => hooks.onTaskFinish(event), event.signal);
606
632
  } catch (error) {
607
633
  logger3.warn("Task finish hook failed", { taskId: event.task.id, error });
608
634
  }
@@ -639,10 +665,10 @@ async function saveExecutionLog(task, trigger, taskType, status, startedAt, fini
639
665
  async function saveInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt) {
640
666
  return saveExecutionLog(task, trigger, taskType, "interrupted", startedAt, finishedAt, null, reason);
641
667
  }
642
- async function interruptTask(task, trigger, reason, hooks) {
668
+ async function interruptTask(task, trigger, reason, hooks, signal = new AbortController().signal) {
643
669
  const startedAt = Date.now();
644
670
  const taskType = resolveTaskType(task, hooks);
645
- await notifyTaskStart(hooks, { task, trigger, startedAt });
671
+ await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
646
672
  const finishedAt = Date.now();
647
673
  const interruptedLog = await saveInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt);
648
674
  await notifyTaskInterrupted(hooks, {
@@ -650,11 +676,12 @@ async function interruptTask(task, trigger, reason, hooks) {
650
676
  trigger,
651
677
  startedAt,
652
678
  interruptedAt: finishedAt,
679
+ signal,
653
680
  reason
654
681
  });
655
682
  return interruptedLog;
656
683
  }
657
- async function executeJsTask(task, context) {
684
+ async function executeJsTask(task, context, signal = new AbortController().signal) {
658
685
  const handler = task.handler;
659
686
  if (!handler || handler.kind !== "js") {
660
687
  return err(
@@ -666,7 +693,7 @@ async function executeJsTask(task, context) {
666
693
  if (!compileResult.success)
667
694
  return compileResult;
668
695
  try {
669
- const result = await compileResult.data(context);
696
+ const result = await compileResult.data(context, signal);
670
697
  return ok(result !== void 0 ? JSON.stringify(result) : null);
671
698
  } catch (error) {
672
699
  const message = error instanceof Error ? error.message : String(error);
@@ -678,7 +705,7 @@ async function executeJsTask(task, context) {
678
705
  );
679
706
  }
680
707
  }
681
- async function executeApiTask(task, context) {
708
+ async function executeApiTask(task, context, signal = new AbortController().signal) {
682
709
  const handler = task.handler;
683
710
  if (!handler || handler.kind !== "api") {
684
711
  return err(
@@ -686,7 +713,9 @@ async function executeApiTask(task, context) {
686
713
  schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
687
714
  );
688
715
  }
689
- const { url, method = "GET", headers, body, timeout = 3e4 } = handler;
716
+ const { url, method = "GET", headers, body, timeout = 3e4, maxResponseBytes = 1024 * 1024 } = handler;
717
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0)
718
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } }));
690
719
  const controller = new AbortController();
691
720
  const timer = setTimeout(() => controller.abort(), timeout);
692
721
  try {
@@ -696,11 +725,31 @@ async function executeApiTask(task, context) {
696
725
  ...headers,
697
726
  ...body !== void 0 ? { "Content-Type": "application/json" } : {}
698
727
  },
699
- signal: controller.signal,
728
+ signal: AbortSignal.any([signal, controller.signal]),
700
729
  ...body !== void 0 ? { body: JSON.stringify(body) } : {}
701
730
  });
702
- clearTimeout(timer);
703
- const responseText = await response.text();
731
+ let receivedBytes = 0;
732
+ let responseText = "";
733
+ const reader = response.body?.getReader();
734
+ if (reader) {
735
+ const decoder = new TextDecoder();
736
+ try {
737
+ while (true) {
738
+ const chunk = await reader.read();
739
+ if (chunk.done)
740
+ break;
741
+ receivedBytes += chunk.value.byteLength;
742
+ if (receivedBytes > maxResponseBytes) {
743
+ controller.abort();
744
+ return err(HaiSchedulerError.API_EXECUTION_FAILED, schedulerM("scheduler_apiResponseTooLarge", { params: { maxResponseBytes } }));
745
+ }
746
+ responseText += decoder.decode(chunk.value, { stream: true });
747
+ }
748
+ responseText += decoder.decode();
749
+ } finally {
750
+ reader.releaseLock();
751
+ }
752
+ }
704
753
  if (!response.ok) {
705
754
  logger3.error("API task returned non-OK status", {
706
755
  taskId: context.taskId,
@@ -716,7 +765,6 @@ async function executeApiTask(task, context) {
716
765
  }
717
766
  return ok(responseText || null);
718
767
  } catch (error) {
719
- clearTimeout(timer);
720
768
  const message = error instanceof Error ? error.message : String(error);
721
769
  logger3.error("API task execution failed", { taskId: context.taskId, error: message, url: sanitizeUrl(url) });
722
770
  return err(
@@ -724,9 +772,11 @@ async function executeApiTask(task, context) {
724
772
  schedulerM("scheduler_apiExecutionFailed", { params: { error: message } }),
725
773
  error
726
774
  );
775
+ } finally {
776
+ clearTimeout(timer);
727
777
  }
728
778
  }
729
- async function executeHookTask(task, context, hooks, startedAt) {
779
+ async function executeHookTask(task, context, hooks, startedAt, signal) {
730
780
  if (!hooks.onTaskExecute) {
731
781
  return err(
732
782
  HaiSchedulerError.EXECUTION_FAILED,
@@ -737,10 +787,11 @@ async function executeHookTask(task, context, hooks, startedAt) {
737
787
  task,
738
788
  trigger: context.trigger,
739
789
  startedAt,
740
- context
790
+ context,
791
+ signal
741
792
  };
742
793
  try {
743
- const result = await hooks.onTaskExecute(executeEvent);
794
+ const result = await waitWithSignal(() => hooks.onTaskExecute(executeEvent), signal);
744
795
  return ok(result !== void 0 ? JSON.stringify(result) : null);
745
796
  } catch (error) {
746
797
  const message = error instanceof Error ? error.message : String(error);
@@ -752,21 +803,25 @@ async function executeHookTask(task, context, hooks, startedAt) {
752
803
  );
753
804
  }
754
805
  }
755
- async function executeTask(task, trigger, hooks) {
806
+ async function executeTask(task, trigger, hooks, signal = new AbortController().signal) {
756
807
  const startedAt = Date.now();
757
808
  const taskType = resolveTaskType(task, hooks);
758
809
  const context = createTaskContext(task, trigger);
759
- await notifyTaskStart(hooks, { task, trigger, startedAt });
810
+ await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
760
811
  let executionResult;
761
812
  let shouldNotifyInterrupted = false;
762
- if (!task.handler) {
763
- executionResult = await executeHookTask(task, context, hooks, startedAt);
813
+ if (signal.aborted) {
814
+ executionResult = err(HaiSchedulerError.EXECUTION_FAILED, schedulerM("scheduler_closing"));
815
+ shouldNotifyInterrupted = true;
816
+ } else if (!task.handler) {
817
+ executionResult = await executeHookTask(task, context, hooks, startedAt, signal);
764
818
  shouldNotifyInterrupted = !executionResult.success;
765
819
  } else if (task.handler.kind === "api") {
766
- executionResult = await executeApiTask(task, context);
820
+ executionResult = await executeApiTask(task, context, signal);
767
821
  } else {
768
- executionResult = await executeJsTask(task, context);
822
+ executionResult = await executeJsTask(task, context, signal);
769
823
  }
824
+ shouldNotifyInterrupted ||= signal.aborted;
770
825
  const finishedAt = Date.now();
771
826
  const status = executionResult.success ? "success" : shouldNotifyInterrupted ? "interrupted" : "failed";
772
827
  const log = await saveExecutionLog(
@@ -785,11 +840,12 @@ async function executeTask(task, trigger, hooks) {
785
840
  trigger,
786
841
  startedAt,
787
842
  interruptedAt: finishedAt,
843
+ signal,
788
844
  reason: executionResult.error.message
789
845
  });
790
846
  }
791
847
  if (status !== "interrupted")
792
- await notifyTaskFinish(hooks, { task, trigger, startedAt, finishedAt, log });
848
+ await notifyTaskFinish(hooks, { task, trigger, startedAt, finishedAt, log, signal });
793
849
  return log;
794
850
  }
795
851
  function parseCronExpression(expression, timezone) {
@@ -808,6 +864,8 @@ function parseCronExpression(expression, timezone) {
808
864
  var logger4 = core.logger.child({ module: "scheduler", scope: "functions" });
809
865
  var taskRegistry = /* @__PURE__ */ new Map();
810
866
  var cronCache = /* @__PURE__ */ new Map();
867
+ var persistedTaskIds = /* @__PURE__ */ new Set();
868
+ var definitionEpoch = 0;
811
869
  var currentHooks = {};
812
870
  function getTaskRegistry() {
813
871
  return taskRegistry;
@@ -819,9 +877,11 @@ function hasTask(taskId) {
819
877
  return taskRegistry.has(taskId);
820
878
  }
821
879
  function setTask(taskId, task) {
880
+ definitionEpoch++;
822
881
  taskRegistry.set(taskId, task);
823
882
  }
824
883
  function deleteTask(taskId) {
884
+ definitionEpoch++;
825
885
  taskRegistry.delete(taskId);
826
886
  }
827
887
  function getCron(taskId) {
@@ -843,6 +903,8 @@ function getHooks() {
843
903
  return currentHooks;
844
904
  }
845
905
  function resetTaskState() {
906
+ definitionEpoch++;
907
+ persistedTaskIds.clear();
846
908
  taskRegistry.clear();
847
909
  cronCache.clear();
848
910
  currentHooks = {};
@@ -876,46 +938,45 @@ function validateRetryPolicy(taskId, retry) {
876
938
  }
877
939
  async function loadPersistedTasks(taskRepo2) {
878
940
  if (!taskRepo2)
879
- return;
941
+ return ok(void 0);
942
+ const epoch = definitionEpoch;
880
943
  const loadResult = await taskRepo2.loadTasks();
881
- if (!loadResult.success) {
882
- logger4.warn("Failed to load persisted tasks", { error: loadResult.error.message });
883
- return;
884
- }
885
- let loadedCount = 0;
944
+ if (!loadResult.success)
945
+ return loadResult;
946
+ if (epoch !== definitionEpoch)
947
+ return err(HaiSchedulerError.EXECUTION_FAILED, schedulerM("scheduler_definitionsChanged"));
948
+ const nextTasks = new Map([...taskRegistry].filter(([id]) => !persistedTaskIds.has(id)));
949
+ const nextCrons = new Map([...cronCache].filter(([id]) => !persistedTaskIds.has(id)));
886
950
  for (const task of loadResult.data) {
887
- const registerResult = await registerTask(task);
888
- if (registerResult.success) {
889
- loadedCount++;
890
- logger4.debug("Loaded persisted task", { taskId: task.id, taskName: task.name });
891
- continue;
892
- }
893
- logger4.warn("Failed to load persisted task", { taskId: task.id, error: registerResult.error.message });
951
+ if (!task.id)
952
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_taskIdEmpty"));
953
+ if (nextTasks.has(task.id))
954
+ return err(HaiSchedulerError.TASK_ALREADY_EXISTS, schedulerM("scheduler_taskAlreadyExists", { params: { taskId: task.id } }));
955
+ const cronResult = parseCronExpression(task.cron, task.timezone);
956
+ if (!cronResult.success)
957
+ return cronResult;
958
+ nextTasks.set(task.id, task);
959
+ nextCrons.set(task.id, cronResult.data);
894
960
  }
895
- if (loadedCount > 0)
896
- logger4.info("Loaded persisted tasks", { count: loadedCount });
961
+ definitionEpoch++;
962
+ taskRegistry.clear();
963
+ cronCache.clear();
964
+ persistedTaskIds.clear();
965
+ for (const [id, task] of nextTasks)
966
+ taskRegistry.set(id, task);
967
+ for (const [id, cron] of nextCrons)
968
+ cronCache.set(id, cron);
969
+ for (const task of loadResult.data)
970
+ persistedTaskIds.add(task.id);
971
+ return ok(void 0);
897
972
  }
898
973
  async function loadConfigTasks(tasks) {
899
- let loadedCount = 0;
900
974
  for (const task of tasks) {
901
- if (!task.id) {
902
- logger4.warn("Skipping config task with empty id");
903
- continue;
904
- }
905
- if (hasTask(task.id)) {
906
- logger4.debug("Skipping config task, already registered from DB", { taskId: task.id });
907
- continue;
908
- }
909
975
  const registerResult = await registerTask(task);
910
- if (registerResult.success) {
911
- loadedCount++;
912
- logger4.debug("Loaded config task", { taskId: task.id, taskName: task.name });
913
- continue;
914
- }
915
- logger4.warn("Failed to load config task", { taskId: task.id, error: registerResult.error.message });
976
+ if (!registerResult.success)
977
+ return registerResult;
916
978
  }
917
- if (loadedCount > 0)
918
- logger4.info("Loaded config tasks", { count: loadedCount });
979
+ return ok(void 0);
919
980
  }
920
981
  async function registerTask(task, taskRepo2) {
921
982
  if (!task.id) {
@@ -959,6 +1020,7 @@ async function registerTask(task, taskRepo2) {
959
1020
  saveResult.error
960
1021
  );
961
1022
  }
1023
+ persistedTaskIds.add(task.id);
962
1024
  }
963
1025
  return ok(void 0);
964
1026
  }
@@ -1058,7 +1120,9 @@ async function queryTaskLogs(logRepo2, options) {
1058
1120
  }
1059
1121
  var logger5 = core.logger.child({ module: "scheduler", scope: "runner" });
1060
1122
  var runningTasks = /* @__PURE__ */ new Set();
1123
+ var activeRuns = /* @__PURE__ */ new Map();
1061
1124
  var tickTimer = null;
1125
+ var tickInProgress = false;
1062
1126
  var lastTickMinute = -1;
1063
1127
  var currentNodeId = "";
1064
1128
  var currentLockTtlSec = 300;
@@ -1087,10 +1151,25 @@ function resetRunner() {
1087
1151
  currentLockTtlSec = 300;
1088
1152
  currentTaskRepo = null;
1089
1153
  }
1090
- async function sleep(ms) {
1091
- if (ms <= 0)
1154
+ async function sleep(ms, signal) {
1155
+ if (ms <= 0 || signal.aborted)
1092
1156
  return;
1093
- await new Promise((resolve) => setTimeout(resolve, ms));
1157
+ await new Promise((resolve) => {
1158
+ let timer;
1159
+ const finish = () => {
1160
+ clearTimeout(timer);
1161
+ signal.removeEventListener("abort", finish);
1162
+ resolve();
1163
+ };
1164
+ timer = setTimeout(finish, ms);
1165
+ signal.addEventListener("abort", finish, { once: true });
1166
+ });
1167
+ }
1168
+ async function drainRunner() {
1169
+ stopTimer();
1170
+ for (const controller of activeRuns.values())
1171
+ controller.abort(new Error(schedulerM("scheduler_closing")));
1172
+ await Promise.allSettled([...activeRuns.keys()]);
1094
1173
  }
1095
1174
  function resolveRetryBackoffMs(task, attempt) {
1096
1175
  const backoff = task.retry?.backoffMs;
@@ -1099,7 +1178,24 @@ function resolveRetryBackoffMs(task, attempt) {
1099
1178
  const index = Math.min(Math.max(attempt - 2, 0), backoff.length - 1);
1100
1179
  return backoff[index] ?? 0;
1101
1180
  }
1102
- function tick() {
1181
+ async function tick() {
1182
+ if (tickInProgress)
1183
+ return;
1184
+ tickInProgress = true;
1185
+ try {
1186
+ const refreshed = await loadPersistedTasks(currentTaskRepo);
1187
+ if (!refreshed.success) {
1188
+ logger5.warn("Task definition refresh failed; skipping scheduling", { error: refreshed.error.message });
1189
+ return;
1190
+ }
1191
+ if (!tickTimer)
1192
+ return;
1193
+ scheduleDueTasks();
1194
+ } finally {
1195
+ tickInProgress = false;
1196
+ }
1197
+ }
1198
+ function scheduleDueTasks() {
1103
1199
  const now = /* @__PURE__ */ new Date();
1104
1200
  const currentMinute = Math.floor(now.getTime() / 6e4);
1105
1201
  if (currentMinute === lastTickMinute)
@@ -1109,7 +1205,7 @@ function tick() {
1109
1205
  if (task.enabled === false)
1110
1206
  continue;
1111
1207
  const cron = getCron(taskId);
1112
- if (!cron || !cron.match(now))
1208
+ if (!cron || !cron.match(new Date(currentMinute * 6e4)))
1113
1209
  continue;
1114
1210
  if (runningTasks.has(taskId)) {
1115
1211
  logger5.debug("Skipping task already running", { taskId, taskName: task.name });
@@ -1120,17 +1216,30 @@ function tick() {
1120
1216
  });
1121
1217
  }
1122
1218
  }
1123
- async function runTask(task, minuteTimestamp, trigger = { type: "manual", source: null }) {
1219
+ function runTask(task, minuteTimestamp, trigger = { type: "manual", source: null }) {
1220
+ const controller = new AbortController();
1221
+ const run = runTaskExecution(task, minuteTimestamp, trigger, controller.signal);
1222
+ activeRuns.set(run, controller);
1223
+ void run.finally(() => activeRuns.delete(run)).catch(() => {
1224
+ });
1225
+ return run;
1226
+ }
1227
+ async function runTaskExecution(task, minuteTimestamp, trigger, signal) {
1228
+ const hooks = getHooks();
1229
+ const repository = currentTaskRepo;
1124
1230
  const lockKey = minuteTimestamp !== void 0 ? `hai:scheduler:${task.id}:${minuteTimestamp}` : void 0;
1125
1231
  if (cache.isInitialized && lockKey) {
1126
- const lockResult = await cache.lock.acquire(lockKey, { ttl: currentLockTtlSec, owner: currentNodeId });
1232
+ const lockResult = await waitWithSignal(() => cache.lock.acquire(lockKey, { ttl: currentLockTtlSec, owner: currentNodeId }), signal).catch(() => null);
1233
+ if (!lockResult)
1234
+ return interruptTask(task, trigger, schedulerM("scheduler_closing"), {}, signal);
1127
1235
  if (!lockResult.success) {
1128
1236
  logger5.warn("Failed to acquire distributed lock, interrupting task", { taskId: task.id, error: lockResult.error.message });
1129
1237
  return interruptTask(
1130
1238
  task,
1131
1239
  trigger,
1132
1240
  schedulerM("scheduler_lockAcquireFailed", { params: { taskId: task.id } }),
1133
- getHooks()
1241
+ hooks,
1242
+ signal
1134
1243
  );
1135
1244
  }
1136
1245
  if (!lockResult.data) {
@@ -1139,7 +1248,8 @@ async function runTask(task, minuteTimestamp, trigger = { type: "manual", source
1139
1248
  task,
1140
1249
  trigger,
1141
1250
  schedulerM("scheduler_lockAcquireFailed", { params: { taskId: task.id } }),
1142
- getHooks()
1251
+ hooks,
1252
+ signal
1143
1253
  );
1144
1254
  }
1145
1255
  }
@@ -1148,7 +1258,8 @@ async function runTask(task, minuteTimestamp, trigger = { type: "manual", source
1148
1258
  task,
1149
1259
  trigger,
1150
1260
  schedulerM("scheduler_taskRunning", { params: { taskId: task.id } }),
1151
- getHooks()
1261
+ hooks,
1262
+ signal
1152
1263
  );
1153
1264
  }
1154
1265
  runningTasks.add(task.id);
@@ -1156,7 +1267,23 @@ async function runTask(task, minuteTimestamp, trigger = { type: "manual", source
1156
1267
  const maxAttempts = Math.max(1, task.retry?.maxAttempts ?? 1);
1157
1268
  let log = null;
1158
1269
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1159
- log = await executeTask(task, trigger, getHooks());
1270
+ if (signal.aborted) {
1271
+ log = await interruptTask(task, trigger, schedulerM("scheduler_closing"), {}, signal);
1272
+ break;
1273
+ }
1274
+ const refreshed = await loadPersistedTasks(repository);
1275
+ if (!refreshed.success) {
1276
+ log = await interruptTask(task, trigger, refreshed.error.message, hooks, signal);
1277
+ break;
1278
+ }
1279
+ const latest = getTask(task.id);
1280
+ const stillDue = minuteTimestamp === void 0 || getCron(task.id)?.match(new Date(minuteTimestamp * 6e4));
1281
+ if (!latest || latest.enabled === false || !stillDue) {
1282
+ log = await interruptTask(task, trigger, schedulerM("scheduler_taskNoLongerDue", { params: { taskId: task.id } }), hooks, signal);
1283
+ break;
1284
+ }
1285
+ task = latest;
1286
+ log = await executeTask(task, trigger, hooks, signal);
1160
1287
  if (log.status !== "failed")
1161
1288
  break;
1162
1289
  if (attempt >= maxAttempts)
@@ -1168,14 +1295,15 @@ async function runTask(task, minuteTimestamp, trigger = { type: "manual", source
1168
1295
  maxAttempts,
1169
1296
  backoffMs
1170
1297
  });
1171
- await sleep(backoffMs);
1298
+ await sleep(backoffMs, signal);
1172
1299
  }
1173
1300
  if (!log) {
1174
1301
  return interruptTask(
1175
1302
  task,
1176
1303
  trigger,
1177
1304
  schedulerM("scheduler_executionFailed", { params: { error: "Task execution produced no log" } }),
1178
- getHooks()
1305
+ hooks,
1306
+ signal
1179
1307
  );
1180
1308
  }
1181
1309
  if (log.status === "failed")
@@ -1184,8 +1312,8 @@ async function runTask(task, minuteTimestamp, trigger = { type: "manual", source
1184
1312
  logger5.debug("Task execution interrupted", { taskId: task.id, error: log.error });
1185
1313
  else
1186
1314
  logger5.debug("Task execution succeeded", { taskId: task.id, duration: log.duration });
1187
- if (task.deleteAfterRun === true) {
1188
- const unregisterResult = await unregisterTask(task.id, currentTaskRepo);
1315
+ if (task.deleteAfterRun === true && !signal.aborted && log.status !== "interrupted") {
1316
+ const unregisterResult = await unregisterTask(task.id, repository);
1189
1317
  if (!unregisterResult.success) {
1190
1318
  logger5.warn("Failed to auto-delete one-time task after run", {
1191
1319
  taskId: task.id,
@@ -1215,6 +1343,7 @@ function stopTimer() {
1215
1343
  var logger6 = core.logger.child({ module: "scheduler", scope: "main" });
1216
1344
  var currentConfig = null;
1217
1345
  var initInProgress = false;
1346
+ var closeInProgress = null;
1218
1347
  var taskRepo = null;
1219
1348
  var logRepo = null;
1220
1349
  var notInitialized = core.module.createNotInitializedKit(
@@ -1261,6 +1390,8 @@ var scheduler = {
1261
1390
  }
1262
1391
  initInProgress = true;
1263
1392
  try {
1393
+ if (closeInProgress)
1394
+ await closeInProgress;
1264
1395
  if (currentConfig) {
1265
1396
  logger6.warn("Scheduler module is already initialized, reinitializing");
1266
1397
  await scheduler.close();
@@ -1279,9 +1410,16 @@ var scheduler = {
1279
1410
  try {
1280
1411
  currentConfig = await initDatabase(parseResult.data);
1281
1412
  setHooks(hooks);
1282
- await loadPersistedTasks(taskRepo);
1283
- if (tasks.length > 0)
1284
- await loadConfigTasks(tasks);
1413
+ const persistedResult = await loadPersistedTasks(taskRepo);
1414
+ if (!persistedResult.success) {
1415
+ await scheduler.close();
1416
+ return persistedResult;
1417
+ }
1418
+ const configResult = await loadConfigTasks(tasks);
1419
+ if (!configResult.success) {
1420
+ await scheduler.close();
1421
+ return configResult;
1422
+ }
1285
1423
  logger6.info("Scheduler module initialized", { enableDb: currentConfig.enableDb, taskCount: getTaskRegistry().size });
1286
1424
  return ok(void 0);
1287
1425
  } catch (error) {
@@ -1302,6 +1440,12 @@ var scheduler = {
1302
1440
  async register(task) {
1303
1441
  if (!currentConfig)
1304
1442
  return notInitialized.result();
1443
+ const config = currentConfig;
1444
+ const refreshed = await loadPersistedTasks(taskRepo);
1445
+ if (!refreshed.success)
1446
+ return refreshed;
1447
+ if (currentConfig !== config)
1448
+ return notInitialized.result();
1305
1449
  const registerResult = await registerTask(task, taskRepo);
1306
1450
  if (registerResult.success) {
1307
1451
  logger6.info("Task registered", { taskId: task.id, taskName: task.name, cron: task.cron });
@@ -1311,6 +1455,12 @@ var scheduler = {
1311
1455
  async unregister(taskId) {
1312
1456
  if (!currentConfig)
1313
1457
  return notInitialized.result();
1458
+ const config = currentConfig;
1459
+ const refreshed = await loadPersistedTasks(taskRepo);
1460
+ if (!refreshed.success)
1461
+ return refreshed;
1462
+ if (currentConfig !== config)
1463
+ return notInitialized.result();
1314
1464
  const unregisterResult = await unregisterTask(taskId, taskRepo);
1315
1465
  if (unregisterResult.success)
1316
1466
  logger6.info("Task unregistered", { taskId });
@@ -1319,6 +1469,12 @@ var scheduler = {
1319
1469
  async updateTask(taskId, updates) {
1320
1470
  if (!currentConfig)
1321
1471
  return notInitialized.result();
1472
+ const config = currentConfig;
1473
+ const refreshed = await loadPersistedTasks(taskRepo);
1474
+ if (!refreshed.success)
1475
+ return refreshed;
1476
+ if (currentConfig !== config)
1477
+ return notInitialized.result();
1322
1478
  const updateResult = await updateRegisteredTask(taskId, updates, taskRepo);
1323
1479
  if (updateResult.success)
1324
1480
  logger6.info("Task updated", { taskId, updates: Object.keys(updates) });
@@ -1353,6 +1509,12 @@ var scheduler = {
1353
1509
  async trigger(taskId, options) {
1354
1510
  if (!currentConfig)
1355
1511
  return notInitialized.result();
1512
+ const config = currentConfig;
1513
+ const refreshed = await loadPersistedTasks(taskRepo);
1514
+ if (!refreshed.success)
1515
+ return refreshed;
1516
+ if (currentConfig !== config)
1517
+ return notInitialized.result();
1356
1518
  const task = getTask(taskId);
1357
1519
  if (!task) {
1358
1520
  return err(
@@ -1402,21 +1564,27 @@ var scheduler = {
1402
1564
  return isTimerRunning();
1403
1565
  },
1404
1566
  async close() {
1405
- if (!currentConfig) {
1406
- logger6.info("Scheduler module already closed, skipping");
1407
- return;
1408
- }
1567
+ if (closeInProgress)
1568
+ return closeInProgress;
1409
1569
  logger6.info("Closing scheduler module");
1410
- stopTimer();
1411
- clearJsTaskHandlerCache();
1412
- resetTaskState();
1413
- resetRunner();
1414
- setLogRepository(null);
1415
- setTaskRepository(null);
1416
1570
  currentConfig = null;
1417
- taskRepo = null;
1418
- logRepo = null;
1419
- logger6.info("Scheduler module closed");
1571
+ stopTimer();
1572
+ closeInProgress = (async () => {
1573
+ await drainRunner();
1574
+ clearJsTaskHandlerCache();
1575
+ resetTaskState();
1576
+ resetRunner();
1577
+ setLogRepository(null);
1578
+ setTaskRepository(null);
1579
+ taskRepo = null;
1580
+ logRepo = null;
1581
+ logger6.info("Scheduler module closed");
1582
+ })();
1583
+ try {
1584
+ await closeInProgress;
1585
+ } finally {
1586
+ closeInProgress = null;
1587
+ }
1420
1588
  }
1421
1589
  };
1422
1590