@jr2/orchestrator 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/console/components/app.ts +55 -15
  2. package/console/components/bottom-drawer.ts +259 -0
  3. package/console/components/drawer.ts +14 -6
  4. package/console/components/fleet.ts +36 -13
  5. package/console/components/machine-pane.ts +61 -5
  6. package/console/components/nav.ts +54 -8
  7. package/console/conversation.ts +161 -0
  8. package/console/login.ts +123 -0
  9. package/console/main.ts +600 -60
  10. package/console/store.ts +611 -64
  11. package/console/style.css +231 -0
  12. package/docs/agents-block.md +2 -2
  13. package/docs/concepts/agents-and-turns.md +142 -29
  14. package/docs/concepts/architecture.md +60 -34
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +13 -8
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +7 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +133 -50
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +14 -4
  23. package/docs/concepts/pools-and-sources.md +193 -17
  24. package/docs/concepts/secrets-and-trust.md +186 -62
  25. package/docs/concepts/the-menu.md +14 -0
  26. package/docs/concepts/workspaces.md +135 -56
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +46 -35
  29. package/docs/get-started/connect-a-model.md +1 -1
  30. package/docs/get-started/create-an-instance.md +17 -14
  31. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  32. package/docs/get-started/install.md +4 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +72 -15
  34. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  35. package/docs/guides/answer-a-gate.md +74 -37
  36. package/docs/guides/bind-open-parts.md +29 -12
  37. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  38. package/docs/guides/cancel-a-run.md +26 -16
  39. package/docs/guides/cut-a-worktree.md +560 -0
  40. package/docs/guides/fix-a-failed-converge.md +19 -7
  41. package/docs/guides/give-a-caller-a-scope.md +203 -0
  42. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  43. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  44. package/docs/guides/let-an-agent-write-a-chart.md +14 -7
  45. package/docs/guides/look-inside-a-sandbox.md +115 -42
  46. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  47. package/docs/guides/open-the-console.md +63 -14
  48. package/docs/guides/push-and-sign-a-branch.md +67 -21
  49. package/docs/guides/read-a-conversation.md +225 -0
  50. package/docs/guides/run-a-chart-by-name.md +17 -16
  51. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  52. package/docs/guides/run-one-worker-per-item.md +34 -27
  53. package/docs/guides/set-an-agents-access-and-dials.md +69 -22
  54. package/docs/guides/size-a-workspace.md +19 -6
  55. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  56. package/docs/guides/start-a-run-from-a-run.md +18 -16
  57. package/docs/guides/start-a-run.md +24 -18
  58. package/docs/guides/steer-an-agent.md +332 -0
  59. package/docs/guides/use-a-hosted-provider.md +7 -1
  60. package/docs/guides/watch-a-run.md +146 -117
  61. package/docs/guides/write-a-hook-kind.md +238 -0
  62. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  63. package/docs/guides/write-a-workflow.md +18 -8
  64. package/docs/llms.txt +110 -23
  65. package/docs/reference/authoring-surface/agent.md +35 -12
  66. package/docs/reference/authoring-surface/answer.md +20 -4
  67. package/docs/reference/authoring-surface/arg.md +28 -6
  68. package/docs/reference/authoring-surface/basic.md +1 -1
  69. package/docs/reference/authoring-surface/bearer.md +1 -1
  70. package/docs/reference/authoring-surface/branchname.md +15 -0
  71. package/docs/reference/authoring-surface/carry.md +23 -0
  72. package/docs/reference/authoring-surface/chartschema.md +1 -1
  73. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  74. package/docs/reference/authoring-surface/command.md +37 -22
  75. package/docs/reference/authoring-surface/customize.md +4 -1
  76. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  77. package/docs/reference/authoring-surface/defineevent.md +1 -1
  78. package/docs/reference/authoring-surface/dispatch.md +5 -4
  79. package/docs/reference/authoring-surface/fail.md +18 -5
  80. package/docs/reference/authoring-surface/file.md +37 -17
  81. package/docs/reference/authoring-surface/frompick.md +1 -1
  82. package/docs/reference/authoring-surface/gate.md +118 -0
  83. package/docs/reference/authoring-surface/group-agents.md +15 -0
  84. package/docs/reference/authoring-surface/group-charts.md +16 -0
  85. package/docs/reference/authoring-surface/group-composition.md +25 -0
  86. package/docs/reference/authoring-surface/group-config.md +17 -0
  87. package/docs/reference/authoring-surface/group-effects.md +48 -0
  88. package/docs/reference/authoring-surface/group-failure.md +13 -0
  89. package/docs/reference/authoring-surface/group-gates.md +16 -0
  90. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  91. package/docs/reference/authoring-surface/group-steering.md +12 -0
  92. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  93. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  94. package/docs/reference/authoring-surface/http.md +109 -9
  95. package/docs/reference/authoring-surface/isfailure.md +1 -1
  96. package/docs/reference/authoring-surface/isready.md +14 -0
  97. package/docs/reference/authoring-surface/jr2setup.md +14 -4
  98. package/docs/reference/authoring-surface/mcp.md +107 -0
  99. package/docs/reference/authoring-surface/open.md +41 -8
  100. package/docs/reference/authoring-surface/openbody.md +1 -1
  101. package/docs/reference/authoring-surface/paramsof.md +23 -5
  102. package/docs/reference/authoring-surface/pool.md +79 -11
  103. package/docs/reference/authoring-surface/push.md +31 -13
  104. package/docs/reference/authoring-surface/readyof.md +21 -0
  105. package/docs/reference/authoring-surface/reread.md +19 -0
  106. package/docs/reference/authoring-surface/secret.md +26 -5
  107. package/docs/reference/authoring-surface/sentby.md +1 -1
  108. package/docs/reference/authoring-surface/skip.md +22 -0
  109. package/docs/reference/authoring-surface/source.md +59 -4
  110. package/docs/reference/authoring-surface/steer.md +93 -0
  111. package/docs/reference/authoring-surface/text.md +1 -1
  112. package/docs/reference/authoring-surface/type-agentdefinition.md +16 -3
  113. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  114. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  115. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  116. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  117. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  118. package/docs/reference/authoring-surface/type-binding.md +6 -7
  119. package/docs/reference/authoring-surface/type-caller.md +40 -0
  120. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  121. package/docs/reference/authoring-surface/type-carry.md +19 -0
  122. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  123. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  124. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  125. package/docs/reference/authoring-surface/type-commandoutput.md +23 -5
  126. package/docs/reference/authoring-surface/type-customize.md +20 -3
  127. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  128. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  129. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  130. package/docs/reference/authoring-surface/type-dispatchoutput.md +9 -2
  131. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  132. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  133. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  134. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  135. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  136. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  137. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  138. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  139. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  140. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  141. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  142. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  143. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  144. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  145. package/docs/reference/authoring-surface/type-hostinjectedinput.md +3 -5
  146. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  147. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  148. package/docs/reference/authoring-surface/type-httpoutput.md +8 -2
  149. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  150. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  151. package/docs/reference/authoring-surface/type-lends.md +5 -1
  152. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  153. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  154. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  155. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  156. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  157. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  158. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  159. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  160. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  161. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  162. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  163. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  164. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  165. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  166. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  167. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  168. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  169. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  170. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  171. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  172. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  173. package/docs/reference/authoring-surface/type-pushoutput.md +33 -8
  174. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  175. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  176. package/docs/reference/authoring-surface/type-reposlot.md +6 -6
  177. package/docs/reference/authoring-surface/type-reread.md +18 -0
  178. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  179. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  180. package/docs/reference/authoring-surface/type-sender.md +26 -1
  181. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  182. package/docs/reference/authoring-surface/type-size.md +1 -1
  183. package/docs/reference/authoring-surface/type-skip.md +19 -0
  184. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  185. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  186. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  187. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  188. package/docs/reference/authoring-surface/type-sourcespec.md +26 -17
  189. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  190. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  191. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  192. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  193. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  194. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  195. package/docs/reference/authoring-surface/type-workspaced.md +30 -9
  196. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  197. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  198. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  199. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  200. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  201. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  202. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  203. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  204. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  205. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  206. package/docs/reference/authoring-surface/workspace.md +66 -38
  207. package/docs/reference/authoring-surface/worktree.md +71 -0
  208. package/docs/reference/authoring-surface.md +88 -147
  209. package/docs/reference/cli.md +413 -74
  210. package/docs/reference/config.md +248 -6
  211. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  212. package/docs/reference/decisions/0002-duplex-streaming-actor.md +81 -14
  213. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  214. package/docs/reference/decisions/0005-sandbox-pod-composition.md +39 -25
  215. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  216. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  217. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  218. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  219. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  220. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +39 -32
  221. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +54 -30
  222. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  223. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +26 -16
  224. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  225. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -10
  226. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  227. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +53 -27
  228. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +16 -2
  229. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  230. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +124 -43
  231. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +36 -38
  232. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  233. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +16 -7
  234. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +36 -19
  235. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  236. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  237. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -15
  238. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  239. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +22 -22
  240. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  241. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +13 -12
  242. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +23 -23
  243. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  244. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  245. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  246. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  247. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  248. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  249. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +31 -24
  250. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +18 -10
  251. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +137 -86
  252. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +165 -91
  253. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +7 -7
  254. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  255. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  256. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +53 -23
  257. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +221 -153
  258. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +161 -96
  259. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  260. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +48 -33
  261. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  262. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  263. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  264. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +296 -0
  265. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +347 -0
  266. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  267. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  268. package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
  269. package/docs/reference/decisions.md +9 -4
  270. package/docs/reference/glossary.md +138 -45
  271. package/docs/reference/hooks.md +1077 -0
  272. package/docs/reference/http-api.md +988 -123
  273. package/docs/reference/machines.md +0 -1
  274. package/docs/why.md +7 -5
  275. package/package.json +10 -3
  276. package/src/actor.ts +194 -42
  277. package/src/agent.ts +56 -6
  278. package/src/ambient.ts +3 -5
  279. package/src/assembly-checks.ts +530 -4
  280. package/src/callers.ts +245 -0
  281. package/src/chart.ts +238 -26
  282. package/src/charts.ts +9 -2
  283. package/src/config.ts +566 -7
  284. package/src/conversations.ts +453 -0
  285. package/src/custodian.lua +17 -5
  286. package/src/custodian.ts +257 -77
  287. package/src/customize.ts +154 -7
  288. package/src/dispatch-effect.ts +142 -26
  289. package/src/durability.ts +2 -0
  290. package/src/duration.ts +20 -0
  291. package/src/effect-checks.ts +52 -4
  292. package/src/effect.ts +401 -132
  293. package/src/effector-wire.ts +278 -30
  294. package/src/fail.ts +17 -4
  295. package/src/gate-brand.ts +16 -0
  296. package/src/gate.ts +634 -48
  297. package/src/harness-client.ts +84 -35
  298. package/src/harness-env.ts +33 -0
  299. package/src/held-secrets.ts +197 -39
  300. package/src/holds.ts +5 -2
  301. package/src/hook-kinds/discord.ts +92 -0
  302. package/src/hook-kinds/door.ts +107 -0
  303. package/src/hook-kinds/github.ts +89 -0
  304. package/src/hook-kinds/gitlab.ts +72 -0
  305. package/src/hook-kinds/hook.ts +7 -0
  306. package/src/hook-kinds/index.ts +465 -0
  307. package/src/hook-kinds/slack.ts +151 -0
  308. package/src/hook-kinds/webhook.ts +113 -0
  309. package/src/hooks.ts +506 -0
  310. package/src/http-effect.ts +164 -70
  311. package/src/http.ts +933 -99
  312. package/src/index.ts +30 -5
  313. package/src/instance.ts +196 -26
  314. package/src/internal.ts +23 -3
  315. package/src/kube-client.ts +6 -3
  316. package/src/machine-doc.ts +29 -5
  317. package/src/mcp-effect.ts +424 -0
  318. package/src/model-keys.ts +24 -0
  319. package/src/mount-digest.ts +41 -7
  320. package/src/names.ts +82 -7
  321. package/src/notices.ts +576 -59
  322. package/src/open-body.ts +110 -30
  323. package/src/open-effect.ts +17 -4
  324. package/src/open.ts +40 -7
  325. package/src/parts.ts +125 -35
  326. package/src/party.ts +35 -1
  327. package/src/pick.ts +43 -9
  328. package/src/pool.ts +1172 -172
  329. package/src/registration.ts +249 -17
  330. package/src/repo-identity.ts +7 -3
  331. package/src/repos.ts +20 -2
  332. package/src/run-host.ts +1614 -190
  333. package/src/run-list.ts +191 -0
  334. package/src/sandbox-effect.ts +217 -66
  335. package/src/sandbox-kube.ts +116 -21
  336. package/src/secret-placement.ts +248 -35
  337. package/src/server.ts +53 -4
  338. package/src/setup.ts +158 -38
  339. package/src/snapshot-store.ts +565 -48
  340. package/src/steer.ts +111 -0
  341. package/src/stub-harness.ts +6 -2
  342. package/src/tokens.ts +362 -46
  343. package/src/vocabulary.ts +23 -4
  344. package/src/wire.ts +27 -10
  345. package/src/workspace.ts +314 -259
  346. package/src/worktree.ts +249 -0
  347. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  348. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
  349. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  350. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
