@openclaw/lobster 2026.9.4 → 2026.9.5

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 (322) hide show
  1. package/assets/activity.svg +4 -0
  2. package/assets/icon.png +0 -0
  3. package/dist/index.js +39 -10
  4. package/node_modules/@clawdbot/lobster/README.md +112 -1
  5. package/node_modules/@clawdbot/lobster/bin/clawd.invoke.js +13 -7
  6. package/node_modules/@clawdbot/lobster/bin/lobster.js +7 -7
  7. package/node_modules/@clawdbot/lobster/bin/openclaw.invoke.js +16 -10
  8. package/node_modules/@clawdbot/lobster/dist/src/abortable_process.js +297 -0
  9. package/node_modules/@clawdbot/lobster/dist/src/abortable_process.js.map +1 -0
  10. package/node_modules/@clawdbot/lobster/dist/src/cli.js +96 -248
  11. package/node_modules/@clawdbot/lobster/dist/src/cli.js.map +1 -1
  12. package/node_modules/@clawdbot/lobster/dist/src/commands/commands_list.js.map +1 -1
  13. package/node_modules/@clawdbot/lobster/dist/src/commands/registry.js +2 -0
  14. package/node_modules/@clawdbot/lobster/dist/src/commands/registry.js.map +1 -1
  15. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/approve.js +2 -0
  16. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/approve.js.map +1 -1
  17. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/ask.js +3 -1
  18. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/ask.js.map +1 -1
  19. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/dedupe.js.map +1 -1
  20. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/diff_last.js +6 -1
  21. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/diff_last.js.map +1 -1
  22. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/email_triage.js.map +1 -1
  23. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/exec.js +30 -34
  24. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/exec.js.map +1 -1
  25. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_search.js +12 -25
  26. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_search.js.map +1 -1
  27. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_send.js +19 -25
  28. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_send.js.map +1 -1
  29. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/group_by.js.map +1 -1
  30. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js.map +1 -1
  31. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/json.js.map +1 -1
  32. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js +596 -42
  33. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js.map +1 -1
  34. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js.map +1 -1
  35. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_agent.js +139 -0
  36. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_agent.js.map +1 -0
  37. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_invoke.js +20 -2
  38. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_invoke.js.map +1 -1
  39. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js.map +1 -1
  40. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/sort.js.map +1 -1
  41. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js +48 -20
  42. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js.map +1 -1
  43. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js.map +1 -1
  44. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/template.js.map +1 -1
  45. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/where.js.map +1 -1
  46. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_list.js.map +1 -1
  47. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_run.js.map +1 -1
  48. package/node_modules/@clawdbot/lobster/dist/src/core/cost_tracker.js +45 -3
  49. package/node_modules/@clawdbot/lobster/dist/src/core/cost_tracker.js.map +1 -1
  50. package/node_modules/@clawdbot/lobster/dist/src/core/filters.js.map +1 -1
  51. package/node_modules/@clawdbot/lobster/dist/src/core/retry.js.map +1 -1
  52. package/node_modules/@clawdbot/lobster/dist/src/core/tool_runtime.js +147 -21
  53. package/node_modules/@clawdbot/lobster/dist/src/core/tool_runtime.js.map +1 -1
  54. package/node_modules/@clawdbot/lobster/dist/src/input_request.js +4 -2
  55. package/node_modules/@clawdbot/lobster/dist/src/input_request.js.map +1 -1
  56. package/node_modules/@clawdbot/lobster/dist/src/parser.js +4 -3
  57. package/node_modules/@clawdbot/lobster/dist/src/parser.js.map +1 -1
  58. package/node_modules/@clawdbot/lobster/dist/src/pipeline_resume_state.js +241 -74
  59. package/node_modules/@clawdbot/lobster/dist/src/pipeline_resume_state.js.map +1 -1
  60. package/node_modules/@clawdbot/lobster/dist/src/read_line.js +52 -7
  61. package/node_modules/@clawdbot/lobster/dist/src/read_line.js.map +1 -1
  62. package/node_modules/@clawdbot/lobster/dist/src/read_response_text.js +86 -0
  63. package/node_modules/@clawdbot/lobster/dist/src/read_response_text.js.map +1 -0
  64. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js +4 -2
  65. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js.map +1 -1
  66. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js +20 -34
  67. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js.map +1 -1
  68. package/node_modules/@clawdbot/lobster/dist/src/recipes/registry.js.map +1 -1
  69. package/node_modules/@clawdbot/lobster/dist/src/renderers/json.js.map +1 -1
  70. package/node_modules/@clawdbot/lobster/dist/src/resume.js.map +1 -1
  71. package/node_modules/@clawdbot/lobster/dist/src/runtime.js +187 -17
  72. package/node_modules/@clawdbot/lobster/dist/src/runtime.js.map +1 -1
  73. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js +10 -2
  74. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js.map +1 -1
  75. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/approve.js.map +1 -1
  76. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js +13 -82
  77. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js.map +1 -1
  78. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js +22 -32
  79. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js.map +1 -1
  80. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js +9 -101
  81. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js.map +1 -1
  82. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js +2 -1
  83. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js.map +1 -1
  84. package/node_modules/@clawdbot/lobster/dist/src/shell.js.map +1 -1
  85. package/node_modules/@clawdbot/lobster/dist/src/state/store.js +619 -19
  86. package/node_modules/@clawdbot/lobster/dist/src/state/store.js.map +1 -1
  87. package/node_modules/@clawdbot/lobster/dist/src/token.js.map +1 -1
  88. package/node_modules/@clawdbot/lobster/dist/src/validation.js.map +1 -1
  89. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js +963 -283
  90. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js.map +1 -1
  91. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js +27 -28
  92. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js.map +1 -1
  93. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js +9 -2
  94. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js.map +1 -1
  95. package/node_modules/@clawdbot/lobster/dist/src/workflows/registry.js.map +1 -1
  96. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js.map +1 -1
  97. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js.map +1 -1
  98. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js +25 -0
  99. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js.map +1 -0
  100. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js +5170 -0
  101. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js.map +1 -0
  102. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js +22 -0
  103. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js.map +1 -1
  104. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js.map +1 -1
  105. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js.map +1 -1
  106. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js.map +1 -1
  107. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js.map +1 -1
  108. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js.map +1 -1
  109. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js +1733 -1
  110. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js.map +1 -1
  111. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js.map +1 -1
  112. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js.map +1 -1
  113. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js.map +1 -1
  114. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js.map +1 -1
  115. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js.map +1 -1
  116. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js.map +1 -1
  117. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js +254 -0
  118. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js.map +1 -1
  119. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js.map +1 -1
  120. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js.map +1 -1
  121. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js +176 -0
  122. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js.map +1 -0
  123. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js +100 -0
  124. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js.map +1 -0
  125. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js.map +1 -1
  126. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js +19 -0
  127. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js.map +1 -0
  128. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js +318 -0
  129. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js.map +1 -0
  130. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js +946 -1
  131. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js.map +1 -1
  132. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js +103 -0
  133. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js.map +1 -1
  134. package/node_modules/@clawdbot/lobster/dist/test/map.test.js.map +1 -1
  135. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js.map +1 -1
  136. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js.map +1 -1
  137. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js +169 -0
  138. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js.map +1 -0
  139. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js +96 -0
  140. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js.map +1 -0
  141. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js.map +1 -1
  142. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js.map +1 -1
  143. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js +57 -0
  144. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js.map +1 -1
  145. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js +173 -0
  146. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js.map +1 -0
  147. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js +54 -0
  148. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js.map +1 -1
  149. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js +39 -0
  150. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js.map +1 -0
  151. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js +1 -1
  152. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js.map +1 -1
  153. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js.map +1 -1
  154. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js +201 -0
  155. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js.map +1 -0
  156. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js.map +1 -1
  157. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js.map +1 -1
  158. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js.map +1 -1
  159. package/node_modules/@clawdbot/lobster/dist/test/state.test.js +584 -1
  160. package/node_modules/@clawdbot/lobster/dist/test/state.test.js.map +1 -1
  161. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js.map +1 -1
  162. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js +44 -0
  163. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js.map +1 -0
  164. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js +67 -1
  165. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js.map +1 -1
  166. package/node_modules/@clawdbot/lobster/dist/test/template.test.js.map +1 -1
  167. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js +37 -0
  168. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js.map +1 -1
  169. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js.map +1 -1
  170. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js.map +1 -1
  171. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js.map +1 -1
  172. package/node_modules/@clawdbot/lobster/dist/test/where.test.js +94 -0
  173. package/node_modules/@clawdbot/lobster/dist/test/where.test.js.map +1 -0
  174. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js +5 -3
  175. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js.map +1 -1
  176. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js.map +1 -1
  177. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js +647 -1
  178. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js.map +1 -1
  179. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js +12 -2
  180. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js.map +1 -1
  181. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js.map +1 -1
  182. package/node_modules/@clawdbot/lobster/package.json +74 -73
  183. package/node_modules/typebox/build/compile/validator.d.mts +1 -1
  184. package/node_modules/typebox/build/compile/validator.mjs +1 -1
  185. package/node_modules/typebox/build/format/idna/idn-hostname.mjs +7 -5
  186. package/node_modules/typebox/build/format/iri.mjs +5 -1
  187. package/node_modules/typebox/build/guard/emit.mjs +2 -4
  188. package/node_modules/typebox/build/guard/guard.mjs +6 -18
  189. package/node_modules/typebox/build/guard/string.mjs +16 -24
  190. package/node_modules/typebox/build/schema/build.mjs +2 -2
  191. package/node_modules/typebox/build/schema/check.mjs +1 -1
  192. package/node_modules/typebox/build/schema/compile.d.mts +3 -2
  193. package/node_modules/typebox/build/schema/compile.mjs +8 -8
  194. package/node_modules/typebox/build/schema/engine/_context.d.mts +6 -7
  195. package/node_modules/typebox/build/schema/engine/_context.mjs +23 -15
  196. package/node_modules/typebox/build/schema/engine/_functions.d.mts +2 -2
  197. package/node_modules/typebox/build/schema/engine/_functions.mjs +1 -1
  198. package/node_modules/typebox/build/schema/engine/_reducer.d.mts +3 -3
  199. package/node_modules/typebox/build/schema/engine/_refine.d.mts +5 -5
  200. package/node_modules/typebox/build/schema/engine/_refine.mjs +4 -7
  201. package/node_modules/typebox/build/schema/engine/_stack.d.mts +34 -16
  202. package/node_modules/typebox/build/schema/engine/_stack.mjs +148 -104
  203. package/node_modules/typebox/build/schema/engine/additionalItems.d.mts +4 -4
  204. package/node_modules/typebox/build/schema/engine/additionalProperties.d.mts +8 -8
  205. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +26 -18
  206. package/node_modules/typebox/build/schema/engine/allOf.d.mts +4 -4
  207. package/node_modules/typebox/build/schema/engine/allOf.mjs +3 -3
  208. package/node_modules/typebox/build/schema/engine/anyOf.d.mts +4 -4
  209. package/node_modules/typebox/build/schema/engine/anyOf.mjs +5 -10
  210. package/node_modules/typebox/build/schema/engine/boolean.d.mts +4 -4
  211. package/node_modules/typebox/build/schema/engine/boolean.mjs +2 -6
  212. package/node_modules/typebox/build/schema/engine/const.d.mts +4 -4
  213. package/node_modules/typebox/build/schema/engine/const.mjs +2 -6
  214. package/node_modules/typebox/build/schema/engine/contains.d.mts +4 -4
  215. package/node_modules/typebox/build/schema/engine/contains.mjs +2 -6
  216. package/node_modules/typebox/build/schema/engine/dependencies.d.mts +4 -4
  217. package/node_modules/typebox/build/schema/engine/dependencies.mjs +3 -6
  218. package/node_modules/typebox/build/schema/engine/dependentRequired.d.mts +4 -4
  219. package/node_modules/typebox/build/schema/engine/dependentRequired.mjs +2 -6
  220. package/node_modules/typebox/build/schema/engine/dependentSchemas.d.mts +4 -4
  221. package/node_modules/typebox/build/schema/engine/dynamicRef.d.mts +4 -4
  222. package/node_modules/typebox/build/schema/engine/dynamicRef.mjs +11 -7
  223. package/node_modules/typebox/build/schema/engine/enum.d.mts +4 -4
  224. package/node_modules/typebox/build/schema/engine/enum.mjs +2 -6
  225. package/node_modules/typebox/build/schema/engine/exclusiveMaximum.d.mts +4 -4
  226. package/node_modules/typebox/build/schema/engine/exclusiveMaximum.mjs +2 -6
  227. package/node_modules/typebox/build/schema/engine/exclusiveMinimum.d.mts +4 -4
  228. package/node_modules/typebox/build/schema/engine/exclusiveMinimum.mjs +2 -6
  229. package/node_modules/typebox/build/schema/engine/format.d.mts +4 -4
  230. package/node_modules/typebox/build/schema/engine/format.mjs +2 -6
  231. package/node_modules/typebox/build/schema/engine/if.d.mts +4 -4
  232. package/node_modules/typebox/build/schema/engine/if.mjs +7 -15
  233. package/node_modules/typebox/build/schema/engine/items.d.mts +4 -4
  234. package/node_modules/typebox/build/schema/engine/items.mjs +1 -1
  235. package/node_modules/typebox/build/schema/engine/maxContains.d.mts +4 -4
  236. package/node_modules/typebox/build/schema/engine/maxContains.mjs +2 -6
  237. package/node_modules/typebox/build/schema/engine/maxItems.d.mts +4 -4
  238. package/node_modules/typebox/build/schema/engine/maxItems.mjs +2 -6
  239. package/node_modules/typebox/build/schema/engine/maxLength.d.mts +4 -4
  240. package/node_modules/typebox/build/schema/engine/maxLength.mjs +2 -6
  241. package/node_modules/typebox/build/schema/engine/maxProperties.d.mts +4 -4
  242. package/node_modules/typebox/build/schema/engine/maxProperties.mjs +2 -6
  243. package/node_modules/typebox/build/schema/engine/maximum.d.mts +4 -4
  244. package/node_modules/typebox/build/schema/engine/maximum.mjs +2 -6
  245. package/node_modules/typebox/build/schema/engine/minContains.d.mts +4 -4
  246. package/node_modules/typebox/build/schema/engine/minContains.mjs +2 -6
  247. package/node_modules/typebox/build/schema/engine/minItems.d.mts +5 -5
  248. package/node_modules/typebox/build/schema/engine/minItems.mjs +2 -6
  249. package/node_modules/typebox/build/schema/engine/minLength.d.mts +4 -4
  250. package/node_modules/typebox/build/schema/engine/minLength.mjs +2 -6
  251. package/node_modules/typebox/build/schema/engine/minProperties.d.mts +4 -4
  252. package/node_modules/typebox/build/schema/engine/minProperties.mjs +2 -6
  253. package/node_modules/typebox/build/schema/engine/minimum.d.mts +4 -4
  254. package/node_modules/typebox/build/schema/engine/minimum.mjs +2 -6
  255. package/node_modules/typebox/build/schema/engine/multipleOf.d.mts +4 -4
  256. package/node_modules/typebox/build/schema/engine/multipleOf.mjs +2 -6
  257. package/node_modules/typebox/build/schema/engine/not.d.mts +4 -4
  258. package/node_modules/typebox/build/schema/engine/not.mjs +4 -8
  259. package/node_modules/typebox/build/schema/engine/oneOf.d.mts +4 -4
  260. package/node_modules/typebox/build/schema/engine/oneOf.mjs +6 -11
  261. package/node_modules/typebox/build/schema/engine/pattern.d.mts +4 -4
  262. package/node_modules/typebox/build/schema/engine/pattern.mjs +3 -7
  263. package/node_modules/typebox/build/schema/engine/patternProperties.d.mts +4 -4
  264. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +1 -1
  265. package/node_modules/typebox/build/schema/engine/prefixItems.d.mts +4 -4
  266. package/node_modules/typebox/build/schema/engine/prefixItems.mjs +1 -1
  267. package/node_modules/typebox/build/schema/engine/properties.d.mts +4 -4
  268. package/node_modules/typebox/build/schema/engine/properties.mjs +1 -1
  269. package/node_modules/typebox/build/schema/engine/propertyNames.d.mts +4 -4
  270. package/node_modules/typebox/build/schema/engine/propertyNames.mjs +4 -10
  271. package/node_modules/typebox/build/schema/engine/recursiveRef.d.mts +4 -4
  272. package/node_modules/typebox/build/schema/engine/recursiveRef.mjs +11 -7
  273. package/node_modules/typebox/build/schema/engine/ref.d.mts +4 -4
  274. package/node_modules/typebox/build/schema/engine/ref.mjs +19 -15
  275. package/node_modules/typebox/build/schema/engine/required.d.mts +4 -4
  276. package/node_modules/typebox/build/schema/engine/required.mjs +2 -6
  277. package/node_modules/typebox/build/schema/engine/schema.d.mts +7 -7
  278. package/node_modules/typebox/build/schema/engine/schema.mjs +137 -133
  279. package/node_modules/typebox/build/schema/engine/type.d.mts +4 -4
  280. package/node_modules/typebox/build/schema/engine/type.mjs +2 -6
  281. package/node_modules/typebox/build/schema/engine/unevaluatedItems.d.mts +4 -4
  282. package/node_modules/typebox/build/schema/engine/unevaluatedItems.mjs +5 -9
  283. package/node_modules/typebox/build/schema/engine/unevaluatedProperties.d.mts +4 -4
  284. package/node_modules/typebox/build/schema/engine/unevaluatedProperties.mjs +5 -9
  285. package/node_modules/typebox/build/schema/engine/uniqueItems.d.mts +4 -4
  286. package/node_modules/typebox/build/schema/engine/uniqueItems.mjs +2 -6
  287. package/node_modules/typebox/build/schema/errors.d.mts +2 -2
  288. package/node_modules/typebox/build/schema/errors.mjs +8 -14
  289. package/node_modules/typebox/build/schema/intern/index.d.mts +1 -0
  290. package/node_modules/typebox/build/schema/intern/index.mjs +1 -0
  291. package/node_modules/typebox/build/schema/intern/intern.d.mts +11 -0
  292. package/node_modules/typebox/build/schema/intern/intern.mjs +293 -0
  293. package/node_modules/typebox/build/schema/meta/draft_2019_09.d.mts +2 -0
  294. package/node_modules/typebox/build/schema/meta/draft_2019_09.mjs +288 -0
  295. package/node_modules/typebox/build/schema/meta/draft_2020_12.d.mts +2 -0
  296. package/node_modules/typebox/build/schema/meta/draft_2020_12.mjs +300 -0
  297. package/node_modules/typebox/build/schema/meta/draft_3.d.mts +2 -0
  298. package/node_modules/typebox/build/schema/meta/draft_3.mjs +143 -0
  299. package/node_modules/typebox/build/schema/meta/draft_4.d.mts +2 -0
  300. package/node_modules/typebox/build/schema/meta/draft_4.mjs +149 -0
  301. package/node_modules/typebox/build/schema/meta/draft_6.d.mts +2 -0
  302. package/node_modules/typebox/build/schema/meta/draft_6.mjs +155 -0
  303. package/node_modules/typebox/build/schema/meta/draft_7.d.mts +2 -0
  304. package/node_modules/typebox/build/schema/meta/draft_7.mjs +172 -0
  305. package/node_modules/typebox/build/schema/meta/index.d.mts +2 -0
  306. package/node_modules/typebox/build/schema/meta/index.mjs +2 -0
  307. package/node_modules/typebox/build/schema/meta/meta.d.mts +9 -0
  308. package/node_modules/typebox/build/schema/meta/meta.mjs +15 -0
  309. package/node_modules/typebox/build/schema/meta/schema.d.mts +6 -0
  310. package/node_modules/typebox/build/schema/meta/schema.mjs +1 -0
  311. package/node_modules/typebox/build/schema/parse.d.mts +1 -0
  312. package/node_modules/typebox/build/schema/parse.mjs +10 -5
  313. package/node_modules/typebox/build/schema/resolve/resolve.d.mts +8 -3
  314. package/node_modules/typebox/build/schema/resolve/resolve.mjs +268 -139
  315. package/node_modules/typebox/build/schema/schema.d.mts +2 -0
  316. package/node_modules/typebox/build/schema/schema.mjs +2 -0
  317. package/node_modules/typebox/build/value/errors/errors.d.mts +2 -12
  318. package/node_modules/typebox/build/value/errors/errors.mjs +2 -7
  319. package/node_modules/typebox/package.json +1 -1
  320. package/node_modules/typebox/readme.md +59 -89
  321. package/openclaw.plugin.json +1 -1
  322. package/package.json +10 -7
