@zucker-framework/task 1.0.0 → 1.0.2

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 ADDED
@@ -0,0 +1,11 @@
1
+ # Single-process supervisor
2
+
3
+ `SingleProcessTaskSupervisor` receives `initialize`, `loadPending`, `run`, `abort`, concurrency and scan interval. It deduplicates pending/running keys and marks startup or manually submitted recovery keys for `run(id, true)`. Register the configured instance once at the composition root. Initialization and scanning failures preserve their separate failure paths; shutdown clears queued work and aborts running work without a drain wait.
4
+
5
+ The consumer owns persistent states, startup state transitions, retry eligibility, progress, billing and checkpoints. This primitive has no database schema, lease, fencing or multi-executor guarantee. It must not be used to justify overlapping executors.
6
+
7
+ ## Execution heartbeat
8
+
9
+ `runWithExecutionHeartbeat({ signal, heartbeat, intervalMs, failureThreshold }, work)` scopes serial heartbeat attempts to one callback. A successful heartbeat resets the consecutive failure count. At the configured threshold the work signal aborts with `ExecutionHeartbeatError`; the parent signal is unaffected, and the failure remains an error even when work swallows its abort. Caller cancellation forwards its original reason. Work must honor cancellation; the scope does not detach or duplicate it.
10
+
11
+ The scope clears its timer/listener and awaits an already-running heartbeat before returning. The consumer owns all database writes and decides how heartbeat failure differs from user cancellation or process shutdown. No state table, lease, retry, worker or execution topology is introduced.
package/dist/index.d.mts CHANGED
@@ -601,4 +601,58 @@ declare class TaskOutboxDispatcher<TPayload = unknown> {
601
601
  runOnce(): Promise<TaskOutboxDispatchResult>;
602
602
  }
603
603
 
