workflow 5.0.0-beta.4 → 5.0.0-beta.40

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 (227) hide show
  1. package/README.md +63 -20
  2. package/dist/api-workflow.d.ts +1 -1
  3. package/dist/api-workflow.d.ts.map +1 -1
  4. package/dist/api-workflow.js +1 -1
  5. package/dist/api.d.ts +5 -1
  6. package/dist/api.d.ts.map +1 -1
  7. package/dist/api.js +12 -2
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -1
  11. package/dist/internal/builtins.d.ts +17 -0
  12. package/dist/internal/builtins.d.ts.map +1 -1
  13. package/dist/internal/builtins.js +65 -1
  14. package/dist/internal/errors.d.ts +1 -1
  15. package/dist/internal/errors.d.ts.map +1 -1
  16. package/dist/internal/errors.js +2 -2
  17. package/dist/nest-builder.d.ts +2 -0
  18. package/dist/nest-builder.d.ts.map +1 -0
  19. package/dist/nest-builder.js +2 -0
  20. package/dist/nest-vercel-builder.d.ts +2 -0
  21. package/dist/nest-vercel-builder.d.ts.map +1 -0
  22. package/dist/nest-vercel-builder.js +2 -0
  23. package/dist/observability.d.ts +1 -1
  24. package/dist/observability.js +2 -2
  25. package/dist/runtime.d.ts +2 -1
  26. package/dist/runtime.d.ts.map +1 -1
  27. package/dist/runtime.js +5 -2
  28. package/docs/ai/chat-session-modeling.mdx +7 -3
  29. package/docs/ai/defining-tools.mdx +2 -2
  30. package/docs/ai/index.mdx +30 -25
  31. package/docs/ai/message-queueing.mdx +10 -10
  32. package/docs/ai/resumable-streams.mdx +9 -1
  33. package/docs/api-reference/index.mdx +24 -0
  34. package/docs/api-reference/meta.json +8 -0
  35. package/docs/api-reference/vitest/index.mdx +28 -7
  36. package/docs/api-reference/workflow/create-hook.mdx +79 -0
  37. package/docs/api-reference/workflow/create-webhook.mdx +1 -0
  38. package/docs/api-reference/workflow/define-hook.mdx +26 -24
  39. package/docs/api-reference/workflow/fatal-error.mdx +29 -7
  40. package/docs/api-reference/workflow/fetch.mdx +8 -4
  41. package/docs/api-reference/workflow/index.mdx +3 -0
  42. package/docs/api-reference/workflow/set-attributes.mdx +61 -0
  43. package/docs/api-reference/workflow/sleep.mdx +1 -1
  44. package/docs/api-reference/workflow-ai/durable-agent.mdx +7 -45
  45. package/docs/api-reference/workflow-ai/index.mdx +2 -2
  46. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +44 -1
  47. package/docs/api-reference/workflow-api/get-hook-by-token.mdx +9 -0
  48. package/docs/api-reference/workflow-api/get-run.mdx +25 -0
  49. package/docs/api-reference/workflow-api/index.mdx +6 -8
  50. package/docs/api-reference/workflow-api/resume-hook.mdx +60 -1
  51. package/docs/api-reference/workflow-api/resume-webhook.mdx +6 -4
  52. package/docs/api-reference/workflow-api/start.mdx +39 -6
  53. package/docs/api-reference/workflow-astro/index.mdx +18 -0
  54. package/docs/api-reference/workflow-astro/meta.json +4 -0
  55. package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
  56. package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
  57. package/docs/api-reference/workflow-errors/index.mdx +88 -0
  58. package/docs/api-reference/workflow-errors/meta.json +6 -0
  59. package/docs/api-reference/workflow-errors/precondition-failed-error.mdx +68 -0
  60. package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
  61. package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
  62. package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
  63. package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
  64. package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
  65. package/docs/api-reference/workflow-globals.mdx +4 -1
  66. package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +33 -0
  67. package/docs/api-reference/workflow-nest/index.mdx +31 -0
  68. package/docs/api-reference/workflow-nest/meta.json +9 -0
  69. package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
  70. package/docs/api-reference/workflow-nest/workflow-controller.mdx +40 -0
  71. package/docs/api-reference/workflow-nest/workflow-module.mdx +74 -0
  72. package/docs/api-reference/workflow-next/with-workflow.mdx +56 -2
  73. package/docs/api-reference/workflow-nitro/index.mdx +60 -0
  74. package/docs/api-reference/workflow-nuxt/index.mdx +48 -0
  75. package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
  76. package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
  77. package/docs/api-reference/workflow-observability/index.mdx +64 -0
  78. package/docs/api-reference/workflow-observability/meta.json +11 -0
  79. package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
  80. package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
  81. package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
  82. package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
  83. package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
  84. package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
  85. package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +7 -10
  86. package/docs/api-reference/workflow-runtime/health-check.mdx +50 -0
  87. package/docs/api-reference/workflow-runtime/index.mdx +43 -0
  88. package/docs/api-reference/workflow-runtime/meta.json +12 -0
  89. package/docs/api-reference/workflow-runtime/set-world.mdx +49 -0
  90. package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +42 -0
  91. package/docs/api-reference/workflow-runtime/world/analytics.mdx +138 -0
  92. package/docs/api-reference/{workflow-api → workflow-runtime}/world/index.mdx +10 -10
  93. package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
  94. package/docs/api-reference/{workflow-api → workflow-runtime}/world/queue.mdx +8 -6
  95. package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +18 -5
  96. package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +2 -2
  97. package/docs/api-reference/workflow-serde/index.mdx +0 -1
  98. package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +1 -2
  99. package/docs/api-reference/workflow-serde/workflow-serialize.mdx +2 -2
  100. package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
  101. package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
  102. package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
  103. package/docs/api-reference/workflow-vite/index.mdx +18 -0
  104. package/docs/api-reference/workflow-vite/meta.json +4 -0
  105. package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
  106. package/docs/changelog/attributes-mvp.mdx +380 -0
  107. package/docs/changelog/eager-processing.mdx +269 -0
  108. package/docs/changelog/index.mdx +3 -1
  109. package/docs/changelog/lazy-event-creation.md +127 -0
  110. package/docs/changelog/meta.json +9 -1
  111. package/docs/changelog/resilient-resume.mdx +22 -0
  112. package/docs/changelog/resilient-start.mdx +31 -283
  113. package/docs/changelog/step-message-ownership.mdx +360 -0
  114. package/docs/changelog/turbo-mode.md +87 -0
  115. package/docs/comparisons/index.mdx +66 -0
  116. package/docs/comparisons/meta.json +11 -0
  117. package/docs/comparisons/workflow-sdk-vs-aws-agentcore.mdx +55 -0
  118. package/docs/comparisons/workflow-sdk-vs-aws-step-functions.mdx +111 -0
  119. package/docs/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx +71 -0
  120. package/docs/comparisons/workflow-sdk-vs-inngest.mdx +102 -0
  121. package/docs/comparisons/workflow-sdk-vs-temporal.mdx +123 -0
  122. package/docs/comparisons/workflow-sdk-vs-trigger-dev.mdx +103 -0
  123. package/docs/configuration/build-and-diagnostics.mdx +70 -0
  124. package/docs/configuration/cli-and-web-ui.mdx +182 -0
  125. package/docs/configuration/framework-options.mdx +165 -0
  126. package/docs/configuration/index.mdx +32 -0
  127. package/docs/configuration/meta.json +12 -0
  128. package/docs/configuration/runtime-tuning.mdx +276 -0
  129. package/docs/configuration/worlds.mdx +275 -0
  130. package/docs/cookbook/advanced/child-workflows.mdx +203 -256
  131. package/docs/cookbook/advanced/meta.json +1 -1
  132. package/docs/cookbook/advanced/publishing-libraries.mdx +33 -24
  133. package/docs/cookbook/advanced/serializable-steps.mdx +12 -4
  134. package/docs/cookbook/advanced/upgrading-workflows.mdx +199 -0
  135. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +34 -75
  136. package/docs/cookbook/agent-patterns/durable-agent.mdx +14 -142
  137. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +11 -3
  138. package/docs/cookbook/common-patterns/batching.mdx +4 -0
  139. package/docs/cookbook/common-patterns/idempotency.mdx +40 -52
  140. package/docs/cookbook/common-patterns/rate-limiting.mdx +5 -1
  141. package/docs/cookbook/common-patterns/saga.mdx +6 -2
  142. package/docs/cookbook/common-patterns/scheduling.mdx +12 -0
  143. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +4 -0
  144. package/docs/cookbook/common-patterns/timeouts.mdx +6 -1
  145. package/docs/cookbook/common-patterns/webhooks.mdx +4 -0
  146. package/docs/cookbook/common-patterns/workflow-composition.mdx +19 -16
  147. package/docs/cookbook/index.mdx +3 -3
  148. package/docs/cookbook/integrations/ai-sdk.mdx +64 -28
  149. package/docs/cookbook/integrations/chat-sdk.mdx +17 -0
  150. package/docs/cookbook/integrations/sandbox.mdx +17 -0
  151. package/docs/{deploying/index.mdx → deploying.mdx} +7 -7
  152. package/docs/errors/abort-signal-timeout-in-workflow.mdx +84 -0
  153. package/docs/errors/corrupted-event-log.mdx +5 -5
  154. package/docs/errors/deployment-mismatch.mdx +71 -0
  155. package/docs/errors/fetch-in-workflow.mdx +4 -0
  156. package/docs/errors/hook-conflict.mdx +60 -4
  157. package/docs/errors/index.mdx +1 -35
  158. package/docs/errors/node-js-module-in-workflow.mdx +4 -0
  159. package/docs/errors/replay-divergence.mdx +27 -0
  160. package/docs/errors/runtime-decryption-failed.mdx +77 -0
  161. package/docs/errors/serialization-failed.mdx +4 -0
  162. package/docs/errors/start-invalid-workflow-function.mdx +4 -0
  163. package/docs/errors/step-executed-multiple-times.mdx +23 -0
  164. package/docs/errors/step-not-registered.mdx +1 -1
  165. package/docs/errors/timeout-in-workflow.mdx +4 -0
  166. package/docs/errors/webhook-response-not-sent.mdx +4 -0
  167. package/docs/foundations/cancellation.mdx +459 -0
  168. package/docs/foundations/errors-and-retries.mdx +7 -3
  169. package/docs/foundations/hooks.mdx +29 -0
  170. package/docs/foundations/idempotency.mdx +243 -11
  171. package/docs/foundations/index.mdx +1 -23
  172. package/docs/foundations/meta.json +3 -1
  173. package/docs/foundations/serialization.mdx +77 -41
  174. package/docs/foundations/starting-workflows.mdx +79 -2
  175. package/docs/foundations/streaming.mdx +14 -23
  176. package/docs/foundations/versioning.mdx +263 -0
  177. package/docs/getting-started/astro.mdx +12 -2
  178. package/docs/getting-started/express.mdx +6 -2
  179. package/docs/getting-started/fastify.mdx +6 -2
  180. package/docs/getting-started/hono.mdx +6 -2
  181. package/docs/getting-started/index.mdx +16 -10
  182. package/docs/getting-started/meta.json +4 -1
  183. package/docs/getting-started/nestjs.mdx +77 -3
  184. package/docs/getting-started/next.mdx +12 -6
  185. package/docs/getting-started/nitro.mdx +28 -2
  186. package/docs/getting-started/nuxt.mdx +6 -2
  187. package/docs/getting-started/python.mdx +24 -18
  188. package/docs/getting-started/react-router/index.mdx +33 -0
  189. package/docs/getting-started/react-router/meta.json +5 -0
  190. package/docs/getting-started/react-router/v7.mdx +237 -0
  191. package/docs/getting-started/react-router/v8.mdx +232 -0
  192. package/docs/getting-started/sveltekit.mdx +12 -2
  193. package/docs/getting-started/tanstack-start.mdx +245 -0
  194. package/docs/getting-started/vite.mdx +6 -2
  195. package/docs/how-it-works/cancellation.mdx +287 -0
  196. package/docs/how-it-works/code-transform.mdx +21 -17
  197. package/docs/how-it-works/encryption.mdx +5 -5
  198. package/docs/how-it-works/event-sourcing.mdx +6 -6
  199. package/docs/how-it-works/framework-integrations.mdx +96 -337
  200. package/docs/how-it-works/meta.json +2 -1
  201. package/docs/internal/index.mdx +21 -0
  202. package/docs/internal/meta.json +10 -0
  203. package/docs/internal/nitro-native-build.mdx +38 -0
  204. package/docs/internal/nitro-web-ui.mdx +24 -0
  205. package/docs/internal/serializable-abort-controller.mdx +148 -0
  206. package/docs/meta.json +2 -2
  207. package/docs/observability/attributes.mdx +112 -0
  208. package/docs/observability/index.mdx +20 -1
  209. package/docs/observability/meta.json +1 -1
  210. package/docs/observability/tracing.mdx +124 -0
  211. package/docs/testing/index.mdx +2 -2
  212. package/package.json +17 -13
  213. package/docs/api-reference/workflow-api/world/meta.json +0 -4
  214. package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
  215. package/docs/cookbook/advanced/distributed-abort-controller.mdx +0 -318
  216. package/docs/deploying/building-a-world.mdx +0 -251
  217. package/docs/deploying/meta.json +0 -4
  218. package/docs/deploying/world/local-world.mdx +0 -84
  219. package/docs/deploying/world/meta.json +0 -4
  220. package/docs/deploying/world/postgres-world.mdx +0 -222
  221. package/docs/deploying/world/vercel-world.mdx +0 -179
  222. package/docs/migration-guides/index.mdx +0 -34
  223. package/docs/migration-guides/meta.json +0 -9
  224. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +0 -363
  225. package/docs/migration-guides/migrating-from-inngest.mdx +0 -314
  226. package/docs/migration-guides/migrating-from-temporal.mdx +0 -318
  227. package/docs/migration-guides/migrating-from-trigger-dev.mdx +0 -337
