@adonis-agora/durable 0.49.1 → 0.51.0

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 (335) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/README.md +53 -22
  3. package/dist/assets/spa/assets/index-DupjrK7N.js +16 -0
  4. package/dist/assets/spa/assets/index-DupjrK7N.js.map +1 -0
  5. package/dist/assets/spa/index.html +1 -1
  6. package/dist/commands/durable_worker.d.ts.map +1 -1
  7. package/dist/commands/durable_worker.js +9 -8
  8. package/dist/commands/durable_worker.js.map +1 -1
  9. package/dist/commands/work.d.ts.map +1 -1
  10. package/dist/commands/work.js +3 -1
  11. package/dist/commands/work.js.map +1 -1
  12. package/dist/configure.d.ts.map +1 -1
  13. package/dist/configure.js +1 -0
  14. package/dist/configure.js.map +1 -1
  15. package/dist/providers/dashboard_provider.d.ts +2 -0
  16. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  17. package/dist/providers/dashboard_provider.js +50 -6
  18. package/dist/providers/dashboard_provider.js.map +1 -1
  19. package/dist/providers/durable_provider.d.ts.map +1 -1
  20. package/dist/providers/durable_provider.js +146 -35
  21. package/dist/providers/durable_provider.js.map +1 -1
  22. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  23. package/dist/src/admission-redis/redis-admission-backend.js +3 -1
  24. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  25. package/dist/src/ambient-step.d.ts +24 -0
  26. package/dist/src/ambient-step.d.ts.map +1 -0
  27. package/dist/src/ambient-step.js +42 -0
  28. package/dist/src/ambient-step.js.map +1 -0
  29. package/dist/src/base-workflow.d.ts +10 -1
  30. package/dist/src/base-workflow.d.ts.map +1 -1
  31. package/dist/src/base-workflow.js.map +1 -1
  32. package/dist/src/codec-state-store.d.ts +28 -2
  33. package/dist/src/codec-state-store.d.ts.map +1 -1
  34. package/dist/src/codec-state-store.js +61 -3
  35. package/dist/src/codec-state-store.js.map +1 -1
  36. package/dist/src/commands/worker.d.ts +8 -3
  37. package/dist/src/commands/worker.d.ts.map +1 -1
  38. package/dist/src/commands/worker.js +35 -8
  39. package/dist/src/commands/worker.js.map +1 -1
  40. package/dist/src/config_types.d.ts +6 -0
  41. package/dist/src/config_types.d.ts.map +1 -1
  42. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  43. package/dist/src/control-plane-redis/redis-control-plane.js +22 -6
  44. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  45. package/dist/src/control-planes/factory.d.ts +6 -0
  46. package/dist/src/control-planes/factory.d.ts.map +1 -1
  47. package/dist/src/control-planes/factory.js +1 -0
  48. package/dist/src/control-planes/factory.js.map +1 -1
  49. package/dist/src/dashboard/gateway-adapter.d.ts +15 -5
  50. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  51. package/dist/src/dashboard/gateway-adapter.js +71 -7
  52. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  53. package/dist/src/dashboard/handlers.d.ts +53 -1
  54. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  55. package/dist/src/dashboard/handlers.js +200 -20
  56. package/dist/src/dashboard/handlers.js.map +1 -1
  57. package/dist/src/dashboard/index.d.ts +2 -2
  58. package/dist/src/dashboard/index.d.ts.map +1 -1
  59. package/dist/src/dashboard/index.js +2 -2
  60. package/dist/src/dashboard/index.js.map +1 -1
  61. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  62. package/dist/src/dashboard/openapi.js +71 -1
  63. package/dist/src/dashboard/openapi.js.map +1 -1
  64. package/dist/src/define_config.d.ts +122 -8
  65. package/dist/src/define_config.d.ts.map +1 -1
  66. package/dist/src/define_config.js.map +1 -1
  67. package/dist/src/diagnostics-bridge.d.ts +16 -3
  68. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  69. package/dist/src/diagnostics-bridge.js +9 -3
  70. package/dist/src/diagnostics-bridge.js.map +1 -1
  71. package/dist/src/discovery-roots.d.ts +92 -0
  72. package/dist/src/discovery-roots.d.ts.map +1 -0
  73. package/dist/src/discovery-roots.js +150 -0
  74. package/dist/src/discovery-roots.js.map +1 -0
  75. package/dist/src/engine.d.ts +428 -21
  76. package/dist/src/engine.d.ts.map +1 -1
  77. package/dist/src/engine.js +1566 -164
  78. package/dist/src/engine.js.map +1 -1
  79. package/dist/src/entities.d.ts +36 -1
  80. package/dist/src/entities.d.ts.map +1 -1
  81. package/dist/src/entities.js +65 -4
  82. package/dist/src/entities.js.map +1 -1
  83. package/dist/src/errors.d.ts +46 -0
  84. package/dist/src/errors.d.ts.map +1 -1
  85. package/dist/src/errors.js +48 -0
  86. package/dist/src/errors.js.map +1 -1
  87. package/dist/src/event-trigger-bridge.d.ts +8 -0
  88. package/dist/src/event-trigger-bridge.d.ts.map +1 -1
  89. package/dist/src/event-trigger-bridge.js +24 -3
  90. package/dist/src/event-trigger-bridge.js.map +1 -1
  91. package/dist/src/handshake/announced.d.ts +27 -0
  92. package/dist/src/handshake/announced.d.ts.map +1 -1
  93. package/dist/src/handshake/announced.js +62 -0
  94. package/dist/src/handshake/announced.js.map +1 -1
  95. package/dist/src/handshake/directory.d.ts +92 -0
  96. package/dist/src/handshake/directory.d.ts.map +1 -0
  97. package/dist/src/handshake/directory.js +139 -0
  98. package/dist/src/handshake/directory.js.map +1 -0
  99. package/dist/src/hooks/barrel-sources.d.ts +28 -0
  100. package/dist/src/hooks/barrel-sources.d.ts.map +1 -0
  101. package/dist/src/hooks/barrel-sources.js +92 -0
  102. package/dist/src/hooks/barrel-sources.js.map +1 -0
  103. package/dist/src/hooks/steps.d.ts +12 -3
  104. package/dist/src/hooks/steps.d.ts.map +1 -1
  105. package/dist/src/hooks/steps.js +7 -11
  106. package/dist/src/hooks/steps.js.map +1 -1
  107. package/dist/src/hooks/workflows.d.ts +12 -3
  108. package/dist/src/hooks/workflows.d.ts.map +1 -1
  109. package/dist/src/hooks/workflows.js +7 -13
  110. package/dist/src/hooks/workflows.js.map +1 -1
  111. package/dist/src/index.d.ts +12 -3
  112. package/dist/src/index.d.ts.map +1 -1
  113. package/dist/src/index.js +12 -3
  114. package/dist/src/index.js.map +1 -1
  115. package/dist/src/interfaces.d.ts +309 -21
  116. package/dist/src/interfaces.d.ts.map +1 -1
  117. package/dist/src/interfaces.js +10 -0
  118. package/dist/src/interfaces.js.map +1 -1
  119. package/dist/src/metrics.d.ts.map +1 -1
  120. package/dist/src/metrics.js +5 -0
  121. package/dist/src/metrics.js.map +1 -1
  122. package/dist/src/namespaces.d.ts +17 -0
  123. package/dist/src/namespaces.d.ts.map +1 -0
  124. package/dist/src/namespaces.js +24 -0
  125. package/dist/src/namespaces.js.map +1 -0
  126. package/dist/src/on-error.d.ts +4 -1
  127. package/dist/src/on-error.d.ts.map +1 -1
  128. package/dist/src/on-error.js +15 -2
  129. package/dist/src/on-error.js.map +1 -1
  130. package/dist/src/otel/durable-metrics.d.ts.map +1 -1
  131. package/dist/src/otel/durable-metrics.js +5 -0
  132. package/dist/src/otel/durable-metrics.js.map +1 -1
  133. package/dist/src/otel/durable-otel.d.ts.map +1 -1
  134. package/dist/src/otel/durable-otel.js +5 -0
  135. package/dist/src/otel/durable-otel.js.map +1 -1
  136. package/dist/src/pollers.d.ts +10 -1
  137. package/dist/src/pollers.d.ts.map +1 -1
  138. package/dist/src/pollers.js +15 -5
  139. package/dist/src/pollers.js.map +1 -1
  140. package/dist/src/protocol.d.ts +12 -0
  141. package/dist/src/protocol.d.ts.map +1 -1
  142. package/dist/src/protocol.js +27 -1
  143. package/dist/src/protocol.js.map +1 -1
  144. package/dist/src/remote-event.d.ts +6 -0
  145. package/dist/src/remote-event.d.ts.map +1 -0
  146. package/dist/src/remote-event.js +19 -0
  147. package/dist/src/remote-event.js.map +1 -0
  148. package/dist/src/remote-workflow-executor.d.ts +34 -12
  149. package/dist/src/remote-workflow-executor.d.ts.map +1 -1
  150. package/dist/src/remote-workflow-executor.js +42 -40
  151. package/dist/src/remote-workflow-executor.js.map +1 -1
  152. package/dist/src/run-detail-summary.d.ts +90 -3
  153. package/dist/src/run-detail-summary.d.ts.map +1 -1
  154. package/dist/src/run-detail-summary.js +360 -2
  155. package/dist/src/run-detail-summary.js.map +1 -1
  156. package/dist/src/run-facets.d.ts +48 -0
  157. package/dist/src/run-facets.d.ts.map +1 -0
  158. package/dist/src/run-facets.js +100 -0
  159. package/dist/src/run-facets.js.map +1 -0
  160. package/dist/src/run-gateway/interface.d.ts +27 -1
  161. package/dist/src/run-gateway/interface.d.ts.map +1 -1
  162. package/dist/src/run-gateway/proxy-run-gateway.d.ts +32 -4
  163. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
  164. package/dist/src/run-gateway/proxy-run-gateway.js +101 -5
  165. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
  166. package/dist/src/run-gateway/run-request-responder.d.ts +18 -5
  167. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
  168. package/dist/src/run-gateway/run-request-responder.js +155 -32
  169. package/dist/src/run-gateway/run-request-responder.js.map +1 -1
  170. package/dist/src/run-gateway/store-run-gateway.d.ts +23 -1
  171. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
  172. package/dist/src/run-gateway/store-run-gateway.js +38 -0
  173. package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
  174. package/dist/src/scheduler.d.ts +44 -3
  175. package/dist/src/scheduler.d.ts.map +1 -1
  176. package/dist/src/scheduler.js +36 -1
  177. package/dist/src/scheduler.js.map +1 -1
  178. package/dist/src/search-attributes-schema.d.ts +44 -0
  179. package/dist/src/search-attributes-schema.d.ts.map +1 -0
  180. package/dist/src/search-attributes-schema.js +51 -0
  181. package/dist/src/search-attributes-schema.js.map +1 -0
  182. package/dist/src/singleton-admission.d.ts +75 -0
  183. package/dist/src/singleton-admission.d.ts.map +1 -0
  184. package/dist/src/singleton-admission.js +156 -0
  185. package/dist/src/singleton-admission.js.map +1 -0
  186. package/dist/src/singleton-gate.d.ts +23 -8
  187. package/dist/src/singleton-gate.d.ts.map +1 -1
  188. package/dist/src/singleton-gate.js +59 -12
  189. package/dist/src/singleton-gate.js.map +1 -1
  190. package/dist/src/standard-schema.d.ts +86 -0
  191. package/dist/src/standard-schema.d.ts.map +1 -0
  192. package/dist/src/standard-schema.js +33 -0
  193. package/dist/src/standard-schema.js.map +1 -0
  194. package/dist/src/step-discovery.d.ts +21 -11
  195. package/dist/src/step-discovery.d.ts.map +1 -1
  196. package/dist/src/step-discovery.js +86 -45
  197. package/dist/src/step-discovery.js.map +1 -1
  198. package/dist/src/step-ref.d.ts +38 -12
  199. package/dist/src/step-ref.d.ts.map +1 -1
  200. package/dist/src/step-ref.js +5 -13
  201. package/dist/src/step-ref.js.map +1 -1
  202. package/dist/src/stores/factory.d.ts +6 -0
  203. package/dist/src/stores/factory.d.ts.map +1 -1
  204. package/dist/src/stores/factory.js +4 -1
  205. package/dist/src/stores/factory.js.map +1 -1
  206. package/dist/src/stores/lucid-helpers.d.ts +2 -0
  207. package/dist/src/stores/lucid-helpers.d.ts.map +1 -1
  208. package/dist/src/stores/lucid-helpers.js +7 -0
  209. package/dist/src/stores/lucid-helpers.js.map +1 -1
  210. package/dist/src/stores/lucid-mappers.d.ts +25 -0
  211. package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
  212. package/dist/src/stores/lucid-mappers.js +81 -18
  213. package/dist/src/stores/lucid-mappers.js.map +1 -1
  214. package/dist/src/stores/lucid-schema.d.ts +59 -1
  215. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  216. package/dist/src/stores/lucid-schema.js +271 -25
  217. package/dist/src/stores/lucid-schema.js.map +1 -1
  218. package/dist/src/stores/lucid.d.ts +61 -2
  219. package/dist/src/stores/lucid.d.ts.map +1 -1
  220. package/dist/src/stores/lucid.js +346 -41
  221. package/dist/src/stores/lucid.js.map +1 -1
  222. package/dist/src/telescope/dashboard.d.ts.map +1 -1
  223. package/dist/src/telescope/dashboard.js +30 -1
  224. package/dist/src/telescope/dashboard.js.map +1 -1
  225. package/dist/src/telescope/data-providers.d.ts +19 -1
  226. package/dist/src/telescope/data-providers.d.ts.map +1 -1
  227. package/dist/src/telescope/data-providers.js +119 -3
  228. package/dist/src/telescope/data-providers.js.map +1 -1
  229. package/dist/src/telescope/extension.d.ts.map +1 -1
  230. package/dist/src/telescope/extension.js +2 -1
  231. package/dist/src/telescope/extension.js.map +1 -1
  232. package/dist/src/telescope/index.d.ts +1 -1
  233. package/dist/src/telescope/index.d.ts.map +1 -1
  234. package/dist/src/telescope/index.js +1 -1
  235. package/dist/src/telescope/index.js.map +1 -1
  236. package/dist/src/testing/in-memory-state-store.d.ts +44 -15
  237. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  238. package/dist/src/testing/in-memory-state-store.js +204 -41
  239. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  240. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts +16 -0
  241. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts.map +1 -0
  242. package/dist/src/testing-kit/checkpoint-aggregation-contract.js +228 -0
  243. package/dist/src/testing-kit/checkpoint-aggregation-contract.js.map +1 -0
  244. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  245. package/dist/src/testing-kit/state-store-conformance.js +355 -3
  246. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  247. package/dist/src/transport-pool.d.ts +15 -1
  248. package/dist/src/transport-pool.d.ts.map +1 -1
  249. package/dist/src/transport-pool.js +50 -4
  250. package/dist/src/transport-pool.js.map +1 -1
  251. package/dist/src/transports/adaptive-concurrency.d.ts +168 -0
  252. package/dist/src/transports/adaptive-concurrency.d.ts.map +1 -0
  253. package/dist/src/transports/adaptive-concurrency.js +353 -0
  254. package/dist/src/transports/adaptive-concurrency.js.map +1 -0
  255. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +1 -165
  256. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -1
  257. package/dist/src/transports/bullmq/adaptive-concurrency.js +3 -351
  258. package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -1
  259. package/dist/src/transports/bullmq/bullmq-transport.d.ts +49 -1
  260. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  261. package/dist/src/transports/bullmq/bullmq-transport.js +330 -38
  262. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  263. package/dist/src/transports/bullmq/deps.d.ts +18 -2
  264. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  265. package/dist/src/transports/bullmq/deps.js +44 -2
  266. package/dist/src/transports/bullmq/deps.js.map +1 -1
  267. package/dist/src/transports/db-helpers.d.ts +39 -0
  268. package/dist/src/transports/db-helpers.d.ts.map +1 -1
  269. package/dist/src/transports/db-helpers.js +73 -0
  270. package/dist/src/transports/db-helpers.js.map +1 -1
  271. package/dist/src/transports/db-remote.d.ts +53 -0
  272. package/dist/src/transports/db-remote.d.ts.map +1 -0
  273. package/dist/src/transports/db-remote.js +147 -0
  274. package/dist/src/transports/db-remote.js.map +1 -0
  275. package/dist/src/transports/db-schema.d.ts +44 -1
  276. package/dist/src/transports/db-schema.d.ts.map +1 -1
  277. package/dist/src/transports/db-schema.js +227 -88
  278. package/dist/src/transports/db-schema.js.map +1 -1
  279. package/dist/src/transports/db.d.ts +189 -6
  280. package/dist/src/transports/db.d.ts.map +1 -1
  281. package/dist/src/transports/db.js +1291 -137
  282. package/dist/src/transports/db.js.map +1 -1
  283. package/dist/src/transports/event-emitter.d.ts +8 -0
  284. package/dist/src/transports/event-emitter.d.ts.map +1 -1
  285. package/dist/src/transports/event-emitter.js +36 -3
  286. package/dist/src/transports/event-emitter.js.map +1 -1
  287. package/dist/src/transports/factory.d.ts +110 -5
  288. package/dist/src/transports/factory.d.ts.map +1 -1
  289. package/dist/src/transports/factory.js +56 -9
  290. package/dist/src/transports/factory.js.map +1 -1
  291. package/dist/src/transports/queue-mock-adapter.d.ts +7 -2
  292. package/dist/src/transports/queue-mock-adapter.d.ts.map +1 -1
  293. package/dist/src/transports/queue-mock-adapter.js +18 -2
  294. package/dist/src/transports/queue-mock-adapter.js.map +1 -1
  295. package/dist/src/transports/queue-remote.d.ts +146 -0
  296. package/dist/src/transports/queue-remote.d.ts.map +1 -0
  297. package/dist/src/transports/queue-remote.js +398 -0
  298. package/dist/src/transports/queue-remote.js.map +1 -0
  299. package/dist/src/transports/queue.d.ts +159 -6
  300. package/dist/src/transports/queue.d.ts.map +1 -1
  301. package/dist/src/transports/queue.js +747 -37
  302. package/dist/src/transports/queue.js.map +1 -1
  303. package/dist/src/transports/task-failure.d.ts +31 -0
  304. package/dist/src/transports/task-failure.d.ts.map +1 -0
  305. package/dist/src/transports/task-failure.js +50 -0
  306. package/dist/src/transports/task-failure.js.map +1 -0
  307. package/dist/src/workflow-als.d.ts +1 -1
  308. package/dist/src/workflow-als.d.ts.map +1 -1
  309. package/dist/src/workflow-ctx.d.ts +10 -0
  310. package/dist/src/workflow-ctx.d.ts.map +1 -1
  311. package/dist/src/workflow-ctx.js +97 -15
  312. package/dist/src/workflow-ctx.js.map +1 -1
  313. package/dist/src/workflow-discovery.d.ts +32 -41
  314. package/dist/src/workflow-discovery.d.ts.map +1 -1
  315. package/dist/src/workflow-discovery.js +65 -88
  316. package/dist/src/workflow-discovery.js.map +1 -1
  317. package/dist/src/workflow-ref.d.ts +26 -0
  318. package/dist/src/workflow-ref.d.ts.map +1 -1
  319. package/dist/src/workflow-ref.js.map +1 -1
  320. package/dist/stubs/config/durable.stub +6 -0
  321. package/dist/stubs/config/durable_dashboard.stub +7 -3
  322. package/dist/stubs/database/migrations/create_durable_tables.stub +2 -1
  323. package/dist/stubs/database/migrations/create_durable_transport_messages.stub +43 -0
  324. package/dist/stubs/database/migrations/create_durable_transport_tables.stub +12 -9
  325. package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +1 -1
  326. package/dist/stubs/make/workflow/main.stub +1 -1
  327. package/package.json +6 -4
  328. package/skills/durable-cluster/SKILL.md +80 -11
  329. package/skills/durable-determinism/SKILL.md +11 -5
  330. package/skills/durable-reliability/SKILL.md +77 -37
  331. package/skills/durable-setup/SKILL.md +51 -22
  332. package/skills/durable-transports-stores/SKILL.md +128 -18
  333. package/skills/durable-workflows/SKILL.md +83 -18
  334. package/dist/assets/spa/assets/index-DdB9zxuX.js +0 -16
  335. package/dist/assets/spa/assets/index-DdB9zxuX.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { InMemoryAdmissionBackend } from './admission.js';