604
- export { type AppendTaskOutboxInput, type ClaimTaskInput, type ClaimTaskOutboxInput, type CreateTaskInput, type CreateTaskResult, type DurableTaskAttemptContext, type DurableTaskHandler, DurableTaskLeaseLostError, DurableTaskNotClaimableError, DurableTaskNotFoundError, DurableTaskRetryScheduledError, type DurableWorkerHeartbeatScheduler, type DurableWorkerRunResult, DurableWorkerRuntime, type DurableWorkerRuntimeLogger, type DurableWorkerRuntimeOptions, ExponentialTaskRetryPolicy, type ExponentialTaskRetryPolicyOptions, type FindExpiredTaskLeasesInput, type FindReadyQueuedTasksInput, type HeartbeatTaskInput, InMemoryTaskOutboxRepository, InMemoryTaskRepository, InvalidTaskTransitionError, type ListOwnedTaskSummariesInput, type OwnedTaskTimelineEvent, type PostgresQueryResult, type PostgresTaskClient, PostgresTaskOutboxRepository, PostgresTaskRedriver, PostgresTaskRepository, PostgresTaskSubmitter, PostgresTaskSummaryReader, PostgresTaskTimelineReader, type PostgresTaskTransactionRunner, type ReadyTaskPublisher, type RecoverExpiredTaskLeaseInput, type RedriveFailedTaskInput, type ReportTaskProgressInput, type SubmitTaskBatchOptions, type SubmitTaskInput, type SubmitTaskOptions, type SubmitTaskOutboxInput, type SubmitTaskResult, TASK_STATUSES, TASK_TIMELINE_EVENT_TYPES, type TaskErrorSummary, type TaskFailureCategory, TaskIdempotencyConflictError, type TaskLeaseReconcileResult, TaskLeaseReconciler, type TaskLeaseReconcilerOptions, TaskOutboxConflictError, type TaskOutboxDispatchResult, TaskOutboxDispatcher, type TaskOutboxDispatcherOptions, type TaskOutboxRecord, type TaskOutboxRepository, type TaskOutboxStatus, type TaskQueuePublisher, type TaskQueueReconcileResult, TaskQueueReconciler, type TaskQueueReconcilerOptions, type TaskRecord, type TaskRedriveResult, type TaskRepository, type TaskResourceRef, type TaskRetryContext, type TaskRetryDecision, type TaskRetryPolicy, type TaskStatus, type TaskSummary, type TaskSummaryError, type TaskTimelineEvent, type TaskTimelineEventType, type TransitionTaskInput, UnsupportedDurableTaskKindError, assertTaskTransition, canTransitionTask, createTaskFingerprint, isTerminalTaskStatus, requiresTaskLease };
604
+ interface SingleProcessTaskSupervisorOptions {
605
+ concurrency: number;
606
+ scanIntervalMs: number;
607
+ /** Perform consumer-owned startup recovery and return keys that require resumption. */
608
+ initialize?: () => Promise<readonly string[]>;
609
+ loadPending: () => Promise<readonly string[]>;
610
+ run: (id: string, recovery: boolean) => Promise<void>;
611
+ abort: (id: string) => void;
612
+ onScanError?: (error: unknown) => void;
613
+ onExecutionError?: (id: string, error: unknown) => void | Promise<void>;
614
+ }
615
+ /**
616
+ * One-process FIFO scheduling, deduplication and recovery polling. Persistent
617
+ * state belongs to the consumer. This provides no lease, fencing or multi-executor safety.
618
+ * Shutdown aborts active callbacks immediately and deliberately does not drain them.
619
+ */
620
+ declare class SingleProcessTaskSupervisor {
621
+ private readonly options;
622
+ private readonly queued;
623
+ private readonly running;
624
+ private readonly interrupted;
625
+ private queue;
626
+ private scanTimer?;
627
+ private stopped;
628
+ private started;
629
+ constructor(options: SingleProcessTaskSupervisorOptions);
630
+ onApplicationBootstrap(): Promise<void>;
631
+ submit(id: string): void;
632
+ submitRecovery(id: string): void;
633
+ waitForIdle(): Promise<void>;
634
+ onApplicationShutdown(): Promise<void>;
635
+ private drain;
636
+ private scanPending;
637
+ private scheduleScan;
638
+ }
639
+
640
+ declare class ExecutionHeartbeatError extends Error {
641
+ readonly consecutiveFailures: number;
642
+ constructor(consecutiveFailures: number, options?: ErrorOptions);
643
+ }
644
+ interface ExecutionHeartbeatOptions {
645
+ signal?: AbortSignal;
646
+ intervalMs: number;
647
+ failureThreshold: number;
648
+ heartbeat: () => Promise<void>;
649
+ }
650
+ /**
651
+ * A single execution's heartbeat and cancellation scope. No lease, persistent
652
+ * state transition or retry policy is implied. Heartbeats never overlap; the
653
+ * final in-flight heartbeat settles before this scope returns. Work must honor
654
+ * its signal, and a heartbeat abort cannot be reported as successful work.
655
+ */
656
+ declare function runWithExecutionHeartbeat<T>(options: ExecutionHeartbeatOptions, work: (signal: AbortSignal) => Promise<T>): Promise<T>;
657
+
658
+ export { type AppendTaskOutboxInput, type ClaimTaskInput, type ClaimTaskOutboxInput, type CreateTaskInput, type CreateTaskResult, type DurableTaskAttemptContext, type DurableTaskHandler, DurableTaskLeaseLostError, DurableTaskNotClaimableError, DurableTaskNotFoundError, DurableTaskRetryScheduledError, type DurableWorkerHeartbeatScheduler, type DurableWorkerRunResult, DurableWorkerRuntime, type DurableWorkerRuntimeLogger, type DurableWorkerRuntimeOptions, ExecutionHeartbeatError, type ExecutionHeartbeatOptions, ExponentialTaskRetryPolicy, type ExponentialTaskRetryPolicyOptions, type FindExpiredTaskLeasesInput, type FindReadyQueuedTasksInput, type HeartbeatTaskInput, InMemoryTaskOutboxRepository, InMemoryTaskRepository, InvalidTaskTransitionError, type ListOwnedTaskSummariesInput, type OwnedTaskTimelineEvent, type PostgresQueryResult, type PostgresTaskClient, PostgresTaskOutboxRepository, PostgresTaskRedriver, PostgresTaskRepository, PostgresTaskSubmitter, PostgresTaskSummaryReader, PostgresTaskTimelineReader, type PostgresTaskTransactionRunner, type ReadyTaskPublisher, type RecoverExpiredTaskLeaseInput, type RedriveFailedTaskInput, type ReportTaskProgressInput, SingleProcessTaskSupervisor, type SingleProcessTaskSupervisorOptions, type SubmitTaskBatchOptions, type SubmitTaskInput, type SubmitTaskOptions, type SubmitTaskOutboxInput, type SubmitTaskResult, TASK_STATUSES, TASK_TIMELINE_EVENT_TYPES, type TaskErrorSummary, type TaskFailureCategory, TaskIdempotencyConflictError, type TaskLeaseReconcileResult, TaskLeaseReconciler, type TaskLeaseReconcilerOptions, TaskOutboxConflictError, type TaskOutboxDispatchResult, TaskOutboxDispatcher, type TaskOutboxDispatcherOptions, type TaskOutboxRecord, type TaskOutboxRepository, type TaskOutboxStatus, type TaskQueuePublisher, type TaskQueueReconcileResult, TaskQueueReconciler, type TaskQueueReconcilerOptions, type TaskRecord, type TaskRedriveResult, type TaskRepository, type TaskResourceRef, type TaskRetryContext, type TaskRetryDecision, type TaskRetryPolicy, type TaskStatus, type TaskSummary, type TaskSummaryError, type TaskTimelineEvent, type TaskTimelineEventType, type TransitionTaskInput, UnsupportedDurableTaskKindError, assertTaskTransition, canTransitionTask, createTaskFingerprint, isTerminalTaskStatus, requiresTaskLease, runWithExecutionHeartbeat };
package/dist/index.d.ts CHANGED
@@ -601,4 +601,58 @@ declare class TaskOutboxDispatcher<TPayload = unknown> {
601
601
  runOnce(): Promise<TaskOutboxDispatchResult>;
602
602
  }
