@kb-labs/workflow-engine 2.119.0 → 2.119.1-canary.b53bbee1d

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.d.ts CHANGED
@@ -43,6 +43,30 @@ declare class WorkflowLoader {
43
43
  private validate;
44
44
  }
45
45
 
46
+ /**
47
+ * How long a daemon's liveness claim on this shared state store stays valid
48
+ * without a heartbeat renewal. This answers a completely different question
49
+ * from `RUN_TTL_MS` above: not "is this run's data still around" but "is the
50
+ * daemon that owns it still alive". `cleanupStaleRuns` uses ONLY this lease —
51
+ * never a run's or job's own elapsed running time — to decide whether a
52
+ * previous daemon instance is genuinely gone. A lease still on file this
53
+ * recently can only mean some process wrote it within the last
54
+ * DAEMON_LEASE_TTL_MS; if that process isn't the one calling
55
+ * `cleanupStaleRuns`, force-failing "running"/"queued" jobs would very likely
56
+ * be abandoning work a still-live sibling instance owns, not cleaning up
57
+ * after a dead one. Kept well short of RUN_TTL_MS on purpose — a run/job can
58
+ * legitimately run for tens of minutes, but no legitimate single daemon
59
+ * instance should ever go this long without renewing its own lease.
60
+ */
61
+ declare const DAEMON_LEASE_TTL_MS = 45000;
62
+ /** How often a live daemon should call `writeDaemonLease` to keep its lease
63
+ * fresh — comfortably under `DAEMON_LEASE_TTL_MS` so a normal GC pause or a
64
+ * slow cache round-trip never lets the lease lapse on its own. */
65
+ declare const DAEMON_LEASE_HEARTBEAT_INTERVAL_MS = 15000;
66
+ interface DaemonLease {
67
+ instanceId: string;
68
+ heartbeatAt: string;
69
+ }
46
70
  declare class StateStore {
47
71
  private readonly logger;
48
72
  private readonly cache;
@@ -51,6 +75,22 @@ declare class StateStore {
51
75
  getRun(runId: string): Promise<WorkflowRun | null>;
52
76
  deleteRun(runId: string): Promise<void>;
53
77
  getAllRunIds(): Promise<string[]>;
78
+ /**
79
+ * Read the current daemon liveness lease, if one is still on file. Returns
80
+ * null both when no daemon has ever written one and when the last writer's
81
+ * lease has expired (per `DAEMON_LEASE_TTL_MS`) — the cache backend itself
82
+ * enforces that expiry, so a non-null result here is proof some process
83
+ * heartbeated within the last `DAEMON_LEASE_TTL_MS`.
84
+ */
85
+ getDaemonLease(): Promise<DaemonLease | null>;
86
+ /**
87
+ * Claim (or renew) the daemon liveness lease under `instanceId`, valid for
88
+ * `DAEMON_LEASE_TTL_MS`. Called once at startup after `cleanupStaleRuns`
89
+ * decides this instance is safe to proceed as the active daemon, and then
90
+ * periodically for as long as the daemon stays up, so its lease never
91
+ * lapses while it's genuinely alive.
92
+ */
93
+ writeDaemonLease(instanceId: string): Promise<void>;
54
94
  /**
55
95
  * Holds an exclusive per-run lock (see `withLock`) for the whole
56
96
  * read-modify-write, so `mutator` runs exactly once per call — no other
@@ -234,11 +274,21 @@ interface WorkflowEngineOptions {
234
274
  snapshotManager?: ISnapshotManager;
235
275
  /** Workspace root (monorepo root) - used for plugin execution context */
236
276
  workspaceRoot?: string;
277
+ /**
278
+ * Unique identifier for this daemon/engine process, used by
279
+ * `cleanupStaleRuns` to tell "genuinely a different daemon instance" apart
280
+ * from "this same instance calling in again". Defaults to a fresh
281
+ * `randomUUID()` per engine — override only in tests that need to assert
282
+ * on a known id or simulate two instances against the same store.
283
+ */
284
+ instanceId?: string;
237
285
  }
238
286
  declare class WorkflowEngine {
239
287
  private readonly options;
240
288
  readonly loader: WorkflowLoader;
241
289
  readonly maxWorkflowDepth: number;
290
+ /** See `WorkflowEngineOptions.instanceId`. */
291
+ readonly instanceId: string;
242
292
  private readonly logger;
243
293
  private readonly analytics?;
244
294
  private readonly stateStore;
@@ -372,6 +422,14 @@ declare class WorkflowEngine {
372
422
  * Get the scheduler for direct access (used by worker for gate re-enqueue).
373
423
  */
374
424
  getScheduler(): Scheduler;
425
+ /**
426
+ * Renew this instance's daemon liveness lease. Call once cleanupStaleRuns
427
+ * has decided this instance is safe to proceed, then periodically (see
428
+ * `DAEMON_LEASE_HEARTBEAT_INTERVAL_MS`) for as long as the daemon stays
429
+ * up — a live daemon's lease must never lapse, since `cleanupStaleRuns`
430
+ * treats a lapsed lease as proof its owner is genuinely gone.
431
+ */
432
+ renewDaemonLease(): Promise<void>;
375
433
  /**
376
434
  * Mark stale running/queued jobs as failed on daemon startup — their
377
435
  * executor process is gone, so they're unrecoverable. The run itself is
@@ -379,6 +437,20 @@ declare class WorkflowEngine {
379
437
  * a run with one abandoned job and one job legitimately parked on a human
380
438
  * approval or a child workflow stays 'running' (only the abandoned job is
381
439
  * failed) until the parked one resolves.
440
+ *
441
+ * Before touching anything, this checks the shared daemon liveness lease
442
+ * (see `StateStore.getDaemonLease`/`writeDaemonLease`). Being CALLED is not
443
+ * by itself evidence of a restart — that's exactly what let a stray or
444
+ * duplicate daemon process (e.g. a second launch racing a daemon that never
445
+ * actually restarted) force-fail a genuinely in-flight run out from under
446
+ * a still-alive sibling instance, purely because it, too, executed its own
447
+ * one-time startup sweep against the same shared state store. A lease still
448
+ * fresh under a DIFFERENT instanceId is real, recent (within
449
+ * `DAEMON_LEASE_TTL_MS`) evidence that some other process is currently
450
+ * alive and heartbeating — in that case this pass must not run at all, no
451
+ * matter how long any individual job has been "running": elapsed job
452
+ * runtime is never a substitute for genuine restart evidence, and is not
453
+ * even consulted here.
382
454
  */
383
455
  cleanupStaleRuns(): Promise<void>;
384
456
  /**
@@ -1058,4 +1130,4 @@ declare class JobManager implements IJobScheduler {
1058
1130
  private jobRecordToHandle;
1059
1131
  }
1060
1132
 
1061
- export { type AcquireOptions, ArtifactMerger, type ArtifactMergerOptions, ConcurrencyManager, type CreateRunInput, type EngineLogger, EnvSecretProvider, type EnvSecretProviderOptions, EventBusBridge, JobManager, type JobManagerConfig, type JobQueueEntry, ManifestConverter, ManifestScanner, type ManifestScannerOptions, type RetryDecision, type RunContext, RunCoordinator, type RunCoordinatorOptions, type RunSnapshot, RunSnapshotStorage, Scheduler, type SchedulerOptions, type SecretProvider, StateStore, type ValidationResult, WorkflowEngine, type WorkflowEngineOptions, type WorkflowEvent, type WorkflowHandlerInfo, type WorkflowListOptions, WorkflowLoader, type WorkflowLoaderOptions, type WorkflowLoaderResult, WorkflowRegistry, type WorkflowRegistryEntry, type WorkflowRegistryOptions, WorkflowRepository, type WorkflowRepositoryOptions, type WorkflowRuntime, type WorkflowSchedule, WorkflowService, type WorkflowServiceListOptions, type WorkflowServiceOptions, type WorkflowStats, type WorkflowTrigger, type WorkflowTriggerType, calculateBackoff, createDefaultSecretProvider, shouldRetry };
1133
+ export { type AcquireOptions, ArtifactMerger, type ArtifactMergerOptions, ConcurrencyManager, type CreateRunInput, DAEMON_LEASE_HEARTBEAT_INTERVAL_MS, DAEMON_LEASE_TTL_MS, type DaemonLease, type EngineLogger, EnvSecretProvider, type EnvSecretProviderOptions, EventBusBridge, JobManager, type JobManagerConfig, type JobQueueEntry, ManifestConverter, ManifestScanner, type ManifestScannerOptions, type RetryDecision, type RunContext, RunCoordinator, type RunCoordinatorOptions, type RunSnapshot, RunSnapshotStorage, Scheduler, type SchedulerOptions, type SecretProvider, StateStore, type ValidationResult, WorkflowEngine, type WorkflowEngineOptions, type WorkflowEvent, type WorkflowHandlerInfo, type WorkflowListOptions, WorkflowLoader, type WorkflowLoaderOptions, type WorkflowLoaderResult, WorkflowRegistry, type WorkflowRegistryEntry, type WorkflowRegistryOptions, WorkflowRepository, type WorkflowRepositoryOptions, type WorkflowRuntime, type WorkflowSchedule, WorkflowService, type WorkflowServiceListOptions, type WorkflowServiceOptions, type WorkflowStats, type WorkflowTrigger, type WorkflowTriggerType, calculateBackoff, createDefaultSecretProvider, shouldRetry };
package/dist/index.js CHANGED
@@ -121,6 +121,9 @@ async function withLock(cache, lockKey, fn) {
121
121
 
122
122
  // src/state-store.ts
123
123
  var RUN_TTL_MS = 24 * 60 * 60 * 1e3;
124
+ var DAEMON_LEASE_TTL_MS = 45e3;
125
+ var DAEMON_LEASE_HEARTBEAT_INTERVAL_MS = 15e3;
126
+ var DAEMON_LEASE_KEY = "kb:daemon:lease";
124
127
  var StateStore = class {
125
128
  constructor(cache, logger) {
126
129
  this.logger = logger;
@@ -159,6 +162,36 @@ var StateStore = class {
159
162
  const runIds = await this.cache.zrangebyscore("workflow:runs:index", -Infinity, Infinity);
160
163
  return runIds ?? [];
161
164
  }
165
+ /**
166
+ * Read the current daemon liveness lease, if one is still on file. Returns
167
+ * null both when no daemon has ever written one and when the last writer's
168
+ * lease has expired (per `DAEMON_LEASE_TTL_MS`) — the cache backend itself
169
+ * enforces that expiry, so a non-null result here is proof some process
170
+ * heartbeated within the last `DAEMON_LEASE_TTL_MS`.
171
+ */
172
+ async getDaemonLease() {
173
+ const raw = await this.cache.get(DAEMON_LEASE_KEY);
174
+ if (!raw) {
175
+ return null;
176
+ }
177
+ try {
178
+ return JSON.parse(raw);
179
+ } catch (error) {
180
+ this.logger.error("Failed to parse stored daemon lease", error instanceof Error ? error : void 0);
181
+ return null;
182
+ }
183
+ }
184
+ /**
185
+ * Claim (or renew) the daemon liveness lease under `instanceId`, valid for
186
+ * `DAEMON_LEASE_TTL_MS`. Called once at startup after `cleanupStaleRuns`
187
+ * decides this instance is safe to proceed as the active daemon, and then
188
+ * periodically for as long as the daemon stays up, so its lease never
189
+ * lapses while it's genuinely alive.
190
+ */
191
+ async writeDaemonLease(instanceId) {
192
+ const lease = { instanceId, heartbeatAt: (/* @__PURE__ */ new Date()).toISOString() };
193
+ await this.cache.set(DAEMON_LEASE_KEY, JSON.stringify(lease), DAEMON_LEASE_TTL_MS);
194
+ }
162
195
  /**
163
196
  * Holds an exclusive per-run lock (see `withLock`) for the whole
164
197
  * read-modify-write, so `mutator` runs exactly once per call — no other
@@ -794,6 +827,7 @@ var WorkflowEngine = class {
794
827
  this.options = options;
795
828
  this.logger = options.logger;
796
829
  this.analytics = options.analytics;
830
+ this.instanceId = options.instanceId ?? randomUUID();
797
831
  this.stateStore = new StateStore(options.cache, this.logger);
798
832
  this.concurrency = new ConcurrencyManager(
799
833
  options.cache,
@@ -816,6 +850,8 @@ var WorkflowEngine = class {
816
850
  options;
817
851
  loader;
818
852
  maxWorkflowDepth;
853
+ /** See `WorkflowEngineOptions.instanceId`. */
854
+ instanceId;
819
855
  logger;
820
856
  analytics;
821
857
  stateStore;
@@ -1523,6 +1559,16 @@ var WorkflowEngine = class {
1523
1559
  getScheduler() {
1524
1560
  return this.scheduler;
1525
1561
  }
1562
+ /**
1563
+ * Renew this instance's daemon liveness lease. Call once cleanupStaleRuns
1564
+ * has decided this instance is safe to proceed, then periodically (see
1565
+ * `DAEMON_LEASE_HEARTBEAT_INTERVAL_MS`) for as long as the daemon stays
1566
+ * up — a live daemon's lease must never lapse, since `cleanupStaleRuns`
1567
+ * treats a lapsed lease as proof its owner is genuinely gone.
1568
+ */
1569
+ async renewDaemonLease() {
1570
+ await this.stateStore.writeDaemonLease(this.instanceId);
1571
+ }
1526
1572
  /**
1527
1573
  * Mark stale running/queued jobs as failed on daemon startup — their
1528
1574
  * executor process is gone, so they're unrecoverable. The run itself is
@@ -1530,8 +1576,36 @@ var WorkflowEngine = class {
1530
1576
  * a run with one abandoned job and one job legitimately parked on a human
1531
1577
  * approval or a child workflow stays 'running' (only the abandoned job is
1532
1578
  * failed) until the parked one resolves.
1579
+ *
1580
+ * Before touching anything, this checks the shared daemon liveness lease
1581
+ * (see `StateStore.getDaemonLease`/`writeDaemonLease`). Being CALLED is not
1582
+ * by itself evidence of a restart — that's exactly what let a stray or
1583
+ * duplicate daemon process (e.g. a second launch racing a daemon that never
1584
+ * actually restarted) force-fail a genuinely in-flight run out from under
1585
+ * a still-alive sibling instance, purely because it, too, executed its own
1586
+ * one-time startup sweep against the same shared state store. A lease still
1587
+ * fresh under a DIFFERENT instanceId is real, recent (within
1588
+ * `DAEMON_LEASE_TTL_MS`) evidence that some other process is currently
1589
+ * alive and heartbeating — in that case this pass must not run at all, no
1590
+ * matter how long any individual job has been "running": elapsed job
1591
+ * runtime is never a substitute for genuine restart evidence, and is not
1592
+ * even consulted here.
1533
1593
  */
1534
1594
  async cleanupStaleRuns() {
1595
+ const priorLease = await this.stateStore.getDaemonLease();
1596
+ if (priorLease && priorLease.instanceId !== this.instanceId) {
1597
+ this.logger.error(
1598
+ "cleanupStaleRuns: a daemon liveness lease held by a different instance is still fresh \u2014 skipping stale-run cleanup entirely to avoid abandoning runs that may genuinely still be in flight under that instance. If no other workflow daemon is actually running, this lease is simply stale past its own restart window and the next daemon start will clean up normally.",
1599
+ void 0,
1600
+ {
1601
+ thisInstanceId: this.instanceId,
1602
+ otherInstanceId: priorLease.instanceId,
1603
+ otherHeartbeatAt: priorLease.heartbeatAt
1604
+ }
1605
+ );
1606
+ return;
1607
+ }
1608
+ await this.stateStore.writeDaemonLease(this.instanceId);
1535
1609
  const runIds = await this.stateStore.getAllRunIds();
1536
1610
  const now = (/* @__PURE__ */ new Date()).toISOString();
1537
1611
  let count = 0;
@@ -3463,6 +3537,6 @@ var JobManager = class {
3463
3537
  }
3464
3538
  };
3465
3539
 
3466
- export { ArtifactMerger, ConcurrencyManager, EnvSecretProvider, EventBusBridge, JobManager, ManifestConverter, ManifestScanner, RunCoordinator, RunSnapshotStorage, Scheduler, StateStore, WorkflowEngine, WorkflowLoader, WorkflowRegistry, WorkflowRepository, WorkflowService, calculateBackoff, createDefaultSecretProvider, shouldRetry };
3540
+ export { ArtifactMerger, ConcurrencyManager, DAEMON_LEASE_HEARTBEAT_INTERVAL_MS, DAEMON_LEASE_TTL_MS, EnvSecretProvider, EventBusBridge, JobManager, ManifestConverter, ManifestScanner, RunCoordinator, RunSnapshotStorage, Scheduler, StateStore, WorkflowEngine, WorkflowLoader, WorkflowRegistry, WorkflowRepository, WorkflowService, calculateBackoff, createDefaultSecretProvider, shouldRetry };
3467
3541
  //# sourceMappingURL=index.js.map
3468
3542
  //# sourceMappingURL=index.js.map