@@ -0,0 +1,269 @@
1
+ ---
2
+ title: Eager Processing of Steps & Incremental Event Replay
3
+ description: Combine workflow event replay and step bundles to do work inline where possible, only deferring to queue for parallelism
4
+ type: overview
5
+ ---
6
+
7
+ # Eager Processing of Steps & Incremental Event Replay
8
+
9
+ **Date**: March 2026
10
+
11
+ This is a major internal architecture change to how Workflow DevKit executes workflows and steps. It reduces function invocations and queue overhead by executing steps _inline_ within the same function invocation as the workflow replay, rather than dispatching every step to a separate function via the queue.
12
+
13
+ ## Previous Architecture
14
+
15
+ The previous architecture used two separate routes, each backed by its own queue trigger:
16
+
17
+ ```
18
+ Queue: __wkf_workflow_* --> /.well-known/workflow/v1/flow (workflow replay in VM)
19
+ |
20
+ suspension (step needed)
21
+ |
22
+ queue step to __wkf_step_*
23
+ |
24
+ Queue: __wkf_step_* --> /.well-known/workflow/v1/step (step execution in Node.js)
25
+ |
26
+ step completes
27
+ |
28
+ queue continuation to __wkf_workflow_*
29
+ v
30
+ (cycle repeats for each step)
31
+ ```
32
+
33
+ Each step required **2 queue messages** (step invoke + workflow continuation) and **2 function invocations**, plus cold start overhead for each. A serial workflow with 10 steps needed ~21 function invocations.
34
+
35
+ ## New Architecture
36
+
37
+ The two routes are merged into a single handler at `/.well-known/workflow/v1/flow` using `workflowEntrypoint()`. The step route is no longer generated.
38
+
39
+ The handler runs an inline execution loop:
40
+
41
+ ```
42
+ receive queue message
43
+ |
44
+ +-- if message has stepId+stepName: execute that step, queue workflow continuation, exit
45
+ |
46
+ v
47
+ replay workflow in VM
48
+ |
49
+ +-- workflow completed --> create run_completed event, exit
50
+ +-- workflow failed --> create run_failed event, exit
51
+ |
52
+ v
53
+ suspension with pending operations
54
+ |
55
+ +-- process hooks and waits (unchanged)
56
+ |
57
+ +-- 0 pending steps --> return (waits/hooks only)
58
+ +-- 1 pending step --> execute inline, loop back to replay
59
+ +-- N pending steps --> queue N-1 to self (with stepId),
60
+ | execute 1 inline, loop back to replay
61
+ |
62
+ +-- timeout check: if wall-clock time >= threshold,
63
+ | re-schedule self via queue and exit
64
+ |
65
+ v
66
+ (loop continues until completion, timeout, or non-step suspension)
67
+ ```
68
+
69
+ A serial workflow with 10 steps now completes in **1 function invocation**.
70
+
71
+ ## Background Steps (Parallel Execution)
72
+
73
+ When a workflow suspends with multiple pending steps (e.g., from `Promise.all`), the handler creates `step_created` events for all of them, queues N-1 back to `__wkf_workflow_*` with `stepId` and `stepName` in the message payload, executes 1 step inline, and loops back to replay.
74
+
75
+ Each background step message is handled by a separate function invocation of the same handler. When a message arrives with `stepId` and `stepName`, the handler executes that specific step, then checks if all parallel steps from the batch are done by comparing `step_created` events against terminal events (`step_completed`/`step_failed`):
76
+
77
+ - **All steps done**: The handler replays the workflow inline, continuing the execution loop without a queue roundtrip.
78
+ - **Steps still pending**: The handler returns without queuing a continuation. The last handler to complete its step will see all steps done and replay inline.
79
+ - **Pending ops (stream writes)**: The handler queues a continuation and returns, so `waitUntil` can flush the pending stream data.
80
+
81
+ ### Convergence After Parallel Steps
82
+
83
+ When multiple background steps complete near-simultaneously, multiple handlers may observe "all steps done" and attempt to advance the workflow concurrently. The event-sourced architecture plus the invariants below ensure safe convergence:
84
+
85
+ - **`step_created` idempotency** — duplicate creates return 409; exactly one handler owns each step
86
+ - **`step_completed` / `step_failed` idempotency** — only the first invocation to record a terminal result wins
87
+ - **Queue idempotency keys** — background step messages use `correlationId` as idempotency key
88
+ - **Deterministic replay** — all invocations produce the same result given the same event log
89
+
90
+ ### Single Inline Executor Per Step
91
+
92
+ Inline step execution combined with background-step dispatch introduces a new coordination requirement: when multiple handlers reach the same `Promise.all` batch concurrently, we need to guarantee that each step body runs at most once via the inline path. Without that guarantee, the event log accumulates duplicate `step_started` events (including some written *after* `step_completed`, which orphans them on replay) and step bodies run redundantly.
93
+
94
+ The design enforces a simple invariant: **exactly one handler owns each step, and only the owner may execute it inline**. Ownership is established by the atomicity of `step_created`:
95
+
96
+ 1. **Atomic `step_created`** — `events.create('step_created', correlationId=X)` is serialized per-correlationId in every world. Exactly one concurrent caller succeeds; the rest receive `EntityConflictError`.
97
+ 2. **Suspension handler reports ownership** — only `step_created` writes that actually succeeded (not those that caught 409) count toward ownership.
98
+ 3. **Inline execution is gated on ownership** — a handler that didn't win any `step_created` race performs no inline execution.
99
+ 4. **Queueing is unconditional** — for every pending step except the one being inline-executed, the handler enqueues a background step message with `idempotencyKey: correlationId`. This is what makes crash recovery work: if a prior handler wrote `step_created` but crashed before enqueueing, a later handler will enqueue the orphaned step. Concurrent handlers' redundant enqueues dedupe on the idempotency key.
100
+
101
+ Together these give: every `step_created` event has exactly one inline executor **and** at least one queued dispatch. Step bodies are never executed concurrently, and `step_started` events never land in the log after `step_completed` for the same step.
102
+
103
+ **Retry semantics are preserved**: a step that is currently running (status=`running`) still accepts a second `step_started` write with an incremented attempt counter — this is how queue redelivery after a SIGKILL mid-execution legitimately re-runs the step.
104
+
105
+ ## Incremental Event Loading
106
+
107
+ The handler caches the event log in memory across loop iterations. Instead of re-fetching the entire event log on each replay:
108
+
109
+ 1. **First iteration**: full load, returning both the events and the final pagination cursor
110
+ 2. **Subsequent iterations**: fetch only events created after the saved cursor and append them to the cached array
111
+
112
+ For a 10-step serial workflow completing in one invocation, the 10th replay loads ~2 new events instead of re-fetching all ~30.
113
+
114
+ Incremental loading depends on the World returning a cursor even on the final page of results. If a World implementation does not return a cursor after the initial load, the handler logs an error and falls back to a full reload.
115
+
116
+ ## Timeout Handling
117
+
118
+ The inline execution loop checks wall-clock time before each replay iteration. If the elapsed time exceeds a configurable threshold (default: 110 seconds, for a 120-second function limit), the handler re-schedules itself via the queue and returns. Configurable via `WORKFLOW_V2_TIMEOUT_MS`.
119
+
120
+ If a single step takes longer than the timeout threshold, the step runs to completion (or SIGKILL) — there is no interruption mechanism for in-progress step execution. This is the same behavior as the previous architecture.
121
+
122
+ ## Queue Message Changes
123
+
124
+ The `WorkflowInvokePayload` schema has two new optional fields: `stepId` and `stepName`. When `stepId` is present, the handler executes that specific step before (or instead of) replaying the workflow. Background steps are queued with both set, so the handler knows which step function to call without loading the event log. Previously, `stepName` was resolved by loading all events and searching for the `step_created` event matching the `stepId` — an O(N) operation on the full event history for every background step arrival.
125
+
126
+ The queue trigger configuration uses `WORKFLOW_QUEUE_TRIGGER` on the `__wkf_workflow_*` topic. The `__wkf_step_*` topic and its separate trigger are no longer generated.
127
+
128
+ ## Generated File Layout
129
+
130
+ ```
131
+ .well-known/workflow/v1/
132
+ flow/
133
+ route.js # Handler (workflowEntrypoint)
134
+ __step_registrations.js # Step function registrations (side effects)
135
+ webhook/
136
+ [token]/
137
+ route.js # Webhook handler (unchanged)
138
+ manifest.json # Workflow/step/class manifest (unchanged)
139
+ config.json # Functions config (single trigger)
140
+ ```
141
+
142
+ The `step/` directory is no longer generated.
143
+
144
+ ## Design Notes and Tradeoffs
145
+
146
+ ### Parent→Child Polling Holds Worker Slots
147
+
148
+ `Run#returnValue` is implemented as a polling step: the workflow awaits the child run's terminal status inside a step body. In worker-based worlds (notably `world-postgres`), each such poll occupies a queue worker slot until the child run finishes. Parent workflows that fan out to many child runs — recursive workflows like `fibonacciWorkflow` are the obvious case — can therefore consume a large fraction of available workers just holding positions in `Promise.all([...children.map(c => c.returnValue)])`.
149
+
150
+ If `queueConcurrency` is smaller than the peak number of concurrent parent polls plus the workers needed for any in-flight children, the system deadlocks: every slot is held by a parent waiting on a child, but no child can acquire a slot to start.
151
+
152
+ For `world-postgres`, the default `queueConcurrency` is set to **50**. Workflows that fan out more aggressively must raise this ceiling.
153
+
154
+ To prevent deadlock when polling is executed inline by the step executor, `Run#pollReturnValue()` detects when it's running inside a step executor and throws `TooEarlyError` instead of polling in a blocking loop. The step executor handles `TooEarlyError` by re-queueing the step with a 1-second delay, freeing the worker. Unlike `RetryableError`, `TooEarlyError` does NOT count against `maxRetries`, so polling steps can retry indefinitely until the child completes.
155
+
156
+ **Follow-up**: Replace the worker-pool sizing requirement with a polling design that does not occupy a worker slot. Options under consideration: moving child-completion polling out of the step body into the suspension layer, or emitting a `run_completed` notification on the parent's stream/queue so the parent only resumes when the child actually finishes.
157
+
158
+ ### Mixed Suspensions
159
+
160
+ A suspension may contain steps, hooks, and waits simultaneously. The handler creates events for all, then dispatches everything we are not running inline as a single parallel batch of queue messages:
161
+
162
+ ```
163
+ ownedPendingSteps = pendingSteps.filter(owned by this handler)
164
+ inlineStep = ownedPendingSteps[0] // optional
165
+
166
+ dispatches = [
167
+ ...for each non-inline pendingStep: queue stepId message (idempotency=correlationId),
168
+ ...if soonest pending wait: queue delayed continuation
169
+ (delaySeconds=min(remaining, maxDelay),
170
+ idempotency=waitCorrelationId[:hop|:secondBucket]),
171
+ ]
172
+ await Promise.all(dispatches)
173
+
174
+ if (!inlineStep) return
175
+ await executeStep(inlineStep)
176
+ ```
177
+
178
+ The wait timer is queued as its own continuation rather than encoded in the handler's return value (`{ timeoutSeconds }`). This is what makes `Promise.race(step, sleep)` behave correctly: even when the inline step blocks the handler for the full step duration, the wait continuation fires in a separate function invocation. If the sleep wins, that parallel invocation observes `wait_completed` via the "complete elapsed waits" pass and finishes the run; if the step wins, the wait continuation fires later and no-ops on the terminal run via the existing terminal-event check.
179
+
180
+ Step queueing remains unconditional (covers crash recovery: if a prior handler wrote `step_created` but crashed before queueing, a later handler will queue it; idempotency keys dedupe redundant queues across concurrent handlers).
181
+
182
+ Wait continuations are likewise deduplicated, keyed on the wait's correlation ID: while a wait is pending, every replay pass over the run re-observes it and would otherwise enqueue another delayed continuation. A key is attached in all cases, since some worlds serialize key-less workflow messages per run, which would park the continuation behind the handler's own inline step execution.
183
+
184
+ Two situations deliver a continuation while its wait is still pending, and each varies the key so the re-enqueue isn't dropped by a world's dedupe window (which outlives the first delivery): waits longer than the maximum queue delay (23h, bounded by VQS's 24h message retention) are clamped and chained across hops, with the hop index suffixed to the key so each hop dedupes within its window but the chain always advances; and near-elapsed waits (≤2s remaining) use a second-bucketed key suffix so a continuation delivered marginally early (clock skew) can enqueue a fresh short-delay retry. See `runtime/wait-continuation.ts` for the full selection logic.
185
+
186
+ The retry/throttle and hook-conflict paths still return `{ timeoutSeconds }` since their semantics are "redeliver THIS message after a delay" rather than "schedule a fresh wait timer." Those can be unified in a follow-up.
187
+
188
+ The unified dispatch requires `world-local` to honor `delaySeconds` on the queue (added in the same PR series). Without it, the wait continuation would fire instantly in dev and trigger a spurious replay before the wait elapsed (recoverable via redelivery, but inefficient and observable as duplicate `step_started` events under contention).
189
+
190
+ ### VM Sandboxing
191
+
192
+ Workflow code still runs in a Node.js VM for determinism and sandboxing. Step code runs in the Node.js host context. The only change is that both happen within the same function invocation.
193
+
194
+ ### Bundle Size and Cold Start
195
+
196
+ The combined bundle is larger (contains both step code and workflow VM code). Cold start time increases slightly. The reduction in total function invocations more than compensates.
197
+
198
+ ### Step Retries
199
+
200
+ When an inline step fails with retries remaining:
201
+
202
+ - `RetryableError` with explicit `retryAfter` delay: re-queue to self with `stepId` and delay
203
+ - Transient errors with immediate retry: re-queue to self with `stepId` (delay = 1s)
204
+ - `FatalError`: fail immediately
205
+
206
+ ### Encryption Key Resolution
207
+
208
+ Encryption keys are resolved once before the inline execution loop starts (after the run status is confirmed as `running`) and reused across all iterations. Background step executions resolve the key independently. The key does not change within a run.
209
+
210
+ ### Module Scope Duplication in Re-Bundled Output
211
+
212
+ Builders that re-bundle the combined output into a single file (standalone CLI, Vercel Build Output API, NestJS) produce a layout where esbuild creates isolated module scopes for each source module, even within the same output file. Without intervention this means `registerStepFunction` and `getStepFunction` operate on different `Map` instances — steps are registered into one Map but looked up from another.
213
+
214
+ The step function registry and the step context storage are `globalThis` singletons (via `Symbol.for`) to ensure all module scopes share the same instances. The same pattern is used for the World singleton and the class serialization registry.
215
+
216
+ ### Inline Step Execution with Pending Stream Operations
217
+
218
+ When a step's arguments or return value include serialized streams (e.g., `WritableStream` from `getWritable()`, or AI SDK streaming steps), the serialization layer creates background `flushablePipe` operations that pipe data to S3. These ops are tracked in an `ops` array and need to complete before the stream data is readable by external consumers.
219
+
220
+ In V1, each step ran in a separate function invocation. After the step completed, `waitUntil(ops)` kept the function alive to flush the ops. In V2, the inline execution loop continues immediately after the step body returns — so we need to know whether to keep looping or break out and let `waitUntil` flush.
221
+
222
+ `executeStep()` attempts a 500ms `Promise.race` between the ops settling and a timeout. If ops settle in time (data confirmed on server), it returns `hasPendingOps: false` and the V2 handler continues the inline loop. If ops don't settle in 500ms (e.g., `WritableStream` kept open across steps), it returns `hasPendingOps: true` and the V2 handler breaks the loop and queues a continuation so `waitUntil` can flush them.
223
+
224
+ **Follow-up**: Shrink the 500ms inline-ops budget once we have confidence that the flush-waiter path settles deterministically across all worlds. A signaled "ops drained" event from the world layer would let `executeStep()` proceed without the timeout in the common case.
225
+
226
+ ### Buffered Stream Flush with Waiter Promises
227
+
228
+ `WorkflowServerWritableStream` buffers writes and flushes via a 10ms `setTimeout` for batching. Naively, `write()` could return immediately after buffering, but that would cause the `flushablePipe`'s `pendingOps` counter to reach 0 before data actually reached the server — the V2 inline loop would see ops as settled prematurely and produce data-loss races on every step with `WritableStream` serialization.
229
+
230
+ `write()` returns a promise that resolves only after the scheduled flush completes. Multiple writes within the 10ms window still share a single batched HTTP request (the batching optimization is preserved). Each write registers a `{resolve, reject}` pair in a `flushWaiters` array. When the `setTimeout` fires and `flush()` completes the HTTP round-trip, all waiters are resolved (or rejected on error). This makes `pendingOps` accurately reflect server-side data state while keeping network-efficient batching.
231
+
232
+ ### Lock-Release Polling Interval
233
+
234
+ `flushablePipe`'s `pollWritableLock` / `pollReadableLock` use `setInterval` to detect when a user releases their stream lock without closing the stream — the Web Streams API has no event for that state. The V2 step executor's `opsSettled` race waits for this poll to resolve after each writable-bearing step body returns, so the polling interval sits on the critical path of every streaming step.
235
+
236
+ The interval was lowered from 100ms to 10ms. Per-step wait drops from ~50ms average to ~5ms (scaling linearly with the number of writable-bearing steps in a workflow). Per-tick work is just `writable.locked` plus a `getWriter()`/`releaseLock()` probe — microsecond-scale, so 10× more ticks is not measurable in practice.
237
+
238
+ **Follow-up**: Replace polling entirely with an event-driven release signal — wrap the writable returned from the `WritableStream` reviver with a writer that fires on `releaseLock()` — bringing the wait to ~0ms. This would also remove a source of timing drift between worlds with synchronous storage (`world-local`) and worlds with HTTP-deferred storage (`world-vercel`).
239
+
240
+ ### Concurrent `step_started` and Attempt Counter
241
+
242
+ When the V2 handler dispatches N parallel steps as background messages, each background step completion queues a workflow continuation. Up to N continuations may replay concurrently, and each may attempt to start the same not-yet-completed step (since `step_started` succeeds for already-running steps). Each call atomically increments the `attempt` counter — so with N=5 parallel steps, the counter can reach 5 on the first genuine execution.
243
+
244
+ The max retries check in `executeStep()` only enforces when `step.error` exists — distinguishing actual retries (failed → retry with error) from concurrent first-attempt races (multiple handlers start the same step simultaneously without any prior failure). Concurrent starts are harmless since `step_completed` idempotency ensures only the first completion wins.
245
+
246
+ ### Unconsumed Event Check Two-Phase Drain
247
+
248
+ The `EventsConsumer`'s unconsumed event check uses a two-phase promise queue drain: yield once after the first drain (via `setTimeout(0)`) so cross-VM promise chains can append follow-up async work, then re-drain before checking. This improves timing for scenarios like `step_completed` → for-await loop resume → next hook hydration.
249
+
250
+ The check additionally arms a `DEFERRED_CHECK_DELAY_MS = 100` `setTimeout` after the second drain, since Node.js does not guarantee that `setTimeout(0)` fires after all cross-context microtasks settle. Any `subscribe()` call arriving during that 100ms window cancels the check via version invalidation + `clearTimeout`, so the delay only adds latency to genuine corruption — never to the happy path.
251
+
252
+ **Follow-up**: 100ms is a heuristic chosen empirically. A deterministic settlement signal — for example, a "VM idle" callback exposed by the workflow VM bridge that fires only after all pending cross-context promise chains have resolved — would let the consumer fire the unconsumed-event check immediately on quiescence instead of waiting for a wall-clock timeout.
253
+
254
+ ### Lazy World Loading
255
+
256
+ Static imports of `world-local` and `world-vercel` from the runtime caused two distinct build-time issues: Next.js production builds pulled both worlds (including their Node-only deps like `debug`'s `tty` requires) into the route module, and Turbopack's NFT (Node File Trace) errored on `process.cwd()` and dynamic `import()` patterns it couldn't statically analyze.
257
+
258
+ A `getWorldLazy()` accessor (backed by a `globalThis` `Symbol.for` cache) replaces the static import in step-side modules. This breaks the static import chain from step code to `world.ts`, preventing both worlds from being bundled into the step registrations.
259
+
260
+ Because tree-shaking can otherwise drop `world.ts`'s module-load registration entirely, a server-only side-effect module (`@workflow/core/runtime/world-init`) imports `./world.js` purely for its module-load side effect. It's wired via package conditions:
261
+
262
+ - `default` → real, loads `world.ts`
263
+ - `workflow` → empty stub, used by VM/step bundles
264
+
265
+ This guarantees the world is loaded for routes that consume `start()` without going through the queue-driven flow handler first, while keeping `world.ts` and its server-only deps out of the workflow sandbox bundle.
266
+
267
+ ### Community Worlds and the `world.streams` API
268
+
269
+ Community world adapters must implement the `world.streams.*` interface. The runtime legacy stream normalization was removed as part of this work.
@@ -12,4 +12,6 @@ Stay up to date with the latest changes to Workflow SDK.
12
12
 
13
13
  ## 2026
14
14
 
15
- - TBD
15
+ - [Resilient hook resume](/docs/changelog/resilient-resume) — July 2026
16
+ - [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) — March 2026
17
+ - Serializable AbortController and AbortSignal — March 12, 2026
@@ -0,0 +1,127 @@
1
+ ---
2
+ title: Lazy inline step start
3
+ description: Defer step_created for the inline step and fold it into a single step_started, saving one world round-trip per inline step.
4
+ ---
5
+
6
+ # Lazy inline step start
7
+
8
+ ## Motivation
9
+
10
+ The owned-inline runtime path used to write two separate world events for a step it already owns and is about to run inline:
11
+
12
+ 1. `step_created` — written by the suspension handler (`suspension-handler.ts`)
13
+ 2. `step_started` — written by `executeStep` (`step-executor.ts`)
14
+ 3. `step_completed` / `step_failed` — written by `executeStep`
15
+
16
+ On the Vercel world each `world.events.create` is a network round-trip, so for a simple sequential `"use step"` workflow this is pure latency between steps. Steps (1) and (2) are two round-trips for a step we already own and are about to execute in the same invocation.
17
+
18
+ This change defers `step_created` for that one inline step: `executeStep` sends a single `step_started` carrying the step input, and the world creates the step on the fly — materializing the step entity **and** a synthetic `step_created` event so replay still observes it. **Two writes per inline step instead of three.** It mirrors the existing [resilient `run_started` → `run_created`](./resilient-start) pattern.
19
+
20
+ Steps that are *queued* (not run inline) keep their eager `step_created` and are unchanged. Only the single inline step per suspension is made lazy.
21
+
22
+ ## Design
23
+
24
+ ### Suspension handler
25
+
26
+ - `handleSuspension` selects exactly one step to defer — the first uncreated step (`stepItems.find(item => stepsNeedingCreation.has(...))`), which matches the inline candidate the caller would have picked.
27
+ - For that step it **skips** the `step_created` write and instead returns it as `lazyInlineStep = { correlationId, stepName, dehydratedInput }`. It is **not** added to `createdStepCorrelationIds` — ownership is no longer decided here.
28
+ - A `lazyInlineStep` is designated only when there is no `hook.getConflict()` awaiter (`hasAwaitedHookCreation === false`). With an awaiter present nothing runs inline, so nothing is deferred.
29
+
30
+ ### `executeStep`
31
+
32
+ - A new `lazyStepInput` parameter carries the already-dehydrated input. When present, the `step_started` event includes `input` so the world creates the step on the fly. When absent, `step_started` carries only `stepName` (the legacy contract).
33
+ - For an **unregistered** step on the lazy path, `executeStep` sends the lazy `step_started` first to materialize the step, *then* writes `step_failed` (see [Materialize before failing](#materialize-before-failing-unregistered-steps) below).
34
+
35
+ ### World contract
36
+
37
+ - `step_started` accepts an optional `input`. When provided for a non-existent step, the world creates the step entity plus a **synthetic `step_created`** event (so the event log reads `created → started → completed`), then records `step_started` — atomically.
38
+ - A `stepCreated` signal is added to the event result so callers can tell whether the lazy `step_started` created the step or attached to an existing one.
39
+ - Worlds updated: `world-local`, `world-postgres`, `world-vercel`.
40
+
41
+ ## Interactions with inline execution
42
+
43
+ ### Exactly-one-owner is preserved (the race moved)
44
+
45
+ The guarantee that exactly one handler runs a step's body inline is intact — it just resolves at a different event:
46
+
47
+ - **Before:** ownership was won at the atomic `step_created` claim in the suspension handler; the loser caught `EntityConflictError` and queued instead.
48
+ - **After:** two concurrent handlers may both *select* the same `lazyInlineStep` (selection is optimistic, before any race). The race is now the world's atomic create-claim inside the lazy `step_started` (lock file in world-local, `onConflictDoNothing` + unique index in world-postgres, `attribute_not_exists` on the server). The loser gets `EntityConflictError`, which `executeStep` maps to `{ type: 'skipped' }`, so it never runs the body.
49
+
50
+ This is safe **because a lazy `step_started` is only ever sent for a brand-new step** — the suspension handler defers only steps with no prior `step_created` (`!hasCreatedEvent`).
51
+
52
+ ### Crash recovery is unchanged
53
+
54
+ On crash recovery the step already has a `step_created` event in the log (`hasCreatedEvent === true`), so it is **not** a lazy candidate. It is re-queued and re-run via a normal **non-lazy** `step_started`, which re-starts a `running` step. At-least-once execution is preserved exactly as before.
55
+
56
+ ### Materialize before failing unregistered steps
57
+
58
+ The inline path previously assumed an invariant: *by the time `executeStep` runs, the step entity already exists* (the suspension handler created it). The unregistered-step branch relied on this — it writes `step_failed` directly, with no preceding `step_started`.
59
+
60
+ With the `step_created` deferred, the entity no longer exists when `executeStep` bails for an unregistered step, so the `step_failed` write hits the world's "step must exist" ordering guard and is rejected — wedging the run until it times out. The fix: on the lazy path, send the lazy `step_started` first (creating the entity + synthetic `step_created`), then write `step_failed`. The lazy `step_started`'s atomic create-claim still preserves exactly-one-owner — a concurrent winner makes our create reject with `EntityConflictError` → `skipped`, so the failure is never written twice.
61
+
62
+ This is the general rule the deferral introduces: **any inline path that writes a terminal step event must first ensure the deferred step has been materialized.**
63
+
64
+ ### Replay correctness and the inline-delta fast path
65
+
66
+ The client step consumer (`step.ts`) sets `hasCreatedEvent` only when it observes a `step_created` event, and checks step-name divergence against `stepName`. The lazy path stays replay-correct only because the world writes a **synthetic `step_created`**: replay still sees `created → started → completed`. The input lives on the synthetic `step_created`; the `step_started` row drops the input but keeps `stepName` for the divergence check.
67
+
68
+ This intersects with the inline-delta optimization. The delta returned on the `step_completed` write is consumed by the *next* replay in place of an `events.list`, diffed against `preInlineWriteCursor` (snapshotted before replay). Because the synthetic `step_created`, `step_started`, and `step_completed` are all written *after* that cursor, the world's "events since cursor" delta carries the full triple — so the next replay does not diverge. The delta gate (one step, no hooks/waits, the lone pending step is the inline one) is unchanged.
69
+
70
+ ### Pre-emption by attributes / hook conflicts
71
+
72
+ When `attr_set` events force an immediate in-process replay, or a hook conflict forces a re-invocation, the handler skips the dispatch loop for that pass. The deferred step is therefore **neither created nor queued** on that pass; it is recreated and run on the following replay (where it is still a lazy candidate).
73
+
74
+ This is a small behavioral improvement: previously the eager `step_created` left an orphan "created but never started" event when a step lost an attribute/hook race (e.g. `Promise.race([setAttributes(), step()])` where the attribute write wins and completes the run). With deferral, a step that loses the race is never created at all — less event-log garbage.
75
+
76
+ ### `hook.getConflict()` awaiter
77
+
78
+ When a `hook.getConflict()` awaiter is present, no `lazyInlineStep` is designated, nothing runs inline, every step gets its eager `step_created` and is queued, and the handler re-invokes immediately so replay resolves the awaiter. This is identical to the pre-change behavior — the deferral never serializes the awaiter's parallel continuation behind an inline step.
79
+
80
+ ## Rollout and compatibility
81
+
82
+ Server-first. The matching world-vercel backend change must deploy before this ships; the Vercel world targets a single backend whose spec version is always at least the SDK's, so the new SDK only ever talks to an already-upgraded backend. An old SDK against a new backend is safe because the lazy path is strictly additive — it triggers only when `step_started` carries both `stepName` and `input`, which old SDKs never send. For `world-local` / `world-postgres` the world ships in the same package as the runtime, so there is no version skew. Detection is by `input` presence on the event, mirroring resilient `run_started` — no capability negotiation is needed.
83
+
84
+ # Parallel inline steps + optimistic start
85
+
86
+ A follow-up builds two more latency wins on top of lazy inline start. Both are client-side only — they reuse the world's lazy create-on-`step_started` support and need no further world/backend changes.
87
+
88
+ ## Inline up to N steps in parallel
89
+
90
+ Previously the owned-inline path ran **exactly one** step inline per suspension and queued the rest. For a `Promise.all([stepA(), stepB(), stepC()])` fan-out that meant one branch ran inline while the others paid a queue round-trip each before making any progress.
91
+
92
+ The suspension handler now defers `step_created` for up to **`WORKFLOW_MAX_INLINE_STEPS` (default 3)** steps and returns them as `lazyInlineSteps`. The runtime runs that batch inline **in parallel** (`Promise.all`), each via its own lazy `step_started`, and queues only the steps beyond the cap.
93
+
94
+ - **Selection:** the first N uncreated steps, matching the previous single-step inline candidate. Steps beyond N keep their eager `step_created` and are queued exactly as before.
95
+ - **Result aggregation:** `retry` steps (whose `step_started` succeeded, so the step exists) are re-queued per-step as background steps with their own delay. `throttled` steps are different: a throttle rejects the lazy `step_started` on the create-claim, so the step was *never created* and has no recoverable input — re-queuing it as an input-less background step would make the world reject the bare `step_started` with "Step not found" and redeliver until it fails. So any throttle instead **defers redelivery of the orchestrator** (by the longest throttle backoff in the batch), which re-runs the throttled step inline *with its input* on replay. The runtime only loops back to replay in-process once every inline step has reached a terminal state.
96
+ - **Inline-delta fast path:** still used only for the single-step sequential case (`lazyInlineSteps.length === 1`). With more than one inline step each writes its own events, so a per-write delta would be partial; multi-step batches fall back to a normal incremental `events.list`.
97
+ - **Config:** `WORKFLOW_MAX_INLINE_STEPS` is clamped to 1..16. Setting it to `1` reproduces the previous single-inline-step behavior exactly (a useful kill-switch). Inline bodies run in parallel within one function invocation, so the cap also bounds per-handler memory/CPU fan-out.
98
+
99
+ ## Optimistic inline start (opt-in, off by default)
100
+
101
+ Normally `executeStep` awaits `step_started` (the lazy create-claim round-trip) before running the body. Because the inline path already holds the step input locally, it doesn't actually need that round-trip to begin.
102
+
103
+ When `WORKFLOW_OPTIMISTIC_INLINE_START` is enabled (set it to `1`/`true` — it is **off by default**), an inline step fires `step_started` **without awaiting it** and starts running the body immediately against locally-synthesized state. A lazy step is always brand-new, so attempt is 1, there is no prior error, and `startedAt` is now — all known without the server. The in-flight `step_started` is reconciled just before the terminal write:
104
+
105
+ - **Lost the create-claim (409 / `EntityConflictError`)** → discard the body result and return `skipped`; the winning handler owns the terminal write.
106
+ - **Run gone / throttled / too-early** → discard the body result and surface `gone` / `throttled` / `retry`.
107
+ - **Transient (non-translatable) failure** → propagate it, so the queue redelivers — exactly as the await path does today.
108
+ - **Success** → write `step_completed` / `step_failed` / `step_retrying` as usual. Awaiting `step_started` before the terminal write keeps the event log ordered (`created → started → completed`).
109
+
110
+ ### Safety and the idempotency tradeoff
111
+
112
+ - **Exactly-one terminal write is preserved.** Optimistic start changes only *when the body runs*, never who writes the terminal event — that is still gated by the lazy `step_started` create-claim, which is awaited before the terminal write. Losers return `skipped`.
113
+ - **Bounded to attempt 1.** Only brand-new (`!hasCreatedEvent`) steps are lazy; a retried step already has a `step_created`, so it takes the normal await-then-run path with the real attempt counter. Synthesizing `attempt = 1` locally is therefore always correct.
114
+ - **Wider double-execution — why it's off by default.** Running the body before confirming ownership means two handlers racing for the same step's create-claim can *both* run the side effects before either wins (previously the loser 409'd on `step_created` and skipped before running anything). This is unsafe for non-idempotent steps: in particular, two concurrent runs of a step that writes to the **workflow stream** (e.g. an AI agent streaming tokens) can interleave and **corrupt the stream data**. So the optimization ships **disabled**; enable it (`WORKFLOW_OPTIMISTIC_INLINE_START=1`) only for deployments whose inline step bodies are idempotent and stream-safe.
115
+
116
+ ## Queue messages: inline steps don't pay a round-trip
117
+
118
+ Inline steps that **complete** never enqueue a per-step flow-route message. When every step in an inline batch reaches a terminal state with no pending background ops, the runtime simply continues its in-process loop and replays — so a sequential chain (or a clean parallel fan-out) of inline steps runs entirely within one invocation with **zero** queue messages. Verified: a workflow whose only work is three parallel inline steps issues no `queue()` calls.
119
+
120
+ The only flow-route messages produced around an inline batch are:
121
+
122
+ - **Pre-batch dispatch** — the steps *beyond* the inline cap (and any pending wait/sleep continuation). Inline steps are explicitly excluded from this dispatch.
123
+ - **`retry` results** — one delayed message per retried step. A retry *is* the step becoming its own background invocation, so this is expected.
124
+ - **`throttled` results** — a single deferral of the orchestrator message (see above).
125
+ - **Pending background ops** — if any inline step left unflushed stream writes (e.g. output streams to blob storage), the loop breaks and enqueues **one** continuation (aggregated across the batch, not per-step) so `waitUntil` can flush before the next replay reads them.
126
+
127
+ In other words: completed inline steps cost no queue round-trips; only steps that genuinely run as their own background invocations create new flow-route messages.
@@ -1,5 +1,13 @@
1
1
  {
2
2
  "title": "Changelog",
3
- "pages": ["index", "eager-processing", "resilient-start"],
3
+ "pages": [
4
+ "index",
5
+ "eager-processing",
6
+ "resilient-resume",
7
+ "resilient-start",
8
+ "lazy-event-creation",
9
+ "turbo-mode",
10
+ "step-message-ownership"
11
+ ],
4
12
  "defaultOpen": false
5
13
  }
@@ -0,0 +1,22 @@
1
+ ---
2
+ title: Resilient hook resume
3
+ description: resumeHook() now tolerates transient event storage failures, as long as the queue is healthy, instead of failing the resume.
4
+ ---
5
+
6
+ # Resilient `resumeHook()`
7
+
8
+ ## Motivation
9
+
10
+ `resumeHook()` used to write the `hook_received` event and dispatch the workflow queue message strictly one after the other, so every resume paid two sequential round trips and a transient event-storage failure failed the whole resume even when the queue was healthy. This change runs both writes **concurrently** — cutting a round trip off resume latency — and, on the same path, brings `resumeHook()` to parity with [resilient `start()`](/docs/changelog/resilient-start): a transient event-write failure no longer fails the resume when the payload can still be delivered through the queue.
11
+
12
+ ## Design
13
+
14
+ - On the fast path, `resumeHook()` writes the `hook_received` event and dispatches the workflow queue message **concurrently** (`Promise.allSettled`). The queue message carries a `hookInput` payload — the dehydrated hook payload plus a client-minted `resumeId` idempotency key, the hook token, and a payload digest.
15
+ - A `(runId, resumeId)` dedup constraint keeps the two writers converging on **exactly one** `hook_received` event: whichever lands first wins, and the other is resolved server-side as success rather than a duplicate. The queue consumer idempotently re-ensures the event from `hookInput` before replay, so the resume is guaranteed even if the direct write never commits.
16
+ - Replay also deduplicates: `hook_received` events sharing a `resumeId` belong to the same resume attempt, and only the first in the event log is delivered to workflow code. Even if a redelivery materializes the event twice, the payload reaches the workflow exactly once.
17
+ - **Queue dispatch failure is fatal** — the run was not re-triggered, so no consumer will re-ensure the event, and `resumeHook()` throws. A transient event-write failure (429/5xx, a transport error, or an expected `(runId, resumeId)` conflict with the consumer's own re-ensure) is swallowed because the queue delivery still guarantees the resume; a terminal run surfaces as `HookNotFoundError`, and any other event-write error is rethrown.
18
+ - `resumeHook()` returns `ResumedHook` (exported from `workflow/api`), which extends `Hook` with an optional `resilientResume` flag. The flag is `true` only when the direct write failed transiently and the resume was recovered through the queue; on the happy path and the sequential fallback it is absent.
19
+
20
+ ## Compatibility
21
+
22
+ The parallel fast path is gated per resume: it activates only when both the target run's queue consumer and the live backend independently attest dedup support (re-checked on every resume, so rollout and rollback both degrade safely). Otherwise — for oversized payloads, legacy runs, or with `WORKFLOW_DISABLE_LAZY_HOOK_RESUME=1` — `resumeHook()` falls back to the original sequential write-then-dispatch path. Because runs keep executing on the deployment they were created on, a resume targeting a run from an older deployment simply uses the sequential path.