603
603
 
604
- export { type AppendTaskOutboxInput, type ClaimTaskInput, type ClaimTaskOutboxInput, type CreateTaskInput, type CreateTaskResult, type DurableTaskAttemptContext, type DurableTaskHandler, DurableTaskLeaseLostError, DurableTaskNotClaimableError, DurableTaskNotFoundError, DurableTaskRetryScheduledError, type DurableWorkerHeartbeatScheduler, type DurableWorkerRunResult, DurableWorkerRuntime, type DurableWorkerRuntimeLogger, type DurableWorkerRuntimeOptions, ExponentialTaskRetryPolicy, type ExponentialTaskRetryPolicyOptions, type FindExpiredTaskLeasesInput, type FindReadyQueuedTasksInput, type HeartbeatTaskInput, InMemoryTaskOutboxRepository, InMemoryTaskRepository, InvalidTaskTransitionError, type ListOwnedTaskSummariesInput, type OwnedTaskTimelineEvent, type PostgresQueryResult, type PostgresTaskClient, PostgresTaskOutboxRepository, PostgresTaskRedriver, PostgresTaskRepository, PostgresTaskSubmitter, PostgresTaskSummaryReader, PostgresTaskTimelineReader, type PostgresTaskTransactionRunner, type ReadyTaskPublisher, type RecoverExpiredTaskLeaseInput, type RedriveFailedTaskInput, type ReportTaskProgressInput, type SubmitTaskBatchOptions, type SubmitTaskInput, type SubmitTaskOptions, type SubmitTaskOutboxInput, type SubmitTaskResult, TASK_STATUSES, TASK_TIMELINE_EVENT_TYPES, type TaskErrorSummary, type TaskFailureCategory, TaskIdempotencyConflictError, type TaskLeaseReconcileResult, TaskLeaseReconciler, type TaskLeaseReconcilerOptions, TaskOutboxConflictError, type TaskOutboxDispatchResult, TaskOutboxDispatcher, type TaskOutboxDispatcherOptions, type TaskOutboxRecord, type TaskOutboxRepository, type TaskOutboxStatus, type TaskQueuePublisher, type TaskQueueReconcileResult, TaskQueueReconciler, type TaskQueueReconcilerOptions, type TaskRecord, type TaskRedriveResult, type TaskRepository, type TaskResourceRef, type TaskRetryContext, type TaskRetryDecision, type TaskRetryPolicy, type TaskStatus, type TaskSummary, type TaskSummaryError, type TaskTimelineEvent, type TaskTimelineEventType, type TransitionTaskInput, UnsupportedDurableTaskKindError, assertTaskTransition, canTransitionTask, createTaskFingerprint, isTerminalTaskStatus, requiresTaskLease };
604
+ interface SingleProcessTaskSupervisorOptions {
605
+ concurrency: number;
606
+ scanIntervalMs: number;
607
+ /** Perform consumer-owned startup recovery and return keys that require resumption. */
608
+ initialize?: () => Promise<readonly string[]>;
609
+ loadPending: () => Promise<readonly string[]>;
610
+ run: (id: string, recovery: boolean) => Promise<void>;
611
+ abort: (id: string) => void;
612
+ onScanError?: (error: unknown) => void;
613
+ onExecutionError?: (id: string, error: unknown) => void | Promise<void>;
614
+ }
615
+ /**
616
+ * One-process FIFO scheduling, deduplication and recovery polling. Persistent
617
+ * state belongs to the consumer. This provides no lease, fencing or multi-executor safety.
618
+ * Shutdown aborts active callbacks immediately and deliberately does not drain them.
619
+ */
620
+ declare class SingleProcessTaskSupervisor {
621
+ private readonly options;
622
+ private readonly queued;
623
+ private readonly running;
624
+ private readonly interrupted;
625
+ private queue;
626
+ private scanTimer?;
627
+ private stopped;
628
+ private started;
629
+ constructor(options: SingleProcessTaskSupervisorOptions);
630
+ onApplicationBootstrap(): Promise<void>;
631
+ submit(id: string): void;
632
+ submitRecovery(id: string): void;
633
+ waitForIdle(): Promise<void>;
634
+ onApplicationShutdown(): Promise<void>;
635
+ private drain;
636
+ private scanPending;
637
+ private scheduleScan;
638
+ }
639
+
640
+ declare class ExecutionHeartbeatError extends Error {
641
+ readonly consecutiveFailures: number;
642
+ constructor(consecutiveFailures: number, options?: ErrorOptions);
643
+ }
644
+ interface ExecutionHeartbeatOptions {
645
+ signal?: AbortSignal;
646
+ intervalMs: number;
647
+ failureThreshold: number;
648
+ heartbeat: () => Promise<void>;
649
+ }
650
+ /**
651
+ * A single execution's heartbeat and cancellation scope. No lease, persistent
652
+ * state transition or retry policy is implied. Heartbeats never overlap; the
653
+ * final in-flight heartbeat settles before this scope returns. Work must honor
654
+ * its signal, and a heartbeat abort cannot be reported as successful work.
655
+ */
656
+ declare function runWithExecutionHeartbeat<T>(options: ExecutionHeartbeatOptions, work: (signal: AbortSignal) => Promise<T>): Promise<T>;
657
+
658
+ export { type AppendTaskOutboxInput, type ClaimTaskInput, type ClaimTaskOutboxInput, type CreateTaskInput, type CreateTaskResult, type DurableTaskAttemptContext, type DurableTaskHandler, DurableTaskLeaseLostError, DurableTaskNotClaimableError, DurableTaskNotFoundError, DurableTaskRetryScheduledError, type DurableWorkerHeartbeatScheduler, type DurableWorkerRunResult, DurableWorkerRuntime, type DurableWorkerRuntimeLogger, type DurableWorkerRuntimeOptions, ExecutionHeartbeatError, type ExecutionHeartbeatOptions, ExponentialTaskRetryPolicy, type ExponentialTaskRetryPolicyOptions, type FindExpiredTaskLeasesInput, type FindReadyQueuedTasksInput, type HeartbeatTaskInput, InMemoryTaskOutboxRepository, InMemoryTaskRepository, InvalidTaskTransitionError, type ListOwnedTaskSummariesInput, type OwnedTaskTimelineEvent, type PostgresQueryResult, type PostgresTaskClient, PostgresTaskOutboxRepository, PostgresTaskRedriver, PostgresTaskRepository, PostgresTaskSubmitter, PostgresTaskSummaryReader, PostgresTaskTimelineReader, type PostgresTaskTransactionRunner, type ReadyTaskPublisher, type RecoverExpiredTaskLeaseInput, type RedriveFailedTaskInput, type ReportTaskProgressInput, SingleProcessTaskSupervisor, type SingleProcessTaskSupervisorOptions, type SubmitTaskBatchOptions, type SubmitTaskInput, type SubmitTaskOptions, type SubmitTaskOutboxInput, type SubmitTaskResult, TASK_STATUSES, TASK_TIMELINE_EVENT_TYPES, type TaskErrorSummary, type TaskFailureCategory, TaskIdempotencyConflictError, type TaskLeaseReconcileResult, TaskLeaseReconciler, type TaskLeaseReconcilerOptions, TaskOutboxConflictError, type TaskOutboxDispatchResult, TaskOutboxDispatcher, type TaskOutboxDispatcherOptions, type TaskOutboxRecord, type TaskOutboxRepository, type TaskOutboxStatus, type TaskQueuePublisher, type TaskQueueReconcileResult, TaskQueueReconciler, type TaskQueueReconcilerOptions, type TaskRecord, type TaskRedriveResult, type TaskRepository, type TaskResourceRef, type TaskRetryContext, type TaskRetryDecision, type TaskRetryPolicy, type TaskStatus, type TaskSummary, type TaskSummaryError, type TaskTimelineEvent, type TaskTimelineEventType, type TransitionTaskInput, UnsupportedDurableTaskKindError, assertTaskTransition, canTransitionTask, createTaskFingerprint, isTerminalTaskStatus, requiresTaskLease, runWithExecutionHeartbeat };
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  DurableTaskNotFoundError: () => DurableTaskNotFoundError,
27
27
  DurableTaskRetryScheduledError: () => DurableTaskRetryScheduledError,