@@ -7,13 +7,48 @@ import { PassThrough } from "node:stream";
7
7
  import { parsePipeline } from "../parser.js";
8
8
  import { runPipeline } from "../runtime.js";
9
9
  import { encodeToken, decodeToken } from "../token.js";
10
- import { createApprovalIndex, deleteStateJson, readStateJson, writeStateJson, } from "../state/store.js";
10
+ import { cleanupApprovalIndexByStateKey, consumeResumeState, createApprovalIndex, deleteStateJsonWithBoundedResumeCleanup, deleteResumeStateWithRollback, deleteStateJson, deleteUnconsumedResumeState, isConsumedResumeState, readStateJsonWithLock, restoreConsumedResumeState, writeStateJson, } from "../state/store.js";
11
11
  import { readLineFromStream } from "../read_line.js";
12
- import { resolveInlineShellCommand } from "../shell.js";
13
12
  import { compileCached } from "../validation.js";
14
- import { CostTracker } from "../core/cost_tracker.js";
13
+ import { createLlmSpendLedger, llmProvenanceOf, restoreLlmProvenance, } from "../commands/stdlib/llm_invoke.js";
14
+ import { CostLimitExceededError, CostTracker } from "../core/cost_tracker.js";
15
15
  import { withRetry, resolveRetryConfig } from "../core/retry.js";
16
16
  import { RequestInputResumeError, validateCommandInputState, } from "../input_request.js";