2
3
  import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
3
4
  import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
@@ -6,21 +7,28 @@ import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } fro
6
7
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
7
8
  import { parseDuration } from './duration.js';
8
9
  import { Entities } from './entities.js';
9
- import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
10
+ import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, RemoteWorkflowTimeout, WorkflowSuspended, } from './errors.js';
10
11
  import { EventAccumulators } from './event-accumulators.js';
11
12
  import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
12
13
  import { childIdsOf, FOLDED_PREFIX, fanOfWaiter, parseStepFanGroup, stepFanGroup, } from './fan-join.js';
13
14
  import { aggregateAnnouncements } from './handshake/announced.js';
15
+ import { buildWorkflowDirectory } from './handshake/directory.js';
14
16
  import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
17
+ import { DEFAULT_NAMESPACE } from './namespaces.js';
15
18
  import { asHeartbeat } from './observability-scope.js';
16
19
  import { assertRoutes, codeAndDetails, HANDOFF_SEQ_BASE, handoffHandlerId, handoffName, liveHandoff, onErrorRunId, retiredHandoffName, routeFor, } from './on-error.js';
17
20
  import { breakpointToken, stepId } from './protocol.js';
21
+ import { markRemoteEvent } from './remote-event.js';
18
22
  import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
23
+ import { loadFanMembers, loadRunDetail, } from './run-detail-summary.js';
24
+ import { runFacetsOf } from './run-facets.js';
19
25
  import { scanRunValueFacets } from './run-value-facets.js';
20
26
  import { listSignalWaitersOfRuns } from './run-waiting.js';
21
- import { scheduleWindow } from './scheduler.js';
27
+ import { resolveSchedule, scheduleWindow } from './scheduler.js';
22
28
  import { ScheduleClient } from './schedules.js';
29
+ import { SINGLETON_RESERVED_TAGS, withoutAdmission, withSingletonTracked, } from './singleton-admission.js';
23
30
  import { SingletonGate } from './singleton-gate.js';
31
+ import { validateStandard, validateStandardSync, } from './standard-schema.js';
24
32
  import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
25
33
  import { TransportPool } from './transport-pool.js';
26
34
  import { workflowAls } from './workflow-als.js';
@@ -74,6 +82,15 @@ const COMPENSATION_SEQ_BASE = -2;
74
82
  const STEP_FAN_CHUNK = 200;
75
83
  /** The default {@link WorkflowEngineDeps.timerBatchSize}. */
76
84
  const DEFAULT_TIMER_BATCH = 500;
85
+ /** The most results of one run settled by one batched write (see `completeRemoteResult`). */
86
+ const RESULT_BATCH_MAX = 256;
87
+ /**
88
+ * How soon (ms) orphan recovery re-drives a REMOTE workflow run found `running` with a call in flight:
89
+ * its turn was lost (an inline advance timed out, or the replica applying a decision died), so it is
90
+ * parked on this short wake rather than the whole `reconcileMs` window. Re-driving dispatches a fresh
91
+ * turn; the calls already in flight are not re-dispatched (their pending checkpoints dedupe them).
92
+ */
93
+ const REMOTE_TURN_REDRIVE_MS = 1_000;
77
94
  /** Fisher–Yates, in place. */