package/src/pool.ts CHANGED
@@ -1,18 +1,34 @@
1
1
  // `pool(worker, spec)` (ADR-0017): the jr2-owned top-of-the-run Machine — one worker per Source
2
2
  // item, at most `cap` at once. It absorbs what every jr-shaped workflow used to hand-roll:
3
3
  // spawn-under-cap, stable child identity, `xstate.done.actor.*` completion collection,
4
- // `stopChild` bookkeeping, the wake gate, and the re-query timer. The one top-level `spawnChild`
5
- // lives HERE, once — which is what keeps the Console's static trace guaranteed by
6
- // construction (the comment-enforced "keep spawnChild top-level" footgun deletes), and the
7
- // worker is a registered string src, which is what makes spawned children persistable at all
8
- // (xstate cannot persist inline-src children).
4
+ // `stopChild` bookkeeping, the Source's Gate, and the re-query timer. The `spawnChild`s live HERE,
5
+ // top-level — which is what keeps the Console's static trace guaranteed by construction (the
6
+ // comment-enforced "keep spawnChild top-level" footgun deletes), and the worker is a registered
7
+ // string src, which is what makes spawned children persistable at all (xstate cannot persist
8
+ // inline-src children).
9
9
  //
10
- // The Source port is generalized — `next(active) → item | null`, plus an optional `wake` event
11
- // def (a webhook / `jr2 send` push seam, served as a standing gate named "source") and
12
- // `pollEvery` (the set mutates underneath us — re-query). A queue, a generator, or a re-queried
13
- // set; tk's claim actor is one adapter (GLOSSARY.md: Work Source is the Source adapter for tickets).
10
+ // ONE CLAIM LOOP, TWO POLICIES. The worker is a child Machine, which runs inside the Pool's run, or
11
+ // a `dispatch(...)` slot (ADR-0071), which makes each item a run of its own, keyed by its item id
12
+ // in the callee Workflow. The loop is the same; the policy says what a settled worker means. A child
13
+ // keeps its output or its Failure, and the Pool ends when it drains. A run's end is one item's end,
14
+ // never the Pool's: a Pool of runs stands (`standing: true`) and never ends by itself.
14
15
  //
15
- // Terminal triage is three-valued (ADR-0017, forced by parking-is-retention):
16
+ // THREE SOURCE FORMS, THREE CLAIM PATHS:
17
+ // - PULL: `next({ active, settled, input }) → item | null`, one item a call, as before.
18
+ // - READ: `read` reads the whole set (an `http` Effect marked idempotent, or a promise), and
19
+ // `items` turns its answer into items. One read claims every free slot (the reads merge), after
20
+ // the Pool drops each known id — and, for a Pool of runs, each key that a run of the callee
21
+ // holds (the store's key check). A `reread` or a settled worker during a read sets the LATCH,
22
+ // and the loop reads once more after that read, so no change is lost.
23
+ // - PUSH: `item`, a zod schema, and `hooks` that `carry(item)`. The Pool HOLDS each carried item
24
+ // in its context (so in the run's blob), in arrival order, and claims it when a slot is free.
25
+ // The key check comes before the hold, and `hold` bounds the held items.
26
+ //
27
+ // THE PUSH HALF of every form is the Pool's `source` Gate (ADR-0011): a Hook's fan-in reaches it as
28
+ // it reaches any open Gate, and its seat on the registration runs the Source's handler and answers
29
+ // the verdict (`held`, `existing`, `hold-full`, `reread`, `skipped`).
30
+ //
31
+ // Terminal triage of a Pool of children is three-valued (ADR-0017, forced by parking-is-retention):
16
32
  // - drained — source empty and all children settled → final (jr exit 0);
17
33
  // - deadlocked — open-but-never-ready items and nothing running that could unblock them →
18
34
  // final, distinct status (jr exit 2), never an indistinguishable idle poll;
@@ -26,20 +42,110 @@
26
42
 
27
43
  import {
28
44
  assign,
45
+ fromPromise,
29
46
  setup,
30
47
  spawnChild,
31
48
  stopChild,
49
+ type AnyActorLogic,
32
50
  type AnyStateMachine,
33
51
  type PromiseActorLogic,
34
52
  type StateMachine,
35
53
  } from "xstate";
36
54
  import type { z } from "zod";
37
55
  import type { EventDef } from "@jr2/agent-protocol";
56
+ import type { KindHooks } from "./hook-kinds/index.ts";
57
+ import type { CallerRecord } from "./callers.ts";
58
+ import { poolWorker } from "./dispatch-effect.ts";
59
+ import type { DispatchOutput, EffectLogic } from "./effect.ts";
38
60
  import { fail, failurePayload, isFailure, type FailureInput } from "./fail.ts";
39
- import { gate } from "./gate.ts";
61
+ import { hooksTableFault, lookFault, plainGate, sourceGate, type LookOutput, type SourceSeatOf } from "./gate.ts";
40
62
  import { attachWrapperBody, type JR2Wrapper, type WrapperActors } from "./parts.ts";
63
+ import { boundRun, type AnyActorSystem, type DispatchedBy, type PoolMarker, type SourceHeard } from "./registration.ts";
64
+ import { parsedNestedDoor } from "./vocabulary.ts";
41
65
  import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
42
66
 
67
+ // --- What a Source's handler gives ----------------------------------------------------------------
68
+
69
+ /** The brand of a Source handler's answer: what tells `reread()`, `carry()` and `skip()` apart from
70
+ * any other value a handler gives. */
71
+ const VERDICT: unique symbol = Symbol.for("jr2.source.verdict");
72
+
73
+ /** What `reread()` gives. Only a Source that can read (`next` or `read`) gives it. */
74
+ export type Reread = { readonly [VERDICT]: "reread" };
75
+ /** What `carry(item)` gives. Only a push Source (one with an `item` schema) gives it. */
76
+ export type Carry<T> = { readonly [VERDICT]: "carry"; readonly item: T };
77
+ /** What `skip(reason)` gives: the delivery brings nothing, and the reason says why. */
78
+ export type Skip = { readonly [VERDICT]: "skip"; readonly reason: string };
79
+
80
+ /**
81
+ * Read the Source again. The Pool calls its `next`, or runs its `read`, at once; a read that runs
82
+ * now reads once more after it ends. Only a Source that can read gives it.
83
+ *
84
+ * @see docs/concepts/pools-and-sources.md
85
+ */
86
+ export function reread(): Reread {
87
+ return { [VERDICT]: "reread" };
88
+ }
89
+
90
+ /**
91
+ * Give the item itself. The Pool holds it, in its run's blob, until it claims it. Only a push
92
+ * Source gives it.
93
+ *
94
+ * @param item - The item, of the type of the Source's `item` schema.
95
+ * @see docs/concepts/pools-and-sources.md
96
+ */
97
+ export function carry<T>(item: T): Carry<T> {
98
+ return { [VERDICT]: "carry", item };
99
+ }
100
+
101
+ /**
102
+ * Bring nothing, and say why. The reason goes in the Hook's answer and in its hook log.
103
+ *
104
+ * @param reason - Why the delivery brings nothing, for a person to read.
105
+ * @see docs/concepts/pools-and-sources.md
106
+ */
107
+ export function skip(reason: string): Skip {
108
+ return { [VERDICT]: "skip", reason: String(reason) };
109
+ }
110
+
111
+ /** What a Source's handler reads beside `payload`. `caller` is the Hook as a Caller, `delivery` the
112
+ * delivery id, and `input` the Pool's input (`{}` for a standing Pool). */
113
+ export type SourceIn<TInput = unknown> = {
114
+ readonly caller: CallerRecord;
115
+ readonly delivery: string;
116
+ readonly input: TInput;
117
+ };
118
+
119
+ /** A Source's handlers, by Hook kind and event key. They have the shape of a Gate's `hooks`. A
120
+ * handler is pure and synchronous. It gives `Out` or `skip(reason)`, never `undefined`; a throw is a
121
+ * skip with the error as its reason. */
122
+ export type SourceHooks<Out, TInput = unknown> = KindHooks<SourceIn<TInput>, Out | Skip>;
123
+
124
+ /**
125
+ * A Source's verdict on one delivery, as its `reply` reads it (ADR-0017). `held`, `existing` and
126
+ * `hold-full` come from a push Source and carry the item and its key; `existing` names the run
127
+ * that holds the key, when one holds it. `reread` comes from a Source that can read. `skipped`
128
+ * carries the reason. A Pool that is paused has no run, so no `reply` of it runs: the door writes
129
+ * that text.
130
+ */
131
+ export type SourceVerdict<T> =
132
+ | { readonly verdict: "held"; readonly key: string; readonly item: T }
133
+ | { readonly verdict: "existing"; readonly key: string; readonly item: T; readonly run?: string }
134
+ | { readonly verdict: "hold-full"; readonly key: string; readonly item: T }
135
+ | { readonly verdict: "reread" }
136
+ | { readonly verdict: "skipped"; readonly reason: string };
137
+
138
+ /**
139
+ * The text that the person who acted reads, from this Source's verdict (ADR-0011, ADR-0017).
140
+ * Plain text: the Hook kind escapes it and puts it in its envelope. Pure and synchronous.
141
+ * `undefined` or `""`: this Source says nothing. It runs only for a delivery whose answer a person
142
+ * reads (`slack`, `discord`). The text of a `skipped` verdict shows only when no other target gave
143
+ * a text.
144
+ */
145
+ export type SourceReply<T> = (verdict: SourceVerdict<T>, at: { readonly caller: CallerRecord }) => string | undefined;
146
+
147
+ // --- The three forms of a Source --------------------------------------------------------------------
148
+
43
149
  // The rich shape's `open` is the one bit that separates DRAINED from DEADLOCKED when the pool goes
44
150
  // idle (ADR-0017); a bare `null` means drained-when-idle.
45
151
  /**
@@ -50,35 +156,89 @@ import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
50
156
  */
51
157
  export type SourceNextResult<T> = T | null | { item: T | null; open: number };
52
158
 
159
+ /** The input of a Source's `next`: the ids of the items that workers have now, the ids of the items
160
+ * that settled, and the Pool's input. So a Source over a list that a Machine holds needs no state
161
+ * of its own. */
162
+ export type PoolNextInput<TInput = unknown> = { active: string[]; settled: string[]; input: TInput };
163
+
53
164
  // The Source port (ADR-0017).