17
+ import { runAbortableProcess } from "../abortable_process.js";
18
+ const NON_RETRYABLE_STEP_ERROR = Symbol("lobster.nonRetryableStepError");
19
+ function createRecord(source) {
20
+ const record = Object.create(null);
21
+ if (!source)
22
+ return record;
23
+ for (const [key, value] of Object.entries(source))
24
+ record[key] = value;
25
+ return record;
26
+ }
27
+ function markStepErrorNonRetryable(error) {
28
+ const normalized = error instanceof Error ? error : new Error(String(error));
29
+ Object.defineProperty(normalized, NON_RETRYABLE_STEP_ERROR, { value: true });
30
+ return normalized;
31
+ }
32
+ function isStepErrorNonRetryable(error) {
33
+ return Boolean(error instanceof Error &&
34
+ error[NON_RETRYABLE_STEP_ERROR]);
35
+ }
36
+ const PARALLEL_BRANCH_ABORT_SETTLE_TIMEOUT_MS = 500;
37
+ async function waitForCancelledParallelBranches(branchesSettled) {
38
+ let timeoutId;
39
+ try {
40
+ await Promise.race([
41
+ branchesSettled,
42
+ new Promise((_resolve, reject) => {
43
+ timeoutId = setTimeout(() => reject(new Error("Parallel branches did not settle after cancellation")), PARALLEL_BRANCH_ABORT_SETTLE_TIMEOUT_MS);
44
+ }),
45
+ ]);
46
+ }
47
+ finally {
48
+ if (timeoutId !== undefined)
49
+ clearTimeout(timeoutId);
50
+ }
51
+ }
17
52
  export class WorkflowResumeArgumentError extends Error {
18
53
  constructor(message) {
19
54
  super(message);
@@ -347,7 +382,7 @@ export async function loadWorkflowFile(filePath) {
347
382
  return parsed;
348
383
  }
349
384
  export function resolveWorkflowArgs(argDefs, provided) {
350
- const resolved = {};
385
+ const resolved = createRecord();
351
386
  if (argDefs) {
352
387
  for (const [key, def] of Object.entries(argDefs)) {
353
388
  if (def && typeof def === "object" && "default" in def) {
@@ -364,11 +399,74 @@ export function resolveWorkflowArgs(argDefs, provided) {
364
399
  }
365
400
  export async function runWorkflowFile({ filePath, args, ctx, resume, approved, response, cancel, }) {
366
401
  const consumedResumeStateKey = resume?.stateKey && typeof resume.stateKey === "string"
367
- ? await resolveWorkflowResumeStateKey(ctx.env, resume.stateKey)
402
+ ? await resolveWorkflowResumeStateKey(ctx.env, resume.stateKey, ctx.signal)
368
403
  : null;
404
+ if (consumedResumeStateKey)
405
+ ctx._onResumeStateResolved?.(consumedResumeStateKey);
369
406
  const resumeState = resume?.stateKey
370
- ? await loadWorkflowResumeState(ctx.env, consumedResumeStateKey ?? resume.stateKey)
407
+ ? await loadWorkflowResumeState(ctx.env, consumedResumeStateKey ?? resume.stateKey, ctx.signal)
371
408
  : (resume ?? null);
409
+ let resumedExecutionStarted = false;
410
+ let resumeStateConsumed = false;
411
+ let executionBoundaryStarted = false;
412
+ const executionSignalListeners = [];
413
+ let executionStart;
414
+ const markExecutionStarted = async (signal) => {
415
+ if (!consumedResumeStateKey && !ctx._onExecutionStarted && !ctx._onExecutionInterrupted)
416
+ return;
417
+ if (executionStart)
418
+ return executionStart;
419
+ executionStart = (async () => {
420
+ signal?.throwIfAborted();
421
+ if (consumedResumeStateKey) {
422
+ const consumption = await consumeResumeState({
423
+ env: ctx.env,
424
+ key: consumedResumeStateKey,
425
+ expectedState: resumeState,
426
+ signal,
427
+ });
428
+ if (!consumption.consumed) {
429
+ throw new Error("Workflow resume state not found");
430
+ }
431
+ if (consumption.signalAbortedAfterCommit) {
432
+ await restoreConsumedResumeState({
433
+ env: ctx.env,
434
+ key: consumedResumeStateKey,
435
+ expectedState: resumeState,
436
+ claimId: consumption.claimId,
437
+ });
438
+ signal?.throwIfAborted();
439
+ }
440
+ signal?.throwIfAborted();
441
+ resumeStateConsumed = true;
442
+ resumedExecutionStarted = true;
443
+ }
444
+ await ctx._onExecutionStarted?.();
445
+ executionBoundaryStarted = true;
446
+ if (!signal)
447
+ return;
448
+ const onAbort = () => {
449
+ ctx._onExecutionInterrupted?.();
450
+ };
451
+ if (signal.aborted) {
452
+ onAbort();
453
+ return;
454
+ }
455
+ signal.addEventListener("abort", onAbort, { once: true });
456
+ executionSignalListeners.push({ signal, onAbort });
457
+ })();
458
+ try {
459
+ await executionStart;
460
+ }
461
+ catch (err) {
462
+ // A per-attempt timeout can abort while this invocation is only waiting
463
+ // to claim its resume state. Keep the successful boundary memoized, but
464
+ // let the workflow retry make a fresh claim after that pre-dispatch wait.
465
+ if (!resumeStateConsumed && !executionBoundaryStarted)
466
+ executionStart = undefined;
467
+ throw err;
468
+ }
469
+ };
372
470
  if (resumeState?.approvalStepId && resumeState?.inputStepId) {
373
471
  throw new Error("Invalid workflow resume state");
374
472
  }
@@ -381,14 +479,26 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
381
479
  }
382
480
  if (cancel === true || approved === false) {
383
481
  if (consumedResumeStateKey) {
384
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
482
+ const deletion = await deleteUnconsumedResumeState({
483
+ env: ctx.env,
484
+ key: consumedResumeStateKey,
485
+ signal: ctx.signal,
486
+ });
487
+ if (deletion !== "deleted")
488
+ throw new Error("Workflow resume state not found");
385
489
  }
386
490
  return { status: "cancelled", output: [] };
387
491
  }
388
492
  }
389
493
  if (resumeState?.inputStepId && cancel === true) {
390
494
  if (consumedResumeStateKey) {
391
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
495
+ const deletion = await deleteUnconsumedResumeState({
496
+ env: ctx.env,
497
+ key: consumedResumeStateKey,
498
+ signal: ctx.signal,
499
+ });
500
+ if (deletion !== "deleted")
501
+ throw new Error("Workflow resume state not found");
392
502
  }
393
503
  return { status: "cancelled", output: [] };
394
504
  }
@@ -408,7 +518,7 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
408
518
  const stepIndexById = new Map(steps.map((step, idx) => [step.id, idx]));
409
519
  const results = resumeState?.steps
410
520
  ? cloneResults(resumeState.steps)
411
- : {};
521
+ : createRecord();
412
522
  const startIndex = resumeState?.resumeAtIndex ?? 0;
413
523
  if (resumeState?.approvalStepId && typeof approved === "boolean") {
414
524
  const previous = results[resumeState.approvalStepId] ?? { id: resumeState.approvalStepId };
@@ -459,11 +569,6 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
459
569
  stepId: resumeState.inputStepId,
460
570
  response,
461
571
  pipelineInput: resumeState.pipelineInput,
462
- onConsumed: consumedResumeStateKey
463
- ? async () => {
464
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
465
- }
466
- : undefined,
467
572
  };
468
573
  }
469
574
  else {
@@ -492,8 +597,30 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
492
597
  return dryRunWorkflow({ steps, resolvedArgs, results, startIndex, ctx });
493
598
  }
494
599
  const costTracker = new CostTracker(CostTracker.parsePricingFromEnv(ctx.env, ctx.stderr), ctx.stderr);
600
+ // A gate pauses a run, it does not reset its spend: seed this run with what the paused
601
+ // run recorded, so `_meta.cost` covers the whole workflow and `cost_limit` cannot be
602
+ // walked past one gate at a time. A replay of a call billed before the pause then stays
603
+ // exempt, which is what an empty ledger already means for a charge someone else settled.
604
+ const pausedCost = resumeState && "cost" in resumeState ? resumeState.cost : undefined;
605
+ costTracker.restore(pausedCost?.byStep);
606
+ // One ledger per run: a live call this run paid for can be recovered from the replay a
607
+ // retry produced, and a cache entry written by any other run has nothing to claim here.
608
+ const llmSpendLedger = createLlmSpendLedger(composingSpendLedger(ctx));
609
+ // A pipeline that paused at an `ask` gate had already paid for its LLM call without
610
+ // billing it, so the charge travels in the same resume state and reopens here. It is the
611
+ // same run continuing, which is why this cannot hand a charge to an unrelated one.
612
+ llmSpendLedger.restore(resumeState && "llmSpend" in resumeState ? resumeState.llmSpend : undefined);
613
+ // The completed steps come back as JSON, so an LLM output among them has lost everything
614
+ // this process attached to it. A later step that re-emits one hands on a copy that still
615
+ // names its call, and the restored record is what lets that copy be recognized as one the
616
+ // paused run already paid for rather than billed a second time on top of `cost`.
617
+ llmSpendLedger.restoreSettled(resumeState && "llmBilled" in resumeState ? resumeState.llmBilled : undefined);
618
+ // Completed results lost their symbol keys when the pause was persisted. Restore those
619
+ // private marks only at this trusted resume boundary; ordinary command JSON stays billable.
620
+ restoreLlmProvenance(results, resumeState && "llmBilled" in resumeState ? resumeState.llmBilled : undefined);
495
621
  let lastStepId = resumeState?.inputStepId ?? findLastCompletedStepId(steps, results);
496
622
  for (let idx = startIndex; idx < steps.length; idx++) {
623
+ ctx.signal?.throwIfAborted();
497
624
  const step = steps[idx];
498
625
  if (!evaluateCondition(step.when ?? step.condition, results)) {
499
626
  results[step.id] = { id: step.id, skipped: true };
@@ -515,40 +642,65 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
515
642
  subject,
516
643
  maxEnvelopeBytes: resolveToolEnvelopeMaxBytes(ctx.env),
517
644
  });
518
- const stateKey = await saveWorkflowResumeState(ctx.env, {
519
- filePath: resolvedFilePath,
520
- resumeAtIndex: idx + 1,
521
- steps: results,
522
- args: resolvedArgs,
523
- inputStepId: step.id,
524
- inputSchema: step.input.responseSchema,
525
- // Preserve the full resolved subject for resume semantics; the tool
526
- // envelope may contain a truncated preview to stay within size limits.
527
- inputSubject: subject,
528
- createdAt: new Date().toISOString(),
529
- });
530
- if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) {
531
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
645
+ let stateKey;
646
+ try {
647
+ stateKey = await saveWorkflowResumeState(ctx.env, {
648
+ filePath: resolvedFilePath,
649
+ resumeAtIndex: idx + 1,
650
+ steps: results,
651
+ args: resolvedArgs,
652
+ inputStepId: step.id,
653
+ inputSchema: step.input.responseSchema,
654
+ // Preserve the full resolved subject for resume semantics; the tool
655
+ // envelope may contain a truncated preview to stay within size limits.
656
+ inputSubject: subject,
657
+ ...(costTracker.hasUsage() ? { cost: costTracker.getSummary() } : null),
658
+ ...(llmSpendLedger.outstanding().length
659
+ ? { llmSpend: llmSpendLedger.outstanding() }
660
+ : null),
661
+ ...(llmSpendLedger.settled().length
662
+ ? { llmBilled: llmSpendLedger.settled() }
663
+ : null),
664
+ supersededResumeStateKeys: collectSupersededWorkflowResumeStateKeys(consumedResumeStateKey, resumeState),
665
+ createdAt: new Date().toISOString(),
666
+ }, ctx.signal);
667
+ const replaced = await replaceWorkflowResumeState({
668
+ env: ctx.env,
669
+ previousStateKey: consumedResumeStateKey,
670
+ expectedPreviousState: resumeState,
671
+ previousStateConsumed: resumeStateConsumed,
672
+ replacementStateKey: stateKey,
673
+ signal: ctx.signal,
674
+ });
675
+ if (!replaced) {
676
+ throw new Error("Workflow resume state not found");
677
+ }
678
+ const resumeToken = encodeToken({
679
+ protocolVersion: 1,
680
+ v: 1,
681
+ kind: "workflow-file",
682
+ stateKey,
683
+ });
684
+ return {
685
+ status: "needs_input",
686
+ output: [],
687
+ requiresInput: {
688
+ ...inputRequest,
689
+ resumeToken,
690
+ },
691
+ };
692
+ }
693
+ catch (err) {
694
+ if (stateKey)
695
+ await discardWorkflowResumeState(ctx.env, stateKey);
696
+ throw err;
532
697
  }
533
- const resumeToken = encodeToken({
534
- protocolVersion: 1,
535
- v: 1,
536
- kind: "workflow-file",
537
- stateKey,
538
- });
539
- return {
540
- status: "needs_input",
541
- output: [],
542
- requiresInput: {
543
- ...inputRequest,
544
- resumeToken,
545
- },
546
- };
547
698
  }
548
699
  ctx.stdout.write(`${step.input.prompt}\n`);
549
700
  ctx.stdout.write("Enter JSON response: ");
550
701
  const raw = await readLineFromStream(ctx.stdin, {
551
702
  timeoutMs: parseApprovalTimeoutMs(ctx.env),
703
+ signal: ctx.signal,
552
704
  });
553
705
  const parsed = parseResponseJson(String(raw ?? "").trim());
554
706
  validateInputResponse({
@@ -564,130 +716,168 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
564
716
  lastStepId = step.id;
565
717
  continue;
566
718
  }
567
- if (typeof step.for_each === "string" && Array.isArray(step.steps)) {
568
- const itemsRef = resolveInputValue(step.for_each, resolvedArgs, results);
569
- if (!Array.isArray(itemsRef)) {
570
- throw new Error(`Workflow step ${step.id} for_each: expected array, got ${typeof itemsRef}`);
571
- }
572
- const itemVar = step.item_var ?? "item";
573
- const indexVar = step.index_var ?? "index";
574
- const batchSize = step.batch_size ?? 1;
575
- const iterationResults = [];
576
- for (let itemIdx = 0; itemIdx < itemsRef.length; itemIdx++) {
577
- if (step.pause_ms && itemIdx > 0 && itemIdx % batchSize === 0) {
578
- await abortableSleep(step.pause_ms, ctx.signal);
579
- }
580
- const item = itemsRef[itemIdx];
581
- const scopedResults = { ...results };
582
- scopedResults[itemVar] = {
583
- id: itemVar,
584
- json: item,
585
- stdout: typeof item === "string" ? item : JSON.stringify(item),
586
- };
587
- scopedResults[indexVar] = {
588
- id: indexVar,
589
- json: itemIdx,
590
- stdout: String(itemIdx),
591
- };
592
- for (const subStep of step.steps) {
593
- if (!evaluateCondition(subStep.when ?? subStep.condition, scopedResults)) {
594
- scopedResults[subStep.id] = { id: subStep.id, skipped: true };
595
- continue;
596
- }
597
- const loopEnvBase = mergeEnv(ctx.env, workflow.env, step.env, resolvedArgs, scopedResults);
598
- const subEnv = subStep.env
599
- ? mergeEnv(loopEnvBase, undefined, subStep.env, resolvedArgs, scopedResults)
600
- : loopEnvBase;
601
- const subCwd = resolveCwd(subStep.cwd ?? step.cwd ?? workflow.cwd, resolvedArgs) ?? ctx.cwd;
602
- const subExecution = getStepExecution(subStep);
603
- let subResult;
604
- if (subExecution.kind === "shell") {
605
- const command = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
606
- const stdinValue = resolveShellStdin(subStep.stdin, resolvedArgs, scopedResults);
607
- const { stdout } = await runShellCommand({
608
- command,
609
- stdin: stdinValue,
610
- env: subEnv,
611
- cwd: subCwd,
612
- signal: ctx.signal,
613
- });
614
- subResult = { id: subStep.id, stdout, json: parseJson(stdout) };
615
- }
616
- else if (subExecution.kind === "pipeline") {
617
- if (!ctx.registry) {
618
- throw new Error(`Workflow step ${step.id} for_each sub-step ${subStep.id} requires a command registry for pipeline execution`);
619
- }
620
- const pipelineText = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
621
- const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
622
- subResult = await runPipelineStep({
623
- stepId: subStep.id,
624
- pipelineText,
625
- inputValue,
626
- ctx,
627
- env: subEnv,
628
- cwd: subCwd,
629
- requestInputEnabled: false,
630
- });
631
- }
632
- else {
633
- const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
634
- subResult = createSyntheticStepResult(subStep.id, inputValue);
635
- }
636
- scopedResults[subStep.id] = subResult;
637
- trackStepCost(costTracker, `${step.id}.${subStep.id}`, subResult);
638
- if (workflow.cost_limit) {
639
- costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
640
- }
641
- }
642
- const iterResult = { [itemVar]: item, [indexVar]: itemIdx };
643
- for (const subStep of step.steps) {
644
- const subResult = scopedResults[subStep.id];
645
- if (subResult && !subResult.skipped) {
646
- iterResult[subStep.id] =
647
- subResult.json !== undefined ? subResult.json : subResult.stdout;
648
- }
649
- }
650
- iterationResults.push(iterResult);
651
- }
652
- const loopResult = {
653
- id: step.id,
654
- json: iterationResults,
655
- stdout: JSON.stringify(iterationResults),
656
- };
657
- results[step.id] = loopResult;
658
- lastStepId = step.id;
659
- trackStepCost(costTracker, step.id, loopResult);
660
- if (workflow.cost_limit) {
661
- costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
662
- }
663
- continue;
664
- }
665
719
  const env = mergeEnv(ctx.env, workflow.env, step.env, resolvedArgs, results);
666
720
  const cwd = resolveCwd(step.cwd ?? workflow.cwd, resolvedArgs) ?? ctx.cwd;
667
721
  const execution = getStepExecution(step);
668
722
  const retryConfig = resolveRetryConfig(step.retry);
669
723
  const executeStepAttempt = async () => {
724
+ let attemptHadNonRetryableSideEffect = false;
725
+ const markNonRetryableSideEffect = () => {
726
+ attemptHadNonRetryableSideEffect = true;
727
+ ctx._onNonRetryableSideEffect?.();
728
+ };
729
+ ctx.signal?.throwIfAborted();
670
730
  // Combine external cancellation and optional per-step timeout into one signal.
671
731
  let stepSignal = ctx.signal;
672
732
  let timeoutId;
733
+ let stepTimeoutController;
673
734
  if (step.timeout_ms) {
674
- const timeoutController = new AbortController();
675
- timeoutId = setTimeout(() => timeoutController.abort(new Error(`Step '${step.id}' timed out after ${step.timeout_ms}ms`)), step.timeout_ms);
735
+ stepTimeoutController = new AbortController();
736
+ timeoutId = setTimeout(() => stepTimeoutController.abort(new Error(`Step '${step.id}' timed out after ${step.timeout_ms}ms`)), step.timeout_ms);
676
737
  stepSignal = ctx.signal
677
- ? AbortSignal.any([ctx.signal, timeoutController.signal])
678
- : timeoutController.signal;
738
+ ? AbortSignal.any([ctx.signal, stepTimeoutController.signal])
739
+ : stepTimeoutController.signal;
679
740
  }
680
741
  let result;
681
742
  let parallelBranchResults = null;
682
743
  try {
683
- if (execution.kind === "parallel") {
744
+ if (typeof step.for_each === "string" && Array.isArray(step.steps)) {
745
+ const itemsRef = resolveInputValue(step.for_each, resolvedArgs, results);
746
+ if (!Array.isArray(itemsRef)) {
747
+ throw new Error(`Workflow step ${step.id} for_each: expected array, got ${typeof itemsRef}`);
748
+ }
749
+ const itemVar = step.item_var ?? "item";
750
+ const indexVar = step.index_var ?? "index";
751
+ const batchSize = step.batch_size ?? 1;
752
+ const iterationResults = [];
753
+ for (let itemIdx = 0; itemIdx < itemsRef.length; itemIdx++) {
754
+ stepSignal?.throwIfAborted();
755
+ if (step.pause_ms && itemIdx > 0 && itemIdx % batchSize === 0) {
756
+ await abortableSleep(step.pause_ms, stepSignal);
757
+ }
758
+ const item = itemsRef[itemIdx];
759
+ const scopedResults = createRecord(results);
760
+ scopedResults[itemVar] = {
761
+ id: itemVar,
762
+ json: item,
763
+ stdout: typeof item === "string" ? item : JSON.stringify(item),
764
+ };
765
+ scopedResults[indexVar] = {
766
+ id: indexVar,
767
+ json: itemIdx,
768
+ stdout: String(itemIdx),
769
+ };
770
+ for (const subStep of step.steps) {
771
+ stepSignal?.throwIfAborted();
772
+ if (!evaluateCondition(subStep.when ?? subStep.condition, scopedResults)) {
773
+ scopedResults[subStep.id] = { id: subStep.id, skipped: true };
774
+ continue;
775
+ }
776
+ const loopEnvBase = mergeEnv(ctx.env, workflow.env, step.env, resolvedArgs, scopedResults);
777
+ const subEnv = subStep.env
778
+ ? mergeEnv(loopEnvBase, undefined, subStep.env, resolvedArgs, scopedResults)
779
+ : loopEnvBase;
780
+ const subCwd = resolveCwd(subStep.cwd ?? step.cwd ?? workflow.cwd, resolvedArgs) ?? ctx.cwd;
781
+ const subExecution = getStepExecution(subStep);
782
+ let subResult;
783
+ if (subExecution.kind === "shell") {
784
+ const command = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
785
+ const stdinValue = resolveShellStdin(subStep.stdin, resolvedArgs, scopedResults);
786
+ await markExecutionStarted(stepSignal);
787
+ const { stdout } = await runShellCommand({
788
+ command,
789
+ stdin: stdinValue,
790
+ env: subEnv,
791
+ cwd: subCwd,
792
+ signal: stepSignal,
793
+ forceTerminationSignal: ctx.forceTerminationSignal,
794
+ killSignal: () => stepTimeoutController?.signal.aborted
795
+ ? "SIGKILL"
796
+ : undefined,
797
+ });
798
+ subResult = { id: subStep.id, stdout, json: parseJson(stdout) };
799
+ }
800
+ else if (subExecution.kind === "pipeline") {
801
+ if (!ctx.registry) {
802
+ throw new Error(`Workflow step ${step.id} for_each sub-step ${subStep.id} requires a command registry for pipeline execution`);
803
+ }
804
+ const pipelineText = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
805
+ const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
806
+ subResult = await runPipelineStep({
807
+ stepId: subStep.id,
808
+ pipelineText,
809
+ inputValue,
810
+ ctx: {
811
+ ...ctx,
812
+ signal: stepSignal,
813
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
814
+ },
815
+ llmSpendLedger,
816
+ env: subEnv,
817
+ cwd: subCwd,
818
+ requestInputEnabled: false,
819
+ onExecutionStart: () => markExecutionStarted(stepSignal),
820
+ });
821
+ }
822
+ else {
823
+ const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
824
+ subResult = createSyntheticStepResult(subStep.id, inputValue);
825
+ }
826
+ scopedResults[subStep.id] = subResult;
827
+ trackStepCost(costTracker, `${step.id}.${subStep.id}`, subResult, llmSpendLedger);
828
+ // Before the next step runs: a charge no item carried is spend the step really made,
829
+ // and leaving it for the end would let `cost_limit` wave through every step after the
830
+ // one that blew the budget.
831
+ settleUnbilledCharges(costTracker, llmSpendLedger);
832
+ if (workflow.cost_limit) {
833
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
834
+ }
835
+ }
836
+ const iterResult = createRecord();
837
+ iterResult[itemVar] = item;
838
+ iterResult[indexVar] = itemIdx;
839
+ for (const subStep of step.steps) {
840
+ const subResult = scopedResults[subStep.id];
841
+ if (subResult && !subResult.skipped) {
842
+ iterResult[subStep.id] =
843
+ subResult.json !== undefined ? subResult.json : subResult.stdout;
844
+ }
845
+ }
846
+ iterationResults.push(Object.fromEntries(Object.entries(iterResult)));
847
+ }
848
+ result = {
849
+ id: step.id,
850
+ json: iterationResults,
851
+ stdout: JSON.stringify(iterationResults),
852
+ };
853
+ }
854
+ else if (execution.kind === "parallel") {
684
855
  const parallel = execution.value;
685
856
  const wait = parallel.wait ?? "all";
686
857
  const branchAbortController = new AbortController();
687
- const branchSignal = stepSignal
688
- ? AbortSignal.any([stepSignal, branchAbortController.signal])
689
- : branchAbortController.signal;
690
- const shouldForceKill = Boolean(step.timeout_ms || parallel.timeout_ms);
858
+ const parallelTimeoutController = new AbortController();
859
+ const parallelSignal = stepSignal
860
+ ? AbortSignal.any([stepSignal, parallelTimeoutController.signal])
861
+ : parallelTimeoutController.signal;
862
+ const branchSignal = AbortSignal.any([parallelSignal, branchAbortController.signal]);
863
+ const timeoutKillSignal = () => stepTimeoutController?.signal.aborted || parallelTimeoutController.signal.aborted
864
+ ? "SIGKILL"
865
+ : undefined;
866
+ // `wait: "any"` keeps the first branch to answer and abandons the rest, so a
867
+ // losing branch spends against a buffer of its own. Only the winner's charges
868
+ // reach the run. `wait: "all"` keeps every branch, so those bill straight
869
+ // through as before.
870
+ const branchLedgers = new Map();
871
+ const ledgerFor = (branchId) => {
872
+ if (wait !== "any")
873
+ return llmSpendLedger;
874
+ let buffered = branchLedgers.get(branchId);
875
+ if (!buffered) {
876
+ buffered = branchSpendBuffer(llmSpendLedger);
877
+ branchLedgers.set(branchId, buffered);
878
+ }
879
+ return buffered;
880
+ };
691
881
  const runBranch = async (branch) => {
692
882
  const mergedBranchEnv = { ...(step.env ?? {}), ...(branch.env ?? {}) };
693
883
  const branchEnv = mergeEnv(ctx.env, workflow.env, mergedBranchEnv, resolvedArgs, results);
@@ -701,13 +891,15 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
701
891
  if (branchExec.kind === "shell") {
702
892
  const command = resolveTemplate(branchExec.value, resolvedArgs, results);
703
893
  const stdinValue = resolveShellStdin(branch.stdin, resolvedArgs, results);
894
+ await markExecutionStarted(branchSignal);
704
895
  const { stdout } = await runShellCommand({
705
896
  command,
706
897
  stdin: stdinValue,
707
898
  env: branchEnv,
708
899
  cwd: branchCwd,
709
900
  signal: branchSignal,
710
- ...(shouldForceKill ? { killSignal: "SIGKILL" } : {}),
901
+ forceTerminationSignal: ctx.forceTerminationSignal,
902
+ killSignal: timeoutKillSignal,
711
903
  });
712
904
  return {
713
905
  branchId: branch.id,
@@ -724,10 +916,16 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
724
916
  stepId: branch.id,
725
917
  pipelineText,
726
918
  inputValue,
727
- ctx: { ...ctx, signal: branchSignal },
919
+ ctx: {
920
+ ...ctx,
921
+ signal: branchSignal,
922
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
923
+ },
924
+ llmSpendLedger: ledgerFor(branch.id),
728
925
  env: branchEnv,
729
926
  cwd: branchCwd,
730
927
  requestInputEnabled: false,
928
+ onExecutionStart: () => markExecutionStarted(branchSignal),
731
929
  });
732
930
  return { branchId: branch.id, result: branchResult };
733
931
  }
@@ -737,26 +935,27 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
737
935
  const timeoutPromise = parallel.timeout_ms
738
936
  ? new Promise((_resolve, reject) => {
739
937
  parallelTimeoutId = setTimeout(() => {
740
- branchAbortController.abort();
938
+ parallelTimeoutController.abort(new Error(`Parallel step ${step.id} timed out after ${parallel.timeout_ms}ms`));
741
939
  reject(new Error(`Parallel step ${step.id} timed out after ${parallel.timeout_ms}ms`));
742
940
  }, parallel.timeout_ms);
743
941
  })
744
942
  : null;
943
+ const branchPromises = parallel.branches.map((branch) => runBranch(branch));
944
+ const branchesSettled = Promise.allSettled(branchPromises);
745
945
  try {
746
946
  if (wait === "any") {
747
- const branchPromises = parallel.branches.map((branch) => runBranch(branch));
748
947
  const winner = (await (timeoutPromise
749
948
  ? Promise.race([...branchPromises, timeoutPromise])
750
949
  : Promise.race(branchPromises)));
751
- parallelBranchResults = { [winner.branchId]: winner.result };
752
- branchAbortController.abort();
950
+ parallelBranchResults = createRecord();
951
+ parallelBranchResults[winner.branchId] = winner.result;
952
+ branchLedgers.get(winner.branchId)?.release();
753
953
  }
754
954
  else {
755
- const branchPromises = parallel.branches.map((branch) => runBranch(branch));
756
955
  const settled = (await (timeoutPromise
757
956
  ? Promise.race([Promise.allSettled(branchPromises), timeoutPromise])
758
- : Promise.allSettled(branchPromises)));
759
- parallelBranchResults = {};
957
+ : branchesSettled));
958
+ parallelBranchResults = createRecord();
760
959
  for (const entry of settled) {
761
960
  if (entry.status === "rejected") {
762
961
  throw new Error(`Parallel branch failed: ${entry.reason?.message ?? String(entry.reason)}`);
@@ -768,11 +967,18 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
768
967
  finally {
769
968
  if (parallelTimeoutId !== undefined)
770
969
  clearTimeout(parallelTimeoutId);
970
+ const branchesCancelled = wait === "any" || parallelSignal.aborted;
971
+ if (branchesCancelled)
972
+ branchAbortController.abort();
973
+ if (branchesCancelled)
974
+ await waitForCancelledParallelBranches(branchesSettled);
975
+ else
976
+ await branchesSettled;
771
977
  }
772
- const merged = {};
773
- for (const [branchId, branchResult] of Object.entries(parallelBranchResults ?? {})) {
774
- merged[branchId] = branchResult.json;
775
- }
978
+ const merged = Object.fromEntries(Object.entries(parallelBranchResults ?? {}).map(([branchId, branchResult]) => [
979
+ branchId,
980
+ branchResult.json,
981
+ ]));
776
982
  result = {
777
983
  id: step.id,
778
984
  json: merged,
@@ -800,10 +1006,25 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
800
1006
  const childActive = new Set(activeWorkflows);
801
1007
  childActive.add(canonicalWorkflowPath);
802
1008
  const subArgs = resolveWorkflowStepArgs(step.workflow_args, resolvedArgs, results);
1009
+ stepSignal?.throwIfAborted();
803
1010
  const subResult = await runWorkflowFile({
804
1011
  filePath: resolvedWorkflowPath,
805
1012
  args: subArgs,
806
- ctx: { ...ctx, env, cwd, _activeWorkflows: childActive },
1013
+ ctx: {
1014
+ ...ctx,
1015
+ env,
1016
+ cwd,
1017
+ signal: stepSignal,
1018
+ _activeWorkflows: childActive,
1019
+ _llmSpendLedger: stepScopedLedger(llmSpendLedger, step.id),
1020
+ _onExecutionStarted: async () => {
1021
+ await markExecutionStarted(stepSignal);
1022
+ },
1023
+ _onExecutionInterrupted: () => {
1024
+ ctx._onExecutionInterrupted?.();
1025
+ },
1026
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
1027
+ },
807
1028
  });
808
1029
  if (subResult.status === "needs_approval" || subResult.status === "needs_input") {
809
1030
  const resumeToken = subResult.requiresApproval?.resumeToken ?? subResult.requiresInput?.resumeToken;
@@ -823,17 +1044,26 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
823
1044
  const json = subResult.output.length === 1 ? subResult.output[0] : subResult.output;
824
1045
  const stdout = subResult.output.length ? serializeValueForStdout(json) : "";
825
1046
  result = { id: step.id, stdout, json };
1047
+ // A child ending in a rendered pipeline (`llm.invoke | json`) prints its items
1048
+ // and returns JSON parsed back out of that print, which no symbol survives.
1049
+ // Carry the child's own items across the composition boundary so this run
1050
+ // applies to them the same replay rule it applies to its own pipelines.
1051
+ const composedItems = pipelineSourceItems(subResult);
1052
+ if (composedItems)
1053
+ attachPipelineSourceItems(result, composedItems);
826
1054
  }
827
1055
  else if (execution.kind === "shell") {
828
1056
  const command = resolveTemplate(execution.value, resolvedArgs, results);
829
1057
  const stdinValue = resolveShellStdin(step.stdin, resolvedArgs, results);
1058
+ await markExecutionStarted(stepSignal);
830
1059
  const { stdout } = await runShellCommand({
831
1060
  command,
832
1061
  stdin: stdinValue,
833
1062
  env,
834
1063
  cwd,
835
1064
  signal: stepSignal,
836
- ...(step.timeout_ms ? { killSignal: "SIGKILL" } : {}),
1065
+ forceTerminationSignal: ctx.forceTerminationSignal,
1066
+ killSignal: () => stepTimeoutController?.signal.aborted ? "SIGKILL" : undefined,
837
1067
  });
838
1068
  result = { id: step.id, stdout, json: parseJson(stdout) };
839
1069
  }
@@ -847,7 +1077,12 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
847
1077
  stepId: step.id,
848
1078
  pipelineText,
849
1079
  inputValue,
850
- ctx: { ...ctx, signal: stepSignal },
1080
+ ctx: {
1081
+ ...ctx,
1082
+ signal: stepSignal,
1083
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
1084
+ },
1085
+ llmSpendLedger,
851
1086
  env,
852
1087
  cwd,
853
1088
  resume: resumedPipelineInput?.stepId === step.id
@@ -857,14 +1092,23 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
857
1092
  onConsumed: resumedPipelineInput.onConsumed,
858
1093
  }
859
1094
  : undefined,
1095
+ onExecutionStart: () => markExecutionStarted(stepSignal),
860
1096
  });
861
1097
  }
862
1098
  else {
1099
+ if (execution.kind !== "none")
1100
+ await markExecutionStarted(stepSignal);
863
1101
  const inputValue = resolveInputValue(step.stdin, resolvedArgs, results);
864
1102
  result = createSyntheticStepResult(step.id, inputValue);
865
1103
  }
866
1104
  return { result, parallelBranchResults };
867
1105
  }
1106
+ catch (error) {
1107
+ if (attemptHadNonRetryableSideEffect) {
1108
+ throw markStepErrorNonRetryable(error);
1109
+ }
1110
+ throw error;
1111
+ }
868
1112
  finally {
869
1113
  if (timeoutId !== undefined)
870
1114
  clearTimeout(timeoutId);
@@ -878,8 +1122,19 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
878
1122
  signal: ctx.signal,
879
1123
  shouldRetry: (error) => {
880
1124
  if (error instanceof WorkflowPipelineInputSuspension ||
881
- error instanceof RequestInputResumeError) {
1125
+ error instanceof RequestInputResumeError ||
1126
+ error instanceof CostLimitExceededError) {
1127
+ return false;
1128
+ }
1129
+ if (isStepErrorNonRetryable(error)) {
1130
+ return false;
1131
+ }
1132
+ if (ctx.signal?.aborted)
882
1133
  return false;
1134
+ if (workflow.cost_limit?.action === "stop") {
1135
+ // Failed pipelines can discard output after the provider was paid.
1136
+ settleUnbilledCharges(costTracker, llmSpendLedger);
1137
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
883
1138
  }
884
1139
  const message = error?.message ?? String(error);
885
1140
  return !/halted (for approval inside|before completion at) pipeline/.test(message);
@@ -902,40 +1157,67 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
902
1157
  subject: err.request.subject,
903
1158
  maxEnvelopeBytes: resolveToolEnvelopeMaxBytes(ctx.env),
904
1159
  });
905
- const stateKey = await saveWorkflowResumeState(ctx.env, {
906
- filePath: resolvedFilePath,
907
- resumeAtIndex: idx,
908
- steps: results,
909
- args: resolvedArgs,
910
- inputStepId: err.stepId,
911
- inputKind: "pipeline_command",
912
- inputSchema: err.request.responseSchema,
913
- inputSubject: err.request.subject,
914
- pipelineInput: err.pipelineInput,
915
- createdAt: new Date().toISOString(),
916
- });
917
- if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) {
918
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
1160
+ let stateKey;
1161
+ try {
1162
+ stateKey = await saveWorkflowResumeState(ctx.env, {
1163
+ filePath: resolvedFilePath,
1164
+ resumeAtIndex: idx,
1165
+ steps: results,
1166
+ args: resolvedArgs,
1167
+ inputStepId: err.stepId,
1168
+ inputKind: "pipeline_command",
1169
+ inputSchema: err.request.responseSchema,
1170
+ inputSubject: err.request.subject,
1171
+ pipelineInput: err.pipelineInput,
1172
+ ...(costTracker.hasUsage() ? { cost: costTracker.getSummary() } : null),
1173
+ ...(llmSpendLedger.outstanding().length
1174
+ ? { llmSpend: llmSpendLedger.outstanding() }
1175
+ : null),
1176
+ ...(llmSpendLedger.settled().length
1177
+ ? { llmBilled: llmSpendLedger.settled() }
1178
+ : null),
1179
+ supersededResumeStateKeys: collectSupersededWorkflowResumeStateKeys(consumedResumeStateKey, resumeState),
1180
+ createdAt: new Date().toISOString(),
1181
+ }, ctx.signal);
1182
+ const replaced = await replaceWorkflowResumeState({
1183
+ env: ctx.env,
1184
+ previousStateKey: consumedResumeStateKey,
1185
+ expectedPreviousState: resumeState,
1186
+ previousStateConsumed: resumeStateConsumed,
1187
+ replacementStateKey: stateKey,
1188
+ signal: ctx.signal,
1189
+ });
1190
+ if (!replaced) {
1191
+ throw new Error("Workflow resume state not found");
1192
+ }
1193
+ const resumeToken = encodeToken({
1194
+ protocolVersion: 1,
1195
+ v: 1,
1196
+ kind: "workflow-file",
1197
+ stateKey,
1198
+ });
1199
+ return {
1200
+ status: "needs_input",
1201
+ output: [],
1202
+ requiresInput: {
1203
+ ...inputRequest,
1204
+ resumeToken,
1205
+ },
1206
+ };
1207
+ }
1208
+ catch (stateError) {
1209
+ if (stateKey)
1210
+ await discardWorkflowResumeState(ctx.env, stateKey);
1211
+ throw stateError;
919
1212
  }
920
- const resumeToken = encodeToken({
921
- protocolVersion: 1,
922
- v: 1,
923
- kind: "workflow-file",
924
- stateKey,
925
- });
926
- return {
927
- status: "needs_input",
928
- output: [],
929
- requiresInput: {
930
- ...inputRequest,
931
- resumeToken,
932
- },
933
- };
934
1213
  }
935
1214
  if (err instanceof RequestInputResumeError) {
936
1215
  throw err;
937
1216
  }
938
- if (ctx.signal?.aborted && (err?.name === "AbortError" || err?.code === "ABORT_ERR")) {
1217
+ if (err instanceof CostLimitExceededError) {
1218
+ throw err;
1219
+ }
1220
+ if (ctx.signal?.aborted) {
939
1221
  throw err;
940
1222
  }
941
1223
  if (err?.message &&
@@ -956,20 +1238,36 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
956
1238
  error: true,
957
1239
  errorMessage,
958
1240
  };
1241
+ // The step failed after paying a provider: a call it made before failing is spend
1242
+ // this run really made, and its items are gone with the failure. Settling here is
1243
+ // what stops a budget already blown from letting the next step run its side
1244
+ // effects, which is the whole point of `action: stop`.
1245
+ settleUnbilledCharges(costTracker, llmSpendLedger);
1246
+ if (workflow.cost_limit) {
1247
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
1248
+ }
959
1249
  if (policy === "skip_rest") {
960
1250
  break;
961
1251
  }
962
1252
  continue;
963
1253
  }
1254
+ // One accounting group per step: its parallel branches and its own result settle
1255
+ // together, so no branch can be billed for a charge another branch is about to claim.
1256
+ const deferredCharges = [];
964
1257
  if (parallelBranchResults) {
965
1258
  for (const [branchId, branchResult] of Object.entries(parallelBranchResults)) {
966
1259
  results[branchId] = branchResult;
967
- trackStepCost(costTracker, branchId, branchResult);
1260
+ trackStepCost(costTracker, branchId, branchResult, llmSpendLedger, deferredCharges);
968
1261
  }
969
1262
  }
970
1263
  results[step.id] = result;
971
1264
  lastStepId = step.id;
972
- trackStepCost(costTracker, step.id, result);
1265
+ trackStepCost(costTracker, step.id, result, llmSpendLedger, deferredCharges);
1266
+ settleDeferredCosts(costTracker, deferredCharges, llmSpendLedger);
1267
+ // Before the next step runs: a charge no item carried is spend the step really made,
1268
+ // and leaving it for the end would let `cost_limit` wave through every step after the
1269
+ // one that blew the budget.
1270
+ settleUnbilledCharges(costTracker, llmSpendLedger);
973
1271
  if (workflow.cost_limit) {
974
1272
  costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
975
1273
  }
@@ -980,45 +1278,64 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
980
1278
  results[step.id].initiatedBy = approvalIdentity.initiatedBy;
981
1279
  }
982
1280
  if (ctx.mode === "tool" || !isInteractive(ctx.stdin)) {
983
- const stateKey = await saveWorkflowResumeState(ctx.env, {
984
- filePath: resolvedFilePath,
985
- resumeAtIndex: idx + 1,
986
- steps: results,
987
- args: resolvedArgs,
988
- approvalStepId: step.id,
989
- approvalIdentity,
990
- createdAt: new Date().toISOString(),
991
- });
992
- let approvalId;
1281
+ let stateKey;
993
1282
  try {
994
- approvalId = await createApprovalIndex({ env: ctx.env, stateKey });
1283
+ stateKey = await saveWorkflowResumeState(ctx.env, {
1284
+ filePath: resolvedFilePath,
1285
+ resumeAtIndex: idx + 1,
1286
+ steps: results,
1287
+ args: resolvedArgs,
1288
+ approvalStepId: step.id,
1289
+ approvalIdentity,
1290
+ ...(costTracker.hasUsage() ? { cost: costTracker.getSummary() } : null),
1291
+ ...(llmSpendLedger.outstanding().length
1292
+ ? { llmSpend: llmSpendLedger.outstanding() }
1293
+ : null),
1294
+ ...(llmSpendLedger.settled().length
1295
+ ? { llmBilled: llmSpendLedger.settled() }
1296
+ : null),
1297
+ supersededResumeStateKeys: collectSupersededWorkflowResumeStateKeys(consumedResumeStateKey, resumeState),
1298
+ createdAt: new Date().toISOString(),
1299
+ }, ctx.signal);
1300
+ const approvalId = await createApprovalIndex({ env: ctx.env, stateKey });
1301
+ ctx.signal?.throwIfAborted();
1302
+ const replaced = await replaceWorkflowResumeState({
1303
+ env: ctx.env,
1304
+ previousStateKey: consumedResumeStateKey,
1305
+ expectedPreviousState: resumeState,
1306
+ previousStateConsumed: resumeStateConsumed,
1307
+ replacementStateKey: stateKey,
1308
+ signal: ctx.signal,
1309
+ });
1310
+ if (!replaced) {
1311
+ throw new Error("Workflow resume state not found");
1312
+ }
1313
+ const resumeToken = encodeToken({
1314
+ protocolVersion: 1,
1315
+ v: 1,
1316
+ kind: "workflow-file",
1317
+ stateKey,
1318
+ });
1319
+ return {
1320
+ status: "needs_approval",
1321
+ output: [],
1322
+ requiresApproval: {
1323
+ ...approval,
1324
+ resumeToken,
1325
+ ...(approvalId ? { approvalId } : null),
1326
+ },
1327
+ };
995
1328
  }
996
1329
  catch (err) {
997
- await deleteStateJson({ env: ctx.env, key: stateKey }).catch(() => { });
1330
+ if (stateKey)
1331
+ await discardWorkflowResumeState(ctx.env, stateKey);
998
1332
  throw err;
999
1333
  }
1000
- if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) {
1001
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
1002
- }
1003
- const resumeToken = encodeToken({
1004
- protocolVersion: 1,
1005
- v: 1,
1006
- kind: "workflow-file",
1007
- stateKey,
1008
- });
1009
- return {
1010
- status: "needs_approval",
1011
- output: [],
1012
- requiresApproval: {
1013
- ...approval,
1014
- resumeToken,
1015
- ...(approvalId ? { approvalId } : null),
1016
- },
1017
- };
1018
1334
  }
1019
1335
  ctx.stdout.write(`${approval.prompt} [y/N] `);
1020
1336
  const answer = await readLineFromStream(ctx.stdin, {
1021
1337
  timeoutMs: parseApprovalTimeoutMs(ctx.env),
1338
+ signal: ctx.signal,
1022
1339
  });
1023
1340
  if (!/^y(es)?$/i.test(String(answer).trim())) {
1024
1341
  throw new Error("Not approved");
@@ -1034,17 +1351,81 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
1034
1351
  results[step.id].approvedBy = approvedBy;
1035
1352
  }
1036
1353
  }
1354
+ // Whatever a skipped or failed step left open, and anything from a run with no step left
1355
+ // to settle it against. A paused run does not reach this line -- its charges travel in
1356
+ // the resume state instead -- so this settles only calls with nowhere left to be billed.
1357
+ settleUnbilledCharges(costTracker, llmSpendLedger);
1358
+ if (workflow.cost_limit) {
1359
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
1360
+ }
1037
1361
  const output = lastStepId ? toOutputItems(results[lastStepId]) : [];
1038
1362
  if (consumedResumeStateKey) {
1039
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
1363
+ if (resumeStateConsumed) {
1364
+ ctx.signal?.throwIfAborted();
1365
+ await deleteStateJson({
1366
+ env: ctx.env,
1367
+ key: consumedResumeStateKey,
1368
+ signal: ctx.signal,
1369
+ });
1370
+ ctx.signal?.throwIfAborted();
1371
+ }
1372
+ else {
1373
+ const deleted = await deleteResumeStateWithRollback({
1374
+ env: ctx.env,
1375
+ key: consumedResumeStateKey,
1376
+ expectedState: resumeState,
1377
+ signal: ctx.signal,
1378
+ });
1379
+ if (!deleted) {
1380
+ throw new Error("Workflow resume state not found");
1381
+ }
1382
+ }
1383
+ await cleanupSupersededWorkflowResumeStates(ctx.env, resumeState?.supersededResumeStateKeys);
1384
+ }
1385
+ else {
1386
+ ctx.signal?.throwIfAborted();
1040
1387
  }
1041
1388
  const runResult = { status: "ok", output };
1389
+ // `output` is what the last step could serialize; a run composing this one also needs the
1390
+ // items themselves, because that is where replay provenance lives.
1391
+ const lastItems = lastStepId ? pipelineSourceItems(results[lastStepId]) : undefined;
1392
+ if (lastItems)
1393
+ attachPipelineSourceItems(runResult, lastItems);
1042
1394
  if (costTracker.hasUsage()) {
1043
1395
  runResult._meta = { cost: costTracker.getSummary() };
1044
1396
  }
1045
1397
  return runResult;
1046
1398
  }
1399
+ catch (err) {
1400
+ if (resumedExecutionStarted && consumedResumeStateKey) {
1401
+ try {
1402
+ if (ctx.signal?.aborted) {
1403
+ await deleteStateJsonWithBoundedResumeCleanup({
1404
+ env: ctx.env,
1405
+ key: consumedResumeStateKey,
1406
+ });
1407
+ }
1408
+ else {
1409
+ await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
1410
+ }
1411
+ }
1412
+ catch {
1413
+ // Leave the tombstone in place if cleanup fails: it is the durable
1414
+ // record that prevents the consumed token from replaying an effect.
1415
+ }
1416
+ // A consumed marker remains non-replayable even when its bounded cleanup
1417
+ // times out, so its short approval ID can be retired independently.
1418
+ await cleanupApprovalIndexByStateKey({
1419
+ env: ctx.env,
1420
+ stateKey: consumedResumeStateKey,
1421
+ }).catch(() => { });
1422
+ }
1423
+ throw err;
1424
+ }
1047
1425
  finally {
1426
+ for (const { signal, onAbort } of executionSignalListeners) {
1427
+ signal.removeEventListener("abort", onAbort);
1428
+ }
1048
1429
  ctx._activeWorkflows?.delete(canonicalFilePath);
1049
1430
  }
1050
1431
  }