78
95
  function shuffle(items) {
79
96
  for (let i = items.length - 1; i > 0; i -= 1) {
@@ -143,11 +160,21 @@ export class WorkflowEngine {
143
160
  /** Whether the worker tick fires {@link schedules} (the `persistedSchedules` option). */
144
161
  persistedSchedulesEnabled;
145
162
  leaseMs;
163
+ /**
164
+ * The engine clock when this instance was constructed. Every lease THIS incarnation writes ends at
165
+ * or after `bootedAt + leaseMs`; a lease held under {@link instanceId} that ends before that was
166
+ * written by a previous process that ran under the same id (a pod restarted after a crash) — see
167
+ * {@link selfReclaimable}.
168
+ */
169
+ bootedAt;
146
170
  maxRecoveryAttempts;
147
171
  reconcileMs;
172
+ heartbeatPersistThrottleMs;
148
173
  timerBatchSize;
174
+ eventBufferTtlMs;
149
175
  remoteRedispatchMs;
150
176
  remoteRedispatchMax;
177
+ remoteAdvanceSilenceMs;
151
178
  webhookUrl;
152
179
  traceparent;
153
180
  context;
@@ -200,7 +227,10 @@ export class WorkflowEngine {
200
227
  accumulators;
201
228
  /** In-flight remote steps awaiting a worker result, keyed by stepId. */
202
229
  pending = new Map();
203
- /** Per-step "reset the liveness timer" callbacks, called when a heartbeat arrives. */
230
+ /** Each run's step results waiting to be settled on this instance — see {@link completeRemoteResult}. */
231
+ resultQueues = new Map();
232
+ /** Per-step "reset the liveness timer" callbacks, called with the beat's worker timestamp when a
233
+ * heartbeat arrives. */
204
234
  heartbeatResets = new Map();
205
235
  listeners = new Set();
206
236
  /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
@@ -211,6 +241,8 @@ export class WorkflowEngine {
211
241
  enqueuedListeners = new Set();
212
242
  /** Notified when a run is dead-lettered (moved to `dead`) — a hook for a DLQ handler. */
213
243
  deadListeners = new Set();
244
+ /** The fleet-default dead-letter workflow's name — see {@link WorkflowEngineDeps.deadLetterWorkflow}. */
245
+ deadLetterWorkflow;
214
246
  /** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
215
247
  updateValidators = new Map();
216
248
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
@@ -237,6 +269,29 @@ export class WorkflowEngine {
237
269
  * writes. `settleRun` checks this and degrades to an echo; `execute`'s finally clears the mark.
238
270
  */
239
271
  leaseLostRuns = new Set();
272
+ /**
273
+ * The lease handle of each execution in flight in this process, by run id. A path that gives the run
274
+ * lease away mid-execution on purpose (a remote turn suspending on its dispatched decision, see
275
+ * {@link releaseTurnLease}) flags it, so the execution's renew loop stops renewing and never reads its
276
+ * own release as a takeover: a late `false` renew would otherwise fence whoever applies the decision.
277
+ */
278
+ executionLeases = new Map();
279
+ /** Stable decision handler subscribed on every remote executor's transport (see registerRemote). */
280
+ decisionHandler = (decision) => {
281
+ // A transport that delivers the decision from INSIDE its own `dispatchWorkflowTask` (an in-process
282
+ // broker) would otherwise wait on the run gate the dispatching execution still holds. Apply it once
283
+ // that execution has returned instead; it is tracked, so drain still waits for it.
284
+ if (turnDispatchAls.getStore() === decision?.runId) {
285
+ this.trackEffect(this.completeRemoteDecision(decision));
286
+ return Promise.resolve();
287
+ }
288
+ return this.completeRemoteDecision(decision);
289
+ };
290
+ /** Earliest clock time an awaited turn's deadline is worth reading again (`runId` → clock ms), the
291
+ * read throttle for {@link rearmDecisionDeadline}. */
292
+ decisionDeadlineRearms = new Map();
293
+ /** Whether any registered remote executor carries its own turn window (`decisionTimeoutMs`). */
294
+ anyDecisionTimeout = false;
240
295
  /**
241
296
  * The in-process execution gate, by run id: the execution of the run currently in flight in THIS
242
297
  * process (`tail`), and at most one resume queued behind it (`queued`). See {@link resumeRun}.
@@ -260,6 +315,10 @@ export class WorkflowEngine {
260
315
  */
261
316
  postSettle = new Set();
262
317
  draining = false;
318
+ /** Shutdown wait already spent before/alongside {@link drain} (see {@link beginDrain}). */
319
+ drainSpentMs = 0;
320
+ /** Re-arms the deadline of every {@link drain} currently waiting, when more wait is reported. */
321
+ drainRearms = new Set();
263
322
  constructor(deps) {
264
323
  this.store = deps.store;
265
324
  this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
@@ -271,7 +330,9 @@ export class WorkflowEngine {
271
330
  // instead of waiting for their retry tick. Best-effort (the retry tick remains the guarantee).
272
331
  this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
273
332
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
274
- this.namespace = deps.namespace;
333
+ // `''` is the default pool (the transports already treat it as bare): normalize it here so the
334
+ // runs this engine stamps, and the pool it polls, are the default pool's.
335
+ this.namespace = deps.namespace === '' ? DEFAULT_NAMESPACE : deps.namespace;
275
336
  // The control-plane's own handshake descriptor — the side negotiated AGAINST each worker so a
276
337
  // protocol-incompatible fleet is detected before dispatch (design §7.3). Scoped to this engine's
277
338
  // namespace/partition; capabilities/protocol are config-driven (default: current single major).
@@ -285,7 +346,13 @@ export class WorkflowEngine {
285
346
  ? { namespace: this.namespace }
286
347
  : {}),
287
348
  });
288
- this.blockedPollMs = deps.blockedPollMs ?? 5000;
349
+ // A non-finite or sub-1 poll falls back to the default: NaN would park a blocked run on an
350
+ // unreachable wake, and 0 would re-drive it in a hot loop.
351
+ const blockedPoll = deps.blockedPollMs;
352
+ this.blockedPollMs =
353
+ blockedPoll !== undefined && Number.isFinite(blockedPoll) && blockedPoll >= 1
354
+ ? blockedPoll
355
+ : 5000;
289
356
  // Propagate the engine's namespace to its transport(s) so the SAME namespace that partitions the
290
357
  // store also partitions the transport's queues/keys — set once on the engine, applied everywhere.
291
358
  // The transport makes "default" a no-op (byte-identical names); an empty pool is a no-op too.
@@ -294,18 +361,34 @@ export class WorkflowEngine {
294
361
  if (this.namespace !== undefined)
295
362
  this.pool.useNamespace(this.namespace);
296
363
  this.leaseMs = deps.leaseMs ?? 30_000;
364
+ this.bootedAt = this.clock();
297
365
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
298
- // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
299
- this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
366
+ // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event). A
367
+ // non-finite or negative value falls back to the default rather than silently disabling the net
368
+ // (NaN) or stamping a wake in the past that re-drives the run every tick (negative).
369
+ const reconcile = deps.reconcileMs;
370
+ this.reconcileMs =
371
+ reconcile === undefined || !Number.isFinite(reconcile) || reconcile < 0
372
+ ? 300_000
373
+ : reconcile || undefined;
374
+ // Validated like `timerBatchSize`: a NaN/negative throttle would silently disable the
375
+ // cross-instance liveness salvage (`timeoutMs + NaN` never compares true). `0` stays valid.
376
+ const throttle = deps.heartbeatPersistThrottleMs;
377
+ this.heartbeatPersistThrottleMs =
378
+ throttle !== undefined && Number.isFinite(throttle) && throttle >= 0 ? throttle : 10_000;
300
379
  // A non-finite or sub-1 value falls back to the default: a NaN limit would stall timer recovery.
301
380
  const batch = deps.timerBatchSize;
302
381
  this.timerBatchSize =
303
382
  batch !== undefined && Number.isFinite(batch) && batch >= 1
304
383
  ? Math.floor(batch)
305
384
  : DEFAULT_TIMER_BATCH;
385
+ const ttl = deps.eventBufferTtlMs;
386
+ this.eventBufferTtlMs = ttl !== undefined && Number.isFinite(ttl) && ttl > 0 ? ttl : undefined;
306
387
  // Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
307
388
  this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
308
389
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
390
+ // Opt-in: unset (or 0) keeps the unbounded wait for a remote workflow turn's decision.
391
+ this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs || undefined;
309
392
  this.webhookUrl = deps.webhookUrl;
310
393
  this.traceparent = deps.traceparent;
311
394
  this.context = deps.context;
@@ -315,6 +398,8 @@ export class WorkflowEngine {
315
398
  this.compensationTimeoutMs = Math.max(1_000, deps.compensationTimeoutMs ?? 300_000);
316
399
  this.retention = deps.retention;
317
400
  this.retentionPolicies = deps.retentionPolicies ?? [];
401
+ this.deadLetterWorkflow =
402
+ deps.deadLetterWorkflow !== undefined ? workflowName(deps.deadLetterWorkflow) : undefined;
318
403
  this.persistedSchedulesEnabled = deps.persistedSchedules ?? false;
319
404
  this.schedules = new ScheduleClient(this.store, {
320
405
  start: (workflow, input, runId, opts) => this.start(workflow, input, runId, opts),
@@ -324,11 +409,22 @@ export class WorkflowEngine {
324
409
  this.stalledAfterMs = Math.max(60_000, deps.stalledAfterMs ?? 900_000);
325
410
  this.trackStepStart = deps.trackStepStart ?? true;
326
411
  // Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
327
- // A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
328
- // run failures themselves are handled inside `execute` and surfaced as the run's `failed` state.
412
+ // A failed pickup is reported, never thrown (the run stays `pending`, or leased until its lease
413
+ // lapses, for a `runPending` poll or recovery to retry); run failures themselves are handled
414
+ // inside `execute` and surfaced as the run's `failed` state. What reaches here is the store or
415
+ // the registry refusing the pickup — a lost connection, a missing table, an unregistered version.
329
416
  this.usesInProcessDispatch = deps.runDispatcher === undefined;
417
+ //
418
+ // Tracked from the moment of dispatch (see {@link trackEffect}): the pickup leases and reads the
419
+ // run before its execution enters `inflight`, so a bare `queueMicrotask` let `drain()` find both
420
+ // registries empty in that gap and return while the pickup was still about to write — onto a
421
+ // closed connection, or a dropped schema. The one-microtask defer is kept.
330
422
  this.runDispatcher = deps.runDispatcher ?? {
331
- dispatch: (runId) => queueMicrotask(() => void this.runOne(runId).catch(() => { })),
423
+ dispatch: (runId) => this.trackEffect(Promise.resolve()
424
+ .then(() => this.runOne(runId))
425
+ .catch((err) => {
426
+ console.warn(`[adonis-durable] in-process pickup of run ${runId} failed (a pending-run poll or recovery retries it): ${err instanceof Error ? err.message : String(err)}`);
427
+ })),
332
428
  };
333
429
  this.singletons = new SingletonGate({
334
430
  store: this.store,
@@ -367,10 +463,22 @@ export class WorkflowEngine {
367
463
  // in-memory waiter (the step suspended DURABLY, possibly on another instance) the beat rearms
368
464
  // the step's durable LEASE instead, so a worker still holding a long step is never presumed
369
465
  // lost and double-dispatched (see rearmStepLease).
466
+ //
467
+ // A RUN-scoped beat (no `stepId`: a worker replaying a long workflow TURN) keeps that turn alive
468
+ // instead: an inline advance awaiting on THIS instance rearms its in-memory deadline, otherwise
469
+ // the awaited turn's durable re-drive deadline moves forward (see rearmDecisionDeadline).
370
470
  async (beat) => {
471
+ if (!beat.stepId) {
472
+ const resetTurn = this.heartbeatResets.get(turnBeatKey(beat.runId));
473
+ if (resetTurn)
474
+ resetTurn(beat.at);
475
+ else
476
+ await this.rearmDecisionDeadline(beat.runId);
477
+ return;
478
+ }
371
479
  const reset = this.heartbeatResets.get(beat.stepId);
372
480
  if (reset)
373
- reset();
481
+ reset(beat.at);
374
482
  else
375
483
  await this.rearmStepLease(beat.runId, beat.seq);
376
484
  this.persistHeartbeat(beat);
@@ -388,7 +496,7 @@ export class WorkflowEngine {
388
496
  return;
389
497
  if (msg.kind === 'event') {
390
498
  // `at` may be a string after JSON transit (Redis) — normalize back to a Date.
391
- this.deliver({ ...msg.event, at: new Date(msg.event.at) });
499
+ this.deliver(markRemoteEvent({ ...msg.event, at: new Date(msg.event.at) }));
392
500
  }
393
501
  else if (msg.kind === 'cancel') {
394
502
  this.notifyCancelled(msg.runId);
@@ -412,7 +520,11 @@ export class WorkflowEngine {
412
520
  }
413
521
  });
414
522
  this.accumulators = new EventAccumulators(this);
415
- this.entities = new Entities(this);
523
+ this.entities = new Entities(this, {
524
+ bufferSignal: (token, payload) => this.store.bufferSignal(token, payload),
525
+ wakeWaiter: (token, payload) => this.wakeSignalWaiter(token, payload),
526
+ ensureStarted: (workflow, input, runId) => this.start(workflow, input, runId),
527
+ });
416
528
  }
417
529
  /**
418
530
  * Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
@@ -435,7 +547,7 @@ export class WorkflowEngine {
435
547
  * Called by `runTick`; idempotent.
436
548
  */
437
549
  adoptSchedules(schedules) {
438
- this.#allSchedules = schedules;
550
+ this.#allSchedules = schedules.map(resolveSchedule);
439
551
  }
440
552
  /**
441
553
  * The runtime pause override for a schedule key: `true`/`false` when the console set one (wins
@@ -514,7 +626,7 @@ export class WorkflowEngine {
514
626
  continue;
515
627
  }
516
628
  seen.add(schedule.key);
517
- this.#discoveredSchedules.push(schedule);
629
+ this.#discoveredSchedules.push(resolveSchedule(schedule));
518
630
  }
519
631
  }
520
632
  /** Colocated schedules discovered from `static schedule`, for the worker loop to merge with config. */
@@ -537,6 +649,42 @@ export class WorkflowEngine {
537
649
  return this.entities.signal(name, key, op, arg);
538
650
  }
539
651
  /** Read an entity's current durable state (published after each op), or undefined if it has none yet. */
652
+ /**
653
+ * The entity lost-wake reconcile: wake (start, if need be) every entity that has ops sitting in its
654
+ * buffer, up to `limit` of them. A workflow-side send stores the op and THEN wakes the entity, so a
655
+ * process that dies in between leaves the op buffered while the entity sleeps; this pass is what
656
+ * applies it. Idempotent — a wake carries a no-op nudge, and the entity reads its buffer in arrival
657
+ * order, consuming each op once (an op with a stable id is skipped if already applied). The worker
658
+ * tick runs it, throttled to one pass a minute. Returns how many entities were woken. A no-op on a
659
+ * store without `listBufferedSignalTokens`.
660
+ */
661
+ async reconcileEntityWakes(limit = 100) {
662
+ if (this.draining || !this.store.listBufferedSignalTokens)
663
+ return 0;
664
+ const tokens = await this.store.listBufferedSignalTokens('entity:', limit);
665
+ let woken = 0;
666
+ for (const token of tokens) {
667
+ if (this.draining)
668
+ break;
669
+ try {
670
+ if (await this.entities.kickRunId(token))
671
+ woken += 1;
672
+ }
673
+ catch (err) {
674
+ console.warn(`[adonis-durable] entity reconcile of ${token} skipped: ${err instanceof Error ? err.message : String(err)}`);
675
+ }
676
+ }
677
+ return woken;
678
+ }
679
+ /** Throttle stamp for the tick's {@link reconcileEntityWakes} pass. */
680
+ #lastEntityReconcileAt = Number.NEGATIVE_INFINITY;
681
+ /** {@link reconcileEntityWakes}, at most once a minute — the worker tick's entry point. */
682
+ async sweepEntityWakes(now = this.clock()) {
683
+ if (now - this.#lastEntityReconcileAt < 60_000)
684
+ return 0;
685
+ this.#lastEntityReconcileAt = now;
686
+ return this.reconcileEntityWakes();
687
+ }
540
688
  getEntityState(name, key) {
541
689
  return this.entities.getState(name, key);
542
690
  }
@@ -571,12 +719,15 @@ export class WorkflowEngine {
571
719
  version,
572
720
  fn,
573
721
  onError: this.checkedRoutes(name, opts?.onError),
722
+ deadLetterWorkflow: opts?.deadLetterWorkflow !== undefined ? workflowName(opts.deadLetterWorkflow) : undefined,
574
723
  tags: opts?.tags,
575
724
  origin: opts?.origin,
576
725
  singleton: opts?.singleton,
577
726
  concurrency: opts?.concurrency,
578
727
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
579
728
  validateInput: opts?.validateInput,
729
+ inputSchema: opts?.inputSchema,
730
+ searchAttributesSchema: opts?.searchAttributesSchema,
580
731
  onEvent: opts?.onEvent,
581
732
  eventBatch: opts?.eventBatch,
582
733
  requires: opts?.requires,
@@ -602,6 +753,7 @@ export class WorkflowEngine {
602
753
  name,
603
754
  version,
604
755
  onError: this.checkedRoutes(name, opts.onError),
756
+ deadLetterWorkflow: opts.deadLetterWorkflow !== undefined ? workflowName(opts.deadLetterWorkflow) : undefined,
605
757
  // A remote workflow has no in-process body; execute() branches on `remote` before this is read.
606
758
  fn: () => {
607
759
  throw new Error(`workflow ${name} is remote — it has no in-process body`);
@@ -611,34 +763,71 @@ export class WorkflowEngine {
611
763
  concurrency: opts.concurrency,
612
764
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
613
765
  validateInput: opts.validateInput,
766
+ inputSchema: opts.inputSchema,
614
767
  remote: { group: opts.group, executor: opts.executor },
615
768
  requires: opts.requires,
616
769
  };
770
+ // A dispatched turn's decision is applied by whichever replica consumes it, so every engine that
771
+ // can dispatch a turn also consumes decisions. The handler is one stable function: a transport that
772
+ // keeps a handler set registers it once however many remote workflows share the transport.
773
+ opts.executor.subscribeDecisions?.(this.decisionHandler);
774
+ if (opts.executor.decisionTimeoutMs)
775
+ this.anyDecisionTimeout = true;
617
776
  this.workflows.set(versionKey(name, version), registered);
618
777
  const current = this.latest.get(name);
619
778
  if (!current || isNewerVersion(version, current.version))
620
779
  this.latest.set(name, registered);
621
780
  }
622
781
  /**
623
- * Convention-dispatch resolution: when `name` isn't locally registered but a LIVE worker group of
624
- * the same name exists, auto-register a remote workflow routed to that group (bare group, no
625
- * partition) so its runs advance over the broker like any `registerRemote`'d one. Always on — the
626
- * queue name IS the routing, exactly as the aviary engine does — so a Python/NestJS/thin-worker
627
- * workflow is reached by NAME with no `registerRemote` boilerplate. Returns whether `name` is
628
- * registered afterwards. A no-op (returns `false`) when no transport is wired (nothing to route
629
- * over) or no matching worker group is live (then `start`/`resume` throws `is not registered`).
782
+ * Register a workflow whose body runs in another SDK, routed to worker `group` — {@link
783
+ * registerRemote} with the executor built for you over this engine's primary transport. `version`
784
+ * defaults to `'1'`; register `name` at a version explicitly to pin starts to it
785
+ * ({@link StartOptions.version}).
786
+ */
787
+ remote(name, opts) {
788
+ if (!this.primaryTransport) {
789
+ throw new Error(`@agora/durable: engine.remote('${name}') needs a transport that carries workflow tasks.`);
790
+ }
791
+ const { version, ...rest } = opts;
792
+ this.registerRemote(name, version ?? '1', {
793
+ ...rest,
794
+ executor: new RemoteWorkflowExecutor(this.primaryTransport, opts.group),
795
+ });
796
+ }
797
+ /**
798
+ * Convention-dispatch resolution: when `name` isn't locally registered but a LIVE worker group for
799
+ * it exists, auto-register a remote workflow routed to that group so its runs advance over the
800
+ * broker like any `registerRemote`'d one. Always on — the queue name IS the routing, exactly as the
801
+ * aviary engine does — so a Python/NestJS/thin-worker workflow is reached by NAME with no
802
+ * `registerRemote` boilerplate. Returns whether `name` is registered (and routable for `namespace`)
803
+ * afterwards. A no-op (returns `false`) when no transport is wired (nothing to route over) or no
804
+ * matching worker group is live (then `start`/`resume` throws `is not registered`).
805
+ *
806
+ * TENANT-AWARE, per run: the group is the one the RUN's namespace routes to,
807
+ * `<name>@<namespace>` (bare for an absent/`default` namespace) — the token a tenant worker
808
+ * (`partition: <tenant>`) serves, and the same rule steps follow ({@link stepGroup}). So an operator
809
+ * starting a tenant's run reaches the tenant's live `<name>@<tenant>` group, not the bare one, and a
810
+ * convention workflow is re-checked for the namespace of every start.
630
811
  */
631
- async ensureConventionWorkflow(name) {
812
+ async ensureConventionWorkflow(name, namespace) {
632
813
  if (!this.primaryTransport)
633
814
  return false;
634
- if (this.latest.get(name))
815
+ const current = this.latest.get(name);
816
+ if (current && !current.convention)
635
817
  return true;
636
- const groups = await this.pool.listWorkerGroups();
637
- if (!groups.includes(name))
818
+ const group = conventionGroup(name, namespace);
819
+ // The transport whose keyspace reports the live group: in a mixed pool each transport scans a
820
+ // different keyspace, and dispatching on the primary while the worker beats on another one parks
821
+ // the turn on a queue nobody consumes.
822
+ const transport = await this.pool.transportWithLiveGroup(group);
823
+ if (!transport)
638
824
  return false;
825
+ if (current)
826
+ return true;
639
827
  this.registerRemote(name, '1', {
640
828
  group: name,
641
- executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
829
+ // Per run: each run dispatches to the group of its own namespace.
830
+ executor: new RemoteWorkflowExecutor(transport, (run) => conventionGroup(run.workflow, run.namespace)),
642
831
  });
643
832
  const registered = this.workflows.get(versionKey(name, '1'));
644
833
  if (registered)
@@ -658,7 +847,7 @@ export class WorkflowEngine {
658
847
  heartbeatPersists = new Map();
659
848
  /**
660
849
  * Persist a worker heartbeat on its step's checkpoint (see {@link StepCheckpoint.lastHeartbeatAt}),
661
- * best-effort and THROTTLED per step (≥10s between writes) — beats may arrive every few seconds
850
+ * best-effort and THROTTLED per step (≥ `heartbeatPersistThrottleMs`, default 10s, between writes) — beats may arrive every few seconds
662
851
  * from a hot batch loop, and liveness observability must not turn into a per-item UPDATE storm.
663
852
  * One exemption: a beat CARRYING progress punches through when the last persisted one had none —
664
853
  * the automatic pickup beat lands first (no payload) and must not shadow the handler's first real
@@ -667,16 +856,19 @@ export class WorkflowEngine {
667
856
  * wholesale past 1024 entries — a rare extra write, never a leak.
668
857
  */
669
858
  persistHeartbeat(beat) {
670
- if (!this.store.recordStepHeartbeat)
859
+ const id = beat.stepId;
860
+ if (!this.store.recordStepHeartbeat || !id)
671
861
  return;
672
862
  const now = this.clock();
673
- const last = this.heartbeatPersists.get(beat.stepId);
863
+ const last = this.heartbeatPersists.get(id);
674
864
  const upgradesToProgress = beat.progress !== undefined && last !== undefined && !last.hadProgress;
675
- if (last !== undefined && now - last.at < 10_000 && !upgradesToProgress)
865
+ if (last !== undefined &&
866
+ now - last.at < this.heartbeatPersistThrottleMs &&
867
+ !upgradesToProgress)
676
868
  return;
677
869
  if (this.heartbeatPersists.size > 1024)
678
870
  this.heartbeatPersists.clear();
679
- this.heartbeatPersists.set(beat.stepId, { at: now, hadProgress: beat.progress !== undefined });
871
+ this.heartbeatPersists.set(id, { at: now, hadProgress: beat.progress !== undefined });
680
872
  void this.store
681
873
  .recordStepHeartbeat(beat.runId, beat.seq, new Date(beat.at ?? now), beat.progress)
682
874
  .catch(() => undefined);
@@ -691,6 +883,28 @@ export class WorkflowEngine {
691
883
  startConsumers() {
692
884
  this.pool.startConsumers();
693
885
  }
886
+ /**
887
+ * Serve a step body by name on this engine's primary transport — the same
888
+ * `transport.handle(name, fn)` the provider calls for every `stepsPath` handler. It makes the
889
+ * engine a {@link import('./step-discovery.js').StepServer}, so app code can serve steps from an
890
+ * extra directory without reaching for the (provider-private) transport:
891
+ *
892
+ * ```ts
893
+ * // start/durable.ts (a preload)
894
+ * import engine from '@adonis-agora/durable/services/main'
895
+ * import { registerStepsFromDir } from '@adonis-agora/durable'
896
+ * await registerStepsFromDir(engine, app.makePath('app/engine/steps'))
897
+ * ```
898
+ *
899
+ * Throws when no transport is wired or the wired one can't serve handlers (no `handle`).
900
+ */
901
+ handle(name, fn) {
902
+ const server = this.primaryTransport;
903
+ if (!server || typeof server.handle !== 'function') {
904
+ throw new Error(`@agora/durable: cannot serve step "${name}": the engine has no transport that serves step handlers (no handle()).`);
905
+ }
906
+ server.handle(name, fn);
907
+ }
694
908
  /** Subscribe to lifecycle events. Returns an unsubscribe function. */
695
909
  subscribe(listener) {
696
910
  this.listeners.add(listener);
@@ -756,6 +970,35 @@ export class WorkflowEngine {
756
970
  /* a dead-letter handler must not break recovery */
757
971
  }
758
972
  }
973
+ this.routeDeadLetter(run);
974
+ }
975
+ /**
976
+ * Hand a `dead` run to its dead-letter workflow, when one is configured: the workflow's own
977
+ * `deadLetterWorkflow`, else the engine's default. Started as `dlq:<runId>` (idempotent: a run
978
+ * dead-lettered twice is handled once; the dashboard links the pair by that id) in the dead run's
979
+ * namespace, with a {@link DeadLetter} input. Tracked, so a drain waits for the start; a failure
980
+ * (e.g. the handler is not registered here) is warned, never thrown into recovery. A handler run
981
+ * that itself goes dead is not routed again — no `dlq:dlq:…` chain.
982
+ */
983
+ routeDeadLetter(run) {
984
+ if (run.id.startsWith('dlq:'))
985
+ return;
986
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
987
+ this.latest.get(run.workflow);
988
+ const target = registered?.deadLetterWorkflow ?? this.deadLetterWorkflow;
989
+ if (!target)
990
+ return;
991
+ const input = {
992
+ deadRunId: run.id,
993
+ workflow: run.workflow,
994
+ input: run.input,
995
+ ...(run.error ? { error: run.error } : {}),
996
+ };
997
+ this.trackEffect(this.start(target, input, `dlq:${run.id}`, {
998
+ ...(run.namespace !== undefined ? { namespace: run.namespace } : {}),
999
+ }).catch((err) => {
1000
+ console.warn(`[adonis-durable] could not start dead-letter workflow ${target} for run ${run.id}: ${err instanceof Error ? err.message : String(err)}`);
1001
+ }));
759
1002
  }
760
1003
  /** Emit a locally-produced lifecycle event: deliver to subscribers AND broadcast it on the
761
1004
  * control plane so other instances (e.g. a dashboard pod) can live-tail this run. */
@@ -797,17 +1040,36 @@ export class WorkflowEngine {
797
1040
  */
798
1041
  async startCore(workflow, input, runId, opts, dispatch) {
799
1042
  const name = workflowName(workflow);
800
- let registered = this.latest.get(name);
1043
+ // A pinned start (`opts.version`) resolves name@version and stops there: no newest, no
1044
+ // convention fallback, since neither can prove that version exists.
1045
+ const pinned = opts?.version;
1046
+ let registered = pinned === undefined ? this.latest.get(name) : this.workflows.get(versionKey(name, pinned));
801
1047
  // Convention dispatch: an unregistered workflow whose name matches a LIVE worker group is routed
802
- // to it as a remote workflow (bare group). No registration needed — the queue name is the routing;
803
- // when no matching group is live this is a no-op and the "not registered" throw below fires.
804
- if (!registered && (await this.ensureConventionWorkflow(name))) {
805
- registered = this.latest.get(name);
806
- }
807
- if (!registered)
808
- throw new Error(`workflow ${name} is not registered`);
809
- // Validate the input up front — a bad payload is rejected before any run is created.
810
- await registered.validateInput?.(input);
1048
+ // to it as a remote workflow — the group of the namespace this run will carry (see
1049
+ // ensureConventionWorkflow). No registration needed — the queue name is the routing; when no
1050
+ // matching group is live this is a no-op and the "not registered" throw below fires. Re-checked
1051
+ // per start for a convention registration, since the tenant can differ from one start to the next.
1052
+ // A pinned start never falls back to convention: it cannot prove the pinned version exists.
1053
+ if (pinned === undefined && (!registered || registered.convention)) {
1054
+ registered = (await this.ensureConventionWorkflow(name, opts?.namespace ?? this.namespace))
1055
+ ? this.latest.get(name)
1056
+ : undefined;
1057
+ }
1058
+ if (!registered) {
1059
+ throw new Error(pinned === undefined
1060
+ ? `workflow ${name} is not registered`
1061
+ : `workflow ${name}@${pinned} is not registered`);
1062
+ }
1063
+ // Validate the input up front — a bad payload is rejected before any run is created. The schema
1064
+ // runs first and `validateInput` sees its OUTPUT (parsed/coerced), but the run stores the input
1065
+ // AS SENT: the body gets the parsed value by re-parsing it before every turn (`turnInput`), so a
1066
+ // value JSON can't carry (a `Date` from `z.coerce.date()`) reaches it the same way on every
1067
+ // turn, whichever store holds the row. Everything else here — singleton and concurrency keys
1068
+ // included — reads the stored input, the one value the singleton gate re-reads later.
1069
+ const parsed = registered.inputSchema
1070
+ ? await validateStandard(registered.inputSchema, input, `workflow "${name}" input`, 'invalid_input')
1071
+ : input;
1072
+ await registered.validateInput?.(parsed);
811
1073
  // Idempotent by run id: a redelivered trigger (at-least-once queues) or a scheduler re-tick for
812
1074
  // the same id is a no-op, returning the existing run's state instead of starting a duplicate.
813
1075
  const prior = await this.store.getRun(runId);
@@ -834,7 +1096,9 @@ export class WorkflowEngine {
834
1096
  ...(registered.singleton ? [this.singletons.tag(registered.singleton, input)] : []),
835
1097
  ...(quota ? [concurrencyTag(quota.key)] : []),
836
1098
  ];
837
- const tags = mergeTags(registered.tags, minted.length ? [...(opts?.tags ?? []), ...minted] : opts?.tags);
1099
+ // The admission marker is owned by the store's atomic claim: a fresh run can neither supply it nor
1100
+ // inherit it (`retryWithInput` copies the original run's tags).
1101
+ const tags = mergeTags(registered.tags, minted.length ? [...(opts?.tags ?? []), ...minted] : opts?.tags)?.filter((t) => !SINGLETON_RESERVED_TAGS.includes(t));
838
1102
  // Singleton back-pressure: reject a start that would grow the same-key backlog past
839
1103
  // `limit + maxQueueDepth` (no-op when no maxQueueDepth is configured).
840
1104
  if (registered.singleton) {
@@ -978,6 +1242,39 @@ export class WorkflowEngine {
978
1242
  listCheckpoints(runId) {
979
1243
  return this.store.listCheckpoints(runId);
980
1244
  }
1245
+ /**
1246
+ * How the runs matching `query` split across `(status, origin)`, in one aggregate when the store
1247
+ * has {@link StateStore.runFacets} (else counted over a listing). Part of the engine's read API.
1248
+ */
1249
+ runFacets(query) {
1250
+ return runFacetsOf(this.store, query);
1251
+ }
1252
+ /**
1253
+ * A run's detail (see `loadRunDetail`), built from the store's checkpoint aggregates when it has
1254
+ * `checkpointStats` + `queryCheckpoints` — a summarized detail then reads a few rows per wide fan
1255
+ * instead of the whole history, and carries an `etag` that `options.ifNoneMatch` can match.
1256
+ */
1257
+ getRunDetail(runId, options) {
1258
+ return loadRunDetail(this.detailSource(), runId, options);
1259
+ }
1260
+ /** One page of a summarized fan's members (see `loadFanMembers`), versioned like {@link getRunDetail}. */
1261
+ getFanMembers(runId, group, query) {
1262
+ return loadFanMembers(this.detailSource(), runId, group, query);
1263
+ }
1264
+ detailSource() {
1265
+ const store = this.store;
1266
+ return {
1267
+ getRun: (id) => this.getRun(id),
1268
+ listCheckpoints: (id) => store.listCheckpoints(id),
1269
+ getRunChildren: (id) => this.getRunChildren(id),
1270
+ ...(store.checkpointStats && store.queryCheckpoints
1271
+ ? {
1272
+ checkpointStats: store.checkpointStats.bind(store),
1273
+ queryCheckpoints: store.queryCheckpoints.bind(store),
1274
+ }
1275
+ : {}),
1276
+ };
1277
+ }
981
1278
  /**
982
1279
  * Hard-delete a run and its whole child subtree from the store, returning the number of runs
983
1280
  * removed (0 if `runId` is unknown). Children are collected BEFORE this run's checkpoints are
@@ -1087,11 +1384,16 @@ export class WorkflowEngine {
1087
1384
  * parents.
1088
1385
  */
1089
1386
  async sweepRetention(now = this.clock()) {
1090
- if ((!this.retention && this.retentionPolicies.length === 0) || this.draining)
1387
+ if ((!this.retention &&
1388
+ this.retentionPolicies.length === 0 &&
1389
+ this.eventBufferTtlMs === undefined) ||
1390
+ this.draining) {
1091
1391
  return 0;
1392
+ }
1092
1393
  if (now - this.#lastRetentionSweepAt < 60_000)
1093
1394
  return 0;
1094
1395
  this.#lastRetentionSweepAt = now;
1396
+ await this.pruneExpiredBufferedEvents(now);
1095
1397
  let evicted = 0;
1096
1398
  const passes = [];
1097
1399
  for (const status of TERMINAL_RUN_STATUSES) {
@@ -1102,7 +1404,9 @@ export class WorkflowEngine {
1102
1404
  }
1103
1405
  for (const policy of this.retentionPolicies) {
1104
1406
  const statuses = policy.statuses.filter((s) => TERMINAL_RUN_STATUSES.includes(s));
1105
- if (statuses.length === 0 || !(policy.maxAgeMs > 0))
1407
+ const byAge = policy.maxAgeMs !== undefined && policy.maxAgeMs > 0;
1408
+ const byCount = policy.maxCount !== undefined && policy.maxCount >= 0;
1409
+ if (statuses.length === 0 || (!byAge && !byCount))
1106
1410
  continue;
1107
1411
  // A namespaced worker only ever prunes its own partition: a scope naming another one is not its
1108
1412
  // to sweep (the operator, `namespace: undefined`, sweeps every partition).
@@ -1111,15 +1415,32 @@ export class WorkflowEngine {
1111
1415
  policy.scope.namespace !== this.namespace) {
1112
1416
  continue;
1113
1417
  }
1114
- passes.push({
1418
+ const scoped = {
1115
1419
  ...policy.scope,
1116
1420
  namespace: policy.scope.namespace ?? this.namespace,
1117
1421
  statuses,
1118
- updatedBefore: now - policy.maxAgeMs,
1119
- });
1422
+ };
1423
+ if (byAge)
1424
+ passes.push({ ...scoped, updatedBefore: now - policy.maxAgeMs });
1425
+ if (byCount) {
1426
+ // Keep the `maxCount` newest (listRuns is newest-first by createdAt): the last one kept marks
1427
+ // the cutoff, and every run created strictly before it goes. A run sharing that start
1428
+ // millisecond is kept too, so a tie never deletes one of the newest `maxCount`.
1429
+ const maxCount = policy.maxCount;
1430
+ if (maxCount === 0) {
1431
+ passes.push(scoped);
1432
+ }
1433
+ else {
1434
+ const [lastKept] = await this.store.listRuns({ ...scoped, page: maxCount, size: 1 });
1435
+ if (lastKept) {
1436
+ passes.push({ ...scoped, createdBefore: lastKept.createdAt.getTime() - 1 });
1437
+ }
1438
+ }
1439
+ }
1120
1440
  }
1121
1441
  for (const pass of passes) {
1122
1442
  const candidates = await this.store.listRuns({ ...pass, size: 100 });
1443
+ const archived = [];
1123
1444
  for (const run of candidates) {
1124
1445
  try {
1125
1446
  if (this.evictListeners.size > 0) {
@@ -1127,16 +1448,81 @@ export class WorkflowEngine {
1127
1448
  for (const listener of this.evictListeners)
1128
1449
  await listener(run, checkpoints);
1129
1450
  }
1130
- evicted += await this.deleteRun(run.id);
1451
+ archived.push(run.id);
1131
1452
  }
1132
1453
  catch (err) {
1133
- // An archival hook (or the delete itself) failed — keep the run; the next sweep retries.
1454
+ // An archival hook failed — keep the run; the next sweep retries.
1134
1455
  console.warn(`[adonis-durable] retention eviction of run ${run.id} skipped: ${err instanceof Error ? err.message : String(err)}`);
1135
1456
  }
1136
1457
  }
1458
+ try {
1459
+ // One batched delete per pass (the store's `deleteRuns`), subtrees included.
1460
+ evicted += await this.deleteRunTrees(archived);
1461
+ }
1462
+ catch (err) {
1463
+ // The delete failed — keep the runs; the next sweep retries.
1464
+ console.warn(`[adonis-durable] retention eviction of ${archived.length} run(s) skipped: ${err instanceof Error ? err.message : String(err)}`);
1465
+ }
1137
1466
  }
1138
1467
  return evicted;
1139
1468
  }
1469
+ /**
1470
+ * The {@link eventBufferTtlMs} prune: delete buffered events older than the TTL, a bounded batch
1471
+ * at a time, through the store's `pruneBufferedEvents`. Runs in {@link sweepRetention}'s pass.
1472
+ * Best-effort — a failure is warned and retried on the next pass.
1473
+ */
1474
+ async pruneExpiredBufferedEvents(now) {
1475
+ const ttl = this.eventBufferTtlMs;
1476
+ if (ttl === undefined || !this.store.pruneBufferedEvents)
1477
+ return;
1478
+ try {
1479
+ for (let pass = 0; pass < 10; pass += 1) {
1480
+ const deleted = await this.store.pruneBufferedEvents(now - ttl, 500);
1481
+ if (deleted < 500)
1482
+ break;
1483
+ }
1484
+ }
1485
+ catch (err) {
1486
+ console.warn(`[adonis-durable] buffered-event prune skipped: ${err instanceof Error ? err.message : String(err)}`);
1487
+ }
1488
+ }
1489
+ /**
1490
+ * Delete the runs `rootIds` and their child subtrees: the ids are collected children-first (the
1491
+ * edge is read from the parent's checkpoints, so before anything is deleted), deleted in one
1492
+ * {@link deleteRunIds} batch, then each one's token-keyed buffered signals are swept like
1493
+ * {@link deleteRun} does. Returns how many runs were deleted.
1494
+ */
1495
+ async deleteRunTrees(rootIds) {
1496
+ const ids = [];
1497
+ const seen = new Set();
1498
+ const visit = async (id) => {
1499
+ if (seen.has(id))
1500
+ return;
1501
+ seen.add(id);
1502
+ if (!(await this.store.getRun(id)))
1503
+ return;
1504
+ for (const child of await this.getRunChildren(id))
1505
+ await visit(child);
1506
+ ids.push(id);
1507
+ };
1508
+ for (const id of rootIds)
1509
+ await visit(id);
1510
+ await this.deleteRunIds(ids);
1511
+ for (const id of ids)
1512
+ await this.sweepRunBufferedSignals(id);
1513
+ return ids.length;
1514
+ }
1515
+ /** Bulk-delete through the store's `deleteRuns`, or one `deleteRun` per id when it has none. */
1516
+ async deleteRunIds(ids) {
1517
+ if (ids.length === 0)
1518
+ return;
1519
+ if (this.store.deleteRuns) {
1520
+ await this.store.deleteRuns(ids);
1521
+ return;
1522
+ }
1523
+ for (const id of ids)
1524
+ await this.store.deleteRun(id);
1525
+ }
1140
1526
  async deleteRun(runId) {
1141
1527
  const run = await this.store.getRun(runId);
1142
1528
  if (!run)
@@ -1217,6 +1603,7 @@ export class WorkflowEngine {
1217
1603
  };
1218
1604
  for (const run of batch)
1219
1605
  await visit(run.id);
1606
+ const doomed = [];
1220
1607
  for (const id of ids) {
1221
1608
  const status = known.has(id) ? known.get(id) : (await this.store.getRun(id))?.status;
1222
1609
  if (status === undefined)
@@ -1226,10 +1613,13 @@ export class WorkflowEngine {
1226
1613
  continue;
1227
1614
  await this.cancel(id).catch(() => undefined);
1228
1615
  }
1229
- await this.store.deleteRun(id);
1230
- await this.sweepRunBufferedSignals(id);
1231
- deleted += 1;
1616
+ doomed.push(id);
1232
1617
  }
1618
+ // One batched delete per page (the store's `deleteRuns`), children before their parent.
1619
+ await this.deleteRunIds(doomed);
1620
+ for (const id of doomed)
1621
+ await this.sweepRunBufferedSignals(id);
1622
+ deleted += doomed.length;
1233
1623
  if (batch.length < size)
1234
1624
  break;
1235
1625
  }
@@ -1352,7 +1742,7 @@ export class WorkflowEngine {
1352
1742
  let registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
1353
1743
  // Convention dispatch: a run recovered on an instance that never registered this workflow (e.g. a
1354
1744
  // crash-recovery pickup) is re-routed to a live worker group of the same name, if one exists.
1355
- if (!registered && (await this.ensureConventionWorkflow(run.workflow))) {
1745
+ if (!registered && (await this.ensureConventionWorkflow(run.workflow, run.namespace))) {
1356
1746
  registered =
1357
1747
  this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
1358
1748
  this.latest.get(run.workflow);
@@ -1405,10 +1795,21 @@ export class WorkflowEngine {
1405
1795
  }
1406
1796
  return (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= from && c.seq <= to && c.status === 'pending').length;
1407
1797
  }
1408
- /** Track an in-flight execution so {@link drain} can wait for it. */
1798
+ /**
1799
+ * Track an in-flight execution so {@link drain} can wait for it.
1800
+ *
1801
+ * The bookkeeping chain must not re-raise: `p.finally(...)` returns a NEW promise that rejects
1802
+ * whenever `p` does, and nothing awaits it. So a turn whose store write failed (a dropped
1803
+ * connection, a missing table) became a process-level unhandled rejection even though the caller
1804
+ * awaiting `p` handled the very same error — fatal under Node's default `--unhandled-rejections`.
1805
+ * The rejection is reported once, to the caller that owns `p`.
1806
+ */
1409
1807
  track(runId, p) {
1410
1808
  this.inflight.set(p, runId);
1411
- void p.finally(() => this.inflight.delete(p));
1809
+ const untrack = () => {
1810
+ this.inflight.delete(p);
1811
+ };
1812
+ p.then(untrack, untrack);
1412
1813
  return p;
1413
1814
  }
1414
1815
  /**
@@ -1428,9 +1829,20 @@ export class WorkflowEngine {
1428
1829
  * (and their per-run lock loops) start nothing once this is called, and {@link runOne} refuses
1429
1830
  * broker-delivered runs. Runs already started finish. {@link drain} calls it first; the worker loop
1430
1831
  * calls it the moment a stop is requested, so a tick still in flight stops leasing a backlog.
1832
+ *
1833
+ * `spentMs` is wait time ALREADY spent on this shutdown (the worker loop waiting for its in-flight
1834
+ * tick). It is deducted from the budget of a later {@link drain}, so that wait plus the drain stay
1835
+ * within one `drainTimeoutMs`; a drain already waiting is shortened. Only time actually spent
1836
+ * waiting is charged, and reports add up. Non-finite or negative values are ignored, so one bad
1837
+ * report can't turn every later drain into a 0 ms wait.
1431
1838
  */
1432
- beginDrain() {
1839
+ beginDrain(spentMs = 0) {
1433
1840
  this.draining = true;
1841
+ if (Number.isFinite(spentMs) && spentMs > 0) {
1842
+ this.drainSpentMs += spentMs;
1843
+ for (const rearm of this.drainRearms)
1844
+ rearm();
1845
+ }
1434
1846
  }
1435
1847
  /** True once {@link beginDrain} (or {@link drain}) has been called. */
1436
1848
  isDraining() {
@@ -1454,15 +1866,40 @@ export class WorkflowEngine {
1454
1866
  * process is going away, so a held lease would only delay the next pod's `recoverIncomplete` until
1455
1867
  * expiry; after release the next pod replays deterministically (a pending step re-dispatches the same
1456
1868
  * `stepId`, idempotent by the durable contract; a completed step short-circuits).
1869
+ *
1870
+ * `timeoutMs` is the TOTAL shutdown budget: wait already reported through {@link beginDrain}
1871
+ * (`spentMs`) is deducted, so a worker that first waited for its in-flight tick drains only for
1872
+ * what is left. With nothing reported, the full `timeoutMs` applies.
1457
1873
  */
1458
1874
  async drain(timeoutMs = 10_000) {
1459
1875
  this.beginDrain();
1460
1876
  if (this.inflight.size === 0 && this.postSettle.size === 0)
1461
1877
  return;
1878
+ // The deadline is `start + timeoutMs - spent`, re-read whenever `beginDrain(spentMs)` reports more
1879
+ // wait — so a shutdown that already waited for an in-flight tick only drains for the remainder.
1880
+ const start = Date.now();
1881
+ let timeout;
1882
+ let rearm = () => { };
1462
1883
  const timer = new Promise((resolve) => {
1463
- const t = setTimeout(() => resolve('timeout'), timeoutMs);
1464
- t.unref?.();
1884
+ rearm = () => {
1885
+ clearTimeout(timeout);
1886
+ const remaining = Math.max(0, start + timeoutMs - this.drainSpentMs - Date.now());
1887
+ timeout = setTimeout(() => resolve('timeout'), remaining);
1888
+ timeout.unref?.();
1889
+ };
1890
+ rearm();
1465
1891
  });
1892
+ this.drainRearms.add(rearm);
1893
+ try {
1894
+ await this.drainUntil(timer);
1895
+ }
1896
+ finally {
1897
+ this.drainRearms.delete(rearm);
1898
+ clearTimeout(timeout);
1899
+ }
1900
+ }
1901
+ /** The {@link drain} wait loop, bounded by `timer`. */
1902
+ async drainUntil(timer) {
1466
1903
  // Loop, don't snapshot-once: settling an in-flight run fires post-settle effects (parent notify,
1467
1904
  // singleton wake) that can resume — and thus re-inflight — another run, which settles and fires
1468
1905
  // more effects. Wait until BOTH sets reach steady-state empty, or the shared timer wins. The
@@ -1576,18 +2013,38 @@ export class WorkflowEngine {
1576
2013
  // that's ~zero rows per tick, instead of fetching every running run and issuing one doomed
1577
2014
  // lock-probe UPDATE per row per worker per second. Falls back to the old full-list + in-process
1578
2015
  // filter for a custom store without the optimized method.
2016
+ //
2017
+ // Self-reclaim: for one lease window after boot, also ask for the runs still leased to THIS
2018
+ // instance id. Under a stable id (a pod name), a SIGKILLed/OOM-killed predecessor's leases read
2019
+ // as "held by me" and would otherwise sit out their full `leaseMs`. After that window no such
2020
+ // lease can still be live, so the extra predicate is dropped and the query is the orphan-only one.
1579
2021
  const incomplete = await asHeartbeat(async () => {
1580
2022
  const nowMs = this.clock();
2023
+ const owner = nowMs < this.bootedAt + this.leaseMs ? this.instanceId : undefined;
1581
2024
  if (this.store.listOrphanedRuns) {
1582
- return this.store.listOrphanedRuns(nowMs, 100, this.namespace);
2025
+ return owner === undefined
2026
+ ? this.store.listOrphanedRuns(nowMs, 100, this.namespace)
2027
+ : this.store.listOrphanedRuns(nowMs, 100, this.namespace, owner);
1583
2028
  }
1584
- return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
2029
+ return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined ||
2030
+ r.lockedUntil <= nowMs ||
2031
+ (owner !== undefined && r.lockedBy === owner));
1585
2032
  });
1586
2033
  for (const listed of incomplete) {
1587
2034
  if (this.draining)
1588
2035
  break; // stop requested mid-sweep
1589
- // A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
1590
- // (its worker crashed). Skip the ones still owned.
2036
+ // A run still leased to this instance id: reclaimable only when a PREVIOUS process under the
2037
+ // same id left that lease behind — never one this process holds (see selfReclaimable). Give the
2038
+ // stale lease up so the normal orphan path below can take it; anything else is skipped.
2039
+ if (listed.lockedBy === this.instanceId &&
2040
+ listed.lockedUntil !== undefined &&
2041
+ listed.lockedUntil > nowMs &&
2042
+ !(await this.releaseStaleSelfLease(listed))) {
2043
+ continue;
2044
+ }
2045
+ // An IN-PROCESS turn renews its lease, so an acquirable lease means no turn is executing this
2046
+ // run right now. Skip the ones still owned. That is NOT the same as "its worker crashed" — see
2047
+ // the dispatched-step guard below for the case it doesn't cover.
1591
2048
  const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1592
2049
  if (!acquired)
1593
2050
  continue;
@@ -1603,6 +2060,38 @@ export class WorkflowEngine {
1603
2060
  await this.store.releaseRunLock(listed.id, this.instanceId);
1604
2061
  continue;
1605
2062
  }
2063
+ // NOT AN ORPHAN: a run with an in-flight (`pending`) remote step is not executed in process at
2064
+ // all — its work sits on the transport, run by a worker that holds NO run lease, so the free
2065
+ // lease proves nothing about it. By contract such a run is durably SUSPENDED (see
2066
+ // `StepCheckpoint.status`); reading `running` means a turn ended without restoring that. Counting
2067
+ // it as a crash would burn the poison-pill budget on a healthy run and, past
2068
+ // `maxRecoveryAttempts`, dead-letter it while its worker is still executing the step. Re-assert
2069
+ // the contract instead: park it `suspended` on the step's deadline or the reconcile timer. The
2070
+ // worker's result resumes it; `resumeDueTimers` is the safety net. A LOST dispatch stays the job
2071
+ // of `remoteRedispatchMs` / `redispatchPending`.
2072
+ const dispatched = await this.inFlightRemoteStep(run.id);
2073
+ if (dispatched) {
2074
+ // A REMOTE workflow reads `running` only while a turn is being computed or applied, so a free
2075
+ // lease here means that turn was lost — the call in flight is not what the run waits on next.
2076
+ // Park it on a near wake so the timer pass re-drives the turn promptly (never later than the
2077
+ // call's own deadline) instead of after the whole reconcile window.
2078
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
2079
+ this.latest.get(run.workflow);
2080
+ const stepWake = this.reconcileWakeAt(dispatched.wakeAt);
2081
+ const wakeAt = registered?.remote
2082
+ ? Math.min(stepWake ?? Number.POSITIVE_INFINITY, this.clock() + REMOTE_TURN_REDRIVE_MS)
2083
+ : stepWake;
2084
+ // Conditional on still-`running`: a lease-free `cancel()` may have settled it meanwhile.
2085
+ const parked = await this.store.updateRunIf(run.id, ['running'], {
2086
+ status: 'suspended',
2087
+ wakeAt,
2088
+ updatedAt: new Date(),
2089
+ });
2090
+ await this.store.releaseRunLock(run.id, this.instanceId);
2091
+ if (parked)
2092
+ results.push({ runId: run.id, status: 'suspended' });
2093
+ continue;
2094
+ }
1606
2095
  // Count the attempt / dead-letter a poison pill past maxRecoveryAttempts.
1607
2096
  const settled = await this.countRecovery(run);
1608
2097
  // `null`: the run left `running` between the re-read above and the dead-letter write (a
@@ -1634,6 +2123,55 @@ export class WorkflowEngine {
1634
2123
  }
1635
2124
  return results;
1636
2125
  }
2126
+ /**
2127
+ * Whether a lease held under this engine's own {@link instanceId} was left behind by a previous
2128
+ * process that ran under the same id, rather than held by this one. Both must hold:
2129
+ * - nothing in this process executes the run: no execution in its run gate and no execution lease
2130
+ * tracked for it;
2131
+ * - the lease was written before this engine booted: every lease this process writes (acquire or
2132
+ * renew) ends at `clock() + leaseMs >= bootedAt + leaseMs`, so an earlier end proves an earlier
2133
+ * writer. This also covers the brief windows where a path of this process holds the lease
2134
+ * without having entered the run gate yet (a sweep between `tryLockRun` and the resume).
2135
+ *
2136
+ * Sound only when no two LIVE processes share an instance id — the documented requirement.
2137
+ */
2138
+ selfReclaimable(run, gated = this.runGates.has(run.id)) {
2139
+ if (run.lockedBy !== this.instanceId || run.lockedUntil === undefined)
2140
+ return false;
2141
+ if (gated || this.executionLeases.has(run.id))
2142
+ return false;
2143
+ return run.lockedUntil - this.leaseMs < this.bootedAt;
2144
+ }
2145
+ /**
2146
+ * Release a lease this instance's previous incarnation left on `listed` (see {@link selfReclaimable}),
2147
+ * so the recovery sweep can take the run like any orphan. The release runs inside the run's
2148
+ * in-process gate, so a resume landing meanwhile here queues behind it instead of executing on the
2149
+ * lease being given up; the run is re-read there and only released if it is still a stale `running`
2150
+ * self-lease. Returns whether the lease was released.
2151
+ */
2152
+ async releaseStaleSelfLease(listed) {
2153
+ if (!this.selfReclaimable(listed))
2154
+ return false;
2155
+ let released = false;
2156
+ await this.serializeOnRun(listed.id, async () => {
2157
+ const run = await this.store.getRun(listed.id);
2158
+ // The gate is ours now (serializeOnRun registered it before any other resume could queue).
2159
+ if (run?.status !== 'running' || !this.selfReclaimable(run, false))
2160
+ return;
2161
+ await this.store.releaseRunLock(run.id, this.instanceId);
2162
+ released = true;
2163
+ });
2164
+ return released;
2165
+ }
2166
+ /**
2167
+ * The remote step this run dispatched and still awaits a result for, if any — the durable marker
2168
+ * that tells {@link recoverIncomplete} "this run's work is on the transport, not in a crashed
2169
+ * process". A `pending` remote checkpoint is written before the dispatch and settled by the result.
2170
+ */
2171
+ async inFlightRemoteStep(runId) {
2172
+ const checkpoints = await this.store.listCheckpoints(runId);
2173
+ return checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
2174
+ }
1637
2175
  /**
1638
2176
  * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
1639
2177
  * `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
@@ -1666,6 +2204,9 @@ export class WorkflowEngine {
1666
2204
  error,
1667
2205
  });
1668
2206
  this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
2207
+ // `dead` is terminal like `failed`: a parent awaiting this run as a child (ctx.child, a ctx.all /
2208
+ // allSettled fan) must learn it now, as a failed child, not on its next reconcile wake.
2209
+ this.notifyParent(run.id, failedCompletion(error));
1669
2210
  return { runId: run.id, status: 'dead', error };
1670
2211
  }
1671
2212
  // Count BEFORE resuming, so a crash mid-resume still advances the counter.
@@ -1743,7 +2284,9 @@ export class WorkflowEngine {
1743
2284
  // live `running` placeholder with its `child:<id>` waiter put back — a separately retried
1744
2285
  // child's completion (already buffered, or still to come) then resumes this run.
1745
2286
  // Retry child and parent in either order; signal buffering makes it converge.
1746
- if (run.status === 'failed' || run.status === 'dead') {
2287
+ // - the run-level `recoveryAttempts` budget goes back to zero (see the final `updateRun`).
2288
+ const resurrecting = run.status === 'failed' || run.status === 'dead';
2289
+ if (resurrecting) {
1747
2290
  const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
1748
2291
  for (const cp of await this.store.listCheckpoints(runId)) {
1749
2292
  // A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
@@ -1827,9 +2370,27 @@ export class WorkflowEngine {
1827
2370
  await this.store.releaseRunLock(runId);
1828
2371
  // Explicit `error: undefined` CLEARS the stale failure (the store spreads the patch over the
1829
2372
  // run) — otherwise the dashboard keeps showing the old error while the run is re-executing.
2373
+ //
2374
+ // RESURRECTING a `failed`/`dead` run also resets `recoveryAttempts`, or the retry is a lie: a run
2375
+ // dead-lettered AT `maxRecoveryAttempts` comes back `pending`, and the next `recoverIncomplete`
2376
+ // pass that finds it orphaned computes `cap + 1` and kills it again with the same
2377
+ // `max_recovery_attempts` error. The budget bounds ONE crash-loop episode; resurrecting a run that
2378
+ // came to rest starts a new one. `0`, not `undefined`, so every store writes a real value.
2379
+ // Gated on `resurrecting`: requeueing a still-live run is not a resurrection, and zeroing there
2380
+ // would let a retry loop keep a crash-looping run alive forever.
2381
+ //
2382
+ // It also drops a stale singleton admission marker (a terminal settle clears it; this covers a
2383
+ // row written before that), so the resurrected run competes for a slot like any waiter.
2384
+ //
2385
+ // And it clears a remote workflow turn's decision marker (`awaitingDecisionTaskId`): the requeued
2386
+ // run dispatches a FRESH turn, so a late decision of the old one must find no marker to match.
2387
+ const staleAdmission = resurrecting ? withoutAdmission(run.tags) : undefined;
1830
2388
  await this.store.updateRun(runId, {
1831
2389
  status: 'pending',
1832
2390
  error: undefined,
2391
+ awaitingDecisionTaskId: undefined,
2392
+ ...(resurrecting ? { recoveryAttempts: 0 } : {}),
2393
+ ...(staleAdmission ? { tags: staleAdmission } : {}),
1833
2394
  updatedAt: new Date(),
1834
2395
  });
1835
2396
  await this.runDispatcher.dispatch(runId);
@@ -1839,8 +2400,9 @@ export class WorkflowEngine {
1839
2400
  * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
1840
2401
  * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
1841
2402
  * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
1842
- * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
1843
- * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2403
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` parks a run with an
2404
+ * in-flight `pending` remote step back to `suspended` instead of re-driving it, and `requeue` just
2405
+ * replays back to the pending guard. This re-enqueues the same `stepId` so the
1844
2406
  * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
1845
2407
  * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
1846
2408
  * run's current status (with the count re-dispatched), or null if the run is unknown.
@@ -1916,8 +2478,13 @@ export class WorkflowEngine {
1916
2478
  */
1917
2479
  waitForRun(runId, opts) {
1918
2480
  const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
1919
- const isSettled = (s) => s !== 'pending' && s !== 'running';
1920
- const shouldResolve = opts?.terminal ? isTerminal : isSettled;
2481
+ // A run `suspended` while it awaits a dispatched remote workflow turn's decision
2482
+ // (`awaitingDecisionTaskId` set) is mid-turn, not settled: that suspend is the engine's re-drive
2483
+ // marker, not a sleep/signal the body asked for. Keep waiting until the decision lands.
2484
+ const isSettled = (run) => run.status !== 'pending' &&
2485
+ run.status !== 'running' &&
2486
+ !(run.status === 'suspended' && run.awaitingDecisionTaskId !== undefined);
2487
+ const shouldResolve = (run) => opts?.terminal ? isTerminal(run.status) : isSettled(run);
1921
2488
  const toResult = (run) => ({
1922
2489
  runId,
1923
2490
  status: run.status,
@@ -1951,7 +2518,7 @@ export class WorkflowEngine {
1951
2518
  void this.store
1952
2519
  .getRun(runId)
1953
2520
  .then((run) => {
1954
- if (run && shouldResolve(run.status))
2521
+ if (run && shouldResolve(run))
1955
2522
  finish(run);
1956
2523
  })
1957
2524
  .catch(() => undefined);
@@ -1970,6 +2537,12 @@ export class WorkflowEngine {
1970
2537
  /**
1971
2538
  * Resume each run only if this instance can acquire its recovery lease — so when several
1972
2539
  * replicas recover or poll at once, each run is picked up by exactly one of them.
2540
+ *
2541
+ * A run this instance cannot resume is SKIPPED (and logged), never allowed to abort the batch: a
2542
+ * leased batch is a mixed bag during a rolling deploy (a pod without a run's workflow throws for
2543
+ * it by design) and a store hiccup can fail one row, but the rest of the batch is runs it can drive
2544
+ * — and every caller is a recovery/poll sweep. The skipped run stays locked only until its lease
2545
+ * expires, then another instance (or a later tick) picks it up.
1973
2546
  */
1974
2547
  async resumeLeased(runs, nowMs = this.clock(), onLocked) {
1975
2548
  if (this.draining)
@@ -1992,9 +2565,26 @@ export class WorkflowEngine {
1992
2565
  const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1993
2566
  if (!acquired)
1994
2567
  continue;
1995
- // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
1996
- const settled = onLocked ? await onLocked(run) : undefined;
1997
- results.push(settled ?? (await this.resumeImplicit(run.id)));
2568
+ if (this.draining) {
2569
+ // Drain began while the lock was being taken. Starting the run now would hand `drain()` work
2570
+ // it may already have stopped waiting for, and an exit would strand the lease until expiry.
2571
+ // Give the lease back; the run stays for the next instance.
2572
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
2573
+ return;
2574
+ }
2575
+ try {
2576
+ // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
2577
+ const settled = onLocked ? await onLocked(run) : undefined;
2578
+ results.push(settled ?? (await this.resumeImplicit(run.id)));
2579
+ }
2580
+ catch (error) {
2581
+ // A foreign-namespace run is simply not ours (resume released its lease) — skip quietly.
2582
+ if (error instanceof NamespaceMismatch)
2583
+ continue;
2584
+ // Loud: a skip is not nothing — the run is still there and not progressing, and the reason
2585
+ // (a version skew, a store outage) is what tells an operator which.
2586
+ console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);
2587
+ }
1998
2588
  }
1999
2589
  };
2000
2590
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, runs.length) }, () => worker()));
@@ -2190,6 +2780,16 @@ export class WorkflowEngine {
2190
2780
  }
2191
2781
  return this.deliverSignal(lateWaiter, reclaimed.payload);
2192
2782
  }
2783
+ /**
2784
+ * Wake the run parked on `token` with `payload`, if there is one; otherwise do nothing — unlike
2785
+ * {@link signal}, it never buffers. For a wake-up whose real payload is ALREADY buffered on the
2786
+ * token (an entity op, see `Entities.kick`): a run that isn't parked reads that buffer at its next
2787
+ * wait, so there is nothing to keep.
2788
+ */
2789
+ async wakeSignalWaiter(token, payload) {
2790
+ const waiter = await this.store.takeSignalWaiter(token);
2791
+ return waiter ? this.deliverSignal(waiter, payload) : null;
2792
+ }
2193
2793
  /**
2194
2794
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2195
2795
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
@@ -2337,7 +2937,13 @@ export class WorkflowEngine {
2337
2937
  return { runId, status: run.status };
2338
2938
  }
2339
2939
  const error = { message: 'cancelled' };
2340
- await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
2940
+ // Clears a remote turn's decision marker too: a cancelled run awaits no decision.
2941
+ await this.store.updateRun(runId, {
2942
+ status: 'cancelled',
2943
+ error,
2944
+ awaitingDecisionTaskId: undefined,
2945
+ updatedAt: new Date(),
2946
+ });
2341
2947
  this.emit({
2342
2948
  type: 'run.failed',
2343
2949
  runId,
@@ -2444,6 +3050,38 @@ export class WorkflowEngine {
2444
3050
  countRuns(query) {
2445
3051
  return countRuns(this.store, query);
2446
3052
  }
3053
+ /**
3054
+ * Every workflow the live fleet can execute in this engine's partition: what workers DECLARE in
3055
+ * their descriptors, plus the live routing tokens no descriptor explains (a worker too old to
3056
+ * describe itself). Each entry says which kind of evidence it rests on. See
3057
+ * {@link workflowDirectory} for why the list is the length it is.
3058
+ */
3059
+ async announcedWorkflows() {
3060
+ return (await this.workflowDirectory()).workflows;
3061
+ }
3062
+ /**
3063
+ * {@link announcedWorkflows}, plus why it is empty when it is: no transport here can read the fleet
3064
+ * (`supported: false`), nothing is live, or workers are live only on OTHER partitions
3065
+ * (`otherPartitions`), which otherwise reads exactly like an empty fleet. Two live reads, neither
3066
+ * retained: the descriptor advertisement and the heartbeat keyspace. Only the BullMQ transport reads
3067
+ * them today.
3068
+ */
3069
+ async workflowDirectory() {
3070
+ const supported = this.pool.introspectsFleet;
3071
+ const [descriptors, heartbeats] = supported
3072
+ ? await Promise.all([
3073
+ this.pool.readAllWorkerDescriptors(),
3074
+ this.pool.readAllWorkerHeartbeats(),
3075
+ ])
3076
+ : [[], []];
3077
+ return buildWorkflowDirectory({
3078
+ descriptors,
3079
+ heartbeats,
3080
+ namespace: this.namespace,
3081
+ supported,
3082
+ sanitize: sanitizeQueueToken,
3083
+ });
3084
+ }
2447
3085
  /** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
2448
3086
  * `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
2449
3087
  * workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
@@ -2460,7 +3098,7 @@ export class WorkflowEngine {
2460
3098
  * (so a registered group with backlog and ZERO workers still reports — the alert case) UNION the
2461
3099
  * groups discovered from live heartbeats (so a local-step group like `pipeline`, not derivable
2462
3100
  * from registrations, shows once its workers beat). Empty when no transport can introspect health
2463
- * (only the BullMQ transport implements `groupHealth`). */
3101
+ * (only the BullMQ, queue and db transports implement `groupHealth`). */
2464
3102
  async workerHealth(extra = []) {
2465
3103
  const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
2466
3104
  // One round per group, in parallel — the dashboard polls this, and each group's health is an
@@ -2627,7 +3265,13 @@ export class WorkflowEngine {
2627
3265
  return { runId: run.id, status: run.status };
2628
3266
  }
2629
3267
  }
3268
+ const lease = { handedOff: false };
3269
+ this.executionLeases.set(run.id, lease);
2630
3270
  const renew = setInterval(() => {
3271
+ // A lease this execution gave away on purpose (see releaseTurnLease) is not renewed, and its
3272
+ // `false` renew is not a takeover.
3273
+ if (lease.handedOff)
3274
+ return;
2631
3275
  // A renew that returns FALSE means the lease was taken over (this turn stalled past expiry
2632
3276
  // and another instance reclaimed the run) — this executor is now a zombie. Fence it: mark
2633
3277
  // the run so its eventual settle degrades to a no-op echo instead of clobbering the new
@@ -2636,7 +3280,7 @@ export class WorkflowEngine {
2636
3280
  void this.store
2637
3281
  .renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
2638
3282
  .then((renewed) => {
2639
- if (!renewed)
3283
+ if (!renewed && !lease.handedOff)
2640
3284
  this.leaseLostRuns.add(run.id);
2641
3285
  })
2642
3286
  .catch(() => undefined);
@@ -2664,6 +3308,8 @@ export class WorkflowEngine {
2664
3308
  finally {
2665
3309
  clearInterval(renew);
2666
3310
  this.leaseLostRuns.delete(run.id);
3311
+ if (this.executionLeases.get(run.id) === lease)
3312
+ this.executionLeases.delete(run.id);
2667
3313
  }
2668
3314
  }
2669
3315
  /**
@@ -2749,7 +3395,7 @@ export class WorkflowEngine {
2749
3395
  async routeFailure(run, error) {
2750
3396
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2751
3397
  const routes = registered?.convention
2752
- ? await this.announcedRoutes(run, registered.remote?.group ?? run.workflow, error)
3398
+ ? await this.announcedRoutes(run, conventionGroup(run.workflow, run.namespace), error)
2753
3399
  : registered?.onError;
2754
3400
  const route = routeFor(routes, error);
2755
3401
  if (!route)
@@ -2958,11 +3604,72 @@ export class WorkflowEngine {
2958
3604
  return wakeAt;
2959
3605
  return this.reconcileMs != null ? this.clock() + this.reconcileMs : undefined;
2960
3606
  }
3607
+ /**
3608
+ * The result of a turn the singleton gate denied. The store already parked the run `suspended` on
3609
+ * its retry timer INSIDE the admission claim (or left it alone, when a concurrent cancel had
3610
+ * settled it), so report what is persisted rather than writing over it.
3611
+ */
3612
+ /**
3613
+ * A run's first execution: `pending` -> `running`. A singleton run not yet admitted is stamped
3614
+ * {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
3615
+ * replica is about to admit or deny is never mistaken for a holder admitted by a replica older than
3616
+ * 0.51 (see `legacySingletonHolders`).
3617
+ */
3618
+ async markStarted(run, singleton) {
3619
+ const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
3620
+ const retag = tags !== run.tags;
3621
+ await this.store.updateRun(run.id, {
3622
+ status: 'running',
3623
+ ...(retag ? { tags } : {}),
3624
+ updatedAt: new Date(),
3625
+ });
3626
+ run.status = 'running';
3627
+ if (retag)
3628
+ run.tags = tags;
3629
+ }
3630
+ async singletonWaitResult(run) {
3631
+ const current = await this.store.getRun(run.id);
3632
+ await this.store.releaseRunLock(run.id, this.instanceId);
3633
+ if (!current)
3634
+ throw new Error(`run ${run.id} was deleted during singleton admission`);
3635
+ if (current.status === 'suspended') {
3636
+ this.emit({
3637
+ type: 'run.suspended',
3638
+ runId: current.id,
3639
+ workflow: current.workflow,
3640
+ namespace: current.namespace,
3641
+ });
3642
+ }
3643
+ return {
3644
+ runId: current.id,
3645
+ status: current.status,
3646
+ output: current.output,
3647
+ error: current.error,
3648
+ };
3649
+ }
2961
3650
  async runRemoteExecution(run, registered) {
2962
3651
  const remote = registered.remote;
3652
+ // A compensating cancel (`cancel(id, { compensate: true })`) resumes the run to unwind it. A remote
3653
+ // workflow registers no engine-side saga compensations, so the unwind is just the `cancelled`
3654
+ // settle — and it must happen even while a dispatched turn is still out (the hold below would
3655
+ // otherwise swallow the cancel's resume, and the in-flight turn's decision would carry the run on
3656
+ // as if it was never cancelled). The late decision then finds the run cancelled and is dropped.
3657
+ if (await this.takeCancelIntent(run.id))
3658
+ return this.compensateAndCancel(run, []);
3659
+ // A dispatched turn is still out and within its liveness window: whatever woke the run (a step
3660
+ // result, a signal, a wait recheck) must not dispatch a second turn over it — that would supersede
3661
+ // the first and drop its decision, re-running whatever local steps that turn ran. Everything such a
3662
+ // wake wrote is durable already; the in-flight turn's decision is applied as usual and its suspend
3663
+ // rechecks what it waits on (the `reconcileMs` wake is the fallback). Only a LAPSED window (the
3664
+ // timer poller, `wakeAt` due) re-drives the turn below.
3665
+ if (run.status === 'suspended' &&
3666
+ run.awaitingDecisionTaskId !== undefined &&
3667
+ (run.wakeAt == null || run.wakeAt > this.clock())) {
3668
+ await this.releaseTurnLease(run.id);
3669
+ return { runId: run.id, status: 'suspended' };
3670
+ }
2963
3671
  if (run.status === 'pending') {
2964
- await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
2965
- run.status = 'running';
3672
+ await this.markStarted(run, registered?.singleton !== undefined);
2966
3673
  this.emit({
2967
3674
  type: 'run.started',
2968
3675
  runId: run.id,
@@ -2971,21 +3678,12 @@ export class WorkflowEngine {
2971
3678
  });
2972
3679
  }
2973
3680
  if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
2974
- const wakeAt = this.singletons.retryWakeAt();
2975
- await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
2976
- this.emit({
2977
- type: 'run.suspended',
2978
- runId: run.id,
2979
- workflow: run.workflow,
2980
- namespace: run.namespace,
2981
- });
2982
- await this.store.releaseRunLock(run.id);
2983
- return { runId: run.id, status: 'suspended' };
3681
+ return this.singletonWaitResult(run);
2984
3682
  }
2985
3683
  // Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
2986
3684
  // its group — if no live worker on that group can run it, park `blocked` before advancing rather
2987
3685
  // than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
2988
- const routePlan = await this.planRoute(remote.group, registered.requires);
3686
+ const routePlan = await this.planRoute(registered.convention ? conventionGroup(run.workflow, run.namespace) : remote.group, registered.requires);
2989
3687
  if (routePlan?.status === 'blocked')
2990
3688
  return this.parkBlocked(run, routePlan);
2991
3689
  const checkpoints = await this.store.listCheckpoints(run.id);
@@ -2995,11 +3693,57 @@ export class WorkflowEngine {
2995
3693
  return this.settleRun(run, await this.followHandoff(run, handoff));
2996
3694
  }
2997
3695
  const history = await this.remoteHistory(run.id, checkpoints);
3696
+ // The remote body gets the parsed input too, re-parsed here each turn like an inline one. It
3697
+ // travels as JSON, so a value JSON can't carry arrives as its JSON form — but coercions and
3698
+ // defaults do apply. Only the TASK carries it: `run` (and so the store) keeps the input as sent.
3699
+ let turnRun;
3700
+ try {
3701
+ turnRun = registered.inputSchema
3702
+ ? { ...run, input: await this.turnInput(run, registered) }
3703
+ : run;
3704
+ }
3705
+ catch (err) {
3706
+ return this.settleRun(run, {
3707
+ kind: 'failed',
3708
+ error: {
3709
+ message: err instanceof Error ? err.message : String(err),
3710
+ ...codeAndDetails(err),
3711
+ },
3712
+ });
3713
+ }
3714
+ // Broker-backed executors: dispatch the turn and suspend on it. The decision is applied by
3715
+ // whichever replica consumes it (completeRemoteDecision), never awaited here.
3716
+ if (remote.executor.dispatch)
3717
+ return this.dispatchRemoteTurn(turnRun, registered, history);
3718
+ // INLINE advance (an executor that produces the decision in-process): await it under the lease.
3719
+ const advance = remote.executor.advance;
3720
+ if (!advance) {
3721
+ throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: its executor implements neither advance nor dispatch`);
3722
+ }
2998
3723
  let decision;
2999
3724
  try {
3000
- decision = await remote.executor.advance(run, history);
3725
+ const silenceMs = this.remoteAdvanceSilenceMs;
3726
+ const pending = advance.call(remote.executor, turnRun, history);
3727
+ decision =
3728
+ silenceMs != null
3729
+ ? await this.awaitTurnWithSilence(run.id, pending, silenceMs)
3730
+ : await pending;
3001
3731
  }
3002
3732
  catch (err) {
3733
+ // A timed-out advance is not a failure: the decision may have been lost while the work completed.
3734
+ // Failing the run would notify its parent of a failure that never happened. Leave it `running`
3735
+ // with its lease released, so crash recovery re-drives it (replaying completed steps; bounded by
3736
+ // `maxRecoveryAttempts`).
3737
+ if (err instanceof RemoteWorkflowTimeout) {
3738
+ await this.store
3739
+ .updateRunIf(run.id, ['running', 'suspended'], {
3740
+ status: 'running',
3741
+ updatedAt: new Date(),
3742
+ })
3743
+ .catch(() => false);
3744
+ await this.releaseTurnLease(run.id);
3745
+ return { runId: run.id, status: 'running' };
3746
+ }
3003
3747
  const error = {
3004
3748
  message: err instanceof Error ? err.message : String(err),
3005
3749
  stack: err instanceof Error ? err.stack : undefined,
@@ -3014,6 +3758,16 @@ export class WorkflowEngine {
3014
3758
  });
3015
3759
  return { runId: run.id, status: 'failed', error };
3016
3760
  }
3761
+ return this.applyDecision(run, decision);
3762
+ }
3763
+ /**
3764
+ * Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then settle
3765
+ * (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by the
3766
+ * INLINE advance path ({@link runRemoteExecution}, under the execution's lease) and the dispatched
3767
+ * path ({@link completeRemoteDecision}, which holds the lease itself and has already checked that
3768
+ * this is the decision of the turn the run awaits).
3769
+ */
3770
+ async applyDecision(run, decision) {
3017
3771
  if (decision.status === 'completed') {
3018
3772
  // Persist the local steps THIS turn ran before marking the run done. A workflow that runs
3019
3773
  // straight to completion in a single turn (every step inline, never suspending — e.g. a Python
@@ -3058,6 +3812,327 @@ export class WorkflowEngine {
3058
3812
  }
3059
3813
  return suspended;
3060
3814
  }
3815
+ /**
3816
+ * The dispatched-turn path of {@link runRemoteExecution}: SUSPEND, then ENQUEUE. The engine mints a
3817
+ * globally unique turn `taskId`, records it on the run as the awaited marker, and releases the lease,
3818
+ * all BEFORE the turn is enqueued, so a worker's decision, however fast and on whichever replica it
3819
+ * lands, always finds both its marker and a free lease (see {@link completeRemoteDecision}). The
3820
+ * deadline is the turn's liveness window ({@link decisionWindowMs}); when it lapses with no decision
3821
+ * and no run-scoped heartbeat, the timer poller re-drives the run, which dispatches a fresh turn.
3822
+ */
3823
+ async dispatchRemoteTurn(run, registered, history) {
3824
+ const executor = registered.remote.executor;
3825
+ const dispatch = executor.dispatch;
3826
+ // Zombie fence, as settleRun's: a lost lease means another instance owns the run now.
3827
+ if (this.leaseLostRuns.has(run.id))
3828
+ return this.echoCurrentStatus(run.id);
3829
+ // Random, not a per-process counter: two replicas re-driving the same run must never mint the same
3830
+ // id (the `db` transport dedupes a turn by it, and the marker gates the decision on it).
3831
+ const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID()}`;
3832
+ const windowMs = this.decisionWindowMs(registered);
3833
+ const marked = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, {
3834
+ status: 'suspended',
3835
+ awaitingDecisionTaskId: taskId,
3836
+ wakeAt: windowMs != null ? this.clock() + Math.min(windowMs, MAX_BACKOFF_MS) : undefined,
3837
+ updatedAt: new Date(),
3838
+ });
3839
+ if (!marked) {
3840
+ // Cancelled / completed / dead while this turn was being prepared: nothing to dispatch.
3841
+ await this.releaseTurnLease(run.id);
3842
+ return this.echoCurrentStatus(run.id);
3843
+ }
3844
+ this.emit({
3845
+ type: 'run.suspended',
3846
+ runId: run.id,
3847
+ workflow: run.workflow,
3848
+ namespace: run.namespace,
3849
+ });
3850
+ await this.releaseTurnLease(run.id);
3851
+ try {
3852
+ await turnDispatchAls.run(run.id, () => dispatch.call(executor, run, history, taskId));
3853
+ }
3854
+ catch (err) {
3855
+ return this.failUndispatchedTurn(run, taskId, err);
3856
+ }
3857
+ return { runId: run.id, status: 'suspended' };
3858
+ }
3859
+ /**
3860
+ * The turn could not be enqueued (the transport cannot carry workflow turns, or the broker refused the
3861
+ * write): fail the run, as an advance that throws does — but only while it still awaits THIS turn, so
3862
+ * a cancel or a re-drive that landed meanwhile wins.
3863
+ */
3864
+ async failUndispatchedTurn(run, taskId, err) {
3865
+ const error = {
3866
+ message: err instanceof Error ? err.message : String(err),
3867
+ stack: err instanceof Error ? err.stack : undefined,
3868
+ };
3869
+ const current = await this.store.getRun(run.id);
3870
+ if (current?.awaitingDecisionTaskId !== taskId)
3871
+ return this.echoCurrentStatus(run.id);
3872
+ const failed = await this.store.updateRunIf(run.id, ['suspended'], {
3873
+ status: 'failed',
3874
+ error,
3875
+ awaitingDecisionTaskId: undefined,
3876
+ wakeAt: undefined,
3877
+ updatedAt: new Date(),
3878
+ });
3879
+ if (!failed)
3880
+ return this.echoCurrentStatus(run.id);
3881
+ this.emit({
3882
+ type: 'run.failed',
3883
+ runId: run.id,
3884
+ workflow: run.workflow,
3885
+ namespace: run.namespace,
3886
+ error,
3887
+ });
3888
+ return { runId: run.id, status: 'failed', error };
3889
+ }
3890
+ /**
3891
+ * Give the run lease away mid-execution on purpose: stop the execution's renew loop from renewing it
3892
+ * (or reading the release as a takeover), then release it. Owner-scoped, so a lease another instance
3893
+ * already holds is left alone.
3894
+ */
3895
+ async releaseTurnLease(runId) {
3896
+ const lease = this.executionLeases.get(runId);
3897
+ if (lease)
3898
+ lease.handedOff = true;
3899
+ await this.store.releaseRunLock(runId, this.instanceId);
3900
+ }
3901
+ /**
3902
+ * The liveness window of a dispatched turn: the engine's `remoteAdvanceSilenceMs` and the executor's
3903
+ * own `decisionTimeoutMs` (a `RemoteWorkflowExecutor`'s `timeoutMs`), the shorter of the two when both
3904
+ * are set. `undefined` = no deadline: the run waits for its decision.
3905
+ */
3906
+ decisionWindowMs(registered) {
3907
+ const own = registered?.remote?.executor.decisionTimeoutMs || undefined;
3908
+ const silence = this.remoteAdvanceSilenceMs;
3909
+ if (own != null && silence != null)
3910
+ return Math.min(own, silence);
3911
+ return own ?? silence;
3912
+ }
3913
+ /**
3914
+ * Await an INLINE advance, rejecting with {@link RemoteWorkflowTimeout} once `silenceMs` passes with
3915
+ * neither a decision nor a run-scoped heartbeat for `runId`. Each beat rearms the window. The beat
3916
+ * hook is registered before this returns, so a beat can't slip in unseen.
3917
+ */
3918
+ awaitTurnWithSilence(runId, advance, silenceMs) {
3919
+ const key = turnBeatKey(runId);
3920
+ const windowMs = Math.min(silenceMs, MAX_BACKOFF_MS);
3921
+ return new Promise((resolve, reject) => {
3922
+ let timer;
3923
+ const cleanup = () => {
3924
+ clearTimeout(timer);
3925
+ if (this.heartbeatResets.get(key) === reset)
3926
+ this.heartbeatResets.delete(key);
3927
+ };
3928
+ const arm = () => {
3929
+ timer = setTimeout(() => {
3930
+ cleanup();
3931
+ reject(new RemoteWorkflowTimeout(runId, silenceMs));
3932
+ }, windowMs);
3933
+ timer.unref?.();
3934
+ };
3935
+ const reset = () => {
3936
+ clearTimeout(timer);
3937
+ arm();
3938
+ };
3939
+ this.heartbeatResets.set(key, reset);
3940
+ arm();
3941
+ advance.then((decision) => {
3942
+ cleanup();
3943
+ resolve(decision);
3944
+ }, (err) => {
3945
+ cleanup();
3946
+ reject(err);
3947
+ });
3948
+ });
3949
+ }
3950
+ /** Whether `run` is awaiting exactly `decision`'s turn (and so may still apply it). */
3951
+ awaitsDecision(run, decision) {
3952
+ return (run !== null &&
3953
+ run.status === 'suspended' &&
3954
+ run.awaitingDecisionTaskId !== undefined &&
3955
+ run.awaitingDecisionTaskId === decision.taskId);
3956
+ }
3957
+ /**
3958
+ * Apply a remote workflow-turn {@link WorkflowDecision} durably, on WHICHEVER replica consumes it: the
3959
+ * dispatching replica may be gone, or a point-to-point decisions queue handed the decision to a
3960
+ * sibling. The durable twin of {@link completeRemoteResult} for a workflow TURN: look the run up by
3961
+ * `decision.runId`, check that it still awaits this `taskId` (a stale, duplicate, foreign or
3962
+ * superseded decision is dropped, so a decision applies at most once), lease it, and apply it.
3963
+ * Serialized with this replica's own executions of the run through the run gate.
3964
+ */
3965
+ async completeRemoteDecision(decision) {
3966
+ if (!decision?.runId || !decision.taskId)
3967
+ return;
3968
+ const run = await this.store.getRun(decision.runId);
3969
+ if (!this.awaitsDecision(run, decision))
3970
+ return;
3971
+ // A scoped engine leaves another pool's runs to that pool (an operator applies everyone's).
3972
+ if (this.namespace !== undefined &&
3973
+ run.namespace !== undefined &&
3974
+ run.namespace !== this.namespace) {
3975
+ return;
3976
+ }
3977
+ await this.serializeOnRun(decision.runId, () => this.applyRemoteDecision(decision));
3978
+ }
3979
+ /**
3980
+ * Run `fn` in the run's in-process execution gate (see {@link resumeRun}): after the execution in
3981
+ * flight here, before any resume that lands later. A resume already queued (not started) runs first.
3982
+ */
3983
+ serializeOnRun(runId, fn) {
3984
+ let gate = this.runGates.get(runId);
3985
+ const prior = gate ? gate.tail.catch(() => undefined) : Promise.resolve();
3986
+ const done = prior.then(fn);
3987
+ const tail = done.then(() => ({ runId, status: 'suspended' }), () => ({ runId, status: 'suspended' }));
3988
+ if (!gate) {
3989
+ gate = { tail };
3990
+ this.runGates.set(runId, gate);
3991
+ }
3992
+ else {
3993
+ gate.tail = tail;
3994
+ }
3995
+ this.closeGateAfter(runId, gate, tail);
3996
+ this.trackEffect(done);
3997
+ return done;
3998
+ }
3999
+ /** {@link completeRemoteDecision} inside the run gate: lease, re-check, claim the turn, apply. */
4000
+ async applyRemoteDecision(decision) {
4001
+ const runId = decision.runId;
4002
+ if (!(await this.leaseForDecision(decision)))
4003
+ return;
4004
+ const renew = setInterval(() => {
4005
+ void this.store
4006
+ .renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs)
4007
+ .then((renewed) => {
4008
+ if (!renewed)
4009
+ this.leaseLostRuns.add(runId);
4010
+ })
4011
+ .catch(() => undefined);
4012
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
4013
+ renew.unref?.();
4014
+ try {
4015
+ const fresh = await this.store.getRun(runId);
4016
+ if (!this.awaitsDecision(fresh, decision))
4017
+ return;
4018
+ // Claim the turn: clear the marker and its deadline, and flip to `running` while the decision is
4019
+ // applied. Under the lease the marker cannot move (only a leased dispatch or re-drive rewrites
4020
+ // it); the status predicate loses to a lease-free cancel/requeue. If this instance dies
4021
+ // mid-apply, the run is a plain `running` orphan that crash recovery re-drives.
4022
+ const claimed = await this.store.updateRunIf(runId, ['suspended'], {
4023
+ status: 'running',
4024
+ awaitingDecisionTaskId: undefined,
4025
+ wakeAt: undefined,
4026
+ updatedAt: new Date(),
4027
+ });
4028
+ if (!claimed)
4029
+ return;
4030
+ this.decisionDeadlineRearms.delete(runId);
4031
+ const run = {
4032
+ ...fresh,
4033
+ status: 'running',
4034
+ awaitingDecisionTaskId: undefined,
4035
+ wakeAt: undefined,
4036
+ };
4037
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
4038
+ this.latest.get(run.workflow);
4039
+ const result = await this.applyDecision(run, decision);
4040
+ // Notify-on-release, as execute does for a turn it settles.
4041
+ if (registered?.singleton &&
4042
+ (result.status === 'completed' ||
4043
+ result.status === 'failed' ||
4044
+ result.status === 'cancelled' ||
4045
+ result.status === 'dead')) {
4046
+ this.trackEffect(this.singletons.wakeNext(run));
4047
+ }
4048
+ }
4049
+ finally {
4050
+ clearInterval(renew);
4051
+ this.leaseLostRuns.delete(runId);
4052
+ await this.store.releaseRunLock(runId, this.instanceId).catch(() => undefined);
4053
+ }
4054
+ }
4055
+ /**
4056
+ * Acquire the run lease to apply `decision`. The dispatcher released it before enqueueing, so this
4057
+ * normally succeeds at once; another replica may still hold it briefly (its wake found the turn in
4058
+ * flight and is releasing), so retry until the lease frees, the run stops awaiting this turn, or one
4059
+ * lease window passes (a crashed holder's lease expires within it). A lease THIS instance holds is
4060
+ * reused: we are inside the run gate, so no execution of it is running here.
4061
+ */
4062
+ async leaseForDecision(decision) {
4063
+ const runId = decision.runId;
4064
+ // Real time, not `this.clock`: a test clock may stand still.
4065
+ const giveUpAt = Date.now() + this.leaseMs + 1_000;
4066
+ let delayMs = 10;
4067
+ for (;;) {
4068
+ const run = await this.store.getRun(runId);
4069
+ if (!this.awaitsDecision(run, decision))
4070
+ return false;
4071
+ const now = this.clock();
4072
+ if (run.lockedBy === this.instanceId && (run.lockedUntil ?? 0) > now)
4073
+ return true;
4074
+ if (await this.store.tryLockRun(runId, this.instanceId, now + this.leaseMs, now))
4075
+ return true;
4076
+ if (Date.now() >= giveUpAt) {
4077
+ console.warn(`[adonis-durable] could not lease run ${runId} to apply the decision of turn ${decision.taskId}; the turn's liveness window re-drives it`);
4078
+ return false;
4079
+ }
4080
+ await new Promise((r) => setTimeout(r, delayMs));
4081
+ delayMs = Math.min(delayMs * 2, 250);
4082
+ }
4083
+ }
4084
+ /**
4085
+ * A run-scoped heartbeat for a run awaiting a dispatched turn's decision: push its re-drive deadline
4086
+ * (`wakeAt`) forward so a worker still replaying the turn is not re-driven. The durable counterpart
4087
+ * of an inline advance's in-memory rearm, and the run-scoped sibling of {@link rearmStepLease}.
4088
+ *
4089
+ * Throttled the same way: a renewal buys at most one window, so it is only worth a write once the
4090
+ * window is half spent. A beat that finds more than half of it left writes nothing and records the
4091
+ * half-spent mark, so later beats skip even the read until then. That bounds the cost at one read and
4092
+ * one write per half window per awaited turn, whatever the beat cadence. The mark is in memory only
4093
+ * (cold on another replica, which re-derives the same answer from the run's `wakeAt`). Best-effort:
4094
+ * it runs inside the transport's serial beat handler, so a store error is swallowed, and the mark only
4095
+ * moves after a write commits, so the next beat retries.
4096
+ */
4097
+ async rearmDecisionDeadline(runId) {
4098
+ // No window anywhere: nothing ever re-drives a turn, so there is no deadline to defend.
4099
+ if (!runId || (this.remoteAdvanceSilenceMs == null && !this.anyDecisionTimeout))
4100
+ return;
4101
+ const now = this.clock();
4102
+ const lookAgainAt = this.decisionDeadlineRearms.get(runId);
4103
+ if (lookAgainAt !== undefined && now < lookAgainAt)
4104
+ return;
4105
+ try {
4106
+ const run = await this.store.getRun(runId);
4107
+ if (run?.status !== 'suspended' || run.awaitingDecisionTaskId === undefined) {
4108
+ this.decisionDeadlineRearms.delete(runId);
4109
+ return;
4110
+ }
4111
+ const windowMs = this.decisionWindowMs(this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
4112
+ this.latest.get(run.workflow));
4113
+ if (windowMs == null)
4114
+ return;
4115
+ const halfWindow = Math.ceil(windowMs / 2);
4116
+ if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
4117
+ this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
4118
+ return;
4119
+ }
4120
+ const renewed = now + Math.min(windowMs, MAX_BACKOFF_MS);
4121
+ // Only while the same turn is still awaited: a decision applied (or a re-drive) meanwhile wins.
4122
+ const applied = await this.store.updateRunIf(runId, ['suspended'], {
4123
+ wakeAt: renewed,
4124
+ updatedAt: new Date(),
4125
+ });
4126
+ if (!applied)
4127
+ return;
4128
+ if (this.decisionDeadlineRearms.size > 1024)
4129
+ this.decisionDeadlineRearms.clear();
4130
+ this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
4131
+ }
4132
+ catch {
4133
+ // Best-effort liveness: a store blip costs one skipped renewal, never the beat loop.
4134
+ }
4135
+ }
3061
4136
  /**
3062
4137
  * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step is
3063
4138
  * still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
@@ -3531,14 +4606,83 @@ export class WorkflowEngine {
3531
4606
  }
3532
4607
  return wakeAt;
3533
4608
  }
4609
+ /**
4610
+ * Whether a compensating cancel is pending for this run, consuming the in-memory flag. The intent
4611
+ * lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same boot), and the
4612
+ * DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or a cross-pod handoff
4613
+ * mid-cancellation can't silently forget the cancel. The marker is peeked (take + re-buffer), not
4614
+ * consumed: it must survive a crash mid-unwind; {@link compensateAndCancel} drains it once
4615
+ * `cancelled` is durably recorded.
4616
+ */
4617
+ async takeCancelIntent(runId) {
4618
+ if (this.cancelRequested.delete(runId))
4619
+ return true;
4620
+ const marker = await this.store.takeBufferedSignal(`cancel:${runId}`);
4621
+ if (!marker)
4622
+ return false;
4623
+ await this.store.bufferSignal(`cancel:${runId}`, marker.payload).catch(() => undefined);
4624
+ return true;
4625
+ }
4626
+ /**
4627
+ * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
4628
+ * exit of a turn with a compensating cancel pending — the body returned, suspended, hit
4629
+ * continue-as-new or threw — so the cancel is never lost because the body didn't stop at a
4630
+ * suspension point.
4631
+ */
4632
+ async compensateAndCancel(run, compensations) {
4633
+ this.cancelRequested.delete(run.id);
4634
+ // Zombie fence (as in settleRun): another instance owns the run now; the durable marker is still
4635
+ // there for it, so this stale turn must neither undo nor write.
4636
+ if (this.leaseLostRuns.has(run.id))
4637
+ return this.echoCurrentStatus(run.id);
4638
+ for (let i = compensations.length - 1; i >= 0; i -= 1) {
4639
+ const comp = compensations[i];
4640
+ if (comp)
4641
+ await this.runCompensation(run, comp, i);
4642
+ }
4643
+ const error = { message: 'cancelled' };
4644
+ await this.store.updateRun(run.id, {
4645
+ status: 'cancelled',
4646
+ error,
4647
+ awaitingDecisionTaskId: undefined,
4648
+ updatedAt: new Date(),
4649
+ });
4650
+ // The cancellation is durably recorded — consume the marker(s) so a later retry of this run id
4651
+ // doesn't get spuriously re-cancelled.
4652
+ for (;;) {
4653
+ const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
4654
+ if (!m)
4655
+ break;
4656
+ }
4657
+ this.emit({
4658
+ type: 'run.failed',
4659
+ runId: run.id,
4660
+ workflow: run.workflow,
4661
+ namespace: run.namespace,
4662
+ error,
4663
+ });
4664
+ return { runId: run.id, status: 'cancelled', error };
4665
+ }
3534
4666
  /** The run body, lease held + renewed by {@link execute}. */
4667
+ /**
4668
+ * The input a turn's body receives: the stored input parsed by the workflow's `inputSchema`, or the
4669
+ * stored input itself without one. Re-parsed on every turn rather than stored parsed, because the
4670
+ * store round-trips the row through JSON (on the SQL stores) or a structured clone (in memory): a
4671
+ * stored parsed value would reach the body as a `Date` in tests and a string in production. A
4672
+ * stored input that no longer parses — its schema changed without a version bump — throws the same
4673
+ * `SchemaValidationError` (`invalid_input`, never retried) that fails the run.
4674
+ */
4675
+ async turnInput(run, registered) {
4676
+ if (!registered?.inputSchema)
4677
+ return run.input;
4678
+ return validateStandard(registered.inputSchema, run.input, `workflow "${run.workflow}" input`, 'invalid_input');
4679
+ }
3535
4680
  async runExecution(run, fn, registered) {
3536
4681
  // First execution of an enqueued run: mark it running and announce the start, BEFORE the singleton
3537
- // gate — `singletons.admit` only counts `running`/`suspended` runs, so a still-`pending` run could
3538
- // never be admitted. A resumed run is already past `pending`, so this fires exactly once.
4682
+ // gate, so an admitted run's body never starts while it still reads `pending`. A resumed run is
4683
+ // already past `pending`, so this fires exactly once.
3539
4684
  if (run.status === 'pending') {
3540
- await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
3541
- run.status = 'running';
4685
+ await this.markStarted(run, registered?.singleton !== undefined);
3542
4686
  this.emit({
3543
4687
  type: 'run.started',
3544
4688
  runId: run.id,
@@ -3549,16 +4693,7 @@ export class WorkflowEngine {
3549
4693
  // Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
3550
4694
  // (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
3551
4695
  if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
3552
- const wakeAt = this.singletons.retryWakeAt();
3553
- await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
3554
- this.emit({
3555
- type: 'run.suspended',
3556
- runId: run.id,
3557
- workflow: run.workflow,
3558
- namespace: run.namespace,
3559
- });
3560
- await this.store.releaseRunLock(run.id, this.instanceId);
3561
- return { runId: run.id, status: 'suspended' };
4696
+ return this.singletonWaitResult(run);
3562
4697
  }
3563
4698
  // Saga compensations registered by completed steps; run in reverse if the run later fails.
3564
4699
  const compensations = [];
@@ -3589,7 +4724,13 @@ export class WorkflowEngine {
3589
4724
  // Establish the ambient ctx for the duration of this body turn so context-aware statics
3590
4725
  // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
3591
4726
  // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
3592
- const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
4727
+ const input = await this.turnInput(run, registered);
4728
+ const output = await workflowAls.run(ctx, () => fn(ctx, input));
4729
+ // A compensating cancel requested while this turn ran (or one whose resume replays straight to
4730
+ // `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
4731
+ if (await this.takeCancelIntent(run.id)) {
4732
+ return await this.compensateAndCancel(run, compensations);
4733
+ }
3593
4734
  return await this.settleRun(run, { kind: 'completed', output });
3594
4735
  }
3595
4736
  catch (err) {
@@ -3598,6 +4739,10 @@ export class WorkflowEngine {
3598
4739
  // complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
3599
4740
  if (this.leaseLostRuns.has(run.id))
3600
4741
  return await this.echoCurrentStatus(run.id);
4742
+ // A cancel in flight wins over continue-as-new: undo + cancel instead of forking the next run.
4743
+ if (await this.takeCancelIntent(run.id)) {
4744
+ return await this.compensateAndCancel(run, compensations);
4745
+ }
3601
4746
  const nextId = nextContinuationId(run.id);
3602
4747
  // Persist the continuation BEFORE the parent's terminal write — the durable ordering that
3603
4748
  // makes the chain crash-safe. The old order (complete parent, then create the continuation
@@ -3639,45 +4784,8 @@ export class WorkflowEngine {
3639
4784
  if (err instanceof WorkflowSuspended) {
3640
4785
  // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
3641
4786
  // so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
3642
- // The intent lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same
3643
- // boot), and the DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or
3644
- // a cross-pod handoff mid-cancellation can't silently forget the cancel and settle the run
3645
- // back to `suspended` as if nothing happened. Peek the marker (take + re-buffer) rather than
3646
- // consume it: it must survive a crash mid-unwind; it's drained below once `cancelled` is
3647
- // durably recorded.
3648
- let cancelling = this.cancelRequested.delete(run.id);
3649
- if (!cancelling) {
3650
- const marker = await this.store.takeBufferedSignal(`cancel:${run.id}`);
3651
- if (marker) {
3652
- cancelling = true;
3653
- await this.store
3654
- .bufferSignal(`cancel:${run.id}`, marker.payload)
3655
- .catch(() => undefined);
3656
- }
3657
- }
3658
- if (cancelling) {
3659
- for (let i = compensations.length - 1; i >= 0; i -= 1) {
3660
- const comp = compensations[i];
3661
- if (comp)
3662
- await this.runCompensation(run, comp, i);
3663
- }
3664
- const error = { message: 'cancelled' };
3665
- await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
3666
- // The cancellation is durably recorded — consume the marker(s) so a later retry of this
3667
- // run id doesn't get spuriously re-cancelled.
3668
- for (;;) {
3669
- const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
3670
- if (!m)
3671
- break;
3672
- }
3673
- this.emit({
3674
- type: 'run.failed',
3675
- runId: run.id,
3676
- workflow: run.workflow,
3677
- namespace: run.namespace,
3678
- error,
3679
- });
3680
- return { runId: run.id, status: 'cancelled', error };
4787
+ if (await this.takeCancelIntent(run.id)) {
4788
+ return await this.compensateAndCancel(run, compensations);
3681
4789
  }
3682
4790
  const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
3683
4791
  // Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
@@ -3695,6 +4803,11 @@ export class WorkflowEngine {
3695
4803
  suspendedOnFan = err.fan;
3696
4804
  return settled;
3697
4805
  }
4806
+ // A cancel in flight that surfaced as a thrown error settles `cancelled`, not `failed`: the saga
4807
+ // undo runs either way; this keeps the terminal status faithful to the cancel request.
4808
+ if (await this.takeCancelIntent(run.id)) {
4809
+ return await this.compensateAndCancel(run, compensations);
4810
+ }
3698
4811
  const error = {
3699
4812
  message: err instanceof Error ? err.message : String(err),
3700
4813
  stack: err instanceof Error ? err.stack : undefined,
@@ -3840,7 +4953,9 @@ export class WorkflowEngine {
3840
4953
  if (this.pool.size === 0)
3841
4954
  throw new Error('dispatched compensation requires a Transport');
3842
4955
  const token = stepGroup(def, run.namespace);
3843
- const validInput = def.input ? def.input.parse(args) : args;
4956
+ const validInput = def.input
4957
+ ? await validateStandard(def.input, args, `step "${def.name}" input`, 'invalid_input')
4958
+ : args;
3844
4959
  const id = `${run.id}:compensate:${def.name}:${attempt}`;
3845
4960
  const name = `compensate:${def.name}`;
3846
4961
  const existing = await this.store.getCheckpoint(run.id, seq);
@@ -3883,7 +4998,7 @@ export class WorkflowEngine {
3883
4998
  });
3884
4999
  }
3885
5000
  const awaited = def.timeoutMs
3886
- ? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
5001
+ ? this.awaitWithHeartbeat(run.id, seq, id, resultPromise, def.timeoutMs)
3887
5002
  : resultPromise;
3888
5003
  await this.awaitCompensationOutcome(run.id, seq, name, id, token, attempt, awaited,
3889
5004
  // With a heartbeat window the def's own liveness handling governs; otherwise bound the wait.
@@ -3972,6 +5087,7 @@ export class WorkflowEngine {
3972
5087
  store: this.store,
3973
5088
  replay,
3974
5089
  clock: this.clock,
5090
+ eventBufferTtlMs: this.eventBufferTtlMs,
3975
5091
  webhookUrl: this.webhookUrl,
3976
5092
  startStep: (s) => this.startStep(s),
3977
5093
  completeStep: (s) => this.completeStep(s),
@@ -3998,8 +5114,19 @@ export class WorkflowEngine {
3998
5114
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
3999
5115
  upsertSearchAttributes: async (runId, attrs) => {
4000
5116
  const run = await this.store.getRun(runId);
5117
+ const merged = { ...(run?.searchAttributes ?? {}), ...attrs };
5118
+ // A declared `searchAttributesSchema` validates the MERGED bag (the schema describes the whole
5119
+ // run's shape, optional keys included) — before the write, so the ctx primitive records no
5120
+ // checkpoint and a rejected write throws in the body (non-retryable SchemaValidationError).
5121
+ const schema = run
5122
+ ? this.workflows.get(versionKey(run.workflow, run.workflowVersion))
5123
+ ?.searchAttributesSchema
5124
+ : undefined;
5125
+ if (schema) {
5126
+ validateStandardSync(schema, merged, `workflow "${run?.workflow}" ctx.upsertSearchAttributes`);
5127
+ }
4001
5128
  await this.store.updateRun(runId, {
4002
- searchAttributes: { ...(run?.searchAttributes ?? {}), ...attrs },
5129
+ searchAttributes: merged,
4003
5130
  updatedAt: new Date(),
4004
5131
  });
4005
5132
  },
@@ -4011,6 +5138,16 @@ export class WorkflowEngine {
4011
5138
  signalEntity: (name, key, op, arg, reply) => {
4012
5139
  this.trackEffect(Promise.resolve().then(() => this.entities.dispatch(name, key, op, arg, reply)));
4013
5140
  },
5141
+ // The durable half of a workflow-side entity send: awaited inside the turn (a store write
5142
+ // only, never an inline entity turn), so the ctx can checkpoint after it. See Entities.enqueue.
5143
+ enqueueEntityOp: (name, key, op, arg, id, reply) => this.entities.enqueue(name, key, op, arg, id, reply),
5144
+ // Start/wake the entity for what enqueueEntityOp buffered — deferred and tracked like
5145
+ // signalEntity above, so it never re-enters a still-executing turn and drain() waits for it.
5146
+ kickEntity: (name, key) => {
5147
+ this.trackEffect(Promise.resolve().then(() => this.entities.kick(name, key).catch((err) => {
5148
+ console.warn(`[durable] waking entity "${name}" key "${key}" failed (its op stays buffered for the next wake):`, err);
5149
+ })));
5150
+ },
4014
5151
  interceptStep: (invocation, body) => this.interceptStep(invocation, body),
4015
5152
  };
4016
5153
  }
@@ -4316,7 +5453,9 @@ export class WorkflowEngine {
4316
5453
  }
4317
5454
  // Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
4318
5455
  // call carries none — the engine passes the input through untouched then).
4319
- const validInput = step.input ? step.input.parse(input) : input;
5456
+ const validInput = step.input
5457
+ ? await validateStandard(step.input, input, `step "${step.name}" input`, 'invalid_input')
5458
+ : input;
4320
5459
  // Routing token: BY NAME (sanitized for brokers), optionally partition-suffixed. Computed once and
4321
5460
  // used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
4322
5461
  // token the worker subscribes to per handler name serves this step.
@@ -4464,8 +5603,12 @@ export class WorkflowEngine {
4464
5603
  async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
4465
5604
  const token = stepGroup(step, runNamespace);
4466
5605
  await this.ensureRoutable(token, step.requires);
4467
- const input = (i) => step.input ? step.input.parse(inputs[i]) : inputs[i];
4468
- const validInputs = new Map(batch.map((i) => [i, input(i)]));
5606
+ const input = (i) => step.input
5607
+ ? validateStandard(step.input, inputs[i], `step "${step.name}" input`, 'invalid_input')
5608
+ : inputs[i];
5609
+ const validInputs = new Map();
5610
+ for (const i of batch)
5611
+ validInputs.set(i, await input(i));
4469
5612
  const enqueuedAt = new Date();
4470
5613
  for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
4471
5614
  await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map((i) => {
@@ -4552,17 +5695,212 @@ export class WorkflowEngine {
4552
5695
  await this.store.saveCheckpoint(checkpoint);
4553
5696
  return true;
4554
5697
  }
4555
- async completeRemoteResult(result) {
5698
+ /**
5699
+ * Settle a durable step result and (barrier permitting) resume its run in the background.
5700
+ *
5701
+ * One run's results are settled in batches: the first starts a batch, and the ones that arrive
5702
+ * while it is being written (with `resultConcurrency > 1`) form the next, up to
5703
+ * {@link RESULT_BATCH_MAX}. A batch of several claims its checkpoints with one
5704
+ * {@link StateStore.saveCheckpointsIfPending} call, asks each step fan's barrier once, and resumes
5705
+ * the run at most once; a batch of one takes the single-result path unchanged. A result resolves
5706
+ * only once its own write committed, so the transport acknowledges nothing that is not durable.
5707
+ */
5708
+ completeRemoteResult(result) {
5709
+ return new Promise((resolve, reject) => {
5710
+ const entry = { result, resolve, reject };
5711
+ const queued = this.resultQueues.get(result.runId);
5712
+ if (queued) {
5713
+ queued.push(entry);
5714
+ return;
5715
+ }
5716
+ const queue = [entry];
5717
+ this.resultQueues.set(result.runId, queue);
5718
+ void this.drainResultQueue(result.runId, queue);
5719
+ });
5720
+ }
5721
+ /** Settle `queue` (one run's queued results) batch by batch until it is empty, then retire it. */
5722
+ async drainResultQueue(runId, queue) {
5723
+ // Results delivered in the same tick as the first join its batch.
5724
+ await Promise.resolve();
5725
+ while (queue.length > 0) {
5726
+ const batch = queue.splice(0, RESULT_BATCH_MAX);
5727
+ let outcomes;
5728
+ try {
5729
+ outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result));
5730
+ }
5731
+ catch (error) {
5732
+ outcomes = batch.map(() => ({ error }));
5733
+ }
5734
+ batch.forEach((q, i) => {
5735
+ const outcome = outcomes[i] ?? true;
5736
+ if (outcome === true)
5737
+ q.resolve();
5738
+ else
5739
+ q.reject(outcome.error);
5740
+ });
5741
+ }
5742
+ // Same tick as the empty check: a result arriving later starts a new queue.
5743
+ this.resultQueues.delete(runId);
5744
+ }
5745
+ /** The single-result path: settle `result`, then resume its run unless its fan is still running. */
5746
+ async completeOneRemoteResult(result) {
4556
5747
  const cp = await this.settleRemoteCheckpoint(result);
4557
5748
  if (cp === undefined)
4558
5749
  return;
4559
5750
  if (await this.stepFanStillRunning(cp, result))
4560
5751
  return;
4561
- const runId = result.runId;
5752
+ this.resumeInBackground(result.runId);
5753
+ }
5754
+ resumeInBackground(runId) {
4562
5755
  this.trackEffect(this.resumeImplicit(runId).catch((err) => {
4563
5756
  console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
4564
5757
  }));
4565
5758
  }
5759
+ /**
5760
+ * Settle a batch of one run's step results; per result, `true` (settled or dropped) or the error
5761
+ * that kept it from settling, which the transport then redelivers. Same outcome per result as
5762
+ * {@link completeOneRemoteResult} on each in turn, with one resume for the batch: that resume
5763
+ * replays every write of the batch, so a second would only replay it again.
5764
+ */
5765
+ async completeRemoteResults(runId, results) {
5766
+ const outcomes = results.map(() => true);
5767
+ const settleOneByOne = async (indexes) => {
5768
+ for (const i of indexes) {
5769
+ try {
5770
+ await this.completeOneRemoteResult(results[i]);
5771
+ }
5772
+ catch (error) {
5773
+ outcomes[i] = { error };
5774
+ }
5775
+ }
5776
+ return outcomes;
5777
+ };
5778
+ if (results.length === 1)
5779
+ return settleOneByOne([0]);
5780
+ // A second copy of a result in the same batch is a duplicate delivery: the first copy settles
5781
+ // the step (or re-drives the resume), so the copy is dropped like a late one.
5782
+ const seen = new Set();
5783
+ const live = results.flatMap((r, i) => {
5784
+ if (seen.has(r.seq))
5785
+ return [];
5786
+ seen.add(r.seq);
5787
+ return [i];
5788
+ });
5789
+ const run = await this.store.getRun(runId);
5790
+ // A finished or unknown run takes the single-result path, which salvages a late success onto a
5791
+ // failed run and frees the slots of a cancelled one. Rare, so it is not batched.
5792
+ if (run === null ||
5793
+ run.status === 'completed' ||
5794
+ run.status === 'failed' ||
5795
+ run.status === 'cancelled' ||
5796
+ run.status === 'dead') {
5797
+ return settleOneByOne(live);
5798
+ }
5799
+ const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
5800
+ // Checkpoints whose result should resume the run (barrier permitting): the ones this batch
5801
+ // settled, and redeliveries of ones settled before — whose resume may never have happened.
5802
+ const resumable = [];
5803
+ const pending = [];
5804
+ reads.forEach((read, k) => {
5805
+ const index = live[k];
5806
+ const result = results[index];
5807
+ if (read.status === 'rejected')
5808
+ outcomes[index] = { error: read.reason };
5809
+ else if (!read.value)
5810
+ return;
5811
+ else if (read.value.status === 'pending')
5812
+ pending.push({ index, cp: read.value });
5813
+ else
5814
+ resumable.push({ cp: read.value, result });
5815
+ });
5816
+ const finishedAt = new Date();
5817
+ const writes = pending.map(({ index, cp }) => {
5818
+ const result = results[index];
5819
+ return {
5820
+ ...cp,
5821
+ status: result.status,
5822
+ output: result.status === 'completed' ? result.output : cp.output,
5823
+ error: result.error,
5824
+ events: result.events ?? cp.events,
5825
+ startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
5826
+ finishedAt,
5827
+ };
5828
+ });
5829
+ // Claimed, not blindly written: a duplicate delivery may have settled a step first.
5830
+ const claims = await this.savePendingCheckpoints(writes);
5831
+ for (const [k, claim] of claims.entries()) {
5832
+ const { index, cp } = pending[k];
5833
+ const result = results[index];
5834
+ if (claim === false) {
5835
+ resumable.push({ cp, result });
5836
+ continue;
5837
+ }
5838
+ if (claim !== true) {
5839
+ outcomes[index] = claim;
5840
+ continue;
5841
+ }
5842
+ try {
5843
+ await this.releaseQueueSlot(cp);
5844
+ }
5845
+ catch (error) {
5846
+ // Written but not acknowledged: the redelivery finds the step settled and re-drives.
5847
+ outcomes[index] = { error };
5848
+ }
5849
+ const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
5850
+ this.emit({
5851
+ type: result.status === 'completed' ? 'step.completed' : 'step.failed',
5852
+ runId,
5853
+ seq: result.seq,
5854
+ name: cp.name,
5855
+ kind: cp.kind,
5856
+ output: result.output,
5857
+ error: result.error,
5858
+ queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
5859
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
5860
+ });
5861
+ resumable.push({ cp, result });
5862
+ }
5863
+ // Asked after every write of the batch committed, so the batch holding a fan's last step sees
5864
+ // its barrier open; once per fan. A barrier that cannot be read counts as open: a spare resume
5865
+ // replays and re-suspends, a lost one strands the run.
5866
+ const barriers = new Map();
5867
+ for (const { cp, result } of resumable) {
5868
+ const fan = parseStepFanGroup(cp.parallelGroup);
5869
+ if (!fan || result.status === 'failed') {
5870
+ this.resumeInBackground(runId);
5871
+ return outcomes;
5872
+ }
5873
+ let closed = barriers.get(fan.firstSeq);
5874
+ if (!closed) {
5875
+ closed = this.countPendingSteps(runId, fan.firstSeq, fan.firstSeq + fan.size - 1).then((n) => n > 0, () => false);
5876
+ barriers.set(fan.firstSeq, closed);
5877
+ }
5878
+ if (!(await closed)) {
5879
+ this.resumeInBackground(runId);
5880
+ return outcomes;
5881
+ }
5882
+ }
5883
+ return outcomes;
5884
+ }
5885
+ /**
5886
+ * {@link savePendingCheckpoint} for checkpoints at distinct seqs: one
5887
+ * {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
5888
+ * Never rejects as a whole: a failed write is reported as that entry's error, and the batch call
5889
+ * rejecting means it wrote nothing, so the entries are then written one by one.
5890
+ */
5891
+ async savePendingCheckpoints(checkpoints) {
5892
+ if (checkpoints.length > 1 && this.store.saveCheckpointsIfPending) {
5893
+ try {
5894
+ const wrote = await this.store.saveCheckpointsIfPending(checkpoints);
5895
+ return checkpoints.map((_, i) => wrote[i] === true);
5896
+ }
5897
+ catch {
5898
+ // Fall through: one bad row must not fail the whole batch.
5899
+ }
5900
+ }
5901
+ const settled = await Promise.allSettled(checkpoints.map((cp) => this.savePendingCheckpoint(cp)));
5902
+ return settled.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason }));
5903
+ }
4566
5904
  /**
4567
5905
  * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
4568
5906
  * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
@@ -4698,7 +6036,9 @@ export class WorkflowEngine {
4698
6036
  async callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission) {
4699
6037
  if (this.pool.size === 0)
4700
6038
  throw new Error('remote steps require a Transport');
4701
- const validInput = step.input ? step.input.parse(input) : input;
6039
+ const validInput = step.input
6040
+ ? await validateStandard(step.input, input, `step "${step.name}" input`, 'invalid_input')
6041
+ : input;
4702
6042
  const token = stepGroup(step, runNamespace);
4703
6043
  // Capability/protocol guard (design §7.5) — same as the durable path, before the `step.started`
4704
6044
  // emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
@@ -4783,7 +6123,7 @@ export class WorkflowEngine {
4783
6123
  }, transport);
4784
6124
  try {
4785
6125
  const resolution = step.timeoutMs
4786
- ? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs, step.pickupTimeoutMs)
6126
+ ? await this.awaitWithHeartbeat(runId, seq, id, resultPromise, step.timeoutMs, step.pickupTimeoutMs)
4787
6127
  : await resultPromise;
4788
6128
  return await this.settleRemoteAttempt(runId, seq, step, validInput, resolution, attempt, token, enqueuedAt);
4789
6129
  }
@@ -4815,7 +6155,9 @@ export class WorkflowEngine {
4815
6155
  }
4816
6156
  /** Shared finalization for a remote resolution, however it arrived (live await or salvaged). */
4817
6157
  async settleRemoteAttempt(runId, seq, step, input, resolution, attempt, token, enqueuedAt) {
4818
- const output = (step.output ? step.output.parse(resolution.output) : resolution.output);
6158
+ const output = (step.output
6159
+ ? await validateStandard(step.output, resolution.output, `step "${step.name}" output`, 'invalid_output')
6160
+ : resolution.output);
4819
6161
  // The worker reports when it actually picked the task up; fall back to dispatch time if a
4820
6162
  // transport doesn't carry it (queue-wait then reads as zero rather than going negative).
4821
6163
  const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
@@ -4839,9 +6181,11 @@ export class WorkflowEngine {
4839
6181
  * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
4840
6182
  * window, so a worker that keeps beating stays alive past `timeoutMs`.
4841
6183
  */
4842
- awaitWithHeartbeat(id, resultPromise, timeoutMs, pickupTimeoutMs) {
6184
+ awaitWithHeartbeat(runId, seq, id, resultPromise, timeoutMs, pickupTimeoutMs) {
4843
6185
  return new Promise((resolve, reject) => {
4844
6186
  let timer;
6187
+ let settled = false;
6188
+ const dispatchedAt = this.clock();
4845
6189
  // Two windows, one timer: until the FIRST beat arrives the job may be sitting in the broker
4846
6190
  // queue behind long work — a stretch that produces no heartbeats by definition, so it gets
4847
6191
  // `pickupTimeoutMs` (default: `timeoutMs`, the historical behavior). The worker's automatic
@@ -4853,20 +6197,63 @@ export class WorkflowEngine {
4853
6197
  // would turn a generous liveness window into an instant RemoteStepTimeout.
4854
6198
  const window = () => Math.min(seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs), MAX_BACKOFF_MS);
4855
6199
  const cleanup = () => {
6200
+ settled = true;
4856
6201
  clearTimeout(timer);
4857
6202
  this.heartbeatResets.delete(id);
4858
6203
  };
4859
- const arm = () => {
4860
- const ms = window();
4861
- timer = setTimeout(() => {
4862
- cleanup();
4863
- this.pending.delete(id);
4864
- reject(new RemoteStepTimeout(id, ms));
4865
- }, ms);
6204
+ const arm = (ms = window()) => {
6205
+ timer = setTimeout(() => void expire(ms), ms);
4866
6206
  timer.unref?.();
4867
6207
  };
4868
- this.heartbeatResets.set(id, () => {
6208
+ // Worker timestamps of the beats THIS instance heard, to tell its own persisted beats from ones
6209
+ // persisted by another instance. Bounded: a step beating for days must not grow it forever.
6210
+ const localBeats = new Set();
6211
+ // Set once a persisted beat of this attempt turns out to come from another instance.
6212
+ let beatsLandElsewhere = false;
6213
+ // The window ran out with no beat seen HERE. With several engine instances consuming the same
6214
+ // heartbeat queue (the `queue` transport: point-to-point), the beats may have landed on another
6215
+ // instance — which persists them on the checkpoint (`persistHeartbeat`) but cannot reach this
6216
+ // timer. Before declaring the worker dead, read the persisted beat: a recent one (from this
6217
+ // attempt) means the step is alive, so re-arm instead of failing over a healthy step and
6218
+ // re-dispatching it while it still runs.
6219
+ //
6220
+ // "Recent" has to account for the persist throttle: the other instance writes at most one beat
6221
+ // per `heartbeatPersistThrottleMs`, so the newest beat it heard can be up to that much newer
6222
+ // than the one on the checkpoint. Once a persisted beat is known to come from elsewhere (its
6223
+ // timestamp is not one this instance heard), that much extra silence is allowed. A beat this
6224
+ // instance persisted itself gets no allowance — it knows its own beats exactly — so a lone
6225
+ // instance detects a dead worker as fast as before.
6226
+ const expire = async (ms) => {
6227
+ if (settled)
6228
+ return;
6229
+ const at = await this.store
6230
+ .getCheckpoint(runId, seq)
6231
+ .then((cp) => cp?.lastHeartbeatAt?.getTime())
6232
+ .catch(() => undefined);
6233
+ if (settled)
6234
+ return;
6235
+ const now = this.clock();
6236
+ if (at !== undefined && at >= dispatchedAt - HEARTBEAT_CLOCK_SKEW_MS) {
6237
+ if (!localBeats.has(at))
6238
+ beatsLandElsewhere = true;
6239
+ const allowance = timeoutMs + (beatsLandElsewhere ? this.heartbeatPersistThrottleMs : 0);
6240
+ if (now - at < allowance) {
6241
+ seenBeat = true;
6242
+ arm(Math.min(Math.max(allowance - (now - at), 1), MAX_BACKOFF_MS));
6243
+ return;
6244
+ }
6245
+ }
6246
+ cleanup();
6247
+ this.pending.delete(id);
6248
+ reject(new RemoteStepTimeout(id, ms));
6249
+ };
6250
+ this.heartbeatResets.set(id, (beatAt) => {
4869
6251
  seenBeat = true;
6252
+ if (beatAt !== undefined) {
6253
+ if (localBeats.size >= 256)
6254
+ localBeats.clear();
6255
+ localBeats.add(beatAt);
6256
+ }
4870
6257
  clearTimeout(timer);
4871
6258
  arm();
4872
6259
  });
@@ -4881,6 +6268,21 @@ export class WorkflowEngine {
4881
6268
  });
4882
6269
  }
4883
6270
  }
6271
+ /** The worker group a convention-routed workflow's run dispatches to: its sanitized name, suffixed by
6272
+ * the run's namespace (bare for absent/`''`/`default`) — the token a tenant worker serves. */
6273
+ function conventionGroup(name, namespace) {
6274
+ return tenantGroup(sanitizeQueueToken(name), namespace);
6275
+ }
6276
+ /** Key of an inline remote turn's liveness reset in `heartbeatResets` (step keys are `runId:seq`). */
6277
+ function turnBeatKey(runId) {
6278
+ return `turn:${runId}`;
6279
+ }
6280
+ /** The run whose remote turn is being enqueued in the current async context — lets the decision
6281
+ * handler spot a transport delivering the decision from inside its own `dispatchWorkflowTask`. */
6282
+ const turnDispatchAls = new AsyncLocalStorage();
6283
+ /** Tolerance (ms) between the worker's clock, which stamps a persisted heartbeat, and this engine's,
6284
+ * when deciding whether a beat belongs to the current attempt (see `awaitWithHeartbeat`). */
6285
+ const HEARTBEAT_CLOCK_SKEW_MS = 1_000;
4884
6286
  /** Raised inside the workflow when a remote worker reports a step failure. */
4885
6287
  export class RemoteStepError extends Error {
4886
6288
  stepError;