@plumbus/core 0.2.9 → 0.3.1

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.
Files changed (182) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ai/__tests__/ai-service.test.js +704 -1
  3. package/dist/ai/__tests__/ai-service.test.js.map +1 -1
  4. package/dist/ai/__tests__/chunking.test.js +34 -0
  5. package/dist/ai/__tests__/chunking.test.js.map +1 -1
  6. package/dist/ai/__tests__/model-pricing.test.js +45 -1
  7. package/dist/ai/__tests__/model-pricing.test.js.map +1 -1
  8. package/dist/ai/__tests__/provider.test.d.ts.map +1 -1
  9. package/dist/ai/__tests__/provider.test.js +730 -1
  10. package/dist/ai/__tests__/provider.test.js.map +1 -1
  11. package/dist/ai/__tests__/validation.test.js +200 -1
  12. package/dist/ai/__tests__/validation.test.js.map +1 -1
  13. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts +2 -0
  14. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts.map +1 -0
  15. package/dist/ai/__tests__/zod-to-provider-schema.test.js +60 -0
  16. package/dist/ai/__tests__/zod-to-provider-schema.test.js.map +1 -0
  17. package/dist/ai/ai-service.d.ts +19 -2
  18. package/dist/ai/ai-service.d.ts.map +1 -1
  19. package/dist/ai/ai-service.js +544 -110
  20. package/dist/ai/ai-service.js.map +1 -1
  21. package/dist/ai/cost-tracker.d.ts +26 -1
  22. package/dist/ai/cost-tracker.d.ts.map +1 -1
  23. package/dist/ai/cost-tracker.js +1 -0
  24. package/dist/ai/cost-tracker.js.map +1 -1
  25. package/dist/ai/index.d.ts +6 -4
  26. package/dist/ai/index.d.ts.map +1 -1
  27. package/dist/ai/index.js +7 -3
  28. package/dist/ai/index.js.map +1 -1
  29. package/dist/ai/model-pricing.d.ts +24 -1
  30. package/dist/ai/model-pricing.d.ts.map +1 -1
  31. package/dist/ai/model-pricing.js +83 -56
  32. package/dist/ai/model-pricing.js.map +1 -1
  33. package/dist/ai/provider.d.ts +110 -1
  34. package/dist/ai/provider.d.ts.map +1 -1
  35. package/dist/ai/provider.js +358 -32
  36. package/dist/ai/provider.js.map +1 -1
  37. package/dist/ai/rag/chunking.js +53 -3
  38. package/dist/ai/rag/chunking.js.map +1 -1
  39. package/dist/ai/refusal.d.ts +28 -0
  40. package/dist/ai/refusal.d.ts.map +1 -0
  41. package/dist/ai/refusal.js +36 -0
  42. package/dist/ai/refusal.js.map +1 -0
  43. package/dist/ai/validation.d.ts +40 -8
  44. package/dist/ai/validation.d.ts.map +1 -1
  45. package/dist/ai/validation.js +97 -6
  46. package/dist/ai/validation.js.map +1 -1
  47. package/dist/ai/zod-to-provider-schema.d.ts +15 -0
  48. package/dist/ai/zod-to-provider-schema.d.ts.map +1 -0
  49. package/dist/ai/zod-to-provider-schema.js +200 -0
  50. package/dist/ai/zod-to-provider-schema.js.map +1 -0
  51. package/dist/cli/__tests__/doctor.test.js +104 -2
  52. package/dist/cli/__tests__/doctor.test.js.map +1 -1
  53. package/dist/cli/__tests__/init.test.js +127 -7
  54. package/dist/cli/__tests__/init.test.js.map +1 -1
  55. package/dist/cli/__tests__/migrate.test.js +159 -2
  56. package/dist/cli/__tests__/migrate.test.js.map +1 -1
  57. package/dist/cli/commands/__tests__/commands.test.js +5 -0
  58. package/dist/cli/commands/__tests__/commands.test.js.map +1 -1
  59. package/dist/cli/commands/dev.d.ts.map +1 -1
  60. package/dist/cli/commands/dev.js +21 -1
  61. package/dist/cli/commands/dev.js.map +1 -1
  62. package/dist/cli/commands/doctor.d.ts +2 -0
  63. package/dist/cli/commands/doctor.d.ts.map +1 -1
  64. package/dist/cli/commands/doctor.js +78 -0
  65. package/dist/cli/commands/doctor.js.map +1 -1
  66. package/dist/cli/commands/generate.d.ts.map +1 -1
  67. package/dist/cli/commands/generate.js +7 -2
  68. package/dist/cli/commands/generate.js.map +1 -1
  69. package/dist/cli/commands/init.d.ts +16 -2
  70. package/dist/cli/commands/init.d.ts.map +1 -1
  71. package/dist/cli/commands/init.js +226 -25
  72. package/dist/cli/commands/init.js.map +1 -1
  73. package/dist/cli/commands/migrate.d.ts.map +1 -1
  74. package/dist/cli/commands/migrate.js +258 -8
  75. package/dist/cli/commands/migrate.js.map +1 -1
  76. package/dist/cli/commands/start.d.ts.map +1 -1
  77. package/dist/cli/commands/start.js +21 -1
  78. package/dist/cli/commands/start.js.map +1 -1
  79. package/dist/data/__tests__/drift-inspector.test.d.ts +2 -0
  80. package/dist/data/__tests__/drift-inspector.test.d.ts.map +1 -0
  81. package/dist/data/__tests__/drift-inspector.test.js +170 -0
  82. package/dist/data/__tests__/drift-inspector.test.js.map +1 -0
  83. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts +2 -0
  84. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts.map +1 -0
  85. package/dist/data/__tests__/framework-migration-rewriter.test.js +329 -0
  86. package/dist/data/__tests__/framework-migration-rewriter.test.js.map +1 -0
  87. package/dist/data/__tests__/migration.test.js +93 -2
  88. package/dist/data/__tests__/migration.test.js.map +1 -1
  89. package/dist/data/__tests__/repository.test.js +126 -0
  90. package/dist/data/__tests__/repository.test.js.map +1 -1
  91. package/dist/data/drift-inspector.d.ts +41 -0
  92. package/dist/data/drift-inspector.d.ts.map +1 -0
  93. package/dist/data/drift-inspector.js +197 -0
  94. package/dist/data/drift-inspector.js.map +1 -0
  95. package/dist/data/framework-migration-rewriter.d.ts +61 -0
  96. package/dist/data/framework-migration-rewriter.d.ts.map +1 -0
  97. package/dist/data/framework-migration-rewriter.js +357 -0
  98. package/dist/data/framework-migration-rewriter.js.map +1 -0
  99. package/dist/data/index.d.ts +2 -1
  100. package/dist/data/index.d.ts.map +1 -1
  101. package/dist/data/index.js +2 -1
  102. package/dist/data/index.js.map +1 -1
  103. package/dist/data/migration.d.ts +31 -1
  104. package/dist/data/migration.d.ts.map +1 -1
  105. package/dist/data/migration.js +113 -3
  106. package/dist/data/migration.js.map +1 -1
  107. package/dist/data/repository.d.ts.map +1 -1
  108. package/dist/data/repository.js +21 -0
  109. package/dist/data/repository.js.map +1 -1
  110. package/dist/define/__tests__/definePrompt.test.js +37 -0
  111. package/dist/define/__tests__/definePrompt.test.js.map +1 -1
  112. package/dist/define/definePrompt.d.ts +13 -0
  113. package/dist/define/definePrompt.d.ts.map +1 -1
  114. package/dist/define/definePrompt.js +3 -0
  115. package/dist/define/definePrompt.js.map +1 -1
  116. package/dist/errors/http.d.ts.map +1 -1
  117. package/dist/errors/http.js +2 -0
  118. package/dist/errors/http.js.map +1 -1
  119. package/dist/errors/index.d.ts +12 -0
  120. package/dist/errors/index.d.ts.map +1 -1
  121. package/dist/errors/index.js +21 -0
  122. package/dist/errors/index.js.map +1 -1
  123. package/dist/events/__tests__/event-emitter.test.js +72 -0
  124. package/dist/events/__tests__/event-emitter.test.js.map +1 -1
  125. package/dist/events/emitter.d.ts.map +1 -1
  126. package/dist/events/emitter.js +51 -0
  127. package/dist/events/emitter.js.map +1 -1
  128. package/dist/execution/__tests__/context-factory.test.js +1 -0
  129. package/dist/execution/__tests__/context-factory.test.js.map +1 -1
  130. package/dist/execution/context-factory.d.ts.map +1 -1
  131. package/dist/execution/context-factory.js +2 -0
  132. package/dist/execution/context-factory.js.map +1 -1
  133. package/dist/flows/__tests__/flow-engine.test.js +451 -4
  134. package/dist/flows/__tests__/flow-engine.test.js.map +1 -1
  135. package/dist/flows/__tests__/flow-service.test.js +8 -0
  136. package/dist/flows/__tests__/flow-service.test.js.map +1 -1
  137. package/dist/flows/__tests__/step-executor.test.js +8 -2
  138. package/dist/flows/__tests__/step-executor.test.js.map +1 -1
  139. package/dist/flows/engine.d.ts +50 -1
  140. package/dist/flows/engine.d.ts.map +1 -1
  141. package/dist/flows/engine.js +427 -23
  142. package/dist/flows/engine.js.map +1 -1
  143. package/dist/flows/flow-service.d.ts.map +1 -1
  144. package/dist/flows/flow-service.js +4 -0
  145. package/dist/flows/flow-service.js.map +1 -1
  146. package/dist/flows/index.d.ts +1 -1
  147. package/dist/flows/index.d.ts.map +1 -1
  148. package/dist/flows/index.js +1 -1
  149. package/dist/flows/index.js.map +1 -1
  150. package/dist/flows/schema.d.ts +34 -0
  151. package/dist/flows/schema.d.ts.map +1 -1
  152. package/dist/flows/schema.js +3 -0
  153. package/dist/flows/schema.js.map +1 -1
  154. package/dist/index.d.ts +4 -4
  155. package/dist/index.d.ts.map +1 -1
  156. package/dist/index.js +3 -3
  157. package/dist/index.js.map +1 -1
  158. package/dist/server/bootstrap.d.ts +14 -0
  159. package/dist/server/bootstrap.d.ts.map +1 -1
  160. package/dist/server/bootstrap.js +23 -4
  161. package/dist/server/bootstrap.js.map +1 -1
  162. package/dist/testing/context.d.ts.map +1 -1
  163. package/dist/testing/context.js +37 -1
  164. package/dist/testing/context.js.map +1 -1
  165. package/dist/types/context.d.ts +110 -0
  166. package/dist/types/context.d.ts.map +1 -1
  167. package/dist/types/enums.d.ts +2 -0
  168. package/dist/types/enums.d.ts.map +1 -1
  169. package/dist/types/enums.js +2 -0
  170. package/dist/types/enums.js.map +1 -1
  171. package/dist/types/prompt.d.ts +64 -0
  172. package/dist/types/prompt.d.ts.map +1 -1
  173. package/dist/worker/__tests__/bootstrap.test.js +36 -2
  174. package/dist/worker/__tests__/bootstrap.test.js.map +1 -1
  175. package/dist/worker/bootstrap.d.ts +7 -0
  176. package/dist/worker/bootstrap.d.ts.map +1 -1
  177. package/dist/worker/bootstrap.js +153 -12
  178. package/dist/worker/bootstrap.js.map +1 -1
  179. package/instructions/framework.md +7 -0
  180. package/instructions/guardrails.md +39 -0
  181. package/instructions/patterns.md +10 -0
  182. package/package.json +3 -2