@@ -1129,7 +1510,7 @@ function dryRunWorkflow({ steps, resolvedArgs, results, startIndex, ctx, }) {
1129
1510
  if (step.pause_ms)
1130
1511
  lines.push(` pause_ms: ${step.pause_ms}`);
1131
1512
  lines.push(` sub-steps: ${step.steps.length}`);
1132
- const loopScopedResults = { ...results };
1513
+ const loopScopedResults = createRecord(results);
1133
1514
  loopScopedResults[dryItemVar] = { id: dryItemVar, json: { _placeholder: true } };
1134
1515
  loopScopedResults[dryIndexVar] = { id: dryIndexVar, json: 0 };
1135
1516
  for (let subIdx = 0; subIdx < step.steps.length; subIdx++) {
@@ -1172,6 +1553,16 @@ function dryRunWorkflow({ steps, resolvedArgs, results, startIndex, ctx, }) {
1172
1553
  }
1173
1554
  loopScopedResults[sub.id] = { id: sub.id };
1174
1555
  }
1556
+ if (step.timeout_ms)
1557
+ lines.push(` timeout: ${step.timeout_ms}ms`);
1558
+ if (step.on_error && step.on_error !== "stop")
1559
+ lines.push(` on_error: ${step.on_error}`);
1560
+ if (step.retry && typeof step.retry === "object") {
1561
+ const rc = resolveRetryConfig(step.retry);
1562
+ if (rc.max > 1) {
1563
+ lines.push(` retry: up to ${rc.max} attempts, ${rc.backoff} backoff (base: ${rc.delay_ms}ms${rc.jitter ? ", jitter" : ""})`);
1564
+ }
1565
+ }
1175
1566
  results[step.id] = { id: step.id };
1176
1567
  continue;
1177
1568
  }