28
28
  DurableWorkerRuntime: () => DurableWorkerRuntime,
29
+ ExecutionHeartbeatError: () => ExecutionHeartbeatError,
29
30
  ExponentialTaskRetryPolicy: () => ExponentialTaskRetryPolicy,
30
31
  InMemoryTaskOutboxRepository: () => InMemoryTaskOutboxRepository,
31
32
  InMemoryTaskRepository: () => InMemoryTaskRepository,
@@ -36,6 +37,7 @@ __export(index_exports, {
36
37
  PostgresTaskSubmitter: () => PostgresTaskSubmitter,
37
38
  PostgresTaskSummaryReader: () => PostgresTaskSummaryReader,
38
39
  PostgresTaskTimelineReader: () => PostgresTaskTimelineReader,
40
+ SingleProcessTaskSupervisor: () => SingleProcessTaskSupervisor,
39
41
  TASK_STATUSES: () => TASK_STATUSES,
40
42
  TASK_TIMELINE_EVENT_TYPES: () => TASK_TIMELINE_EVENT_TYPES,
41
43
  TaskIdempotencyConflictError: () => TaskIdempotencyConflictError,
@@ -48,7 +50,8 @@ __export(index_exports, {
48
50
  canTransitionTask: () => canTransitionTask,
49
51
  createTaskFingerprint: () => createTaskFingerprint,
50
52
  isTerminalTaskStatus: () => isTerminalTaskStatus,
51
- requiresTaskLease: () => requiresTaskLease
53
+ requiresTaskLease: () => requiresTaskLease,
54
+ runWithExecutionHeartbeat: () => runWithExecutionHeartbeat
52
55
  });
53
56
  module.exports = __toCommonJS(index_exports);
54
57
 
@@ -1900,6 +1903,159 @@ var TaskOutboxDispatcher = class {
1900
1903
  return result;
1901
1904
  }
1902
1905
  };
1906
+
1907
+ // src/single-process-task-supervisor.ts
1908
+ var SingleProcessTaskSupervisor = class {
1909
+ static {
1910
+ __name(this, "SingleProcessTaskSupervisor");
1911
+ }
1912
+ options;
1913
+ queued = /* @__PURE__ */ new Set();
1914
+ running = /* @__PURE__ */ new Map();
1915
+ interrupted = /* @__PURE__ */ new Set();
1916
+ queue = [];
1917
+ scanTimer;
1918
+ stopped = false;
1919
+ started = false;
1920
+ constructor(options) {
1921
+ this.options = options;
1922
+ }
1923
+ async onApplicationBootstrap() {
1924
+ if (this.started || this.stopped) return;
1925
+ this.started = true;
1926
+ const interrupted = await this.options.initialize?.() ?? [];
1927
+ interrupted.forEach((id) => this.interrupted.add(id));
1928
+ await this.scanPending();
1929
+ this.scheduleScan();
1930
+ }
1931
+ submit(id) {
1932
+ if (this.stopped || this.queued.has(id) || this.running.has(id)) return;
1933
+ this.queued.add(id);
1934
+ this.queue.push(id);
1935
+ this.drain();
1936
+ }
1937
+ submitRecovery(id) {
1938
+ if (this.stopped || this.queued.has(id) || this.running.has(id)) return;
1939
+ this.interrupted.add(id);
1940
+ this.submit(id);
1941
+ }
1942
+ async waitForIdle() {
1943
+ while (this.queue.length > 0 || this.running.size > 0) {
1944
+ await new Promise((resolve) => setTimeout(resolve, 5));
1945
+ }
1946
+ }
1947
+ async onApplicationShutdown() {
1948
+ this.stopped = true;
1949
+ if (this.scanTimer) clearTimeout(this.scanTimer);
1950
+ this.queue = [];
1951
+ this.queued.clear();
1952
+ this.interrupted.clear();
1953
+ for (const id of this.running.keys()) this.options.abort(id);
1954
+ }
1955
+ drain() {
1956
+ while (!this.stopped && this.running.size < this.options.concurrency && this.queue.length > 0) {
1957
+ const id = this.queue.shift();
1958
+ if (id === void 0) return;
1959
+ this.queued.delete(id);
1960
+ const recovery = this.interrupted.delete(id);
1961
+ const execution = Promise.resolve().then(() => this.options.run(id, recovery)).catch((error) => this.options.onExecutionError?.(id, error)).catch(() => {
1962
+ }).finally(() => {
1963
+ this.running.delete(id);
1964
+ this.drain();
1965
+ });
1966
+ this.running.set(id, execution);
1967
+ }
1968
+ }
1969
+ async scanPending() {
1970
+ if (this.stopped) return;
1971
+ const pending = await this.options.loadPending();
1972
+ pending.forEach((id) => this.submit(id));
1973
+ }
1974
+ scheduleScan() {
1975
+ if (this.stopped) return;
1976
+ this.scanTimer = setTimeout(() => {
1977
+ void this.scanPending().catch((error) => this.options.onScanError?.(error)).catch(() => {
1978
+ }).finally(() => this.scheduleScan());
1979
+ }, this.options.scanIntervalMs);
1980
+ this.scanTimer.unref?.();
1981
+ }
1982
+ };
1983
+
1984
+ // src/execution-heartbeat.ts
1985
+ var ExecutionHeartbeatError = class extends Error {
1986
+ static {
1987
+ __name(this, "ExecutionHeartbeatError");
1988
+ }
1989
+ consecutiveFailures;
1990
+ constructor(consecutiveFailures, options) {
1991
+ super(`Execution heartbeat failed ${consecutiveFailures} consecutive times`, options), this.consecutiveFailures = consecutiveFailures;
1992
+ this.name = "ExecutionHeartbeatError";
1993
+ }
1994
+ };
1995
+ async function runWithExecutionHeartbeat(options, work) {
1996
+ if (!Number.isFinite(options.intervalMs) || options.intervalMs <= 0 || !Number.isSafeInteger(options.failureThreshold) || options.failureThreshold < 1) {
1997
+ throw new RangeError("Heartbeat interval and failure threshold must be positive");
1998
+ }
1999
+ const controller = new AbortController();
2000
+ let stopped = false;
2001
+ let failures = 0;
2002
+ let heartbeatFailure;
2003
+ let timer;
2004
+ let inFlight;
2005
+ const cancel = /* @__PURE__ */ __name(() => {
2006
+ if (timer) clearTimeout(timer);
2007
+ controller.abort(options.signal?.reason);
2008
+ }, "cancel");
2009
+ options.signal?.addEventListener("abort", cancel, {
2010
+ once: true
2011
+ });
2012
+ if (options.signal?.aborted) cancel();
2013
+ const schedule = /* @__PURE__ */ __name(() => {
2014
+ if (stopped || controller.signal.aborted) return;
2015
+ timer = setTimeout(() => {
2016
+ inFlight = tick();
2017
+ }, options.intervalMs);
2018
+ timer.unref?.();
2019
+ }, "schedule");
2020
+ const tick = /* @__PURE__ */ __name(async () => {
2021
+ if (stopped || controller.signal.aborted) return;
2022
+ try {
2023
+ await options.heartbeat();
2024
+ failures = 0;
2025
+ } catch (error) {
2026
+ if (!stopped && !controller.signal.aborted) {
2027
+ failures++;
2028
+ if (failures >= options.failureThreshold) {
2029
+ heartbeatFailure = new ExecutionHeartbeatError(failures, {
2030
+ cause: error
2031
+ });
2032
+ controller.abort(heartbeatFailure);
2033
+ }
2034
+ }
2035
+ } finally {
2036
+ schedule();
2037
+ }
2038
+ }, "tick");
2039
+ try {
2040
+ controller.signal.throwIfAborted();
2041
+ schedule();
2042
+ let result;
2043
+ try {
2044
+ result = await work(controller.signal);
2045
+ } catch (error) {
2046
+ throw heartbeatFailure ?? error;
2047
+ }
2048
+ if (heartbeatFailure) throw heartbeatFailure;
2049
+ controller.signal.throwIfAborted();
2050
+ return result;
2051
+ } finally {
2052
+ stopped = true;
2053
+ if (timer) clearTimeout(timer);
2054
+ options.signal?.removeEventListener("abort", cancel);
2055
+ await inFlight;
2056
+ }
2057
+ }
2058
+ __name(runWithExecutionHeartbeat, "runWithExecutionHeartbeat");
1903
2059
  // Annotate the CommonJS export names for ESM import in node:
1904
2060
  0 && (module.exports = {
1905
2061
  DurableTaskLeaseLostError,
@@ -1907,6 +2063,7 @@ var TaskOutboxDispatcher = class {
1907
2063
  DurableTaskNotFoundError,
1908
2064
  DurableTaskRetryScheduledError,
1909
2065
  DurableWorkerRuntime,
2066
+ ExecutionHeartbeatError,
1910
2067
  ExponentialTaskRetryPolicy,
1911
2068
  InMemoryTaskOutboxRepository,
1912
2069
  InMemoryTaskRepository,
@@ -1917,6 +2074,7 @@ var TaskOutboxDispatcher = class {
1917
2074
  PostgresTaskSubmitter,
1918
2075
  PostgresTaskSummaryReader,
1919
2076
  PostgresTaskTimelineReader,
2077
+ SingleProcessTaskSupervisor,
1920
2078
  TASK_STATUSES,
1921
2079
  TASK_TIMELINE_EVENT_TYPES,
1922
2080
  TaskIdempotencyConflictError,
@@ -1929,5 +2087,6 @@ var TaskOutboxDispatcher = class {
1929
2087
  canTransitionTask,
1930
2088
  createTaskFingerprint,
1931
2089
  isTerminalTaskStatus,
1932
- requiresTaskLease
2090
+ requiresTaskLease,
2091
+ runWithExecutionHeartbeat
1933
2092
  });
package/dist/index.mjs CHANGED
@@ -1849,12 +1849,166 @@ var TaskOutboxDispatcher = class {
1849
1849
  return result;
1850
1850
  }
1851
1851
  };
1852
+
1853
+ // src/single-process-task-supervisor.ts
1854
+ var SingleProcessTaskSupervisor = class {
1855
+ static {
1856
+ __name(this, "SingleProcessTaskSupervisor");
1857
+ }
1858
+ options;
1859
+ queued = /* @__PURE__ */ new Set();
1860
+ running = /* @__PURE__ */ new Map();
1861
+ interrupted = /* @__PURE__ */ new Set();
1862
+ queue = [];
1863
+ scanTimer;
1864
+ stopped = false;
1865
+ started = false;
1866
+ constructor(options) {
1867
+ this.options = options;
1868
+ }
1869
+ async onApplicationBootstrap() {
1870
+ if (this.started || this.stopped) return;
1871
+ this.started = true;
1872
+ const interrupted = await this.options.initialize?.() ?? [];
1873
+ interrupted.forEach((id) => this.interrupted.add(id));
1874
+ await this.scanPending();
1875
+ this.scheduleScan();
1876
+ }
1877
+ submit(id) {
1878
+ if (this.stopped || this.queued.has(id) || this.running.has(id)) return;
1879
+ this.queued.add(id);
1880
+ this.queue.push(id);
1881
+ this.drain();
1882
+ }
1883
+ submitRecovery(id) {
1884
+ if (this.stopped || this.queued.has(id) || this.running.has(id)) return;
1885
+ this.interrupted.add(id);
1886
+ this.submit(id);
1887
+ }
1888
+ async waitForIdle() {
1889
+ while (this.queue.length > 0 || this.running.size > 0) {
1890
+ await new Promise((resolve) => setTimeout(resolve, 5));
1891
+ }
1892
+ }
1893
+ async onApplicationShutdown() {
1894
+ this.stopped = true;
1895
+ if (this.scanTimer) clearTimeout(this.scanTimer);
1896
+ this.queue = [];
1897
+ this.queued.clear();
1898
+ this.interrupted.clear();
1899
+ for (const id of this.running.keys()) this.options.abort(id);
1900
+ }
1901
+ drain() {
1902
+ while (!this.stopped && this.running.size < this.options.concurrency && this.queue.length > 0) {
1903
+ const id = this.queue.shift();
1904
+ if (id === void 0) return;
1905
+ this.queued.delete(id);
1906
+ const recovery = this.interrupted.delete(id);
1907
+ const execution = Promise.resolve().then(() => this.options.run(id, recovery)).catch((error) => this.options.onExecutionError?.(id, error)).catch(() => {
1908
+ }).finally(() => {
1909
+ this.running.delete(id);
1910
+ this.drain();
1911
+ });
1912
+ this.running.set(id, execution);
1913
+ }
1914
+ }
1915
+ async scanPending() {
1916
+ if (this.stopped) return;
1917
+ const pending = await this.options.loadPending();
1918
+ pending.forEach((id) => this.submit(id));
1919
+ }
1920
+ scheduleScan() {
1921
+ if (this.stopped) return;
1922
+ this.scanTimer = setTimeout(() => {
1923
+ void this.scanPending().catch((error) => this.options.onScanError?.(error)).catch(() => {
1924
+ }).finally(() => this.scheduleScan());
1925
+ }, this.options.scanIntervalMs);
1926
+ this.scanTimer.unref?.();
1927
+ }
1928
+ };
1929
+
1930
+ // src/execution-heartbeat.ts
1931
+ var ExecutionHeartbeatError = class extends Error {
1932
+ static {
1933
+ __name(this, "ExecutionHeartbeatError");
1934
+ }
1935
+ consecutiveFailures;
1936
+ constructor(consecutiveFailures, options) {
1937
+ super(`Execution heartbeat failed ${consecutiveFailures} consecutive times`, options), this.consecutiveFailures = consecutiveFailures;
1938
+ this.name = "ExecutionHeartbeatError";
1939
+ }
1940
+ };
1941
+ async function runWithExecutionHeartbeat(options, work) {
1942
+ if (!Number.isFinite(options.intervalMs) || options.intervalMs <= 0 || !Number.isSafeInteger(options.failureThreshold) || options.failureThreshold < 1) {
1943
+ throw new RangeError("Heartbeat interval and failure threshold must be positive");
1944
+ }
1945
+ const controller = new AbortController();
1946
+ let stopped = false;
1947
+ let failures = 0;
1948
+ let heartbeatFailure;
1949
+ let timer;
1950
+ let inFlight;
1951
+ const cancel = /* @__PURE__ */ __name(() => {
1952
+ if (timer) clearTimeout(timer);
1953
+ controller.abort(options.signal?.reason);
1954
+ }, "cancel");
1955
+ options.signal?.addEventListener("abort", cancel, {
1956
+ once: true
1957
+ });
1958
+ if (options.signal?.aborted) cancel();
1959
+ const schedule = /* @__PURE__ */ __name(() => {
1960
+ if (stopped || controller.signal.aborted) return;
1961
+ timer = setTimeout(() => {
1962
+ inFlight = tick();
1963
+ }, options.intervalMs);
1964
+ timer.unref?.();
1965
+ }, "schedule");
1966
+ const tick = /* @__PURE__ */ __name(async () => {
1967
+ if (stopped || controller.signal.aborted) return;
1968
+ try {
1969
+ await options.heartbeat();
1970
+ failures = 0;
1971
+ } catch (error) {
1972
+ if (!stopped && !controller.signal.aborted) {
1973
+ failures++;
1974
+ if (failures >= options.failureThreshold) {
1975
+ heartbeatFailure = new ExecutionHeartbeatError(failures, {
1976
+ cause: error
1977
+ });
1978
+ controller.abort(heartbeatFailure);
1979
+ }
1980
+ }
1981
+ } finally {
1982
+ schedule();
1983
+ }
1984
+ }, "tick");
1985
+ try {
1986
+ controller.signal.throwIfAborted();
1987
+ schedule();
1988
+ let result;
1989
+ try {
1990
+ result = await work(controller.signal);
1991
+ } catch (error) {
1992
+ throw heartbeatFailure ?? error;
1993
+ }
1994
+ if (heartbeatFailure) throw heartbeatFailure;
1995
+ controller.signal.throwIfAborted();
1996
+ return result;
1997
+ } finally {
1998
+ stopped = true;
1999
+ if (timer) clearTimeout(timer);
2000
+ options.signal?.removeEventListener("abort", cancel);
2001
+ await inFlight;
2002
+ }
2003
+ }
2004
+ __name(runWithExecutionHeartbeat, "runWithExecutionHeartbeat");
1852
2005
  export {
1853
2006
  DurableTaskLeaseLostError,
1854
2007
  DurableTaskNotClaimableError,
1855
2008
  DurableTaskNotFoundError,
1856
2009
  DurableTaskRetryScheduledError,
1857
2010
  DurableWorkerRuntime,
2011
+ ExecutionHeartbeatError,
1858
2012
  ExponentialTaskRetryPolicy,
1859
2013
  InMemoryTaskOutboxRepository,
1860
2014
  InMemoryTaskRepository,
@@ -1865,6 +2019,7 @@ export {
1865
2019
  PostgresTaskSubmitter,
1866
2020
  PostgresTaskSummaryReader,
1867
2021
  PostgresTaskTimelineReader,
2022
+ SingleProcessTaskSupervisor,
1868
2023
  TASK_STATUSES,
1869
2024
  TASK_TIMELINE_EVENT_TYPES,
1870
2025
  TaskIdempotencyConflictError,
@@ -1877,5 +2032,6 @@ export {
1877
2032
  canTransitionTask,
1878
2033
  createTaskFingerprint,
1879
2034
  isTerminalTaskStatus,
1880
- requiresTaskLease
2035
+ requiresTaskLease,
2036
+ runWithExecutionHeartbeat
1881
2037
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zucker-framework/task",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Durable task lifecycle contracts and state machine for Zucker applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",