54
- /** Where a Pool gets its work items: the Source. Make one with {@link source}. */
55
- export type SourceSpec<T> = {
165
+ /** A pull Source. `next` claims one item at a time. Make one with {@link source}. */
166
+ export type SourceSpec<T, TInput = unknown> = {
56
167
  // A union of the accepted output shapes because xstate's actor-logic type is invariant in its
57
168
  // output — a `fromPromise` inferred as `T | null` must still assign.
58
169
  /** Claim the next ready item. Write it as an xstate promise actor,
59
- * `fromPromise(async ({ input }) => …)`. Its input is `{ active }`: the ids of the items that
60
- * workers have now, which it must not give again. It must also not give again an item that a
61
- * worker finished. It resolves to a {@link SourceNextResult}. The Pool calls it when the run
62
- * starts, after each claim, and when a worker settles. While workers run and no item is ready, it
63
- * also calls it on `wake` and every `pollEvery`. */
170
+ * `fromPromise(async ({ input }) => …)`. Its input is {@link PoolNextInput}: `active`, the ids of
171
+ * the items that workers have now, `settled`, the ids of the items that finished or failed, and
172
+ * `input`, the Pool's input. It must not give again an item in `active` or `settled`. It resolves
173
+ * to a {@link SourceNextResult}. The Pool calls it when the run starts, after each claim, and when a worker settles. While workers run and no item is ready, it
174
+ * also calls it on `wake`, on a Hook's `reread()` and every `pollEvery`. */
64
175
  next:
65
- | PromiseActorLogic<SourceNextResult<T>, { active: string[] }>
66
- | PromiseActorLogic<{ item: T | null; open: number }, { active: string[] }>
67
- | PromiseActorLogic<T | null, { active: string[] }>
68
- | PromiseActorLogic<null, { active: string[] }>;
69
- // The webhook / `jr2 send` push seam. The def IS the pool machine's vocabulary (ADR-0011).
176
+ | PromiseActorLogic<SourceNextResult<T>, PoolNextInput<TInput>>
177
+ | PromiseActorLogic<{ item: T | null; open: number }, PoolNextInput<TInput>>
178
+ | PromiseActorLogic<T | null, PoolNextInput<TInput>>
179
+ | PromiseActorLogic<null, PoolNextInput<TInput>>;
180
+ // The `jr2 send` push seam. The def IS the pool machine's vocabulary (ADR-0011).
70
181
  /** An external event, made with `defineEvent`, that tells the Pool that new work is ready, for
71
- * example from a webhook or `jr2 send`. While workers run and no item is ready, it makes the Pool
72
- * call `next` at once. The Pool serves it as a Gate named `source` for the whole run
73
- * (`<path>.source` when the Pool is composed in another Machine). Default: none. */
182
+ * example from `jr2 send`. While workers run and no item is ready, it makes the Pool call `next`
183
+ * at once. The Pool serves it as a Gate named `source` for the whole run (`<path>.source` when the
184
+ * Pool is composed in another Machine). Default: none. */
74
185
  wake?: EventDef;
75
186
  // For sets that mutate underneath us.
76
187
  /** While workers run and no item is ready, call `next` again after this many milliseconds. Use
77
188
  * it for a Source whose items change while the run waits. Default: none, so the Pool calls
78
- * `next` again only when a worker settles or `wake` comes. */
189
+ * `next` again only when a worker settles, `wake` comes, or a Hook's handler gives `reread()`. */
79
190
  pollEvery?: number;
191
+ /** What a Hook's delivery does: a handler for each Hook kind id and event key gives `reread()` or
192
+ * `skip(reason)`. Default: none. */
193
+ hooks?: SourceHooks<Reread, TInput>;
194
+ /** The text that the person who acted reads, from the verdict. Default: none. */
195
+ reply?: SourceReply<T>;
80
196
  };
81
197
 
198
+ /**
199
+ * A read Source. `read` reads the whole set, and `items` turns its answer into the items, in
200
+ * order. The read is an `http` Effect marked `idempotent: true`, so it can spend a secret that the
201
+ * Instance Effector holds, or a promise (`fromPromise`). Its input is the Pool's input. A read that
202
+ * fails gives no items. The read is the truth: a Hook's delivery only says "read again". Make one
203
+ * with {@link source}.
204
+ */
205
+ export type ReadSourceSpec<T, TInput = unknown, R extends AnyActorLogic = AnyActorLogic> = {
206
+ /** The read: an `http` Effect marked `idempotent: true`, or a promise. The Pool runs it at the
207
+ * start, after a worker settles, on `wake`, on a Hook's `reread()` and every `pollEvery`. */
208
+ read: R;
209
+ /** The items in the read's answer, in order. Default: the answer itself, when it is an array. */
210
+ items?: (output: LookOutput<R>, args: { input: TInput }) => readonly T[];
211
+ /** As a pull Source's `wake`. */
212
+ wake?: EventDef;
213
+ /** Read again after this many milliseconds while the Pool waits. Default: none. */
214
+ pollEvery?: number;
215
+ /** What a Hook's delivery does: a handler gives `reread()` or `skip(reason)`. Default: none. */
216
+ hooks?: SourceHooks<Reread, TInput>;
217
+ /** The text that the person who acted reads, from the verdict. Default: none. */
218
+ reply?: SourceReply<T>;
219
+ };
220
+
221
+ /**
222
+ * A push Source. Nothing can be read again, and each item comes in a Hook's delivery. A handler
223
+ * gives `carry(item)` or `skip(reason)`. The Pool holds each carried item in its run's blob, in the
224
+ * order of arrival, until it claims it. Only a standing Pool takes a push Source. Make one with
225
+ * {@link source}.
226
+ */
227
+ export type PushSourceSpec<T> = {
228
+ /** The schema of an item. The Pool parses each carried item with it before it holds it. */
229
+ item: z.ZodType<T>;
230
+ /** A handler for each Hook kind id and event key. It gives `carry(item)` or `skip(reason)`. */
231
+ hooks: SourceHooks<Carry<T>, {}>;
232
+ /** The most items that the Pool holds. Above it, a carried item is `hold-full`, and the Hook's
233
+ * answer is 429. Default: 1000. */
234
+ hold?: number;
235
+ /** The text that the person who acted reads, from the verdict. Default: none. */
236
+ reply?: SourceReply<T>;
237
+ };
238
+
239
+ /** Any form of Source. */
240
+ export type AnySourceSpec<T, TInput = unknown> = SourceSpec<T, TInput> | ReadSourceSpec<T, TInput> | PushSourceSpec<T>;
241
+
82
242
  // Identity by design — the value IS the spec; this is the naming/typing seam.
83
243
  /**
84
244
  * Declare a Source: where a `pool()` gets its work items, one at a time. It gives back the spec
@@ -91,7 +251,7 @@ export type SourceSpec<T> = {
91
251
  * @example
92
252
  * ```ts
93
253
  * import { fromPromise } from "xstate";
94
- * import { source } from "@jr2/orchestrator";
254
+ * import { source, type PoolNextInput } from "@jr2/orchestrator";
95
255
  *
96
256
  * type Issue = { id: string; title: string };
97
257
  *
@@ -101,7 +261,7 @@ export type SourceSpec<T> = {
101
261
  * ];
102
262
  *
103
263
  * export const issues = source<Issue>({
104
- * next: fromPromise(async ({ input }: { input: { active: string[] } }) => {
264
+ * next: fromPromise(async ({ input }: { input: PoolNextInput }) => {
105
265
  * const issue = backlog.find((i) => !input.active.includes(i.id)) ?? null;
106
266
  * if (issue) backlog.splice(backlog.indexOf(issue), 1);
107
267
  * return issue;
@@ -114,10 +274,39 @@ export type SourceSpec<T> = {
114
274
  * @see docs/concepts/pools-and-sources.md
115
275
  * @see docs/reference/decisions/0017-pool-over-a-source-port.md
116
276
  */
117
- export function source<T>(spec: SourceSpec<T>): SourceSpec<T> {
277
+ export function source<T, TInput = unknown>(spec: SourceSpec<T, TInput>): SourceSpec<T, TInput>;
278
+ /**
279
+ * Declare a PUSH Source: the schema of an item, and the handlers that carry it. Only a standing
280
+ * Pool of runs takes it.
281
+ *
282
+ * @param spec - `item`, `hooks`, `hold` and `reply`. See {@link PushSourceSpec}.
283
+ * @returns The same spec.
284
+ *
285
+ * @see docs/concepts/pools-and-sources.md
286
+ */
287
+ export function source<const S extends z.ZodType>(spec: {
288
+ item: S;
289
+ hooks: SourceHooks<Carry<z.output<NoInfer<S>>>, {}>;
290
+ hold?: number;
291
+ reply?: SourceReply<z.output<NoInfer<S>>>;
292
+ }): PushSourceSpec<z.output<S>>;
293
+ /**
294
+ * Declare a READ Source: a read of the whole set, and the items in its answer.
295
+ *
296
+ * @param spec - `read`, `items`, `wake`, `pollEvery`, `hooks` and `reply`. See {@link ReadSourceSpec}.
297
+ * @returns The same spec.
298
+ *
299
+ * @see docs/concepts/pools-and-sources.md
300
+ */
301
+ export function source<T, TInput = unknown, R extends AnyActorLogic = AnyActorLogic>(
302
+ spec: ReadSourceSpec<T, TInput, R>,
303
+ ): ReadSourceSpec<T, TInput, R>;
304
+ export function source(spec: unknown): unknown {
118
305
  return spec;
119
306
  }
120
307
 
308
+ // --- The Pool ------------------------------------------------------------------------------------
309
+
121
310
  /** How a pool run ends (its machine output). `items` maps itemId → the output of a worker that got
122
311
  * to a final state. `failures` maps itemId → the Failure of a worker that failed (ADR-0073). */
123
312
  export type PoolOutput = {
@@ -131,8 +320,8 @@ export type PoolOutput = {
131
320
  // permissive, so jr2 knows nothing and says so, exactly as `workspace()`'s door-less path does.
132
321
  // A pool that is fed by something other than a caller may state what it is fed by annotating the
133
322
  // mapper's parameter; for anything a caller starts, the honest fix is to declare `input`.
134
- /** What you pass to `pool()`: where the items come from, how many workers run at once, and what
135
- * each worker gets. */
323
+ /** What you pass to `pool()` with a child Machine as its worker. It says where the items come
324
+ * from, how many workers run at once, and what each worker gets. */
136
325
  export type PoolSpec<T, TInput = unknown> = {
137
326
  /** The Machine id: the name of the Pool in the Console. Default: `"pool"`. */
138
327
  id?: string;
@@ -143,8 +332,11 @@ export type PoolSpec<T, TInput = unknown> = {
143
332
  * `itemInput` read it, typed by it. A worker never gets it: each worker gets its item. Default:
144
333
  * none, and then their `input` is `unknown`. */
145
334
  input?: z.ZodObject;
146
- /** Where the items come from. Make it with {@link source}. */
147
- source: SourceSpec<T>;
335
+ // `SourceSpec<T>` too: xstate's actor logic is invariant in its input, so a `next` that does not
336
+ // read the Pool's input must still assign under a typed door.
337
+ /** Where the items come from: a pull or a read Source. Make it with {@link source}. A push Source
338
+ * needs a standing Pool of runs. */
339
+ source: SourceSpec<T, TInput> | SourceSpec<T> | ReadSourceSpec<T, TInput> | ReadSourceSpec<T>;
148
340
  // Stable child identity: the spawn id, the active-set entry, the outcome key.
149
341
  /** The id of an item. The Pool uses it as the id of the item's worker and in the `active` list
150
342
  * that `next` gets. It is also the key of that worker's output in `items`. Give each item an id
@@ -164,6 +356,32 @@ export type PoolSpec<T, TInput = unknown> = {
164
356
  * `"<n> of <m> items failed"` and the detail `{ status, items, failures }`. `"report"`: the Pool
165
357
  * ends `done`, with the failures in its output. Default: `"fail"`. */
166
358
  failures?: "fail" | "report";
359
+ /** A Pool of child Machines never stands: a child cannot be handed over. */
360
+ standing?: false;
361
+ };
362
+
363
+ /**
364
+ * What you pass to `pool()` for a Pool of runs. The worker is a `dispatch(...)` slot, and the Pool
365
+ * stands. Each item is a run of the slot's Workflow, keyed by `itemId` in that Workflow.
366
+ */
367
+ export type StandingPoolSpec<T, I> = {
368
+ /** The Machine id: the name of the Pool in the Console. Default: `"pool"`. */
369
+ id?: string;
370
+ /** A Pool of runs stands: jr2 keeps one live run of its Workflow, and it never ends by itself. */
371
+ standing: true;
372
+ /** Where the items come from: a pull, a read or a push Source. Make it with {@link source}. */
373
+ source: SourceSpec<T, {}> | SourceSpec<T> | ReadSourceSpec<T, {}> | ReadSourceSpec<T> | PushSourceSpec<T>;
374
+ /** The id of an item, and the KEY of its run in the slot's Workflow (ADR-0071): every Pool, every
375
+ * hand-over and every `jr2 run` that gives this key to that Workflow gets the same run while its
376
+ * row lives — while it is live, and for `runs.retain` after it ends. */
377
+ itemId: (item: T) => string;
378
+ /** The most LIVE runs at once that this Pool dispatched. You must choose it. */
379
+ cap: number;
380
+ /** Make a run's body from its item. Default: the item itself. */
381
+ itemInput?: (item: T) => I;
382
+ input?: never;
383
+ onDrained?: never;
384
+ failures?: never;
167
385
  };
168
386
 
169
387
  /**
@@ -193,16 +411,50 @@ export type PoolMachine<TWorker extends AnyStateMachine, TInput> = StateMachine<
193
411
  > &
194
412
  JR2Wrapper<TWorker>;
195
413
 
414
+ /**
415
+ * What `pool()` returns for a Pool of runs: a Machine with no input and no output, because it
416
+ * stands. It is not a wrapper: `customize()` reaches nothing in the runs (ADR-0049).
417
+ *
418
+ * @internal
419
+ */
420
+ export type StandingPoolMachine = StateMachine<any, any, any, any, any, any, any, any, any, {}, never, any, any, any>;
421
+
422
+ /** One item that the Pool holds or claimed: its key, the item, and the Hook delivery that carried
423
+ * it, when one did. */
424
+ type Held = { key: string; item: unknown; hook?: DispatchedBy["hook"] };
425
+
196
426
  type PoolCtx = {
197
427
  runInput: Record<string, unknown>;
198
428
  cap: number;
199
429
  /** Item ids currently being worked — the spawn ids of live workers, and `next`'s exclusion. */
200
430
  active: string[];
201
- /** Settled workers' outputs, by item id — the pool's output collects them. */
431
+ /** Settled workers' outputs, by item id — the pool's output collects them. For a Pool of runs, the
432
+ * run and its status, for the runs that ended in this run's life. */
202
433
  items: Record<string, unknown>;
203
434
  /** Failed workers' payloads, by item id (ADR-0073). */
204
435
  failures: Record<string, FailureInput>;
205
436
  status?: "drained" | "deadlocked";
437
+ /** A push Source's carried items, in arrival order, until the Pool claims them (ADR-0017). */
438
+ held: Held[];
439
+ /** The items that the last read found and may start, in the read's order: claimed while a slot
440
+ * is free, and the rest dropped when the claim ends — the next read finds them again. */
441
+ ready: Held[];
442
+ /** A Pool of runs: each active key's item, so a hand-over can send it again (ADR-0017). */
443
+ claimed: Record<string, Held>;
444
+ /** A Pool of runs: the keys whose wait ended while their run was still live. Each is sent again
445
+ * under its key: the key finds the run, and the wait follows it, so `cap` still counts it. */
446
+ again: string[];
447
+ /** A `reread`, a `wake` or a settled worker came while the Source read: read once more after it. */
448
+ latch: boolean;
449
+ /** How many items this run claimed and spawned a worker for (ADR-0017). */
450
+ taken: number;
451
+ /** A Pool of runs: the active keys that a hand-over carried to this run (ADR-0017). Each is sent
452
+ * again and finds its run, so its run is not one that this run's items started: the standing
453
+ * streak reads only the items that this run took. */
454
+ carried: string[];
455
+ /** The held items that a hand-over carried and the `item` schema refused: each is a
456
+ * `carry-refused` mark when the run starts, and then the list is empty. */
457
+ refused: Array<{ key: string; reason: string }>;
206
458
  };
207
459
 
208
460
  /** A claimed item, normalized (see {@link SourceNextResult}). */
@@ -220,13 +472,198 @@ function normalizeClaim(output: unknown): Claim {
220
472
  return { item: output, open: 0 };
221
473
  }
222
474
 
475
+ /** The event that holds a carried item (ADR-0017): sent by the `source` Gate's seat after the key
476
+ * check. */
477
+ const CARRY = "jr2.source.carry";
478
+ /** The event that makes the Source read again: a Hook's `reread()`. */
479
+ const REREAD = "jr2.source.reread";
480
+ /** The default of `hold`: not measured (ADR-0017). */
481
+ export const HOLD_DEFAULT = 1000;
482
+
483
+ /** The key of the run input that carries a hand-over to a standing Pool's new run (ADR-0017): the
484
+ * held items and the active keys of the run before it. Only jr2 starts a standing run, so no Caller
485
+ * sends it; the Pool takes it out of its input, so a handler never reads it. */
486
+ export const HAND_OVER = "jr2.pool.handOver";
487
+
488
+ /** One item of a standing Pool that a hand-over carries: its key, the item, and the Hook delivery
489
+ * that carried it, when one did. JSON-safe: it rides the new run's input. */
490
+ export type StandingItem = { key: string; item: unknown; hook?: DispatchedBy["hook"] };
491
+
492
+ /** What a hand-over carries to the new run (ADR-0017): the held items, in the order of arrival, and
493
+ * the item of each active key, which the new run sends again with `wait: true`. */
494
+ export type StandingCarry = { held: StandingItem[]; active: StandingItem[] };
495
+
496
+ /**
497
+ * What a hand-over carries from a standing Pool's context, live or saved (ADR-0017): its held items
498
+ * (none with `dropHeld`) and the item of each active key. A context that is not a Pool's carries
499
+ * nothing.
500
+ */
501
+ export function standingCarryOf(context: unknown, opts: { dropHeld?: boolean } = {}): StandingCarry {
502
+ const c = (context ?? {}) as Partial<PoolCtx>;
503
+ const held = Array.isArray(c.held) && !opts.dropHeld ? c.held : [];
504
+ const active = Array.isArray(c.active) ? c.active.filter((k): k is string => typeof k === "string") : [];
505
+ const claimed = typeof c.claimed === "object" && c.claimed !== null ? c.claimed : {};
506
+ return {
507
+ held: held.filter((h) => typeof h?.key === "string").map((h) => ({ ...h })),
508
+ active: active.map((key) => ({ ...(claimed[key] ?? { key, item: undefined }), key })),
509
+ };
510
+ }
511
+
512
+ /** How many items a standing Pool's run took (ADR-0017): each claim that spawned its worker, not a
513
+ * key that a hand-over carried. jr2 reads it for the bound. */
514
+ export function standingTaken(context: unknown): number {
515
+ const taken = (context as Partial<PoolCtx> | null | undefined)?.taken;
516
+ return typeof taken === "number" ? taken : 0;
517
+ }
518
+
519
+ /**
520
+ * What the items that a standing Pool's run took itself did (ADR-0017), not the keys that a
521
+ * hand-over carried to it: `started` when one of them started a run (or found its run under its
522
+ * key), and `answered`, how many of them ended in this run's life. A settled item names its run in
523
+ * the context; a live worker's dispatch names it on its ledger row, which `runOf` reads by the
524
+ * worker's Effect id. The standing streak counts a run whose own items started no run.
525
+ */
526
+ export function standingTook(
527
+ snapshot: unknown,
528
+ runOf: (effectId: string) => string | undefined,
529
+ ): { started: boolean; answered: number } {
530
+ const snap = (snapshot ?? {}) as {
531
+ context?: Partial<PoolCtx>;
532
+ children?: Record<string, { getSnapshot?: () => unknown } | undefined>;
533
+ };
534
+ const c = snap.context ?? {};
535
+ const carried = new Set(Array.isArray(c.carried) ? c.carried : []);
536
+ let started = false;
537
+ let answered = 0;
538
+ for (const [key, v] of Object.entries(typeof c.items === "object" && c.items !== null ? c.items : {})) {
539
+ if (carried.has(key)) continue;
540
+ answered++;
541
+ if (typeof (v as { run?: unknown } | undefined)?.run === "string") started = true;
542
+ }
543
+ if (!started) {
544
+ const active = new Set(Array.isArray(c.active) ? c.active : []);
545
+ for (const [id, child] of Object.entries(snap.children ?? {})) {
546
+ if (!active.has(id) || carried.has(id)) continue;
547
+ const effectId = (child?.getSnapshot?.() as { effectId?: unknown } | undefined)?.effectId;
548
+ if (typeof effectId === "string" && runOf(effectId) !== undefined) started = true;
549
+ }
550
+ }
551
+ return { started, answered };
552
+ }
553
+
554
+ /** Is this worker a `dispatch(...)` slot (ADR-0071)? */
555
+ function isDispatchWorker(worker: unknown): worker is EffectLogic<unknown, DispatchOutput> {
556
+ return (worker as { effect?: { kind?: unknown } } | null)?.effect?.kind === "dispatch";
557
+ }
558
+
559
+ /** The form of a Source, or why it has none. */
560
+ function formOf(src: unknown): "pull" | "read" | "push" | { fault: string } {
561
+ if (typeof src !== "object" || src === null) return { fault: "its source is made with source({ … })" };
562
+ const s = src as Record<string, unknown>;
563
+ const forms = (["next", "read", "item"] as const).filter((k) => s[k] !== undefined);
564
+ if (forms.length !== 1) {
565
+ return {
566
+ fault:
567
+ "its source has exactly one of `next` (a pull Source), `read` (a read Source) and `item` (a push Source)" +
568
+ (forms.length ? `, and it has ${forms.map((f) => `\`${f}\``).join(" and ")}` : ""),
569
+ };
570
+ }
571
+ return forms[0] === "next" ? "pull" : forms[0] === "read" ? "read" : "push";
572
+ }
573
+
574
+ /** Every refusal of a Pool's spec that `pool()` can see, with its fix (ADR-0017). Thrown at
575
+ * `createMachine` time, so the load walk refuses the Workflow, naming the fix. */
576
+ function refusal(worker: unknown, spec: Record<string, unknown>): string | undefined {
577
+ if (typeof worker === "string") {
578
+ return (
579
+ `its worker is the Workflow name "${worker}" — a worker is a Machine, or a dispatch(...) slot for a run of ` +
580
+ `each item: pool(dispatch(${JSON.stringify(worker)}, { input, wait: true }), { … })`
581
+ );
582
+ }
583
+ const form = formOf(spec.source);
584
+ if (typeof form === "object") return form.fault;
585
+ const src = spec.source as Record<string, unknown>;
586
+ const hooksFault = hooksTableFault(src.hooks);
587
+ if (hooksFault) return `its source: ${hooksFault.replace(/^its /, "")}`;
588
+ if (form === "read") {
589
+ const fault = lookFault({ read: src.read, input: () => undefined, until: () => undefined });
590
+ if (fault) return `its source's read: ${fault.replace(/^its look's read is|^its look reads with/, "it is")}`;
591
+ if (src.items !== undefined && typeof src.items !== "function") return "its source's items is a function";
592
+ }
593
+ if (form === "push") {
594
+ if (typeof (src.item as { safeParse?: unknown }).safeParse !== "function") {
595
+ return "its source's item is a zod schema";
596
+ }
597
+ if (src.hold !== undefined && !(Number.isInteger(src.hold) && (src.hold as number) > 0)) {
598
+ return "its source's hold is a whole number above 0";
599
+ }
600
+ }
601
+ if (isDispatchWorker(worker)) {
602
+ const effect = (worker as unknown as { effect: { workflow: string; wait: boolean; target?: unknown } }).effect;
603
+ if (spec.standing !== true) {
604
+ return (
605
+ "a Pool of runs stands: give it `standing: true`. A Pool of runs that a Caller starts and that ends is not " +
606
+ "offered: its itemId is a key that lives for runs.retain past the run, so a second batch in that time does no work"
607
+ );
608
+ }
609
+ if (effect.wait !== true) {
610
+ return `its dispatch slot for "${effect.workflow}" has no \`wait: true\` — with \`started\` alone, the Pool cannot know when a run ends, and \`cap\` counts nothing`;
611
+ }
612
+ if (effect.target !== undefined) {
613
+ return `its dispatch slot for "${effect.workflow}" has a \`target\` — a Pool of runs on another Orchestrator is not designed: remove the target`;
614
+ }
615
+ if (!(Number.isInteger(spec.cap) && (spec.cap as number) > 0)) {
616
+ return "a Pool of runs states its `cap`, a whole number above 0: the most live runs at once";
617
+ }
618
+ for (const key of ["input", "onDrained", "failures"] as const) {
619
+ if (spec[key] !== undefined) return `a standing Pool has no \`${key}\`: it never ends, and nobody starts it`;
620
+ }
621
+ return undefined;
622
+ }
623
+ if (spec.standing === true) {
624
+ return "`standing: true` needs a Pool of runs (a dispatch(...) slot as its worker): a child Machine cannot be handed over";
625
+ }
626
+ if (form === "push") {
627
+ return "a push Source needs a standing Pool of runs: a Pool that ends when it drains would end at once";
628
+ }
629
+ return undefined;
630
+ }
631
+
632
+ /** The standing mark: a Machine that `pool()` made with `standing: true` (ADR-0017). What the load
633
+ * walk reads to refuse one that is not the top Machine of its Workflow. */
634
+ const STANDING = Symbol.for("jr2.pool.standing");
635
+
636
+ /** What jr2 reads of a standing Pool (ADR-0017): the Machine id, the callee Workflow, the Hook kinds
637
+ * that its Source's handlers hear, and whether a handler hears one kind and event key. */
638
+ export type StandingPoolInfo = {
639
+ id: string;
640
+ callee: string;
641
+ kinds: string[];
642
+ hears: (kind: string, key: string) => boolean;
643
+ };
644
+
645
+ /** What a standing Pool says of itself, or undefined for any other Machine (ADR-0017). */
646
+ export function standingPoolOf(machine: unknown): StandingPoolInfo | undefined {
647
+ if (typeof machine !== "object" || machine === null) return undefined;
648
+ const info = (machine as Record<symbol, unknown>)[STANDING];
649
+ return typeof info === "object" && info !== null ? (info as StandingPoolInfo) : undefined;
650
+ }
651
+
652
+ /** Is this Machine a standing Pool (ADR-0017)? */
653
+ export function isStandingPool(machine: unknown): boolean {
654
+ return standingPoolOf(machine) !== undefined;
655
+ }
656
+
223
657
  // Build the pool machine (ADR-0017). Returns a plain machine — the usual workflow ROOT, but
224
658
  // nestable as a child like any other. Its vocabulary is the wake def alone: the worker keeps its
225
659
  // own (ADR-0011, ADR-0049).
226
660
  /**
227
- * Make a Pool: a Machine that runs one worker Machine for each item that a Source gives, with at
228
- * most `cap` workers at once. Use it as the root of a Workflow (`export const machine = pool(…)`),
229
- * or compose it in another Machine.
661
+ * Make a Pool: a Machine that runs one worker for each item that a Source gives, with at most
662
+ * `cap` workers at once. Use it as the root of a Workflow (`export const machine = pool(…)`), or
663
+ * compose it in another Machine.
664
+ *
665
+ * The worker is a Machine, which runs inside the Pool's run, or a `dispatch(...)` slot, which makes
666
+ * each item a run of its own (a Pool of runs, which stands; see the third form).
230
667
  *
231
668
  * The run ends when the Source has no item ready and no worker runs. Its output is
232
669
  * `{ status, items, failures }`: `status` is `"drained"` when no item is left, or `"deadlocked"`
@@ -250,7 +687,7 @@ function normalizeClaim(output: unknown): Claim {
250
687
  * @example
251
688
  * ```ts
252
689
  * import { fromPromise } from "xstate";
253
- * import { customize, pool, source } from "@jr2/orchestrator";
690
+ * import { customize, pool, source, type PoolNextInput } from "@jr2/orchestrator";
254
691
  * import { task } from "@jr2/machines";
255
692
  *
256
693
  * type Issue = { id: string; title: string };
@@ -261,7 +698,7 @@ function normalizeClaim(output: unknown): Claim {
261
698
  * ];
262
699
  *
263
700
  * const issues = source<Issue>({
264
- * next: fromPromise(async ({ input }: { input: { active: string[] } }) => {
701
+ * next: fromPromise(async ({ input }: { input: PoolNextInput }) => {
265
702
  * const issue = backlog.find((i) => !input.active.includes(i.id)) ?? null;
266
703
  * if (issue) backlog.splice(backlog.indexOf(issue), 1);
267
704
  * return issue;
@@ -308,154 +745,687 @@ export function pool<TWorker extends AnyStateMachine, TInput = unknown>(
308
745
  worker: TWorker,
309
746
  spec: PoolSpec<any, TInput> & { input?: never },
310
747
  ): PoolMachine<TWorker, TInput>;
311
- export function pool(worker: AnyStateMachine, spec: PoolSpec<any, any>): AnyStateMachine {
312
- const wake = spec.source.wake;
748
+ /**
749
+ * Make a standing Pool of runs: each item that the Source gives is a run of the `dispatch` slot's
750
+ * Workflow, keyed by `itemId` in that Workflow, with at most `cap` live at once (ADR-0017). The
751
+ * slot says `wait: true` and has no `target`. The Pool never ends by itself: when it has no work, it
752
+ * waits for a Hook's delivery, a `reread` or its `pollEvery`. A run that ends in any way frees its
753
+ * slot, and is a `settled` mark on the run's feed; it is never the Pool's error, because the run is
754
+ * not the Pool's child. Use it as the top Machine of its Workflow, in a file of its own.
755
+ *
756
+ * @param worker - The `dispatch(...)` slot of the Workflow that runs each item.
757
+ * @param spec - The Source, the item id, the cap and the run's body. See {@link StandingPoolSpec}.
758
+ * @returns The standing Pool, as a Machine.
759
+ *
760
+ * @example
761
+ * ```ts
762
+ * import { z } from "zod";
763
+ * import { carry, dispatch, pool, skip, source } from "@jr2/orchestrator";
764
+ * import type {} from "@jr2/orchestrator/hooks/gitlab";
765
+ *
766
+ * const failed = z.object({ sha: z.string() });
767
+ *
768
+ * export const machine = pool(dispatch("triage", { input: failed, wait: true }), {
769
+ * id: "pipelines",
770
+ * standing: true,
771
+ * source: source({
772
+ * item: failed,
773
+ * hooks: {
774
+ * gitlab: {
775
+ * "pipeline.failed": ({ payload }) =>
776
+ * payload.object_attributes.sha === "" ? skip("no commit") : carry({ sha: payload.object_attributes.sha }),
777
+ * },
778
+ * },
779
+ * }),
780
+ * itemId: (p) => `pipeline-${p.sha}`,
781
+ * cap: 4,
782
+ * });
783
+ * ```
784
+ *
785
+ * @see docs/concepts/pools-and-sources.md
786
+ * @see docs/reference/decisions/0017-pool-over-a-source-port.md
787
+ */
788
+ export function pool<I, T>(worker: EffectLogic<I, DispatchOutput>, spec: StandingPoolSpec<T, I>): StandingPoolMachine;
789
+ export function pool(worker: unknown, specIn: unknown): AnyStateMachine {
790
+ const spec = specIn as PoolSpec<any, any> & { standing?: boolean };
791
+ const refused = refusal(worker, spec as unknown as Record<string, unknown>);
792
+ if (refused) throw new Error(`pool(${JSON.stringify(spec?.id ?? "pool")}): ${refused} (ADR-0017)`);
793
+ return poolMachine(worker, spec);
794
+ }
795
+
796
+ function poolMachine(workerIn: unknown, spec: PoolSpec<any, any> & { standing?: boolean }): AnyStateMachine {
797
+ const runs = isDispatchWorker(workerIn);
798
+ const callee = runs ? (workerIn as unknown as { effect: { workflow: string } }).effect.workflow : undefined;
799
+ const form = formOf(spec.source) as "pull" | "read" | "push";
800
+ const src = spec.source as unknown as Partial<
801
+ SourceSpec<unknown> & ReadSourceSpec<unknown> & PushSourceSpec<unknown>
802
+ > & { hooks?: Record<string, Record<string, (d: unknown) => unknown>> };
803
+ const id = spec.id ?? "pool";
804
+ const wake = form === "push" ? undefined : src.wake;
805
+ const hooks = src.hooks ?? {};
806
+ const hears = Object.values(hooks).some((keys) => Object.keys(keys ?? {}).length > 0);
807
+ const hold = form === "push" ? (src.hold ?? HOLD_DEFAULT) : 0;
808
+ // A Pool of runs spawns its slot with the item's id as the dispatch's key (ADR-0017, ADR-0071).
809
+ const worker = runs ? poolWorker(workerIn as EffectLogic<any, DispatchOutput>) : (workerIn as AnyStateMachine);
810
+
811
+ const ctxOf = (context: unknown): PoolCtx => context as PoolCtx;
313
812
  const claimOf = (event: unknown): Claim => normalizeClaim((event as { output: unknown }).output);
314
813
  const itemIdOf = (event: unknown): string => spec.itemId(claimOf(event).item as never);
315
- /** `xstate.done.actor.<childId>` → the settled worker's item id. */
316
- const doneChildOf = (event: unknown): string =>
317
- ((event as { type: string }).type.match(/^xstate\.done\.actor\.(.+)$/) as RegExpMatchArray)[1]!;
318
- /** `xstate.error.actor.<childId>` → the failed worker's item id. */
319
- const errorChildOf = (event: unknown): string =>
320
- ((event as { type: string }).type.match(/^xstate\.error\.actor\.(.+)$/) as RegExpMatchArray)[1]!;
321
-
814
+ /** `xstate.done.actor.<childId>` or `xstate.error.actor.<childId>` → the settled worker's item id. */
815
+ const childOf = (event: unknown): string =>
816
+ ((event as { type: string }).type.match(/^xstate\.(?:done|error)\.actor\.(.+)$/) as RegExpMatchArray)[1]!;
322
817
  const report = spec.failures === "report";
323
- const failuresOf = (context: unknown) => (context as PoolCtx).failures;
324
- const machine = setup({
325
- actors: { worker, next: spec.source.next, gate, fail: fail() },
326
- }).createMachine({
327
- id: spec.id ?? "pool",
328
- context: ({ input }): PoolCtx => ({
329
- runInput: (input ?? {}) as Record<string, unknown>,
330
- cap: typeof spec.cap === "function" ? spec.cap({ input }) : (spec.cap ?? 1),
331
- active: [],
332
- items: {},
333
- failures: {},
818
+ const failuresOf = (context: unknown) => ctxOf(context).failures;
819
+ /** The worker's input: the item, or what `itemInput` makes of it; for a Pool of runs, the run's
820
+ * body and the Hook delivery that carried the item. */
821
+ const inputFor = (h: Held, runInput: Record<string, unknown>): unknown => {
822
+ const body = spec.itemInput ? spec.itemInput(h.item as never, { input: runInput }) : h.item;
823
+ return runs ? { body, ...(h.hook !== undefined ? { hook: h.hook } : {}) } : body;
824
+ };
825
+ const mark = (system: unknown, m: Omit<PoolMarker, "kind" | "pool">): void =>
826
+ boundRun(system as AnyActorSystem)?.marker?.({ kind: "pool", pool: id, ...m });
827
+
828
+ // The push half of the Source (ADR-0017): the seat on the `source` Gate's registration.
829
+ const seat: SourceSeatOf = ({ pool: poolRef, send, system }) => {
830
+ const now = (): PoolCtx => ctxOf(poolRef.getSnapshot().context);
831
+ const skipped = (reason: string): SourceHeard => ({ verdict: "skipped", reason });
832
+ const reply = typeof src.reply === "function" ? src.reply : undefined;
833
+ return {
834
+ hears: (kind, key) => typeof hooks[kind]?.[key] === "function",
835
+ ...(reply ? { reply: (verdict, caller) => reply(verdict as never, { caller }) } : {}),
836
+ hear: async (d) => {
837
+ const handler = hooks[d.kind]?.[d.key];
838
+ if (typeof handler !== "function") return skipped("no handler of the Source hears this kind and key");
839
+ let given: unknown;
840
+ try {
841
+ given = handler({ payload: d.payload, caller: d.caller, delivery: d.delivery, input: now().runInput });
842
+ } catch (err) {
843
+ return skipped(`its handler threw: ${err instanceof Error ? err.message : String(err)}`);
844
+ }
845
+ const v = (given as { [VERDICT]?: unknown } | null | undefined)?.[VERDICT];
846
+ if (v === "skip") return skipped((given as Skip).reason);
847
+ if (v === "reread") {
848
+ if (form === "push") return skipped("its handler gave reread(), and a push Source cannot read again");
849
+ if (poolRef.getSnapshot().status !== "active") return skipped("the Pool's run has ended");
850
+ send({ type: REREAD } as never);
851
+ return { verdict: "reread" };
852
+ }
853
+ if (v !== "carry") return skipped("its handler gave neither carry(item), reread() nor skip(reason)");
854
+ if (form !== "push") return skipped("its handler gave carry(item), and only a push Source holds items");
855
+ const parsed = (src.item as z.ZodType).safeParse((given as Carry<unknown>).item);
856
+ if (!parsed.success) {
857
+ return skipped(
858
+ `the carried item does not parse: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(item)"}: ${i.message}`).join("; ")}`,
859
+ );
860
+ }
861
+ let key: unknown;
862
+ try {
863
+ key = spec.itemId(parsed.data as never);
864
+ } catch (err) {
865
+ return skipped(`its itemId threw: ${err instanceof Error ? err.message : String(err)}`);
866
+ }
867
+ if (typeof key !== "string" || key === "") return skipped("its itemId gave no id");
868
+ // The key check before the hold (ADR-0017): a held item, then a run of the callee, then an
869
+ // active worker. Asked again after the store's answer, because another delivery can hold the
870
+ // key while it is asked.
871
+ const item: unknown = parsed.data;
872
+ const local = (): SourceHeard | undefined => {
873
+ const c = now();
874
+ if (c.held.some((h) => h.key === key) || c.active.includes(key)) return { verdict: "existing", key, item };
875
+ if (c.held.length >= hold) return { verdict: "hold-full", key, item };
876
+ return undefined;
877
+ };
878
+ if (now().held.some((h) => h.key === key)) return { verdict: "existing", key, item };
879
+ const found = callee ? await boundRun(system)?.dispatch?.lookup?.(callee, key) : undefined;
880
+ if (found) return { verdict: "existing", key, run: found.runId, item };
881
+ const again = local();
882
+ if (again) return again;
883
+ if (poolRef.getSnapshot().status !== "active") return skipped("the Pool's run has ended");
884
+ const hook: Held["hook"] = {
885
+ name: d.hook,
886
+ delivery: d.delivery,
887
+ ...(d.caller.onBehalfOf !== undefined ? { onBehalfOf: d.caller.onBehalfOf } : {}),
888
+ };
889
+ send({ type: CARRY, key, item: parsed.data, hook } as never);
890
+ return now().held.some((h) => h.key === key) || now().active.includes(key)
891
+ ? { verdict: "held", key, item }
892
+ : skipped("the Pool did not take the item");
893
+ },
894
+ };
895
+ };
896
+
897
+ // The items that a read skipped, by run: a read at each poll finds them again, so each is one
898
+ // `skipped` mark in a run's life, not one at each read.
899
+ const skippedIn = new WeakMap<object, Set<string>>();
900
+ const skipOnce = (system: unknown, item: unknown, reason: string): void => {
901
+ let text: string;
902
+ try {
903
+ text = JSON.stringify(item) ?? String(item);
904
+ } catch {
905
+ text = String(item);
906
+ }
907
+ const seen = skippedIn.get(system as object) ?? new Set<string>();
908
+ skippedIn.set(system as object, seen);
909
+ if (seen.has(text)) return;
910
+ seen.add(text);
911
+ mark(system, { mark: "skipped", reason });
912
+ };
913
+
914
+ // The read's key check (ADR-0017): drop each known id, then — for a Pool of runs — each key that a
915
+ // run of the callee holds. For a Pool of runs, the store is the record of an item that started a
916
+ // run; `knownOf` holds each item that ended with no run.
917
+ const check = fromPromise(
918
+ async ({
919
+ input,
920
+ system,
921
+ }: {
922
+ input: { output: unknown; runInput: Record<string, unknown>; known: string[] };
923
+ system: AnyActorSystem;
924
+ }): Promise<Held[]> => {
925
+ const found = src.items ? src.items(input.output as never, { input: input.runInput }) : input.output;
926
+ if (!Array.isArray(found)) throw new Error("the read's items are not an array");
927
+ const known = new Set(input.known);
928
+ const out: Held[] = [];
929
+ for (const [i, item] of found.entries()) {
930
+ // One item with no id is that item's fault, as for a push Source: it is skipped with a mark,
931
+ // and the rest of the read goes on (ADR-0017).
932
+ let key: unknown;
933
+ try {
934
+ key = spec.itemId(item as never);
935
+ } catch (err) {
936
+ skipOnce(
937
+ system,
938
+ item,
939
+ `item ${i} of the read: its itemId threw: ${err instanceof Error ? err.message : String(err)}`,
940
+ );
941
+ continue;
942
+ }
943
+ if (typeof key !== "string" || key === "") {
944
+ skipOnce(system, item, `item ${i} of the read: its itemId gave no id`);
945
+ continue;
946
+ }
947
+ if (known.has(key)) continue;
948
+ known.add(key);
949
+ if (callee && (await boundRun(system)?.dispatch?.lookup?.(callee, key))) continue;
950
+ out.push({ key, item });
951
+ }
952
+ return out;
953
+ },
954
+ );
955
+
956
+ // A Pool of runs knows, too, each item that ended with no run (`start-refused`, `dispatch-error`,
957
+ // `unreachable`, `unknown`): the store has no row for it, so without this the read that its own
958
+ // settle starts claims it again, at once and with no end (ADR-0017). The next run of the Pool
959
+ // tries it again.
960
+ const knownOf = (c: PoolCtx): string[] => [
961
+ ...c.active,
962
+ ...c.held.map((h) => h.key),
963
+ ...(runs
964
+ ? Object.entries(c.items)
965
+ .filter(([, v]) => (v as { run?: unknown } | undefined)?.run === undefined)
966
+ .map(([k]) => k)
967
+ : [...Object.keys(c.items), ...Object.keys(c.failures)]),
968
+ ];
969
+ const settledOf = (c: PoolCtx): string[] => [...Object.keys(c.items), ...Object.keys(c.failures)];
970
+
971
+ /** Free a settled worker's slot and stop its ref. */
972
+ const free = [
973
+ assign({
974
+ active: ({ context, event }) => ctxOf(context).active.filter((k) => k !== childOf(event)),
975
+ claimed: ({ context, event }) => {
976
+ const { [childOf(event)]: _gone, ...rest } = ctxOf(context).claimed;
977
+ return rest;
978
+ },
334
979
  }),
335
- // The wake seam: a standing gate for the whole run's life (root invokes stop only at final).
336
- // The invoke id IS the gate's name (the id derives from the actor path — ADR-0011):
337
- // `source` when the pool is the root, `<path>.source` nested — so two nested
338
- // pools' wake gates cannot collide.
339
- invoke: wake ? [{ id: "source", src: "gate", input: { accepts: [wake.name] } }] : [],
340
- initial: "discovering",
341
- on: {
342
- // A worker settled, at any moment: collect its output, free its slot, stop the ref, and
343
- // go look for more work. Internal to jr2 once — the consumer casts this used to force
344
- // (`DoneActorEvent` unions, `assertEvent`) ship as documented patterns instead.
345
- "xstate.done.actor.*": {
346
- target: ".discovering",
347
- actions: [
348
- assign({
349
- items: ({ context, event }) => ({
350
- ...(context as unknown as PoolCtx).items,
351
- [doneChildOf(event)]: (event as unknown as { output: unknown }).output,
352
- }),
353
- active: ({ context, event }) =>
354
- (context as unknown as PoolCtx).active.filter((id) => id !== doneChildOf(event)),
980
+ stopChild(({ event }) => childOf(event)),
981
+ ];
982
+
983
+ // What a settled worker means: the policy. A child keeps its output or its Failure; a run's end
984
+ // is one item's end, with its status, and never the Pool's.
985
+ const doneActions = runs
986
+ ? [
987
+ ({ event, system }: { event: unknown; system: unknown }) => {
988
+ const out = (event as { output?: DispatchOutput }).output;
989
+ mark(system, { mark: "settled", key: childOf(event), ...settledOfRun(out) });
990
+ },
991
+ assign({
992
+ items: ({ context, event }) => ({
993
+ ...ctxOf(context).items,
994
+ [childOf(event)]: settledOfRun((event as { output?: DispatchOutput }).output),
355
995
  }),
356
- stopChild(({ event }) => doneChildOf(event)),
357
- ],
358
- },
359
- // A worker failed (ADR-0073): keep its Failure, free its slot, stop the ref, and go look for
360
- // more work. The Pool is the parent that catches it, so one failed item does not end the run
361
- // and the host does not delete the Sandboxes of the other workers — a worker parked at a Gate
362
- // keeps its work (parking is retention, ADR-0012). Only a Failure of a live worker: a defect,
363
- // or an error of `next`, takes no transition here, so it goes up and ends the run `error`.
364
- "xstate.error.actor.*": {
365
- guard: ({ context, event }) =>
366
- isFailure((event as { error?: unknown }).error) &&
367
- (context as unknown as PoolCtx).active.includes(errorChildOf(event)),
368
- target: ".discovering",
369
- actions: [
370
- assign({
371
- failures: ({ context, event }) => ({
372
- ...(context as unknown as PoolCtx).failures,
373
- [errorChildOf(event)]: failurePayload((event as unknown as { error: never }).error),
374
- }),
375
- active: ({ context, event }) =>
376
- (context as unknown as PoolCtx).active.filter((id) => id !== errorChildOf(event)),
996
+ }),
997
+ ...free,
998
+ ]
999
+ : [
1000
+ assign({
1001
+ items: ({ context, event }) => ({
1002
+ ...ctxOf(context).items,
1003
+ [childOf(event)]: (event as unknown as { output: unknown }).output,
1004
+ }),
1005
+ }),
1006
+ ...free,
1007
+ ];
1008
+ const errorActions = runs
1009
+ ? [
1010
+ ({ event, system }: { event: unknown; system: unknown }) => {
1011
+ const error = (event as { error?: unknown }).error;
1012
+ mark(system, {
1013
+ mark: "settled",
1014
+ key: childOf(event),
1015
+ status: "dispatch-error",
1016
+ reason: error instanceof Error ? error.message : String(error),
1017
+ });
1018
+ },
1019
+ assign({
1020
+ items: ({ context, event }) => ({ ...ctxOf(context).items, [childOf(event)]: { status: "dispatch-error" } }),
1021
+ }),
1022
+ ...free,
1023
+ ]
1024
+ : [
1025
+ assign({
1026
+ failures: ({ context, event }) => ({
1027
+ ...ctxOf(context).failures,
1028
+ [childOf(event)]: failurePayload((event as unknown as { error: never }).error),
377
1029
  }),
378
- stopChild(({ event }) => errorChildOf(event)),
379
- ],
1030
+ }),
1031
+ ...free,
1032
+ ];
1033
+ const isActive = ({ context, event }: { context: unknown; event: unknown }) =>
1034
+ ctxOf(context).active.includes(childOf(event));
1035
+ /** A Pool of runs: the wait ended at its timeout with the run still live. */
1036
+ const stillLive = ({ event }: { event: unknown }) => {
1037
+ const out = (event as { output?: DispatchOutput }).output;
1038
+ return out?.outcome === "settled" && out.status === "live";
1039
+ };
1040
+ const waitAgain = [
1041
+ assign({ again: ({ context, event }) => [...ctxOf(context).again, childOf(event)] }),
1042
+ stopChild(({ event }: { event: unknown }) => childOf(event)),
1043
+ ];
1044
+ // After a settle: claim again. A pull Source asks `next`; a read Source reads (or latches, when
1045
+ // it reads now); a push Source claims from its held items (the root's `always`).
1046
+ const settleTarget = form === "pull" ? ".discovering" : form === "read" ? ".reading" : undefined;
1047
+ const settleTransitions = (inRead: boolean) => ({
1048
+ "xstate.done.actor.*": [
1049
+ ...(runs ? [{ guard: (a: any) => isActive(a) && stillLive(a), actions: waitAgain }] : []),
1050
+ {
1051
+ // A Pool of runs hears only its own live workers' ends; a Pool of children, as before, every
1052
+ // child's.
1053
+ ...(runs ? { guard: isActive } : {}),
1054
+ ...(inRead ? {} : settleTarget ? { target: settleTarget } : {}),
1055
+ actions: [...doneActions, ...(inRead ? [assign({ latch: true })] : [])],
380
1056
  },
1057
+ ],
1058
+ // A worker failed (ADR-0073): keep its Failure, free its slot, stop the ref, and go look for
1059
+ // more work. The Pool is the parent that catches it, so one failed item does not end the run and
1060
+ // the host does not delete the Sandboxes of the other workers — a worker parked at a Gate keeps
1061
+ // its work (parking is retention, ADR-0012). Only a Failure of a live worker: a defect, or an
1062
+ // error of `next`, takes no transition here, so it goes up and ends the run `error`. A Pool of
1063
+ // runs takes any error of a live worker: a dispatch's error is its item's end, not the Pool's.
1064
+ "xstate.error.actor.*": {
1065
+ guard: (a: { context: unknown; event: unknown }) =>
1066
+ isActive(a) && (runs || isFailure((a.event as { error?: unknown }).error)),
1067
+ ...(inRead ? {} : settleTarget ? { target: settleTarget } : {}),
1068
+ actions: [...errorActions, ...(inRead ? [assign({ latch: true })] : [])],
381
1069
  },
1070
+ });
1071
+
1072
+ const hearAgain = form === "pull" ? "discovering" : form === "read" ? "reading" : undefined;
1073
+ const wakeNames = [...(wake ? [wake.name] : []), ...(hears ? [REREAD] : [])];
1074
+ const latchOn = Object.fromEntries(wakeNames.map((n) => [n, { actions: assign({ latch: true }) }]));
1075
+
1076
+ /** Spawn the next claimed item: the root's `always`, one item a microstep, so each spawn is a
1077
+ * top-level `spawnChild`. A wait sent again first (its slot is counted already), then a read's
1078
+ * ready items, then the held items, while a slot is free. */
1079
+ const nextClaim = (c: PoolCtx): Held | undefined => c.ready[0] ?? c.held[0];
1080
+ const claimAlways = [
1081
+ ...(runs
1082
+ ? [
1083
+ {
1084
+ guard: ({ context }: { context: unknown }) => ctxOf(context).again.length > 0,
1085
+ actions: [
1086
+ spawnChild("worker", {
1087
+ id: ({ context }) => ctxOf(context).again[0]!,
1088
+ input: ({ context }: { context: unknown }) => {
1089
+ const c = ctxOf(context);
1090
+ const h = c.claimed[c.again[0]!] ?? { key: c.again[0]!, item: undefined };
1091
+ // The key finds the run before the body is read (ADR-0071), so a body that this
1092
+ // Assembly's `itemInput` cannot make from an item an older one claimed is no fault:
1093
+ // the item goes as it is.
1094
+ try {
1095
+ return inputFor(h, c.runInput) as never;
1096
+ } catch {
1097
+ return { body: h.item, ...(h.hook !== undefined ? { hook: h.hook } : {}) } as never;
1098
+ }
1099
+ },
1100
+ }),
1101
+ assign({ again: ({ context }) => ctxOf(context).again.slice(1) }),
1102
+ ],
1103
+ },
1104
+ ]
1105
+ : []),
1106
+ {
1107
+ guard: ({ context }: { context: unknown }) => {
1108
+ const c = ctxOf(context);
1109
+ return nextClaim(c) !== undefined && c.active.length < c.cap;
1110
+ },
1111
+ actions: [
1112
+ ({ context, system }: { context: unknown; system: unknown }) => {
1113
+ if (runs) mark(system, { mark: "started", key: nextClaim(ctxOf(context))!.key });
1114
+ },
1115
+ spawnChild("worker", {
1116
+ id: ({ context }) => nextClaim(ctxOf(context))!.key,
1117
+ input: ({ context }: { context: unknown }) =>
1118
+ inputFor(nextClaim(ctxOf(context))!, ctxOf(context).runInput) as never,
1119
+ }),
1120
+ assign(({ context }) => {
1121
+ const c = ctxOf(context);
1122
+ const h = nextClaim(c)!;
1123
+ const fromReady = c.ready.length > 0;
1124
+ return {
1125
+ active: [...c.active, h.key],
1126
+ ready: fromReady ? c.ready.slice(1) : c.ready,
1127
+ held: fromReady ? c.held : c.held.slice(1),
1128
+ claimed: runs ? { ...c.claimed, [h.key]: h } : c.claimed,
1129
+ taken: c.taken + 1,
1130
+ } as never;
1131
+ }),
1132
+ ],
1133
+ },
1134
+ ];
1135
+
1136
+ /** Idle with nothing to claim: a Pool of children ends; a standing Pool parks. */
1137
+ const idleEnd = (status: (event: unknown) => "drained" | "deadlocked") => [
1138
+ ...(report || runs
1139
+ ? []
1140
+ : [
1141
+ {
1142
+ guard: ({ context }: { context: unknown }) => Object.keys(failuresOf(context)).length > 0,
1143
+ target: "failing",
1144
+ actions: assign({ status: ({ event }) => status(event) }) as never,
1145
+ },
1146
+ ]),
1147
+ ];
1148
+
1149
+ /**
1150
+ * What a hand-over carries into this run (ADR-0017): the held items, each parsed again by THIS
1151
+ * Assembly's `item` schema (an item that does not parse, or whose key is held or active already,
1152
+ * is refused, with the reason), and the active keys. A Source that is not a push Source holds
1153
+ * nothing, so a held item that it is given is refused too.
1154
+ */
1155
+ const carriedOf = (
1156
+ handOver: unknown,
1157
+ ): { held: Held[]; active: string[]; claimed: Record<string, Held>; refused: PoolCtx["refused"] } => {
1158
+ const given = (handOver ?? {}) as Partial<StandingCarry>;
1159
+ const claimed: Record<string, Held> = {};
1160
+ for (const a of Array.isArray(given.active) ? given.active : []) {
1161
+ if (typeof a?.key === "string" && a.key !== "") claimed[a.key] = { ...a };
1162
+ }
1163
+ const held: Held[] = [];
1164
+ const refused: PoolCtx["refused"] = [];
1165
+ for (const h of Array.isArray(given.held) ? given.held : []) {
1166
+ if (typeof h?.key !== "string") continue;
1167
+ if (form !== "push") {
1168
+ refused.push({ key: h.key, reason: "the Source is no longer a push Source, so it holds no item" });
1169
+ continue;
1170
+ }
1171
+ const parsed = (src.item as z.ZodType).safeParse(h.item);
1172
+ if (!parsed.success) {
1173
+ refused.push({
1174
+ key: h.key,
1175
+ reason: `the item does not parse: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(item)"}: ${i.message}`).join("; ")}`,
1176
+ });
1177
+ continue;
1178
+ }
1179
+ if (claimed[h.key] || held.some((x) => x.key === h.key)) continue;
1180
+ held.push({ key: h.key, item: parsed.data, ...(h.hook !== undefined ? { hook: h.hook } : {}) });
1181
+ }
1182
+ return { held, active: Object.keys(claimed), claimed, refused };
1183
+ };
1184
+
1185
+ const actors: Record<string, AnyActorLogic> = {
1186
+ worker: worker as AnyActorLogic,
1187
+ ...(src.next ? { next: src.next as AnyActorLogic } : {}),
1188
+ ...(src.read ? { read: src.read as AnyActorLogic, check } : {}),
1189
+ gate: hears ? sourceGate(seat) : plainGate,
1190
+ fail: fail(),
1191
+ };
1192
+ const machine = setup({ actors }).createMachine({
1193
+ id,
1194
+ context: ({ input, self }: { input: unknown; self: { _parent?: unknown } }): PoolCtx => {
1195
+ // RunHost parsed a root run's door. A parent's input is parsed here, and a value that is
1196
+ // wrong starts no worker (ADR-0033). The handles and `instanceId` are no caller's to send.
1197
+ const { [HAND_OVER]: handOver, ...sent } = (input ?? {}) as Record<string, unknown>;
1198
+ const runInput =
1199
+ spec.input && self._parent
1200
+ ? parsedNestedDoor(spec.input, sent, ["workspace", "instanceId"], "pool input invalid, before any worker")
1201
+ : sent;
1202
+ const carried = runs ? carriedOf(handOver) : { held: [], active: [], claimed: {}, refused: [] };
1203
+ return {
1204
+ runInput,
1205
+ cap: typeof spec.cap === "function" ? spec.cap({ input: runInput as never }) : (spec.cap ?? 1),
1206
+ // A hand-over's active keys are counted at once, and each is sent again under its key: the
1207
+ // key finds its run, and the wait follows it (ADR-0017).
1208
+ active: carried.active,
1209
+ items: {},
1210
+ failures: {},
1211
+ held: carried.held,
1212
+ ready: [],
1213
+ claimed: carried.claimed,
1214
+ again: [...carried.active],
1215
+ latch: false,
1216
+ taken: 0,
1217
+ carried: [...carried.active],
1218
+ refused: carried.refused,
1219
+ };
1220
+ },
1221
+ // Each held item that the hand-over carried and the `item` schema refused is a mark on the feed.
1222
+ entry: [
1223
+ ({ context, system }: { context: unknown; system: unknown }) => {
1224
+ for (const r of ctxOf(context).refused) mark(system, { mark: "carry-refused", key: r.key, reason: r.reason });
1225
+ },
1226
+ assign({ refused: [] }) as never,
1227
+ ],
1228
+ // The Source's Gate: a standing gate for the whole run's life (root invokes stop only at final).
1229
+ // The invoke id IS the gate's name (the id derives from the actor path — ADR-0011): `source`
1230
+ // when the pool is the root, `<path>.source` nested — so two nested pools' Gates cannot collide.
1231
+ // It hears `wake` (`jr2 send`), and a Hook's delivery reaches the Source's handlers through it.
1232
+ invoke: wake || hears ? [{ id: "source", src: "gate", input: { accepts: wake ? [wake.name] : [] } }] : [],
1233
+ initial: form === "pull" ? "discovering" : form === "read" ? "reading" : "parked",
1234
+ // A pull Source claims in `discovering`; the other paths claim here.
1235
+ ...(form !== "pull" || runs ? { always: claimAlways as never } : {}),
1236
+ on: {
1237
+ ...settleTransitions(false),
1238
+ ...latchOn,
1239
+ // The push half: the seat checked the key and the bound before it sent this. Checked again
1240
+ // here, in the transition, so a held item is held once.
1241
+ ...(form === "push"
1242
+ ? {
1243
+ [CARRY]: {
1244
+ guard: ({ context, event }: { context: unknown; event: unknown }) => {
1245
+ const c = ctxOf(context);
1246
+ const key = (event as { key: string }).key;
1247
+ return c.held.length < hold && !c.held.some((h) => h.key === key) && !c.active.includes(key);
1248
+ },
1249
+ actions: assign({
1250
+ held: ({ context, event }) => {
1251
+ const e = event as unknown as Held;
1252
+ return [...ctxOf(context).held, { key: e.key, item: e.item, ...(e.hook ? { hook: e.hook } : {}) }];
1253
+ },
1254
+ }),
1255
+ },
1256
+ }
1257
+ : {}),
1258
+ } as never,
382
1259
  states: {
383
- discovering: {
384
- // Saturated: don't even query — park until a worker settles (which re-enters here).
385
- always: [{ guard: ({ context }) => context.active.length >= (context as PoolCtx).cap, target: "saturated" }],
386
- invoke: {
387
- id: "discover",
388
- src: "next",
389
- input: ({ context }) => ({ active: (context as PoolCtx).active }),
390
- onDone: [
391
- {
392
- // Claimed: record it, spawn its worker (TOP-LEVEL spawnChild — the visualize
393
- // trace), and loop for more. `reenter` re-invokes the query actor.
394
- guard: ({ event }) => claimOf(event).item !== null,
395
- target: "discovering",
396
- reenter: true,
397
- actions: [
398
- assign({
399
- active: ({ context, event }) => [...(context as unknown as PoolCtx).active, itemIdOf(event)],
1260
+ ...(form === "pull"
1261
+ ? {
1262
+ discovering: {
1263
+ entry: assign({ latch: false }),
1264
+ // Saturated: don't even query — park until a worker settles (which re-enters here).
1265
+ always: [
1266
+ {
1267
+ guard: ({ context }: { context: unknown }) => ctxOf(context).active.length >= ctxOf(context).cap,
1268
+ target: "saturated",
1269
+ },
1270
+ ],
1271
+ invoke: {
1272
+ id: "discover",
1273
+ src: "next",
1274
+ input: ({ context }: { context: unknown }) => ({
1275
+ active: ctxOf(context).active,
1276
+ settled: settledOf(ctxOf(context)),
1277
+ input: ctxOf(context).runInput,
400
1278
  }),
401
- spawnChild("worker", {
402
- id: ({ event }) => itemIdOf(event),
403
- input: ({ context, event }) => {
404
- const item = claimOf(event).item;
405
- const runInput = (context as unknown as PoolCtx).runInput;
406
- return (spec.itemInput ? spec.itemInput(item as never, { input: runInput }) : item) as never;
1279
+ onDone: [
1280
+ {
1281
+ // Claimed: record it, spawn its worker (TOP-LEVEL spawnChild — the visualize
1282
+ // trace), and loop for more. `reenter` re-invokes the query actor.
1283
+ guard: ({ event }: { event: unknown }) => claimOf(event).item !== null,
1284
+ target: "discovering",
1285
+ reenter: true,
1286
+ actions: [
1287
+ ({ event, system }: { event: unknown; system: unknown }) => {
1288
+ if (runs) mark(system, { mark: "started", key: itemIdOf(event) });
1289
+ },
1290
+ assign({
1291
+ taken: ({ context }) => ctxOf(context).taken + 1,
1292
+ active: ({ context, event }) => [...ctxOf(context).active, itemIdOf(event)],
1293
+ claimed: ({ context, event }) =>
1294
+ runs
1295
+ ? {
1296
+ ...ctxOf(context).claimed,
1297
+ [itemIdOf(event)]: { key: itemIdOf(event), item: claimOf(event).item },
1298
+ }
1299
+ : ctxOf(context).claimed,
1300
+ }),
1301
+ spawnChild("worker", {
1302
+ id: ({ event }) => itemIdOf(event),
1303
+ input: ({ context, event }: { context: unknown; event: unknown }) =>
1304
+ inputFor(
1305
+ { key: itemIdOf(event), item: claimOf(event).item },
1306
+ ctxOf(context).runInput,
1307
+ ) as never,
1308
+ }),
1309
+ ],
407
1310
  },
408
- }),
409
- ],
410
- },
411
- // Nothing ready, workers live → healthy parking: their settling re-queries, as do
412
- // the wake gate and the poll timer. This is jr's exit-3 "waiting" as a state.
413
- { guard: ({ context }) => (context as PoolCtx).active.length > 0, target: "parked" },
414
- // Idle with failures: the run fails (ADR-0017, ADR-0073), at a drain and at a deadlock.
415
- // No worker is live, so the Failure deletes no sibling's Sandbox. `"report"` has no
416
- // `failing` state and no branch to it, so the walk lists no state that fails the run. The
417
- // branch is spread in, so xstate cannot infer the actor type of its action: `as never`
418
- // lets the check pass.
419
- ...(report
420
- ? []
421
- : [
1311
+ // A `reread` or a `wake` came while `next` ran: ask once more.
422
1312
  {
423
- guard: ({ context }: { context: unknown }) => Object.keys(failuresOf(context)).length > 0,
424
- target: "failing",
425
- actions: assign({
426
- status: ({ event }) => (claimOf(event).open > 0 ? ("deadlocked" as const) : ("drained" as const)),
427
- }) as never,
1313
+ guard: ({ context }: { context: unknown }) => ctxOf(context).latch,
1314
+ target: "discovering",
1315
+ reenter: true,
428
1316
  },
429
- ]),
430
- // Idle + items exist that will never become ready by themselves → jr's exit 2.
431
- {
432
- guard: ({ event }) => claimOf(event).open > 0,
433
- target: "deadlocked",
434
- actions: assign({ status: () => "deadlocked" as const }),
1317
+ // Nothing ready, workers live → healthy parking: their settling re-queries, as do
1318
+ // the Source's Gate and the poll timer. This is jr's exit-3 "waiting" as a state.
1319
+ // A standing Pool parks whatever runs.
1320
+ {
1321
+ guard: ({ context }: { context: unknown }) => runs || ctxOf(context).active.length > 0,
1322
+ target: "parked",
1323
+ },
1324
+ // Idle with failures: the run fails (ADR-0017, ADR-0073), at a drain and at a deadlock.
1325
+ // No worker is live, so the Failure deletes no sibling's Sandbox. `"report"` has no
1326
+ // `failing` state and no branch to it, so the walk lists no state that fails the run.
1327
+ ...(runs
1328
+ ? []
1329
+ : [
1330
+ ...idleEnd((event) => (claimOf(event).open > 0 ? "deadlocked" : "drained")),
1331
+ // Idle + items exist that will never become ready by themselves → jr's exit 2.
1332
+ {
1333
+ guard: ({ event }: { event: unknown }) => claimOf(event).open > 0,
1334
+ target: "deadlocked",
1335
+ actions: assign({ status: () => "deadlocked" as const }),
1336
+ },
1337
+ // Idle + nothing anywhere → done (jr exit 0).
1338
+ { target: "drained", actions: assign({ status: () => "drained" as const }) },
1339
+ ]),
1340
+ ],
1341
+ },
435
1342
  },
436
- // Idle + nothing anywhere → done (jr exit 0).
437
- { target: "drained", actions: assign({ status: () => "drained" as const }) },
438
- ],
439
- },
440
- },
441
- saturated: {
442
- // Every slot full: only a settling worker (the root `xstate.done.actor.*` or a caught
443
- // Failure) moves us.
444
- },
1343
+ saturated: {
1344
+ // Every slot full: only a settling worker (the root `xstate.done.actor.*` or a caught
1345
+ // Failure) moves us.
1346
+ },
1347
+ }
1348
+ : {}),
1349
+ ...(form === "read"
1350
+ ? {
1351
+ reading: {
1352
+ entry: assign({ latch: false, ready: [] }),
1353
+ // During a read, a `reread`, a `wake` and a settled worker set the latch: the loop
1354
+ // reads once more after this read, so no change is lost.
1355
+ on: { ...settleTransitions(true), ...latchOn },
1356
+ initial: "fetch",
1357
+ states: {
1358
+ fetch: {
1359
+ invoke: {
1360
+ id: "read",
1361
+ src: "read",
1362
+ input: ({ context }: { context: unknown }) => ctxOf(context).runInput,
1363
+ onDone: { target: "check" },
1364
+ // A failed read gives no items. The next settle, `reread` or poll reads again.
1365
+ onError: { target: "#jr2PoolReadDone" },
1366
+ },
1367
+ },
1368
+ check: {
1369
+ invoke: {
1370
+ id: "check",
1371
+ src: "check",
1372
+ input: ({ context, event }: { context: unknown; event: unknown }) => ({
1373
+ output: (event as { output?: unknown }).output,
1374
+ runInput: ctxOf(context).runInput,
1375
+ known: knownOf(ctxOf(context)),
1376
+ }),
1377
+ onDone: {
1378
+ target: "claiming",
1379
+ actions: assign({ ready: ({ event }) => (event as unknown as { output: Held[] }).output }),
1380
+ },
1381
+ onError: { target: "#jr2PoolReadDone" },
1382
+ },
1383
+ },
1384
+ // The reads merge: the root's `always` claims the ready items while a slot is free.
1385
+ // The rest are dropped: the next read finds them again.
1386
+ claiming: {
1387
+ always: {
1388
+ guard: ({ context }: { context: unknown }) =>
1389
+ ctxOf(context).ready.length === 0 || ctxOf(context).active.length >= ctxOf(context).cap,
1390
+ target: "#jr2PoolReadDone",
1391
+ actions: assign({ ready: [] }),
1392
+ },
1393
+ },
1394
+ },
1395
+ },
1396
+ readDone: {
1397
+ id: "jr2PoolReadDone",
1398
+ always: [
1399
+ { guard: ({ context }: { context: unknown }) => ctxOf(context).latch, target: "reading" },
1400
+ {
1401
+ guard: ({ context }: { context: unknown }) => runs || ctxOf(context).active.length > 0,
1402
+ target: "parked",
1403
+ },
1404
+ ...(runs
1405
+ ? []
1406
+ : [
1407
+ ...idleEnd(() => "drained"),
1408
+ { target: "drained", actions: assign({ status: () => "drained" as const }) },
1409
+ ]),
1410
+ ],
1411
+ },
1412
+ }
1413
+ : {}),
445
1414
  parked: {
446
- // Waiting on the world: wake early on push, or re-query on the poll cadence.
447
- on: wake ? { [wake.name]: "discovering" } : {},
448
- after: spec.source.pollEvery ? { [spec.source.pollEvery]: { target: "discovering" } } : {},
1415
+ // Waiting on the world: a `wake`, a Hook's `reread()`, or the poll's cadence reads again. A
1416
+ // push Source's held items are claimed by the root's `always` as slots free.
1417
+ on: hearAgain ? Object.fromEntries(wakeNames.map((n) => [n, { target: hearAgain }])) : {},
1418
+ after: hearAgain && src.pollEvery ? { [src.pollEvery]: { target: hearAgain } } : {},
449
1419
  },
450
1420
  // Not a final state: the run ends `failed`, and the Failure says which items failed.
451
- ...(report
1421
+ ...(report || runs
452
1422
  ? {}
453
1423
  : {
454
1424
  failing: {
455
1425
  invoke: {
456
1426
  src: "fail" as const,
457
1427
  input: ({ context }: { context: unknown }) => {
458
- const { status, items, failures } = context as PoolCtx;
1428
+ const { status, items, failures } = ctxOf(context);
459
1429
  const n = Object.keys(failures).length;
460
1430
  const m = n + Object.keys(items).length;
461
1431
  return { reason: `${n} of ${m} items failed`, detail: { status, items, failures } };
@@ -463,16 +1433,16 @@ export function pool(worker: AnyStateMachine, spec: PoolSpec<any, any>): AnyStat
463
1433
  },
464
1434
  },
465
1435
  }),
466
- drained: { type: "final" },
467
- deadlocked: { type: "final" },
1436
+ // A standing Pool never ends by itself (ADR-0017): it has no final state.
1437
+ ...(runs ? {} : { drained: { type: "final" as const }, deadlocked: { type: "final" as const } }),
468
1438
  },
469
1439
  // Outcome-in-context (the documented cast-free idiom): the two finals stamped `status`.
470
- output: ({ context }): PoolOutput => ({
471
- status: (context as unknown as PoolCtx).status ?? "drained",
472
- items: (context as unknown as PoolCtx).items,
473
- failures: (context as unknown as PoolCtx).failures,
1440
+ output: ({ context }: { context: unknown }): PoolOutput => ({
1441
+ status: ctxOf(context).status ?? "drained",
1442
+ items: ctxOf(context).items,
1443
+ failures: ctxOf(context).failures,
474
1444
  }),
475
- });
1445
+ } as never) as unknown as AnyStateMachine;
476
1446
 
477
1447
  // The pool's vocabulary is the pool's OWN — the wake def and nothing else (ADR-0011,
478
1448
  // ADR-0049). The worker's names stay the worker's: its gates and menus resolve against the
@@ -480,6 +1450,18 @@ export function pool(worker: AnyStateMachine, spec: PoolSpec<any, any>): AnyStat
480
1450
  // same-name/different-payload pair between two workers a collision that per-Machine scoping
481
1451
  // had already avoided.
482
1452
  if (wake) attachVocabulary(machine, new Map([[wake.name, wake]]));
1453
+ if (runs) {
1454
+ // A standing Pool is the Workflow (ADR-0017): the load walk refuses one that is not the top
1455
+ // Machine. A run is not the Pool's child, so `customize()` reaches nothing in it (ADR-0049).
1456
+ const info: StandingPoolInfo = {
1457
+ id,
1458
+ callee: callee!,
1459
+ kinds: Object.keys(hooks).filter((kind) => Object.keys(hooks[kind] ?? {}).length > 0),
1460
+ hears: (kind, key) => typeof hooks[kind]?.[key] === "function",
1461
+ };
1462
+ Object.defineProperty(machine, STANDING, { value: info, enumerable: false });
1463
+ return machine;
1464
+ }
483
1465
  // The pool is TRANSPARENT to its worker (ADR-0049), exactly as `workspace()` is to its body: a
484
1466
  // `customize(machine, { agents })` on a pool-rooted workflow means the worker's Agents, which
485
1467
  // is the only Machine under a pool that carries any.
@@ -493,3 +1475,21 @@ export function pool(worker: AnyStateMachine, spec: PoolSpec<any, any>): AnyStat
493
1475
  if (spec.input) attachInputSchema(machine, spec.input);
494
1476
  return machine;
495
1477
  }
1478
+
1479
+ /** What a Pool of runs records of a settled item (ADR-0017): the run and its status, or why no run
1480
+ * came of it. */
1481
+ function settledOfRun(out: DispatchOutput | undefined): { run?: string; status: string; reason?: string } {
1482
+ if (!out) return { status: "unknown" };
1483
+ switch (out.outcome) {
1484
+ case "settled":
1485
+ return { run: out.run, status: out.status };
1486
+ case "started":
1487
+ return { run: out.run, status: "started" };
1488
+ case "refused":
1489
+ return { status: "start-refused", reason: `${out.status}: ${out.report.join("; ")}` };
1490
+ case "unreachable":
1491
+ return { status: "unreachable" };
1492
+ default:
1493
+ return { status: "unknown", ...("reason" in out ? { reason: String(out.reason) } : {}) };
1494
+ }
1495
+ }