@@ -1319,12 +1710,87 @@ export function decodeWorkflowResumePayload(payload) {
1319
1710
  throw new Error("Invalid workflow token");
1320
1711
  return data;
1321
1712
  }
1322
- async function saveWorkflowResumeState(env, state) {
1713
+ async function saveWorkflowResumeState(env, state, signal) {
1323
1714
  const stateKey = `workflow_resume_${randomUUID()}`;
1324
- await writeStateJson({ env, key: stateKey, value: state });
1325
- return stateKey;
1715
+ try {
1716
+ signal?.throwIfAborted();
1717
+ await writeStateJson({ env, key: stateKey, value: state, signal });
1718
+ signal?.throwIfAborted();
1719
+ return stateKey;
1720
+ }
1721
+ catch (err) {
1722
+ if (signal?.aborted)
1723
+ await discardWorkflowResumeState(env, stateKey);
1724
+ throw err;
1725
+ }
1726
+ }
1727
+ async function replaceWorkflowResumeState({ env, previousStateKey, expectedPreviousState, previousStateConsumed, replacementStateKey, signal, }) {
1728
+ if (!previousStateKey || previousStateKey === replacementStateKey) {
1729
+ signal?.throwIfAborted();
1730
+ return true;
1731
+ }
1732
+ if (previousStateConsumed) {
1733
+ signal?.throwIfAborted();
1734
+ // The successor is already durable. Retire the predecessor's short ID here,
1735
+ // rather than when the preceding unsafe execution is only being claimed.
1736
+ await cleanupApprovalIndexByStateKey({ env, stateKey: previousStateKey }).catch(() => { });
1737
+ return true;
1738
+ }
1739
+ if (!expectedPreviousState)
1740
+ return false;
1741
+ let claimId;
1742
+ try {
1743
+ const consumption = await consumeResumeState({
1744
+ env,
1745
+ key: previousStateKey,
1746
+ expectedState: expectedPreviousState,
1747
+ signal,
1748
+ });
1749
+ if (!consumption.consumed)
1750
+ return false;
1751
+ claimId = consumption.claimId;
1752
+ // Keep the consumed marker until terminal cleanup. It is the atomic
1753
+ // predecessor-to-successor handoff and prevents another resume from
1754
+ // creating a competing successor after this call returns.
1755
+ signal?.throwIfAborted();
1756
+ await cleanupApprovalIndexByStateKey({ env, stateKey: previousStateKey }).catch(() => { });
1757
+ return true;
1758
+ }
1759
+ catch (err) {
1760
+ if (claimId && signal?.aborted) {
1761
+ await restoreConsumedResumeState({
1762
+ env,
1763
+ key: previousStateKey,
1764
+ expectedState: expectedPreviousState,
1765
+ claimId,
1766
+ }).catch(() => { });
1767
+ }
1768
+ throw err;
1769
+ }
1770
+ }
1771
+ function collectSupersededWorkflowResumeStateKeys(previousStateKey, previousState) {
1772
+ return [
1773
+ ...(previousState?.supersededResumeStateKeys ?? []),
1774
+ ...(previousStateKey ? [previousStateKey] : []),
1775
+ ].filter((stateKey, index, all) => stateKey && all.indexOf(stateKey) === index);
1776
+ }
1777
+ async function cleanupSupersededWorkflowResumeStates(env, stateKeys) {
1778
+ for (const stateKey of stateKeys ?? []) {
1779
+ try {
1780
+ if (isConsumedResumeState(await readStateJsonWithLock({ env, key: stateKey }))) {
1781
+ await deleteStateJson({ env, key: stateKey });
1782
+ }
1783
+ }
1784
+ catch {
1785
+ // The completed successor makes retained ancestor markers safe.
1786
+ }
1787
+ }
1788
+ }
1789
+ async function discardWorkflowResumeState(env, stateKey) {
1790
+ await cleanupApprovalIndexByStateKey({ env, stateKey }).catch(() => { });
1791
+ await deleteStateJson({ env, key: stateKey }).catch(() => { });
1326
1792
  }
1327
- function alternateWorkflowResumeStateKey(stateKey) {
1793
+ export function alternateWorkflowResumeStateKey(stateKey) {
1328
1794
  if (stateKey.includes("workflow-resume_")) {
1329
1795
  return stateKey.replace("workflow-resume_", "workflow_resume_");
1330
1796
  }
@@ -1333,8 +1799,8 @@ function alternateWorkflowResumeStateKey(stateKey) {
1333
1799
  }
1334
1800
  return null;
1335
1801
  }
1336
- async function resolveWorkflowResumeStateKey(env, stateKey) {
1337
- const stored = await readStateJson({ env, key: stateKey });
1802
+ async function resolveWorkflowResumeStateKey(env, stateKey, signal) {
1803
+ const stored = await readStateJsonWithLock({ env, key: stateKey, signal });
1338
1804
  if (stored && typeof stored === "object") {
1339
1805
  return stateKey;
1340
1806
  }
@@ -1342,18 +1808,24 @@ async function resolveWorkflowResumeStateKey(env, stateKey) {
1342
1808
  if (!altKey) {
1343
1809
  return stateKey;
1344
1810
  }
1345
- const altStored = await readStateJson({ env, key: altKey });
1811
+ const altStored = await readStateJsonWithLock({ env, key: altKey, signal });
1346
1812
  if (altStored && typeof altStored === "object") {
1347
1813
  return altKey;
1348
1814
  }
1349
1815
  return stateKey;
1350
1816
  }
1351
- async function loadWorkflowResumeState(env, stateKey) {
1352
- let stored = await readStateJson({ env, key: stateKey });
1817
+ async function loadWorkflowResumeState(env, stateKey, signal) {
1818
+ let stored = await readStateJsonWithLock({ env, key: stateKey, signal });
1819
+ if (isConsumedResumeState(stored)) {
1820
+ throw new Error("Workflow resume state not found");
1821
+ }
1353
1822
  if ((!stored || typeof stored !== "object") && typeof stateKey === "string") {
1354
1823
  const altKey = alternateWorkflowResumeStateKey(stateKey);
1355
1824
  if (altKey) {
1356
- stored = await readStateJson({ env, key: altKey });
1825
+ stored = await readStateJsonWithLock({ env, key: altKey, signal });
1826
+ if (isConsumedResumeState(stored)) {
1827
+ throw new Error("Workflow resume state not found");
1828
+ }
1357
1829
  }
1358
1830
  }
1359
1831
  if (!stored || typeof stored !== "object") {
@@ -1368,6 +1840,11 @@ async function loadWorkflowResumeState(env, stateKey) {
1368
1840
  throw new Error("Invalid workflow resume state");
1369
1841
  if (!data.args || typeof data.args !== "object")
1370
1842
  throw new Error("Invalid workflow resume state");
1843
+ if (data.supersededResumeStateKeys !== undefined &&
1844
+ (!Array.isArray(data.supersededResumeStateKeys) ||
1845
+ data.supersededResumeStateKeys.some((stateKey) => typeof stateKey !== "string"))) {
1846
+ throw new Error("Invalid workflow resume state");
1847
+ }
1371
1848
  if (data.inputKind !== undefined &&
1372
1849
  !["workflow_step", "pipeline_command"].includes(data.inputKind)) {
1373
1850
  throw new Error("Invalid workflow resume state");
@@ -1418,7 +1895,7 @@ function validateWorkflowPipelineShape(pipeline) {
1418
1895
  }
1419
1896
  }
1420
1897
  function mergeEnv(base, workflowEnv, stepEnv, args, results) {
1421
- const env = { ...base };
1898
+ const env = createRecord(base);
1422
1899
  // Expose resolved args as env vars so shell commands can safely reference them
1423
1900
  // without embedding raw values into the command string.
1424
1901
  // Example: $LOBSTER_ARG_TEXT
@@ -1449,7 +1926,14 @@ function normalizeArgEnvKey(key) {
1449
1926
  return null;
1450
1927
  // Keep it predictable for shells: uppercase and [A-Z0-9_]
1451
1928
  const up = trimmed.toUpperCase();
1452
- const normalized = up.replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
1929
+ let normalized = up.replace(/[^A-Z0-9]+/g, "_");
1930
+ let start = 0;
1931
+ while (normalized[start] === "_")
1932
+ start++;
1933
+ let end = normalized.length;
1934
+ while (end > start && normalized[end - 1] === "_")
1935
+ end--;
1936
+ normalized = normalized.slice(start, end);
1453
1937
  return normalized || null;
1454
1938
  }
1455
1939
  function resolveCwd(cwd, args) {
@@ -1478,8 +1962,8 @@ function resolveTemplate(input, args, results) {
1478
1962
  }
1479
1963
  function resolveWorkflowStepArgs(workflowArgs, parentArgs, results) {
1480
1964
  if (!workflowArgs)
1481
- return {};
1482
- const resolved = {};
1965
+ return createRecord();
1966
+ const resolved = createRecord();
1483
1967
  for (const [key, value] of Object.entries(workflowArgs)) {
1484
1968
  if (typeof value === "string") {
1485
1969
  resolved[key] = resolveTemplate(value, parentArgs, results);
@@ -1492,7 +1976,7 @@ function resolveWorkflowStepArgs(workflowArgs, parentArgs, results) {
1492
1976
  }
1493
1977
  function resolveArgsTemplate(input, args) {
1494
1978
  return input.replace(/\$\{([A-Za-z0-9_-]+)\}/g, (match, key) => {
1495
- if (key in args)
1979
+ if (Object.hasOwn(args, key))
1496
1980
  return String(args[key]);
1497
1981
  return match;
1498
1982
  });
@@ -1671,21 +2155,222 @@ function parseBoolLike(value) {
1671
2155
  return false;
1672
2156
  return undefined;
1673
2157
  }
1674
- function trackStepCost(costTracker, stepId, result) {
1675
- const json = result.json;
2158
+ // The objects a step's pipeline produced, held apart from the serializable step result.
2159
+ // A rendered pipeline such as `llm.invoke | json` prints its items and returns an empty
2160
+ // stream, so the step's `json` is parsed back out of stdout and only carries what JSON can
2161
+ // express. A symbol key survives neither `JSON.stringify` nor `JSON.parse`, so this holds
2162
+ // exactly the items this process made, and no workflow input can put one here.
2163
+ const PIPELINE_SOURCE_ITEMS = Symbol("lobster.workflow.pipelineSourceItems");
2164
+ function attachPipelineSourceItems(target, items) {
2165
+ Object.defineProperty(target, PIPELINE_SOURCE_ITEMS, {
2166
+ value: items,
2167
+ enumerable: false,
2168
+ configurable: true,
2169
+ });
2170
+ return target;
2171
+ }
2172
+ function pipelineSourceItems(target) {
2173
+ if (!target)
2174
+ return undefined;
2175
+ const items = target[PIPELINE_SOURCE_ITEMS];
2176
+ return Array.isArray(items) ? items : undefined;
2177
+ }
2178
+ // The ledger of the run that invoked this one as a `workflow:` step. A composed run bills its
2179
+ // own steps and the composing run bills the output handed back to it, so a live call has to be
2180
+ // open in both ledgers for each of them to count it once. Read defensively: this arrives
2181
+ // through the same context object a caller supplies.
2182
+ function composingSpendLedger(ctx) {
2183
+ const ledger = ctx._llmSpendLedger;
2184
+ if (!ledger || typeof ledger !== "object")
2185
+ return null;
2186
+ return typeof ledger.record === "function" && typeof ledger.claim === "function" ? ledger : null;
2187
+ }
2188
+ /**
2189
+ * One `wait: "any"` branch's view of the run ledger. The race returns as soon as a branch
2190
+ * answers and leaves the others running, so a losing branch can pay a provider long after the
2191
+ * step settled. Writing that charge straight to the run ledger makes the workflow's own total
2192
+ * depend on how long the run happened to live afterwards: land before the run ends and the
2193
+ * discarded branch's spend is billed, land after and it is billed nowhere.
2194
+ *
2195
+ * So a branch opens its charges here instead, and only the winner releases them into the run.
2196
+ * The output of a losing branch is thrown away; its accounting goes with it. Reads pass
2197
+ * through, because they settle charges the run opened before the race and are made by the
2198
+ * workflow after a branch has returned, never from inside one.
2199
+ */
2200
+ function branchSpendBuffer(ledger) {
2201
+ const held = [];
2202
+ let released = false;
2203
+ return {
2204
+ record: (cacheKey, charge) => {
2205
+ if (released)
2206
+ ledger.record(cacheKey, charge);
2207
+ else
2208
+ held.push({ cacheKey, charge });
2209
+ },
2210
+ claim: (cacheKey, cost) => ledger.claim(cacheKey, cost),
2211
+ billCopy: (cacheKey, model, usage) => ledger.billCopy(cacheKey, model, usage),
2212
+ outstanding: () => ledger.outstanding(),
2213
+ restore: (charges) => ledger.restore(charges),
2214
+ settled: () => ledger.settled(),
2215
+ restoreSettled: (charges) => ledger.restoreSettled(charges),
2216
+ release: () => {
2217
+ if (released)
2218
+ return;
2219
+ released = true;
2220
+ for (const { cacheKey, charge } of held)
2221
+ ledger.record(cacheKey, charge);
2222
+ held.length = 0;
2223
+ },
2224
+ };
2225
+ }
2226
+ /**
2227
+ * The run ledger seen by one step, so a charge opened inside it knows where it came from. A
2228
+ * composed run's charges reach the composing run through this too, which is why the step id is
2229
+ * overwritten rather than kept: in the parent's totals the spend belongs to the `workflow:`
2230
+ * step, not to whatever the child called its own.
2231
+ */
2232
+ function stepScopedLedger(ledger, stepId) {
2233
+ return {
2234
+ record: (cacheKey, charge) => ledger.record(cacheKey, { ...charge, stepId }),
2235
+ claim: (cacheKey, cost) => ledger.claim(cacheKey, cost),
2236
+ billCopy: (cacheKey, model, usage) => ledger.billCopy(cacheKey, model, usage),
2237
+ outstanding: () => ledger.outstanding(),
2238
+ restore: (charges) => ledger.restore(charges),
2239
+ settled: () => ledger.settled(),
2240
+ restoreSettled: (charges) => ledger.restoreSettled(charges),
2241
+ };
2242
+ }
2243
+ /**
2244
+ * Bills the calls that ended the run unclaimed. An item is the usual carrier of usage, but a
2245
+ * renderer can consume the pipeline before a later stage emits items of its own, an `ask` gate
2246
+ * can swallow the only item, and a composed run can end on a step that carries none. The
2247
+ * charge outlives all three, and dropping it would leave real spend out of `_meta.cost` and
2248
+ * out of `cost_limit`.
2249
+ */
2250
+ function settleUnbilledCharges(costTracker, llmSpendLedger) {
2251
+ for (const charge of llmSpendLedger.outstanding()) {
2252
+ if (!charge.usage)
2253
+ continue;
2254
+ if (!llmSpendLedger.claim(charge.cacheKey))
2255
+ continue;
2256
+ costTracker.recordUsage(charge.stepId ?? "llm", charge.model ?? null, charge.usage);
2257
+ }
2258
+ }
2259
+ function trackStepCost(costTracker, stepId, result, llmSpendLedger, deferredCharges) {
2260
+ // Bill the items the pipeline produced rather than the JSON re-read from its stdout: a
2261
+ // renderer serializes them, and replay provenance does not survive that round trip. Steps
2262
+ // without a pipeline (shell commands) have no such items and are read from `json` as before.
2263
+ const json = pipelineSourceItems(result) ?? result.json;
1676
2264
  if (!json || typeof json !== "object")
1677
2265
  return;
2266
+ // Replays wait for the live results accounted with them. Branches of one parallel step are
2267
+ // billed in declaration order, so a cached replay declared before an identical `--refresh`
2268
+ // branch would otherwise claim the charge that branch had just opened — and the live branch,
2269
+ // billed whatever its own claim returns, would be billed for it too. One provider call, two
2270
+ // charges, and a `cost_limit` tripped on spend that never happened.
2271
+ const deferred = deferredCharges ?? [];
1678
2272
  const items = Array.isArray(json) ? json : [json];
1679
2273
  for (const item of items) {
1680
2274
  if (!item || typeof item !== "object")
1681
2275
  continue;
1682
- const usage = item.usage;
2276
+ const record = item;
2277
+ const usage = record.usage;
1683
2278
  if (!usage || typeof usage !== "object")
1684
2279
  continue;
1685
- const modelValue = item.model;
2280
+ const modelValue = record.model;
1686
2281
  const model = typeof modelValue === "string" ? modelValue : null;
2282
+ // Provenance the LLM commands attach in-process, never a field in the JSON: a step that
2283
+ // prints a replay-shaped object stays billed, so `cost_limit` cannot be evaded. A
2284
+ // projection can drop the item's own mark while carrying its usage record across, so the
2285
+ // record is read too — both marks are symbol keys built in this process.
2286
+ const provenance = llmProvenanceOf(record) ?? llmProvenanceOf(usage);
2287
+ if (provenance) {
2288
+ if (provenance.replayed) {
2289
+ deferred.push({
2290
+ kind: "replay",
2291
+ stepId,
2292
+ cacheKey: provenance.cacheKey,
2293
+ model,
2294
+ usage: usage,
2295
+ });
2296
+ continue;
2297
+ }
2298
+ // Settles the charge this live call opened -- the one that cost what this item cost,
2299
+ // not merely the first under the key. A step retried after an attempt that failed
2300
+ // after paying leaves more than one charge open, and they did not cost the same.
2301
+ const settled = llmSpendLedger.claim(provenance.cacheKey, {
2302
+ model,
2303
+ usage: usage,
2304
+ });
2305
+ // Bill what the provider was really asked for, not what the item says by the time it
2306
+ // gets here. A pipeline can drop the model on the way -- `| pick usage` hands on the
2307
+ // marked usage record without it -- or rewrite it, and the step is then priced at the
2308
+ // wrong rate or at nothing at all while `cost_limit` reads the difference as room left.
2309
+ // The charge the call opened is the same witness a replay is already billed from. A
2310
+ // charge restored from resume state written before it carried a cost falls back.
2311
+ if (settled?.usage) {
2312
+ costTracker.recordUsage(stepId, settled.model ?? null, settled.usage);
2313
+ continue;
2314
+ }
2315
+ }
2316
+ else {
2317
+ // No mark, but numbers: a stage that JSON round-trips the stream hands on a copy that
2318
+ // kept `model` and `usage` and lost everything this process attached -- sometimes the
2319
+ // cache key with it. Held back like a replay, and for the same reason: a copy that
2320
+ // lost its key is read by cost alone, and until the marked results have settled their
2321
+ // own charges an identical parallel call's charge is still open to be mistaken for
2322
+ // the one this copy came from.
2323
+ deferred.push({
2324
+ kind: "copy",
2325
+ stepId,
2326
+ cacheKey: typeof record.cacheKey === "string" && record.cacheKey ? record.cacheKey : null,
2327
+ model,
2328
+ usage: usage,
2329
+ });
2330
+ continue;
2331
+ }
1687
2332
  costTracker.recordUsage(stepId, model, usage);
1688
2333
  }
2334
+ if (!deferredCharges)
2335
+ settleDeferredCosts(costTracker, deferred, llmSpendLedger);
2336
+ }
2337
+ /**
2338
+ * Bills what a step held back, now that every marked live result accounted alongside them has
2339
+ * settled its own charge. Replays go first: one carries the mark of the call it stands in for,
2340
+ * which is better evidence than the cost a copy carries on its own.
2341
+ *
2342
+ * A replay carries the usage of the call that produced it, so counting it again charges a run
2343
+ * for tokens no provider was asked to spend. It is billed only when the call it replays is
2344
+ * still unpaid for: a step is costed once it succeeds, so a step that failed after its LLM
2345
+ * call and was retried leaves its real spend riding on the replay the retry produced.
2346
+ *
2347
+ * A copy is billed either way — it is a record of a call, and nothing in a JSON payload can be
2348
+ * trusted to say otherwise — but the ledger still decides whether it settles a charge, so the
2349
+ * step settlement does not add a second record of the same call.
2350
+ */
2351
+ function settleDeferredCosts(costTracker, deferred, llmSpendLedger) {
2352
+ for (const replay of deferred) {
2353
+ if (replay.kind !== "replay" || replay.cacheKey === null)
2354
+ continue;
2355
+ const settled = llmSpendLedger.claim(replay.cacheKey);
2356
+ if (!settled)
2357
+ continue;
2358
+ // Bill what the call this settles actually cost, not what the replay carries. Identical
2359
+ // calls that raced on a cold cache are separate charges under one key, and every replay
2360
+ // of them repeats whichever single answer was stored -- so costing each replay by its
2361
+ // own copy reports all of those calls at the price of the one that won the cache write.
2362
+ // A charge restored from resume state written before it carried a cost falls back.
2363
+ const cost = typeof settled === "object" && settled.usage ? settled : null;
2364
+ costTracker.recordUsage(replay.stepId, cost ? (cost.model ?? null) : replay.model, cost?.usage ?? replay.usage);
2365
+ }
2366
+ for (const copy of deferred) {
2367
+ if (copy.kind !== "copy")
2368
+ continue;
2369
+ if (llmSpendLedger.billCopy(copy.cacheKey, copy.model, copy.usage)) {
2370
+ costTracker.recordUsage(copy.stepId, copy.model, copy.usage);
2371
+ }
2372
+ }
2373
+ deferred.length = 0;
1689
2374
  }
1690
2375
  function parseJson(stdout) {
1691
2376
  const trimmed = stdout.trim();
@@ -1713,7 +2398,7 @@ function toOutputItems(result) {
1713
2398
  return [];
1714
2399
  }
1715
2400
  function cloneResults(results) {
1716
- const out = {};
2401
+ const out = createRecord();
1717
2402
  for (const [key, value] of Object.entries(results)) {
1718
2403
  out[key] = { ...value };
1719
2404
  }
@@ -2141,39 +2826,22 @@ function parseQuotedConditionString(expression, startIndex, quoteChar) {
2141
2826
  }
2142
2827
  throw new Error(`Unsupported condition: ${expression}`);
2143
2828
  }
2144
- async function runShellCommand({ command, stdin, env, cwd, signal, killSignal, }) {
2145
- const { spawn } = await import("node:child_process");
2146
- return await new Promise((resolve, reject) => {
2147
- const shell = resolveInlineShellCommand({ command, env });
2148
- const child = spawn(shell.command, shell.argv, {
2149
- env,
2150
- cwd,
2151
- signal,
2152
- killSignal,
2153
- stdio: ["pipe", "pipe", "pipe"],
2154
- });
2155
- let stdout = "";
2156
- let stderr = "";
2157
- child.stdout.setEncoding("utf8");
2158
- child.stderr.setEncoding("utf8");
2159
- child.stdout.on("data", (d) => {
2160
- stdout += d;
2161
- });
2162
- child.stderr.on("data", (d) => {
2163
- stderr += d;
2164
- });
2165
- if (typeof stdin === "string") {
2166
- child.stdin.setDefaultEncoding("utf8");
2167
- child.stdin.write(stdin);
2168
- }
2169
- child.stdin.end();
2170
- child.on("error", reject);
2171
- child.on("close", (code) => {
2172
- if (code === 0)
2173
- return resolve({ stdout, stderr });
2174
- reject(new Error(`workflow command failed (${code}): ${stderr.trim() || stdout.trim() || command}`));
2175
- });
2829
+ async function runShellCommand({ command, stdin, env, cwd, signal, forceTerminationSignal, killSignal, }) {
2830
+ signal?.throwIfAborted();
2831
+ signal?.throwIfAborted();
2832
+ const { stdout, stderr, code } = await runAbortableProcess({
2833
+ shellCommand: command,
2834
+ env,
2835
+ cwd,
2836
+ stdin,
2837
+ signal,
2838
+ forceTerminationSignal,
2839
+ killSignal,
2840
+ notFoundMessage: "workflow shell not found; check LOBSTER_SHELL or ComSpec",
2176
2841
  });
2842
+ if (code === 0)
2843
+ return { stdout, stderr };
2844
+ throw new Error(`workflow command failed (${code}): ${stderr.trim() || stdout.trim() || command}`);
2177
2845
  }
2178
2846
  function getStepExecution(step) {
2179
2847
  if (step.parallel && typeof step.parallel === "object" && !Array.isArray(step.parallel)) {
@@ -2191,7 +2859,7 @@ function getStepExecution(step) {
2191
2859
  }
2192
2860
  return { kind: "none" };
2193
2861
  }
2194
- async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd, resume, requestInputEnabled = true, }) {
2862
+ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, llmSpendLedger, env, cwd, resume, requestInputEnabled = true, onExecutionStart, }) {
2195
2863
  let pipeline;
2196
2864
  try {
2197
2865
  const currentPipeline = parsePipeline(pipelineText);
@@ -2228,7 +2896,11 @@ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd
2228
2896
  mode: ctx.mode,
2229
2897
  cwd,
2230
2898
  signal: ctx.signal,
2899
+ forceTerminationSignal: ctx.forceTerminationSignal,
2900
+ haltAfterStageOnAbort: true,
2231
2901
  llmAdapters: ctx.llmAdapters,
2902
+ // Scoped so a charge nothing bills can still name the step that opened it.
2903
+ llmSpendLedger: llmSpendLedger ? stepScopedLedger(llmSpendLedger, stepId) : undefined,
2232
2904
  input: resume ? resume.pipelineInput.items : inputValueToPipelineItems(inputValue),
2233
2905
  requestInputEnabled,
2234
2906
  requestInputResume: resume
@@ -2238,8 +2910,11 @@ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd
2238
2910
  onConsumed: resume.onConsumed,
2239
2911
  }
2240
2912
  : undefined,
2913
+ onExecutionStart,
2914
+ onNonRetryableSideEffect: ctx._onNonRetryableSideEffect,
2241
2915
  });
2242
2916
  stdout.end();
2917
+ ctx.signal?.throwIfAborted();
2243
2918
  if (result.halted) {
2244
2919
  const haltedName = result.haltedAt?.stage?.name ?? "unknown";
2245
2920
  if (result.items.length === 1 && result.items[0]?.type === "approval_request") {
@@ -2273,11 +2948,16 @@ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd
2273
2948
  ? result.items[0]
2274
2949
  : result.items
2275
2950
  : parseJson(renderedStdout);
2276
- return {
2951
+ const stepResult = {
2277
2952
  id: stepId,
2278
2953
  stdout: normalizedStdout,
2279
2954
  json,
2280
2955
  };
2956
+ // Only when a renderer consumed the pipeline: otherwise `json` already is those items.
2957
+ if (!result.items.length && result.renderedItems.length) {
2958
+ attachPipelineSourceItems(stepResult, result.renderedItems);
2959
+ }
2960
+ return stepResult;
2281
2961
  }
2282
2962
  function createSyntheticStepResult(stepId, value) {
2283
2963
  if (value === null || value === undefined) {