@@ -1,15 +1,64 @@
1
- import { and, eq, inArray, isNull, lte, or } from 'drizzle-orm';
1
+ import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { hostname } from 'node:os';
4
+ import { FlowCancelledError, LeaseLostError } from '../errors/index.js';
3
5
  import { BackoffStrategy } from '../types/enums.js';
4
6
  import { flowExecutionsTable } from './schema.js';
5
7
  import { assertTransition, FlowStatus, isTerminal, StepStatus, } from './state-machine.js';
6
- import { buildHistoryEntry, executeStep } from './step-executor.js';
8
+ import { buildHistoryEntry, executeStep, } from './step-executor.js';
9
+ /** Default lease duration: 5 minutes */
10
+ const DEFAULT_LEASE_DURATION_MS = 300_000;
11
+ /** Default claim batch size */
12
+ const DEFAULT_CLAIM_BATCH_SIZE = 50;
13
+ /**
14
+ * Read the affected-row count from a Drizzle UPDATE/INSERT/DELETE result in a
15
+ * driver-agnostic way. postgres-js exposes this as `.count` (the array-like
16
+ * `Result` has `length === 0` when no RETURNING clause is used), while
17
+ * node-postgres exposes it as `.rowCount`. Returning `length` is only a
18
+ * correct fallback for queries that include `.returning(...)`.
19
+ *
20
+ * Historical bug: reading only `rowCount ?? length` silently returned 0 for
21
+ * every postgres-js UPDATE without RETURNING, which caused `extendLease` to
22
+ * always report "lease lost" even when the row was successfully updated.
23
+ */
24
+ function getRowsAffected(result) {
25
+ const r = result;
26
+ if (typeof r?.rowCount === 'number')
27
+ return r.rowCount;
28
+ if (typeof r?.count === 'number')
29
+ return r.count;
30
+ if (typeof r?.length === 'number')
31
+ return r.length;
32
+ return 0;
33
+ }
34
+ /**
35
+ * Generate a deterministic-ish worker id for the current process.
36
+ */
37
+ export function generateWorkerId() {
38
+ return `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
39
+ }
7
40
  /**
8
41
  * Creates the flow execution engine that manages flow lifecycle:
9
42
  * start, run steps, handle wait/resume, and persist state.
43
+ *
44
+ * Supports lease-based claiming to prevent duplicate step execution
45
+ * across multiple workers sharing the same database.
10
46
  */
11
47
  export function createFlowEngine(config) {
12
48
  const { db, registry, stepDeps, audit, onFlowError, createEventService } = config;
49
+ const workerId = config.workerId ?? generateWorkerId();
50
+ const leaseDurationMs = config.flowLeaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
51
+ const heartbeatIntervalMs = config.flowHeartbeatIntervalMs ?? Math.floor(leaseDurationMs / 3);
52
+ const claimBatchSize = config.flowClaimBatchSize ?? DEFAULT_CLAIM_BATCH_SIZE;
53
+ const logger = config.logger;
54
+ /**
55
+ * AbortControllers for currently-executing steps in THIS engine instance,
56
+ * keyed by executionId. `cancel()` uses this to abort the running step
57
+ * synchronously when the cancel request arrives in the same process as
58
+ * the worker. Cross-process cancels still converge via the next heartbeat
59
+ * (which now also fails when status != 'running').
60
+ */
61
+ const activeStepAborts = new Map();
13
62
  /**
14
63
  * Start a new flow execution.
15
64
  */
@@ -59,9 +108,125 @@ export function createFlowEngine(config) {
59
108
  status: FlowStatus.Created,
60
109
  };
61
110
  }
111
+ /**
112
+ * Atomically claim up to `batchSize` runnable flow executions.
113
+ * Uses FOR UPDATE SKIP LOCKED so concurrent workers each get different rows.
114
+ * Also reclaims rows whose lease has expired (crash recovery).
115
+ */
116
+ async function claimNext(batchSize) {
117
+ const limit = batchSize ?? claimBatchSize;
118
+ const leaseDurationInterval = `${leaseDurationMs} milliseconds`;
119
+ const result = await db.execute(sql `
120
+ UPDATE flow_executions
121
+ SET status = ${FlowStatus.Running},
122
+ lease_owner = ${workerId},
123
+ lease_expires_at = now() + ${leaseDurationInterval}::interval,
124
+ updated_at = now()
125
+ WHERE id IN (
126
+ SELECT id
127
+ FROM flow_executions
128
+ WHERE
129
+ status = ${FlowStatus.Created}
130
+ OR (status = ${FlowStatus.Running} AND lease_expires_at < now())
131
+ OR (status = ${FlowStatus.Waiting} AND waiting_for_event IS NULL AND wake_at <= now())
132
+ ORDER BY created_at
133
+ FOR UPDATE SKIP LOCKED
134
+ LIMIT ${limit}
135
+ )
136
+ RETURNING *
137
+ `);
138
+ const rows = result;
139
+ for (const row of rows) {
140
+ if (audit) {
141
+ const wasExpired = row.status === FlowStatus.Running;
142
+ if (wasExpired) {
143
+ await audit.record('flow.lease.expired', {
144
+ executionId: row.id,
145
+ flowName: row.flowName,
146
+ workerId,
147
+ previousOwner: row.leaseOwner,
148
+ step: row.currentStep,
149
+ });
150
+ }
151
+ await audit.record('flow.lease.claimed', {
152
+ executionId: row.id,
153
+ flowName: row.flowName,
154
+ workerId,
155
+ step: row.currentStep,
156
+ });
157
+ }
158
+ }
159
+ return rows;
160
+ }
161
+ /**
162
+ * Extend the lease for a flow execution.
163
+ * Returns true if the lease was extended, false if the lease was lost.
164
+ *
165
+ * The WHERE clause also requires `status = 'running'`. If another actor
166
+ * (e.g. `cancel()`) has flipped the status to `cancelled`/`failed`, the
167
+ * UPDATE matches 0 rows and we take the same "lease lost" path — which
168
+ * the heartbeat uses to abort the per-step AbortController so the running
169
+ * capability can cooperatively stop its in-flight AI / HTTP work.
170
+ */
171
+ async function extendLease(executionId) {
172
+ const leaseDurationInterval = `${leaseDurationMs} milliseconds`;
173
+ const result = await db
174
+ .update(flowExecutionsTable)
175
+ .set({
176
+ leaseExpiresAt: sql `now() + ${leaseDurationInterval}::interval`,
177
+ updatedAt: new Date(),
178
+ })
179
+ .where(and(eq(flowExecutionsTable.id, executionId), eq(flowExecutionsTable.leaseOwner, workerId), eq(flowExecutionsTable.status, FlowStatus.Running)));
180
+ const rowsAffected = getRowsAffected(result);
181
+ if (rowsAffected === 0 && logger) {
182
+ // D1 diagnostic: the heartbeat's status+owner guard didn't match, which
183
+ // means something mutated the row out from under us (status flipped by
184
+ // cancel/complete/failure, or a different worker stole the lease). Read
185
+ // the row back and log its actual shape plus our own identity so we can
186
+ // tell at a glance which dimension diverged. Best-effort: a failure
187
+ // here must not break the heartbeat.
188
+ try {
189
+ const rows = await db
190
+ .select({
191
+ status: flowExecutionsTable.status,
192
+ leaseOwner: flowExecutionsTable.leaseOwner,
193
+ leaseExpiresAt: flowExecutionsTable.leaseExpiresAt,
194
+ updatedAt: flowExecutionsTable.updatedAt,
195
+ currentStep: flowExecutionsTable.currentStep,
196
+ })
197
+ .from(flowExecutionsTable)
198
+ .where(eq(flowExecutionsTable.id, executionId))
199
+ .limit(1);
200
+ const row = rows[0];
201
+ logger.warn('extendLease matched 0 rows', {
202
+ executionId,
203
+ expectedWorkerId: workerId,
204
+ expectedStatus: FlowStatus.Running,
205
+ actual: row
206
+ ? {
207
+ status: row.status,
208
+ leaseOwner: row.leaseOwner,
209
+ leaseExpiresAt: row.leaseExpiresAt?.toISOString() ?? null,
210
+ updatedAt: row.updatedAt?.toISOString() ?? null,
211
+ currentStep: row.currentStep,
212
+ }
213
+ : null,
214
+ nowMs: Date.now(),
215
+ });
216
+ }
217
+ catch (diagErr) {
218
+ logger.warn('extendLease diagnostic SELECT failed', {
219
+ executionId,
220
+ error: diagErr instanceof Error ? diagErr.message : String(diagErr),
221
+ });
222
+ }
223
+ }
224
+ return rowsAffected > 0;
225
+ }
62
226
  /**
63
227
  * Run the next step(s) for a flow execution.
64
228
  * This is designed to be called by a worker process.
229
+ * The execution must have been claimed by this worker (via claimNext) first.
65
230
  */
66
231
  async function runNext(executionId, ctx) {
67
232
  const rows = await db
@@ -87,13 +252,15 @@ export function createFlowEngine(config) {
87
252
  if (row.status === FlowStatus.Waiting && row.wakeAt && row.wakeAt.getTime() > Date.now()) {
88
253
  return { id: row.id, flowName: row.flowName, status: FlowStatus.Waiting };
89
254
  }
90
- // Transition to running
255
+ // Transition to running (only if not already claimed via claimNext)
91
256
  if (row.status === FlowStatus.Created || row.status === FlowStatus.Waiting) {
92
257
  assertTransition(row.status, FlowStatus.Running);
93
- await updateExecution(executionId, {
258
+ await guardedUpdate(executionId, {
94
259
  status: FlowStatus.Running,
95
260
  waitingForEvent: null,
96
261
  wakeAt: null,
262
+ leaseOwner: workerId,
263
+ leaseExpiresAt: new Date(Date.now() + leaseDurationMs),
97
264
  });
98
265
  }
99
266
  // Find current step
@@ -117,19 +284,121 @@ export function createFlowEngine(config) {
117
284
  };
118
285
  const flowData = config.createDataService ? config.createDataService(flowAuth) : ctx.data;
119
286
  const flowEvents = createEventService ? createEventService(flowAuth) : ctx.events;
287
+ // Create a heartbeat function for manual use by step handlers
288
+ const heartbeatFn = async () => {
289
+ const extended = await extendLease(executionId);
290
+ if (!extended) {
291
+ throw new LeaseLostError(`Lease lost for flow execution "${executionId}" (worker: ${workerId})`, { executionId, workerId, step: currentStepName });
292
+ }
293
+ };
294
+ // Per-step AbortController: aborted when the lease is lost (either
295
+ // because another worker claimed the execution OR because cancel() flipped
296
+ // the status out of 'running'). Capability handlers can subscribe via
297
+ // `ctx.signal` and pass it to AI/HTTP calls to stop cooperatively rather
298
+ // than continuing to burn budget as a zombie.
299
+ const stepAc = new AbortController();
300
+ activeStepAborts.set(executionId, stepAc);
301
+ // Auto-thread `signal` into AI calls so capability authors don't have to
302
+ // pass it explicitly. An explicit `signal` in the call overrides the
303
+ // step default. See AIService type for the per-method signal parameter.
304
+ const flowAi = wrapAiWithDefaultSignal(ctx.ai, stepAc.signal);
120
305
  const flowCtx = {
121
306
  ...ctx,
122
307
  auth: flowAuth,
123
308
  data: flowData,
124
309
  events: flowEvents,
310
+ ai: flowAi,
125
311
  state: row.state,
126
312
  step: currentStepName,
127
313
  flowId: executionId,
314
+ workerId,
315
+ signal: stepAc.signal,
316
+ flows: {
317
+ ...ctx.flows,
318
+ heartbeat: heartbeatFn,
319
+ },
128
320
  };
321
+ // Start automatic heartbeat timer
322
+ let leaseLost = false;
323
+ let heartbeatTickCount = 0;
324
+ const heartbeatStartedAtMs = Date.now();
325
+ const heartbeatTimer = setInterval(async () => {
326
+ heartbeatTickCount += 1;
327
+ const tick = heartbeatTickCount;
328
+ const firedAtMs = Date.now();
329
+ const expectedFireAtMs = heartbeatStartedAtMs + tick * heartbeatIntervalMs;
330
+ const driftMs = firedAtMs - expectedFireAtMs;
331
+ // D2 diagnostic: emit one log line per heartbeat tick so we can see
332
+ // whether ticks are firing on schedule (event-loop starvation shows up
333
+ // here as large positive `driftMs`). First tick is logged at `info`
334
+ // because that's typically enough for a post-mortem; subsequent ticks
335
+ // at `debug` to avoid log spam for long-running flows.
336
+ if (logger) {
337
+ const payload = {
338
+ executionId,
339
+ workerId,
340
+ step: currentStepName,
341
+ tick,
342
+ firedAtMs,
343
+ expectedFireAtMs,
344
+ driftMs,
345
+ heartbeatIntervalMs,
346
+ };
347
+ if (tick === 1) {
348
+ logger.info('flow.heartbeat.tick', payload);
349
+ }
350
+ else {
351
+ logger.debug('flow.heartbeat.tick', payload);
352
+ }
353
+ }
354
+ const extended = await extendLease(executionId);
355
+ if (extended) {
356
+ if (audit) {
357
+ await audit.record('flow.lease.extended', {
358
+ executionId,
359
+ flowName: row.flowName,
360
+ workerId,
361
+ step: currentStepName,
362
+ });
363
+ }
364
+ }
365
+ else {
366
+ leaseLost = true;
367
+ if (!stepAc.signal.aborted) {
368
+ stepAc.abort(new LeaseLostError(`Lease lost for flow execution "${executionId}" (worker: ${workerId})`, { executionId, workerId, step: currentStepName }));
369
+ }
370
+ if (audit) {
371
+ await audit.record('flow.lease.lost', {
372
+ executionId,
373
+ flowName: row.flowName,
374
+ workerId,
375
+ step: currentStepName,
376
+ });
377
+ }
378
+ }
379
+ }, heartbeatIntervalMs);
129
380
  // Execute the step
381
+ let result;
130
382
  const startedAt = new Date();
131
- const result = await executeStep(step, flowCtx, row.input, row.state, stepDeps);
383
+ try {
384
+ result = await executeStep(step, flowCtx, row.input, row.state, stepDeps);
385
+ }
386
+ finally {
387
+ clearInterval(heartbeatTimer);
388
+ // Abort any stray detached tasks once the step settles so they observe
389
+ // the abort promptly. If already aborted (lease loss), this is a no-op.
390
+ if (!stepAc.signal.aborted) {
391
+ stepAc.abort();
392
+ }
393
+ if (activeStepAborts.get(executionId) === stepAc) {
394
+ activeStepAborts.delete(executionId);
395
+ }
396
+ }
132
397
  const completedAt = new Date();
398
+ // Check if lease was lost during step execution
399
+ if (leaseLost) {
400
+ throw new LeaseLostError(`Lease lost during step "${currentStepName}" of flow execution "${executionId}"`, { executionId, workerId, step: currentStepName });
401
+ }
133
402
  const historyEntry = buildHistoryEntry(currentStepName, result, startedAt, completedAt);
134
403
  const history = Array.isArray(row.stepHistory) ? row.stepHistory : [];
135
404
  history.push(historyEntry);
@@ -149,14 +418,23 @@ export function createFlowEngine(config) {
149
418
  }
150
419
  // Wait step — pause the flow
151
420
  if (result.waitEvent) {
152
- await updateExecution(executionId, {
421
+ const ok = await guardedUpdate(executionId, {
153
422
  status: FlowStatus.Waiting,
154
423
  state: nextState,
155
424
  stepHistory: history,
156
425
  currentStep: currentStepName, // stay on current step until resumed
157
426
  waitingForEvent: result.waitEvent,
158
427
  wakeAt: null,
428
+ leaseOwner: null,
429
+ leaseExpiresAt: null,
159
430
  });
431
+ if (!ok) {
432
+ throw new LeaseLostError(`Lease lost committing wait for flow execution "${executionId}"`, {
433
+ executionId,
434
+ workerId,
435
+ step: currentStepName,
436
+ });
437
+ }
160
438
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Waiting };
161
439
  }
162
440
  // Delay step — schedule next step after duration
@@ -169,13 +447,15 @@ export function createFlowEngine(config) {
169
447
  catch (err) {
170
448
  return handleStepFailure(executionId, row, flow, history, err instanceof Error ? err.message : String(err));
171
449
  }
172
- await updateExecution(executionId, {
450
+ await guardedUpdate(executionId, {
173
451
  status: FlowStatus.Waiting,
174
452
  state: nextState,
175
453
  stepHistory: history,
176
454
  currentStep: nextStep,
177
455
  waitingForEvent: null,
178
456
  wakeAt: new Date(Date.now() + delayMs),
457
+ leaseOwner: null,
458
+ leaseExpiresAt: null,
179
459
  });
180
460
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Waiting };
181
461
  }
@@ -205,7 +485,7 @@ export function createFlowEngine(config) {
205
485
  return handleStepFailure(executionId, row, flow, history, 'One or more parallel branches failed');
206
486
  }
207
487
  const nextStep = getNextStepName(flow.steps, currentStepName);
208
- await updateExecution(executionId, {
488
+ await guardedUpdate(executionId, {
209
489
  state: parallelState,
210
490
  stepHistory: history,
211
491
  currentStep: nextStep,
@@ -218,7 +498,7 @@ export function createFlowEngine(config) {
218
498
  }
219
499
  // Conditional step — jump to the chosen branch
220
500
  if (result.nextStep) {
221
- await updateExecution(executionId, {
501
+ await guardedUpdate(executionId, {
222
502
  state: nextState,
223
503
  stepHistory: history,
224
504
  currentStep: result.nextStep,
@@ -228,11 +508,11 @@ export function createFlowEngine(config) {
228
508
  // Normal completion — advance to next step
229
509
  const nextStep = getNextStepName(flow.steps, currentStepName);
230
510
  if (!nextStep) {
231
- await updateExecution(executionId, { state: nextState, stepHistory: history });
511
+ await guardedUpdate(executionId, { state: nextState, stepHistory: history });
232
512
  await completeFlow(executionId, row.flowName);
233
513
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Completed };
234
514
  }
235
- await updateExecution(executionId, {
515
+ await guardedUpdate(executionId, {
236
516
  state: nextState,
237
517
  stepHistory: history,
238
518
  currentStep: nextStep,
@@ -257,27 +537,47 @@ export function createFlowEngine(config) {
257
537
  const flow = registry.get(row.flowName);
258
538
  if (!flow)
259
539
  throw new Error(`Flow "${row.flowName}" not registered`);
260
- // Advance past the wait/delay step
540
+ // Advance past the wait/delay step — set to created so claimNext picks it up
261
541
  const nextStep = getNextStepName(flow.steps, row.currentStep ?? '');
262
542
  await updateExecution(executionId, {
263
- status: FlowStatus.Running,
543
+ status: FlowStatus.Created,
264
544
  currentStep: nextStep,
265
545
  state: signal !== undefined ? signal : row.state,
266
546
  waitingForEvent: null,
267
547
  wakeAt: null,
548
+ leaseOwner: null,
549
+ leaseExpiresAt: null,
268
550
  });
269
551
  }
270
552
  /**
271
553
  * Resume all flows waiting on a specific event type.
554
+ * Uses FOR UPDATE SKIP LOCKED to prevent duplicate resumes across workers.
272
555
  * Returns the number of executions resumed.
273
556
  */
274
557
  async function resumeWaitingByEvent(eventType, signal) {
275
- const rows = await db
276
- .select({ id: flowExecutionsTable.id })
277
- .from(flowExecutionsTable)
278
- .where(and(eq(flowExecutionsTable.status, FlowStatus.Waiting), eq(flowExecutionsTable.waitingForEvent, eventType)));
279
- for (const row of rows) {
280
- await resume(row.id, signal);
558
+ const result = await db.execute(sql `
559
+ UPDATE flow_executions
560
+ SET status = ${FlowStatus.Created},
561
+ waiting_for_event = NULL,
562
+ wake_at = NULL,
563
+ lease_owner = NULL,
564
+ lease_expires_at = NULL,
565
+ updated_at = now()
566
+ WHERE id IN (
567
+ SELECT id
568
+ FROM flow_executions
569
+ WHERE status = ${FlowStatus.Waiting}
570
+ AND waiting_for_event = ${eventType}
571
+ FOR UPDATE SKIP LOCKED
572
+ )
573
+ RETURNING id
574
+ `);
575
+ const rows = result;
576
+ // If a signal was provided, update state for each resumed execution
577
+ if (signal !== undefined) {
578
+ for (const row of rows) {
579
+ await updateExecution(row.id, { state: signal });
580
+ }
281
581
  }
282
582
  return rows.length;
283
583
  }
@@ -313,6 +613,13 @@ export function createFlowEngine(config) {
313
613
  status: FlowStatus.Cancelled,
314
614
  completedAt: new Date(),
315
615
  });
616
+ // Synchronously abort the step's AbortController if it is running in THIS
617
+ // engine instance. Cross-process cancellations still converge via the next
618
+ // heartbeat (which fails because status is no longer 'running').
619
+ const activeAc = activeStepAborts.get(executionId);
620
+ if (activeAc && !activeAc.signal.aborted) {
621
+ activeAc.abort(new FlowCancelledError(executionId));
622
+ }
316
623
  if (audit) {
317
624
  await audit.record(`flow.cancelled.${row.flowName}`, {
318
625
  executionId,
@@ -340,7 +647,7 @@ export function createFlowEngine(config) {
340
647
  const maxRetries = flow.retry?.attempts ?? 0;
341
648
  if (retryCount <= maxRetries) {
342
649
  // Retry: keep current step, increment counter
343
- await updateExecution(executionId, {
650
+ await guardedUpdate(executionId, {
344
651
  stepHistory: history,
345
652
  retryCount,
346
653
  lastError: error ?? null,
@@ -352,21 +659,29 @@ export function createFlowEngine(config) {
352
659
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Failed };
353
660
  }
354
661
  async function completeFlow(executionId, flowName) {
355
- await updateExecution(executionId, {
662
+ const ok = await guardedUpdate(executionId, {
356
663
  status: FlowStatus.Completed,
357
664
  currentStep: null,
358
665
  completedAt: new Date(),
666
+ leaseOwner: null,
667
+ leaseExpiresAt: null,
359
668
  });
669
+ if (!ok)
670
+ return; // Lease lost — another worker owns this execution
360
671
  if (audit) {
361
672
  await audit.record(`flow.completed.${flowName}`, { executionId, flowName });
362
673
  }
363
674
  }
364
675
  async function failFlow(executionId, flowName, error, row) {
365
- await updateExecution(executionId, {
676
+ const ok = await guardedUpdate(executionId, {
366
677
  status: FlowStatus.Failed,
367
678
  lastError: error ?? 'Unknown error',
368
679
  completedAt: new Date(),
680
+ leaseOwner: null,
681
+ leaseExpiresAt: null,
369
682
  });
683
+ if (!ok)
684
+ return; // Lease lost — another worker owns this execution
370
685
  if (audit) {
371
686
  await audit.record(`flow.failed.${flowName}`, {
372
687
  executionId,
@@ -397,7 +712,96 @@ export function createFlowEngine(config) {
397
712
  .set({ ...updates, updatedAt: new Date() })
398
713
  .where(eq(flowExecutionsTable.id, id));
399
714
  }
400
- return { start, runNext, resume, resumeWaitingByEvent, listRunnable, cancel, status };
715
+ /**
716
+ * Finalize an execution as `failed` from the flow runner's perspective when
717
+ * `runNext` rejects with an unhandled error (e.g. `LeaseLostError` from a
718
+ * heartbeat). Without this, a zombie row left by a previously-crashed worker
719
+ * would be re-claimed by `claimNext` on every poll cycle (via the
720
+ * `status='running' AND lease_expires_at < now()` crash-recovery branch),
721
+ * retry forever, and pile up log noise.
722
+ *
723
+ * The guard is `status IN ('running','created','waiting')` (a set-guard, NOT
724
+ * a `lease_owner = workerId` check): on `LeaseLostError` the lease is by
725
+ * definition no longer ours, and we still want to stop the loop. A
726
+ * concurrent successful completion or an explicit `cancel()` will have
727
+ * already moved the row to a terminal status, and the `IN (...)` clause
728
+ * turns this call into a no-op in those races.
729
+ *
730
+ * Idempotent: second call on an already-finalized row is a no-op.
731
+ */
732
+ async function markFailedFromRunner(executionId, error) {
733
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error';
734
+ const result = await db
735
+ .update(flowExecutionsTable)
736
+ .set({
737
+ status: FlowStatus.Failed,
738
+ lastError: message,
739
+ completedAt: new Date(),
740
+ leaseOwner: null,
741
+ leaseExpiresAt: null,
742
+ updatedAt: new Date(),
743
+ })
744
+ .where(and(eq(flowExecutionsTable.id, executionId), inArray(flowExecutionsTable.status, [
745
+ FlowStatus.Running,
746
+ FlowStatus.Created,
747
+ FlowStatus.Waiting,
748
+ ])));
749
+ const rowsAffected = getRowsAffected(result);
750
+ if (rowsAffected > 0 && audit) {
751
+ await audit.record('flow.runner.finalized', {
752
+ executionId,
753
+ workerId,
754
+ error: message,
755
+ });
756
+ }
757
+ return rowsAffected > 0;
758
+ }
759
+ /**
760
+ * Update execution with lease ownership guard.
761
+ * Returns true if the update succeeded (we still own the lease),
762
+ * false if the lease was lost (0 rows affected).
763
+ */
764
+ async function guardedUpdate(id, updates) {
765
+ const result = await db
766
+ .update(flowExecutionsTable)
767
+ .set({ ...updates, updatedAt: new Date() })
768
+ .where(and(eq(flowExecutionsTable.id, id), eq(flowExecutionsTable.leaseOwner, workerId)));
769
+ const rowsAffected = getRowsAffected(result);
770
+ return rowsAffected > 0;
771
+ }
772
+ return {
773
+ start,
774
+ runNext,
775
+ resume,
776
+ resumeWaitingByEvent,
777
+ listRunnable,
778
+ claimNext,
779
+ cancel,
780
+ status,
781
+ markFailedFromRunner,
782
+ get workerId() {
783
+ return workerId;
784
+ },
785
+ };
786
+ }
787
+ /**
788
+ * Wrap an AIService so that every method defaults `signal` to the flow step's
789
+ * AbortSignal when the caller doesn't pass one explicitly. `streamGenerate`
790
+ * is a sync function returning an AsyncIterable and is wrapped accordingly.
791
+ */
792
+ function wrapAiWithDefaultSignal(ai, defaultSignal) {
793
+ const withSignal = (params) => ({
794
+ ...params,
795
+ signal: params.signal ?? defaultSignal,
796
+ });
797
+ return {
798
+ generate: (params) => ai.generate(withSignal(params)),
799
+ generateWithUsage: (params) => ai.generateWithUsage(withSignal(params)),
800
+ streamGenerate: (params) => ai.streamGenerate(withSignal(params)),
801
+ extract: (params) => ai.extract(withSignal(params)),
802
+ classify: (params) => ai.classify(withSignal(params)),
803
+ retrieve: (params) => ai.retrieve(withSignal(params)),
804
+ };
401
805
  }
402
806
  function mergeExecutionState(currentState, stepData) {
403
807
  if (stepData === undefined) {