@jr2/orchestrator 0.5.1 → 0.6.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 (185) hide show
  1. package/docs/agents-block.md +2 -2
  2. package/docs/concepts/agents-and-turns.md +56 -20
  3. package/docs/concepts/architecture.md +30 -17
  4. package/docs/concepts/charts.md +6 -3
  5. package/docs/concepts/converge.md +3 -0
  6. package/docs/concepts/effects.md +86 -31
  7. package/docs/concepts/machines-and-workflows.md +9 -0
  8. package/docs/concepts/pools-and-sources.md +6 -6
  9. package/docs/concepts/secrets-and-trust.md +126 -42
  10. package/docs/concepts/the-menu.md +3 -0
  11. package/docs/concepts/workspaces.md +129 -51
  12. package/docs/get-started/an-agent-in-a-sandbox.md +21 -19
  13. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  14. package/docs/get-started/install.md +4 -0
  15. package/docs/guides/add-tools-to-the-sandbox.md +17 -4
  16. package/docs/guides/bind-open-parts.md +5 -4
  17. package/docs/guides/cut-a-worktree.md +560 -0
  18. package/docs/guides/fix-a-failed-converge.md +1 -1
  19. package/docs/guides/let-an-agent-write-a-chart.md +5 -1
  20. package/docs/guides/look-inside-a-sandbox.md +18 -15
  21. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  22. package/docs/guides/push-and-sign-a-branch.md +67 -21
  23. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  24. package/docs/guides/run-one-worker-per-item.md +19 -18
  25. package/docs/guides/set-an-agents-access-and-dials.md +67 -22
  26. package/docs/guides/size-a-workspace.md +19 -6
  27. package/docs/guides/use-a-hosted-provider.md +6 -0
  28. package/docs/guides/watch-a-run.md +47 -24
  29. package/docs/guides/write-a-workflow.md +1 -1
  30. package/docs/llms.txt +33 -12
  31. package/docs/reference/authoring-surface/agent.md +34 -11
  32. package/docs/reference/authoring-surface/answer.md +19 -3
  33. package/docs/reference/authoring-surface/arg.md +27 -5
  34. package/docs/reference/authoring-surface/branchname.md +15 -0
  35. package/docs/reference/authoring-surface/command.md +36 -21
  36. package/docs/reference/authoring-surface/fail.md +17 -4
  37. package/docs/reference/authoring-surface/file.md +36 -16
  38. package/docs/reference/authoring-surface/http.md +108 -8
  39. package/docs/reference/authoring-surface/isready.md +14 -0
  40. package/docs/reference/authoring-surface/jr2setup.md +1 -1
  41. package/docs/reference/authoring-surface/open.md +36 -5
  42. package/docs/reference/authoring-surface/paramsof.md +22 -4
  43. package/docs/reference/authoring-surface/pool.md +4 -4
  44. package/docs/reference/authoring-surface/push.md +30 -12
  45. package/docs/reference/authoring-surface/readyof.md +21 -0
  46. package/docs/reference/authoring-surface/secret.md +25 -4
  47. package/docs/reference/authoring-surface/source.md +3 -3
  48. package/docs/reference/authoring-surface/type-agentdefinition.md +2 -2
  49. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  50. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  51. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  52. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  53. package/docs/reference/authoring-surface/type-binding.md +5 -6
  54. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  55. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  56. package/docs/reference/authoring-surface/type-commandoutput.md +22 -4
  57. package/docs/reference/authoring-surface/type-customize.md +4 -2
  58. package/docs/reference/authoring-surface/type-dispatchoutput.md +8 -1
  59. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  60. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  61. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  62. package/docs/reference/authoring-surface/type-hostinjectedinput.md +2 -4
  63. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  64. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  65. package/docs/reference/authoring-surface/type-httpoutput.md +7 -1
  66. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  67. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  68. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  69. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  70. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  71. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  72. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  73. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  74. package/docs/reference/authoring-surface/type-poolspec.md +2 -2
  75. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  76. package/docs/reference/authoring-surface/type-pushoutput.md +32 -7
  77. package/docs/reference/authoring-surface/type-reposlot.md +5 -5
  78. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  79. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  80. package/docs/reference/authoring-surface/type-sourcespec.md +10 -10
  81. package/docs/reference/authoring-surface/type-workspaced.md +29 -8
  82. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  83. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  84. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  85. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  86. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  87. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  88. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  89. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  90. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  91. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  92. package/docs/reference/authoring-surface/workspace.md +65 -37
  93. package/docs/reference/authoring-surface/worktree.md +71 -0
  94. package/docs/reference/authoring-surface.md +30 -10
  95. package/docs/reference/cli.md +1 -1
  96. package/docs/reference/config.md +13 -4
  97. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  98. package/docs/reference/decisions/0002-duplex-streaming-actor.md +17 -10
  99. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  100. package/docs/reference/decisions/0005-sandbox-pod-composition.md +29 -19
  101. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  102. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +30 -30
  103. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +35 -17
  104. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +8 -2
  105. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  106. package/docs/reference/decisions/0017-pool-over-a-source-port.md +6 -4
  107. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +21 -9
  108. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +2 -1
  109. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +8 -9
  110. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +62 -65
  111. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +9 -1
  112. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +13 -12
  113. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +4 -1
  114. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +18 -19
  115. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +8 -8
  116. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +4 -4
  117. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  118. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  119. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  120. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +24 -24
  121. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +5 -3
  122. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +110 -67
  123. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +101 -74
  124. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +5 -6
  125. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +2 -0
  126. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +181 -153
  127. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +74 -63
  128. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +31 -20
  129. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  130. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
  131. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
  132. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  133. package/docs/reference/decisions.md +3 -0
  134. package/docs/reference/glossary.md +51 -12
  135. package/docs/reference/http-api.md +45 -7
  136. package/docs/reference/machines.md +0 -1
  137. package/docs/why.md +5 -4
  138. package/package.json +3 -3
  139. package/src/actor.ts +161 -35
  140. package/src/agent.ts +6 -6
  141. package/src/ambient.ts +3 -5
  142. package/src/chart.ts +186 -19
  143. package/src/charts.ts +9 -2
  144. package/src/config.ts +16 -6
  145. package/src/custodian.lua +17 -5
  146. package/src/custodian.ts +251 -77
  147. package/src/customize.ts +11 -3
  148. package/src/effect-checks.ts +50 -3
  149. package/src/effect.ts +343 -127
  150. package/src/effector-wire.ts +6 -3
  151. package/src/fail.ts +17 -4
  152. package/src/harness-client.ts +74 -31
  153. package/src/harness-env.ts +33 -0
  154. package/src/held-secrets.ts +125 -16
  155. package/src/holds.ts +3 -0
  156. package/src/http-effect.ts +88 -32
  157. package/src/http.ts +56 -17
  158. package/src/index.ts +15 -1
  159. package/src/internal.ts +1 -0
  160. package/src/kube-client.ts +6 -3
  161. package/src/machine-doc.ts +3 -4
  162. package/src/model-keys.ts +24 -0
  163. package/src/names.ts +19 -6
  164. package/src/notices.ts +443 -54
  165. package/src/open-body.ts +85 -19
  166. package/src/open.ts +36 -5
  167. package/src/parts.ts +40 -28
  168. package/src/party.ts +33 -0
  169. package/src/pick.ts +41 -7
  170. package/src/pool.ts +44 -23
  171. package/src/registration.ts +53 -11
  172. package/src/repo-identity.ts +7 -3
  173. package/src/repos.ts +20 -2
  174. package/src/run-host.ts +106 -20
  175. package/src/sandbox-effect.ts +217 -66
  176. package/src/sandbox-kube.ts +63 -16
  177. package/src/secret-placement.ts +9 -2
  178. package/src/setup.ts +86 -6
  179. package/src/tokens.ts +107 -32
  180. package/src/vocabulary.ts +23 -4
  181. package/src/wire.ts +10 -10
  182. package/src/workspace.ts +264 -253
  183. package/src/worktree.ts +249 -0
  184. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  185. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
package/src/effect.ts CHANGED
@@ -44,6 +44,7 @@ import type { ExecOutcome, ExecRead, ExecRequest, SyncAnswer } from "./wire.ts";
44
44
  import { bindOpenSecretIn, type EffectorPortFactory, type OpenSecretIn } from "./http-effect.ts";
45
45
  import { commandEnvParts, credentialedCommandWith, sandboxEffectorPort, type CommandEnv } from "./sandbox-effect.ts";
46
46
  import { open } from "./open.ts";
47
+ import type { AnyWorktreePath, WorktreeOutput } from "./worktree.ts";
47
48
  import {
48
49
  effectBindingShape,
49
50
  openEffectLogic,
@@ -76,30 +77,52 @@ export type Arg<N extends string = string> = { readonly arg: N };
76
77
  *
77
78
  * @example
78
79
  * ```ts
79
- * import { arg, command, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
80
+ * import {
81
+ * arg,
82
+ * command,
83
+ * fail,
84
+ * isReady,
85
+ * jr2Setup,
86
+ * workspace,
87
+ * worktree,
88
+ * type Workspaced,
89
+ * } from "@jr2/orchestrator";
80
90
  *
81
91
  * type Input = Workspaced<{ file: string }, "app">;
82
92
  *
93
+ * const lintTree = (context: Input) => `${context.workspace.repos.app}/lint` as const;
94
+ *
83
95
  * const body = jr2Setup({
84
96
  * types: {} as { context: Input; input: Input },
85
- * actors: { lint: command(["npx", "eslint", arg("file")]), fail: fail() },
97
+ * actors: { cut: worktree(), lint: command(["npx", "eslint", arg("file")]), fail: fail() },
86
98
  * }).createMachine({
87
99
  * id: "lint",
88
100
  * context: ({ input }) => input,
89
101
  * on: { "workspace.lost": ".lost" },
90
- * initial: "lint",
102
+ * initial: "cutting",
91
103
  * states: {
104
+ * cutting: {
105
+ * invoke: {
106
+ * src: "cut",
107
+ * input: ({ context }) => ({ path: lintTree(context), branch: "lint" }),
108
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "lint" }, { target: "notCut" }],
109
+ * },
110
+ * },
92
111
  * lint: {
93
- * invoke: { src: "lint", input: ({ context }) => ({ file: context.file }), onDone: "done" },
112
+ * invoke: {
113
+ * src: "lint",
114
+ * input: ({ context }) => ({ file: context.file, worktree: lintTree(context) }),
115
+ * onDone: "done",
116
+ * },
94
117
  * },
95
118
  * done: { type: "final" },
119
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
96
120
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
97
121
  * },
98
122
  * });
99
123
  *
100
124
  * export const machine = workspace(body, {
101
125
  * repos: { app: { url: "https://github.com/you/app.git" } },
102
- * spec: () => ({ branch: "lint" }),
103
126
  * });
104
127
  * ```
105
128
  *
@@ -109,6 +132,13 @@ export type Arg<N extends string = string> = { readonly arg: N };
109
132
  */
110
133
  export const arg = <const N extends string>(name: N): Arg<N> => ({ arg: name });
111
134
 
135
+ /** What an Effect's input takes: `required` keys, `optional` keys, and exactly one of `oneOf`. */
136
+ export type InputShape = {
137
+ readonly required: readonly string[];
138
+ readonly optional: readonly string[];
139
+ readonly oneOf?: readonly string[];
140
+ };
141
+
112
142
  /** A `command` Effect's plain data. */
113
143
  export type CommandParts = {
114
144
  readonly kind: "command";
@@ -124,8 +154,12 @@ export type CommandParts = {
124
154
  /** A credentialed command's `pin`: its input names the commit (`{ sha }`, an approved one) in
125
155
  * place of the Agent's branch tip (ADR-0067). */
126
156
  readonly pin?: true;
127
- /** The Repo Slot whose Worktree is the cwd. Needed only where the Workspace carries several. */
128
- readonly slot?: string;
157
+ /** The kit's cut (`worktree()`): its `expect`, static, so that a Chart cannot change it. A command
158
+ * with this is the kit's program, and its output is a `WorktreeOutput`. */
159
+ readonly cut?: { readonly expect?: "new" | "existing" };
160
+ /** Marked by the kit, on a cut alone: a restore or a Harness restart sends it again, under its
161
+ * id. An author has no such option: a command is at most once (ADR-0067). */
162
+ readonly idempotent?: true;
129
163
  readonly timeoutMs: number;
130
164
  /** Characters of the stream's end kept in the output. The full stream goes to the feed. */
131
165
  readonly tail: number;
@@ -156,16 +190,15 @@ export type HttpParts = {
156
190
  /** Marked by the author: a restore that cannot find the Effect sends it again. */
157
191
  readonly idempotent: boolean;
158
192
  /** The body is a Worktree file (`body: file(...)`, ADR-0067): the Effect runs in the Sandbox
159
- * Effector of the Workspace whose body invokes it. `slot` is the Repo Slot whose Worktree holds
160
- * the file — needed only where the Workspace carries several. Absent: a json, text or no body, in
193
+ * Effector of the Workspace whose body invokes it. The input's `worktree` holds the file. Absent: a json, text or no body, in
161
194
  * the Instance Effector. */
162
- readonly file?: { readonly slot?: string; readonly contentType?: string };
195
+ readonly file?: { readonly contentType?: string };
163
196
  };
164
197
 
165
198
  /**
166
- * A `push` Effect's plain data (ADR-0067, ADR-0051): the Workspace's branch to its Repo Slot's
199
+ * A `push` Effect's plain data (ADR-0067, ADR-0075): a cut Worktree's branch to its Repo Slot's
167
200
  * Binding. `sign`, `squash`, `force` are the author's; `pin` says the Effect's input names the
168
- * commit (an approved one) instead of the Agent's branch tip.
201
+ * commit (an approved one) instead of the branch's tip.
169
202
  */
170
203
  export type PushParts = {
171
204
  readonly kind: "push";
@@ -291,10 +324,30 @@ export function targetText(parts: { readonly target: HttpParts["target"] | typeo
291
324
  * `customize()` binds it (ADR-0054); and for a dispatch with no input schema: the callee's door is
292
325
  * the only check, so nothing here says what it reads (ADR-0071). */
293
326
  export function inputKeysOf(parts: EffectParts): string[] | undefined {
294
- if (parts.kind === "dispatch") return parts.inputKeys === undefined ? undefined : [...parts.inputKeys];
295
- if (parts.kind !== "command") return [...parts.inputKeys];
327
+ const shape = inputShapeOf(parts);
328
+ return shape && [...shape.required, ...shape.optional, ...(shape.oneOf ?? [])];
329
+ }
330
+
331
+ /** The shape of a cut's input. */
332
+ const CUT_SHAPE: InputShape = { required: ["path"], optional: ["from"], oneOf: ["branch", "detach"] };
333
+
334
+ /** An Effect's input as a Chart's door and its start hold a Chart to it. Undefined where
335
+ * {@link inputKeysOf} is. `worktree` is optional for a key the Workspace decides: the door makes it
336
+ * required when it knows that the Workspace has Repo Slots, and a start refuses it then. */
337
+ export function inputShapeOf(parts: EffectParts): InputShape | undefined {
338
+ if (parts.kind === "dispatch") {
339
+ return parts.inputKeys === undefined ? undefined : { required: [...parts.inputKeys], optional: [] };
340
+ }
341
+ if (parts.kind === "push") return { required: [...parts.inputKeys], optional: [] };
342
+ if (parts.kind === "http") {
343
+ return { required: [...parts.inputKeys], optional: parts.file ? ["worktree"] : [] };
344
+ }
345
+ if (parts.cut) return CUT_SHAPE;
296
346
  if (parts.argv === open) return undefined;
297
- return [...parts.argv.flatMap((a) => (typeof a === "string" ? [] : [a.arg])), ...(parts.pin ? ["sha"] : [])];
347
+ const keys = [...parts.argv.flatMap((a) => (typeof a === "string" ? [] : [a.arg])), ...(parts.pin ? ["sha"] : [])];
348
+ // A command with an Effect secret runs on a commit of a Worktree, so it names one in each Workspace.
349
+ const credentialed = (parts.secrets?.length ?? 0) > 0 || parts.openSecret === true;
350
+ return credentialed ? { required: [...keys, "worktree"], optional: [] } : { required: keys, optional: ["worktree"] };
298
351
  }
299
352
 
300
353
  // --- Output -------------------------------------------------------------------------------------
@@ -304,6 +357,11 @@ export function inputKeysOf(parts: EffectParts): string[] | undefined {
304
357
  * not run the Effect again. */
305
358
  export type Unknown = { outcome: "unknown"; reason: string };
306
359
 
360
+ /** A value of `outcome` that this kit does not give. It stands in the union so that no author can
361
+ * write an exhaustive check, so that a later outcome is no break: route on what you know, and give
362
+ * the rest a last arm. */
363
+ export type LaterOutcome = { outcome: "(a later outcome)"; reason?: string };
364
+
307
365
  // `dirty` is a credentialed command's alone (ADR-0067): it runs in the Sandbox Effector against
308
366
  // the pinned commit, never the live Worktree.
309
367
  /**
@@ -321,34 +379,48 @@ export type Unknown = { outcome: "unknown"; reason: string };
321
379
  * characters. `dirty` is `true` when a command that names an Effect secret ran against a commit,
322
380
  * and the Worktree held changes that it did not see.
323
381
  *
382
+ * The union is open ({@link LaterOutcome}): give the rest a last arm with no guard.
383
+ *
324
384
  * @example
325
385
  * ```ts
326
386
  * import {
327
387
  * command,
328
388
  * type CommandOutput,
329
389
  * fail,
390
+ * isReady,
330
391
  * jr2Setup,
331
392
  * workspace,
393
+ * worktree,
332
394
  * type Workspaced,
333
395
  * } from "@jr2/orchestrator";
334
396
  *
335
397
  * type Input = Workspaced<{}, "app">;
336
398
  *
399
+ * const testTree = (context: Input) => `${context.workspace.repos.app}/test` as const;
400
+ *
337
401
  * const passed = (output: CommandOutput): boolean =>
338
402
  * output.outcome === "exited" && output.code === 0;
339
403
  *
340
404
  * const body = jr2Setup({
341
405
  * types: {} as { context: Input; input: Input },
342
- * actors: { tests: command(["npm", "test"], { timeout: "15m" }), fail: fail() },
406
+ * actors: { cut: worktree(), tests: command(["npm", "test"], { timeout: "15m" }), fail: fail() },
343
407
  * }).createMachine({
344
408
  * id: "tests",
345
409
  * context: ({ input }) => input,
346
410
  * on: { "workspace.lost": ".lost" },
347
- * initial: "testing",
411
+ * initial: "cutting",
348
412
  * states: {
413
+ * cutting: {
414
+ * invoke: {
415
+ * src: "cut",
416
+ * input: ({ context }) => ({ path: testTree(context), branch: "tests" }),
417
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
418
+ * },
419
+ * },
349
420
  * testing: {
350
421
  * invoke: {
351
422
  * src: "tests",
423
+ * input: ({ context }) => ({ worktree: testTree(context) }),
352
424
  * onDone: [
353
425
  * { guard: ({ event }) => passed(event.output), target: "green" },
354
426
  * { target: "red" },
@@ -358,13 +430,13 @@ export type Unknown = { outcome: "unknown"; reason: string };
358
430
  * green: { type: "final" },
359
431
  * // Not a final state: a final state means `done`, and the run ends `failed` here.
360
432
  * red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
433
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
361
434
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
362
435
  * },
363
436
  * });
364
437
  *
365
438
  * export const machine = workspace(body, {
366
439
  * repos: { app: { url: "https://github.com/you/app.git" } },
367
- * spec: () => ({ branch: "tests" }),
368
440
  * });
369
441
  * ```
370
442
  *
@@ -372,7 +444,7 @@ export type Unknown = { outcome: "unknown"; reason: string };
372
444
  * @see docs/guides/run-a-command-or-call-a-url.md
373
445
  * @see docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md
374
446
  */
375
- export type CommandOutput = (ExecOutcome & { dirty?: boolean }) | Unknown;
447
+ export type CommandOutput = (ExecOutcome & { dirty?: boolean }) | Unknown | LaterOutcome;
376
448
 
377
449
  // Reach is output, as an http Effect's is (ADR-0067): a body a model wrote can be fixed through
378
450
  // the Chain, so a refusal is output too.
@@ -390,6 +462,8 @@ export type CommandOutput = (ExecOutcome & { dirty?: boolean }) | Unknown;
390
462
  * - `unreachable`: the Orchestrator of the target did not answer. No run started.
391
463
  * - `unknown`: jr2 cannot tell if a run started.
392
464
  *
465
+ * The union is open ({@link LaterOutcome}): give the rest a last arm with no guard.
466
+ *
393
467
  * @see docs/concepts/effects.md
394
468
  */
395
469
  export type DispatchOutput =
@@ -402,7 +476,8 @@ export type DispatchOutput =
402
476
  }
403
477
  | { outcome: "refused"; status: number; report: string[] }
404
478
  | { outcome: "unreachable"; reason: string }
405
- | Unknown;
479
+ | Unknown
480
+ | LaterOutcome;
406
481
 
407
482
  // --- The ledger row (persisted in the run's blob, ADR-0007) --------------------------------------
408
483
 
@@ -445,15 +520,19 @@ export type EffectRow =
445
520
  run?: string;
446
521
  at: string;
447
522
  }
448
- /** `sandbox` and `repo`: the Workspace and the Repo Slot an Effect of the Sandbox Effector ran on
449
- * — every later push of its own Workspace and Repo Slot (one branch, one remote) sends its pair,
450
- * and the newest push's unfinished sync runs again in that slot's Worktree. */
523
+ /** `sandbox`, `repo` and `branch`: the Workspace, the Repo Slot and the branch an Effect of the
524
+ * Sandbox Effector ran on — every later push of its own (slot, branch) sends its pair, and the
525
+ * newest push's unfinished sync runs again in `worktree`. `cut`: a `worktree()` that settled
526
+ * `ready`: its path and branch as fields, because a settled row drops its tail. A push, a
527
+ * credentialed command and a file body read their branch here. */
451
528
  | {
452
529
  state: "settled";
453
530
  kind: EffectParts["kind"];
454
531
  slot: string;
455
532
  sandbox?: string;
456
533
  repo?: string;
534
+ branch?: string;
535
+ cut?: { path: string; branch: string | null };
457
536
  output: unknown;
458
537
  at: string;
459
538
  }
@@ -530,19 +609,19 @@ export type PushedRow = { published: string; source: string; sync?: SyncAnswer["
530
609
  unknown
531
610
  >;
532
611
 
533
- /** The ledger's last push of a Workspace's Repo Slot that published: its row, by `at`, never this
534
- * Effect's (`self`). A Workspace with two Repo Slots has two branches on two remotes, so two pairs.
535
- */
612
+ /** The ledger's last push of a (slot, branch) of a Workspace that published: its row, by `at`, never
613
+ * this Effect's (`self`). Each (slot, branch) has its own pairs: one branch on one remote. */
536
614
  export function lastPushOf(
537
615
  rows: Readonly<Record<string, EffectRow>>,
538
616
  sandbox: string,
539
617
  repo: string,
618
+ branch: string,
540
619
  self?: string,
541
620
  ): { rowId: string; row: Extract<EffectRow, { state: "settled" }>; output: PushedRow } | undefined {
542
621
  let best: { rowId: string; row: Extract<EffectRow, { state: "settled" }>; output: PushedRow } | undefined;
543
622
  for (const [rowId, row] of Object.entries(rows)) {
544
623
  if (rowId === self || row.state !== "settled" || row.kind !== "push") continue;
545
- if (row.sandbox !== sandbox || row.repo !== repo) continue;
624
+ if (row.sandbox !== sandbox || row.repo !== repo || row.branch !== branch) continue;
546
625
  const output = row.output as Partial<PushedRow> & { outcome?: unknown };
547
626
  if (output?.outcome !== "pushed" || typeof output.published !== "string" || typeof output.source !== "string") {
548
627
  continue;
@@ -552,20 +631,21 @@ export function lastPushOf(
552
631
  return best;
553
632
  }
554
633
 
555
- /** Every pair the ledger holds for a Workspace's Repo Slot (ADR-0067): each settled push row that
556
- * published, oldest first by `at`, never this Effect's (`self`) — its published commit, its source,
557
- * and its own `squashed`. What a push start carries, so the Effector sees every push's pair. */
634
+ /** Every pair the ledger holds for a (slot, branch) of a Workspace (ADR-0067): each settled push row
635
+ * that published, oldest first by `at`, never this Effect's (`self`) — its published commit, its
636
+ * source, and its own `squashed`. What a push start carries, so the Effector sees every push's pair. */
558
637
  export function pushPairsOf(
559
638
  rows: Readonly<Record<string, EffectRow>>,
560
639
  sandbox: string,
561
640
  repo: string,
641
+ branch: string,
562
642
  self?: string,
563
643
  ): Array<{ published: string; source: string; squashed?: "signed" | "unsigned" }> {
564
644
  const found: Array<{ at: string; pair: { published: string; source: string; squashed?: "signed" | "unsigned" } }> =
565
645
  [];
566
646
  for (const [rowId, row] of Object.entries(rows)) {
567
647
  if (rowId === self || row.state !== "settled" || row.kind !== "push") continue;
568
- if (row.sandbox !== sandbox || row.repo !== repo) continue;
648
+ if (row.sandbox !== sandbox || row.repo !== repo || row.branch !== branch) continue;
569
649
  const output = row.output as Partial<PushedRow> & { outcome?: unknown; squashed?: unknown };
570
650
  if (output?.outcome !== "pushed" || typeof output.published !== "string" || typeof output.source !== "string") {
571
651
  continue;
@@ -579,21 +659,41 @@ export function pushPairsOf(
579
659
  return found.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map((f) => f.pair);
580
660
  }
581
661
 
582
- /** The row id of each Repo Slot's newest push that published (`lastPushOf`), over the ledger. */
662
+ /** The row id of each (slot, branch)'s newest push that published (`lastPushOf`), over the ledger. */
583
663
  function newestPushesOf(effects: Readonly<Record<string, EffectRow>>): Set<string> {
584
664
  const ids = new Set<string>();
585
665
  const seen = new Set<string>();
586
666
  for (const row of Object.values(effects)) {
587
667
  if (row.state !== "settled" || row.kind !== "push" || row.sandbox === undefined || row.repo === undefined) continue;
588
- const key = JSON.stringify([row.sandbox, row.repo]);
668
+ if (row.branch === undefined) continue;
669
+ const key = JSON.stringify([row.sandbox, row.repo, row.branch]);
589
670
  if (seen.has(key)) continue;
590
671
  seen.add(key);
591
- const last = lastPushOf(effects, row.sandbox, row.repo);
672
+ const last = lastPushOf(effects, row.sandbox, row.repo, row.branch);
592
673
  if (last !== undefined) ids.add(last.rowId);
593
674
  }
594
675
  return ids;
595
676
  }
596
677
 
678
+ /** The cut that made `path` in this Sandbox: the newest settled `ready` row, whatever its Repo Slot.
679
+ * What a push, a credentialed command and a file body read. A row of another Sandbox says nothing
680
+ * of this one: a re-provision has an empty `/work`. */
681
+ export function cutRowOf(
682
+ rows: Readonly<Record<string, EffectRow>>,
683
+ sandbox: string,
684
+ path: unknown,
685
+ ): { repo: string; path: string; branch: string | null } | undefined {
686
+ let best: { at: string; repo: string; path: string; branch: string | null } | undefined;
687
+ for (const row of Object.values(rows)) {
688
+ if (row.state !== "settled" || row.cut === undefined || row.sandbox !== sandbox) continue;
689
+ if (row.cut.path !== path || row.repo === undefined) continue;
690
+ if (best === undefined || row.at > best.at) {
691
+ best = { at: row.at, repo: row.repo, path: row.cut.path, branch: row.cut.branch };
692
+ }
693
+ }
694
+ return best;
695
+ }
696
+
597
697
  /** One Effect line on the run's feed (Instance-token band, like the Turn markers). `output` lines
598
698
  * carry the stream; `aborted` and an `unknown` settle say the outside world may have acted. */
599
699
  export type EffectMarker = {
@@ -602,7 +702,7 @@ export type EffectMarker = {
602
702
  effect: string;
603
703
  stage: "started" | "output" | "settled" | "aborted";
604
704
  text?: string;
605
- output?: CommandOutput | HttpOutput | DispatchOutput;
705
+ output?: CommandOutput | WorktreeOutput | HttpOutput | DispatchOutput;
606
706
  };
607
707
 
608
708
  // --- The port -----------------------------------------------------------------------------------
@@ -643,19 +743,33 @@ export type EffectLogic<I, O> = ActorLogic<EffectSnapshot<O, I>, EventObject, I>
643
743
  type ArgNames<A extends readonly unknown[]> = A[number] extends infer E ? (E extends Arg<infer N> ? N : never) : never;
644
744
  /** A command's input: one string per argv slot, and with `pin` the approved commit (`{ sha }`);
645
745
  * none when there is neither. */
646
- export type CommandInput<A extends readonly unknown[], P = false> = [
746
+ export type CommandInput<A extends readonly unknown[], P = false, Opt = unknown> = [
647
747
  ArgNames<A> | ([P] extends [true] ? "sha" : never),
648
748
  ] extends [never]
649
- ? void
650
- : { [K in ArgNames<A> | ([P] extends [true] ? "sha" : never)]: string };
749
+ ? (WorktreeKey<Opt> & NotWorktreeKeys) | ([Opt] extends [{ readonly env: object }] ? never : void)
750
+ : { [K in ArgNames<A> | ([P] extends [true] ? "sha" : never)]: string } & WorktreeKey<Opt> &
751
+ Omit<NotWorktreeKeys, ArgNames<A>>;
752
+
753
+ // A command takes its Worktree in the invoke's input. `jr2Setup` makes the key required under a body with Repo Slots, and drops it under a body with
754
+ // none. A credentialed command runs against one commit of a Worktree, so there it is always required.
755
+ /** @internal */
756
+ export type WorktreeKey<Opt> = [Opt] extends [{ readonly env: object }]
757
+ ? { worktree: AnyWorktreePath }
758
+ : { worktree?: AnyWorktreePath };
759
+
760
+ /** The keys of a Turn and a cut, refused by name on an Effect's input: a mapper's excess key is no
761
+ * compile error, and the Worktree would be lost.
762
+ * @internal */
763
+ export type NotWorktreeKeys = { cwd?: never; path?: never; branch?: never };
651
764
 
652
765
  // An Open argv's slots are the composer's (ADR-0054), so any key is one string — what the
653
766
  // package's invoke hands it, read by the argv the composer binds (`argvOf`).
654
767
  /** The input of a `command(open)`: a string for each `arg()` of the argv that the Instance binds,
655
768
  * and with `pin`, the commit (`{ sha }`). The compiler cannot check the names. When the input has
656
769
  * no string for an `arg()`, the Effect fails when it starts. */
657
- export type OpenCommandInput<P = false> = Readonly<Record<string, string>> &
658
- ([P] extends [true] ? { sha: string } : unknown);
770
+ export type OpenCommandInput<P = false, Opt = unknown> = Readonly<Record<string, string>> &
771
+ ([P] extends [true] ? { sha: string } : unknown) &
772
+ WorktreeKey<Opt>;
659
773
 
660
774
  /** A length of time: a positive number and a unit, `s`, `m` or `h`. For example `"90s"`, `"10m"`
661
775
  * or `"1h"`. */
@@ -664,28 +778,6 @@ export type Duration = `${number}s` | `${number}m` | `${number}h`;
664
778
  /** The options of `command()`: the Repo Slot it runs in, how long it can run, how much of its
665
779
  * output it keeps, its Effect secrets, and the commit it runs against. */
666
780
  export type CommandOptions = {
667
- /**
668
- * The Repo Slot whose Worktree is the working directory of the command. Write it only when the
669
- * Workspace has more than one Repo Slot. In a Workspace with no Repo Slot, the command starts in
670
- * `/work`, the folder that the steps of the Workspace share.
671
- *
672
- * The command starts in the root of that Worktree. To run it in a subfolder, give the path as an
673
- * argument, or write `["sh", "-c", "cd <dir> && <program>"]`.
674
- *
675
- * When it starts, it refuses:
676
- * - no `slot` under a Workspace with more than one Repo Slot: `command "<slot>" runs under a
677
- * Workspace carrying more than one Repo Slot (<slots>) and names none — say which Worktree it
678
- * runs in: command(argv, { slot: "<slot>" }) (ADR-0051)`. When `env` names an Effect secret,
679
- * the text is `command "<slot>" runs under a Workspace carrying more than one Repo Slot
680
- * (<slots>) — say which: { slot: "<slot>" } (ADR-0051)`.
681
- * - with an Effect secret in `env`, a Workspace with no Repo Slot: `command "<slot>" runs under
682
- * a Workspace with no Repo Slot: a command with an Effect secret runs against one commit of a
683
- * Repo Slot's branch, and there is none — declare a Repo Slot, or name no secret in its env, and
684
- * it runs in /work beside the Agent (ADR-0051)`.
685
- * - a `slot` that the Workspace does not have: `command "<slot>" names Repo Slot "<name>", which
686
- * its Workspace does not carry (<slots>)`.
687
- */
688
- slot?: string;
689
781
  /**
690
782
  * How long the command can run: a number and a unit, such as `"90s"`, `"10m"` or `"1h"`.
691
783
  * Default `"10m"`. When the time ends, jr2 kills the process group, and the output is
@@ -735,10 +827,14 @@ export type EnvOpenParts<Opt> = Opt extends { readonly env: infer E }
735
827
 
736
828
  /**
737
829
  * Why an Effect kind cannot run in a Workspace with no Repo Slot (ADR-0051, ADR-0067), or undefined
738
- * when it can: a `push` and a credentialed `command` run on a commit of a slot's branch, and that
739
- * Workspace has none. The invoke and the `jr2 up` walk refuse with the same words.
830
+ * when it can: a `push` and a credentialed `command` run on a commit of a slot's branch, and a
831
+ * `worktree` makes a Worktree of a slot's clone, and that Workspace has none. The invoke and the
832
+ * `jr2 up` walk refuse with the same words.
740
833
  */
741
834
  export function needsASlot(kind: string): string | undefined {
835
+ if (kind === "worktree") {
836
+ return 'a cut makes a Worktree of a Repo Slot, and there is none — declare one: repos: { app: "<url>" }';
837
+ }
742
838
  if (kind === "push")
743
839
  return 'a push publishes a Repo Slot\'s branch, and there is none — declare one: repos: { app: "<url>" }';
744
840
  if (kind === "command") {
@@ -773,7 +869,7 @@ export function commandWith<const Opt extends CommandOptions = CommandOptions>(
773
869
  argv: typeof open,
774
870
  options?: Opt,
775
871
  effectorPorts?: EffectorPortFactory,
776
- ): EffectLogic<OpenCommandInput<Opt["pin"]>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
872
+ ): EffectLogic<OpenCommandInput<Opt["pin"], Opt>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
777
873
  export function commandWith<
778
874
  const A extends readonly (string | Arg)[],
779
875
  const Opt extends CommandOptions = CommandOptions,
@@ -782,12 +878,15 @@ export function commandWith<
782
878
  argv: A,
783
879
  options?: Opt,
784
880
  effectorPorts?: EffectorPortFactory,
785
- ): EffectLogic<CommandInput<A, Opt["pin"]>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
881
+ cut?: CommandParts["cut"],
882
+ ): EffectLogic<CommandInput<A, Opt["pin"], Opt>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
786
883
  export function commandWith(
787
884
  portFactory: ExecPortFactory,
788
885
  argv: readonly (string | Arg)[] | typeof open,
789
886
  options: CommandOptions = {},
790
887
  effectorPorts: EffectorPortFactory = sandboxEffectorPort,
888
+ // `worktree()` alone passes this: an author's command is at most once.
889
+ cut?: CommandParts["cut"],
791
890
  // The implementation of the overloads above: each one states the input type.
792
891
  ): EffectLogic<any, CommandOutput> {
793
892
  if (argv === open) {
@@ -814,9 +913,9 @@ export function commandWith(
814
913
  argv: [...argv],
815
914
  timeoutMs: options.timeout === undefined ? DEFAULT_TIMEOUT_MS : parseDuration(options.timeout),
816
915
  tail: options.tail ?? DEFAULT_TAIL,
817
- ...(options.slot !== undefined ? { slot: options.slot } : {}),
818
916
  ...(options.env !== undefined && Object.keys(options.env).length > 0 ? commandEnvParts(options.env) : {}),
819
917
  ...(options.pin === true ? { pin: true as const } : {}),
918
+ ...(cut !== undefined ? { cut, idempotent: true as const } : {}),
820
919
  };
821
920
  if (parts.pin && (parts.secrets?.length ?? 0) === 0 && !parts.openSecret) {
822
921
  // A command that names no secret runs in the Harness container, in the live Worktree: there
@@ -844,7 +943,7 @@ export function commandWith(
844
943
  ) as unknown as EffectLogic<unknown, CommandOutput>;
845
944
  }
846
945
  type I = unknown;
847
- type O = CommandOutput;
946
+ type O = CommandOutput | WorktreeOutput;
848
947
  const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
849
948
  getInitialSnapshot: (_scope, input) => ({
850
949
  status: "active",
@@ -878,12 +977,63 @@ export function commandWith(
878
977
  },
879
978
  getPersistedSnapshot: (snapshot) => snapshot,
880
979
  };
881
- return Object.assign(logic, { effect: parts });
980
+ return Object.assign(logic, { effect: parts }) as unknown as EffectLogic<any, CommandOutput>;
882
981
  }
883
982
 
884
983
  /** The slot's name as its parent invoked it — what the ledger and the feed call the Effect. */
885
984
  const slotOf = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
886
985
 
986
+ /** How many times a marked Effect goes out again after a Harness that lost it. Bounded, so that a
987
+ * Harness in a crash loop ends the wait: the Effect settles `unknown`, and the Machine routes. */
988
+ const RESENDS = 3;
989
+
990
+ /** What a cut's program answers: its last line. Anything else is a cut that did not end. */
991
+ function cutOutputOf(raw: CommandOutput): WorktreeOutput {
992
+ if (raw.outcome === "unknown") return raw as Unknown;
993
+ const tail = "tail" in raw && typeof raw.tail === "string" ? raw.tail : "";
994
+ if (raw.outcome === "exited" && raw.code === 0) {
995
+ try {
996
+ const answer = JSON.parse(tail.trim().split("\n").pop() ?? "") as { outcome?: unknown };
997
+ if (answer.outcome === "ready" || answer.outcome === "refused") return answer as WorktreeOutput;
998
+ } catch {
999
+ // Falls through: the program said no answer.
1000
+ }
1001
+ }
1002
+ return { outcome: "unknown", reason: `the cut ended ${raw.outcome} with no answer: ${tail.slice(-400)}` };
1003
+ }
1004
+
1005
+ /** Why a cut's input cannot go to the Harness, or undefined. The door checked a Chart's literals; a
1006
+ * reference, a pick param or a mapper can still say this. No exec, no fetch. */
1007
+ function cutFaultOf(input: unknown, ambient: AmbientHandles): string | undefined {
1008
+ const given = (input ?? {}) as { path?: unknown; branch?: unknown; detach?: unknown; from?: unknown };
1009
+ const fault = worktreePathFault(given.path, ambient, { cut: true });
1010
+ if (fault !== undefined) return fault;
1011
+ if ((given.branch === undefined) === (given.detach === undefined))
1012
+ return "a cut names a branch, or a commit in `detach`";
1013
+ if (given.detach !== undefined && given.from !== undefined) return "a detached cut takes no `from`";
1014
+ for (const key of ["branch", "detach", "from"] as const) {
1015
+ if (given[key] !== undefined && typeof given[key] !== "string") return `a cut's \`${key}\` is a string`;
1016
+ }
1017
+ return undefined;
1018
+ }
1019
+
1020
+ /** The cut's one argument: its input, its id (a re-send answers the first `found`), its static
1021
+ * `expect`, and the base, sent each time, so that no checkout of an Agent's can move it. */
1022
+ function cutRequestOf(id: string, input: unknown, parts: CommandParts, ambient: AmbientHandles): string {
1023
+ const given = (input ?? {}) as { path: string; branch?: string; detach?: string; from?: string };
1024
+ const slot = given.path.split("/")[2]!;
1025
+ const base = ambient.bases?.[slot];
1026
+ return JSON.stringify({
1027
+ id,
1028
+ path: given.path,
1029
+ ...(given.branch !== undefined ? { branch: given.branch } : {}),
1030
+ ...(given.detach !== undefined ? { detach: given.detach } : {}),
1031
+ ...(given.from !== undefined ? { from: given.from } : {}),
1032
+ ...(given.branch !== undefined && parts.cut?.expect ? { expect: parts.cut.expect } : {}),
1033
+ ...(base !== undefined ? { base } : {}),
1034
+ });
1035
+ }
1036
+
887
1037
  /** Run, re-attach to, or deliver one Effect, by its ledger row (see the module header). */
888
1038
  async function drive<I, O>(
889
1039
  self: AnyActorRef,
@@ -900,14 +1050,30 @@ async function drive<I, O>(
900
1050
  const now = (): string => new Date().toISOString();
901
1051
  const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
902
1052
  binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
903
- const settle = (output: CommandOutput): void => {
904
- if (signal.aborted || self.getSnapshot().status !== "active") return;
905
- // The settled row first, saved at the write: a restart before the transition is saved delivers
906
- // this output, and runs nothing.
907
- binding?.recordEffect?.(id, { state: "settled", kind: parts.kind, slot, output, at: now() });
908
- mark({ stage: "settled", output });
909
- self.send({ type: "jr2.effect.settle", output } satisfies Settle);
910
- };
1053
+ /** A settled row of an exec. `sandbox` is the row's own on a restore: the Workspace it ran in. */
1054
+ const settleIn =
1055
+ (sandbox: string | undefined) =>
1056
+ (raw: CommandOutput | WorktreeOutput): void => {
1057
+ if (signal.aborted || self.getSnapshot().status !== "active") return;
1058
+ const output = parts.cut && raw.outcome !== "refused" ? cutOutputOf(raw as CommandOutput) : raw;
1059
+ // The settled row first, saved at the write: a restart before the transition is saved delivers
1060
+ // this output, and runs nothing. A `ready` cut's row keeps its fields: a push reads them.
1061
+ const cutRow =
1062
+ parts.cut && output.outcome === "ready"
1063
+ ? { sandbox, repo: output.slot, cut: { path: output.path, branch: output.branch } }
1064
+ : {};
1065
+ binding?.recordEffect?.(id, {
1066
+ state: "settled",
1067
+ kind: parts.kind,
1068
+ slot,
1069
+ ...(cutRow as object),
1070
+ output,
1071
+ at: now(),
1072
+ });
1073
+ mark({ stage: "settled", output });
1074
+ self.send({ type: "jr2.effect.settle", output } satisfies Settle);
1075
+ };
1076
+ const settle = settleIn(undefined);
911
1077
 
912
1078
  const row = binding?.effectRow?.(id);
913
1079
  if (row?.state === "settled") {
@@ -923,13 +1089,14 @@ async function drive<I, O>(
923
1089
  return;
924
1090
  }
925
1091
  if (row?.state === "intent" && row.kind === "command" && "endpoint" in row) {
926
- // In flight at an Orchestrator restart: re-attach by id, or settle `unknown`. Never run again.
1092
+ // In flight at an Orchestrator restart: re-attach by id, or settle `unknown`. Never run again,
1093
+ // unless the kit marked it: a cut goes on below, under the same id.
927
1094
  const port = portFactory(row.endpoint, binding?.harnessBearer?.(row.placement));
928
1095
  run.port = port;
929
1096
  run.started = true;
930
1097
  const first = await retrying(() => port.read(id, undefined, signal), signal);
931
1098
  if (signal.aborted) return;
932
- if (first === undefined) {
1099
+ if (first === undefined && !parts.idempotent) {
933
1100
  const output: Unknown = {
934
1101
  outcome: "unknown",
935
1102
  reason: "it was running when the Orchestrator restarted, and the Harness no longer knows it; not run again",
@@ -937,8 +1104,11 @@ async function drive<I, O>(
937
1104
  settle(output);
938
1105
  return;
939
1106
  }
940
- await follow(port, id, first, signal, mark, settle);
941
- return;
1107
+ if (first !== undefined) {
1108
+ const lost = await follow(port, id, first, signal, mark, settleIn(row.placement), parts.idempotent === true);
1109
+ if (!lost) return;
1110
+ }
1111
+ mark({ stage: "started", text: "sent again: the Harness lost it, and a cut is idempotent" });
942
1112
  }
943
1113
 
944
1114
  // Fresh: nothing was sent. Where it runs is the enclosing Workspace's Harness — refused outside one.
@@ -949,9 +1119,43 @@ async function drive<I, O>(
949
1119
  `workspace() — invoke it from a Workspace's body (ADR-0067)`,
950
1120
  );
951
1121
  }
1122
+ const settled = settleIn(ambient.sandbox);
1123
+ // A bad Worktree is a word an Agent or a Chart can say, so it is output (ADR-0067), in the form a
1124
+ // command that could not start has: exited 127, with the reason in the tail. A key that no one
1125
+ // wrote is a defect of the Machine: a throw, and `onError`.
1126
+ const worktree = (snapshot.input as { worktree?: unknown } | undefined | void)?.worktree;
1127
+ if (parts.cut) {
1128
+ if (Object.keys(ambient.repos).length === 0) {
1129
+ throw new Error(
1130
+ `worktree "${slot}" runs under a Workspace with no Repo Slot: ${needsASlot("worktree")} (ADR-0075)`,
1131
+ );
1132
+ }
1133
+ const fault = cutFaultOf(snapshot.input, ambient);
1134
+ if (fault !== undefined) {
1135
+ settled({ outcome: "refused", reason: fault });
1136
+ return;
1137
+ }
1138
+ } else if (worktree === undefined) {
1139
+ if (Object.keys(ambient.repos).length > 0) {
1140
+ throw new Error(
1141
+ `command "${slot}" runs under a Workspace with Repo Slots (${Object.keys(ambient.repos).join(", ")}) and its ` +
1142
+ "input names no `worktree`: say which, a Worktree that a cut gave or `<slot>/default`",
1143
+ );
1144
+ }
1145
+ } else {
1146
+ const fault = worktreePathFault(worktree, ambient);
1147
+ if (fault !== undefined) {
1148
+ settled({ outcome: "exited", code: 127, tail: `jr2: ${fault}; nothing ran\n` });
1149
+ return;
1150
+ }
1151
+ }
952
1152
  const req: ExecRequest = {
953
- argv: argvOf(slot, parts, snapshot.input),
954
- ...cwdOf(slot, parts, ambient),
1153
+ argv: parts.cut
1154
+ ? [...(parts.argv as string[]), cutRequestOf(id, snapshot.input, parts, ambient)]
1155
+ : argvOf(slot, parts, snapshot.input),
1156
+ // Named, never left to the Harness: a spawn with no cwd runs in the image's own WORKDIR.
1157
+ cwd: parts.cut ? WORK_ROOT : ((worktree as string | undefined) ?? WORK_ROOT),
1158
+ ...(!parts.cut && worktree !== undefined ? { root: true as const } : {}),
955
1159
  timeoutMs: parts.timeoutMs,
956
1160
  tail: parts.tail,
957
1161
  };
@@ -969,26 +1173,38 @@ async function drive<I, O>(
969
1173
  });
970
1174
  run.started = true;
971
1175
  mark({ stage: "started", text: req.argv.join(" ") });
972
- const sent = await startOnce(port, id, req, signal);
973
- if (signal.aborted) return;
974
- const first = await retrying(() => port.read(id, 0, signal), signal);
975
- if (signal.aborted) return;
976
- if (first === undefined) {
977
- if (sent === "uncertain") {
978
- // The start may still be on its way to the Harness that answers now: a kill keeps it from
979
- // running there (exec.ts keeps an unknown id killed). Best effort — `unknown` either way.
980
- void killUntilKept(port, id, parts.timeoutMs).catch(() => {});
1176
+ // One pass for every Effect but the kit's cut: a Harness that restarted knows no id, and a start
1177
+ // that may have reached it is never sent again. A cut goes out again: it is idempotent.
1178
+ const passes = parts.idempotent ? RESENDS + 1 : 1;
1179
+ for (let pass = 1; pass <= passes; pass++) {
1180
+ const sent = await startOnce(port, id, req, signal);
1181
+ if (signal.aborted) return;
1182
+ const first = await retrying(() => port.read(id, 0, signal), signal);
1183
+ if (signal.aborted) return;
1184
+ const last = pass === passes;
1185
+ if (first === undefined) {
1186
+ if (!last) {
1187
+ mark({ stage: "started", text: "sent again: the Harness restarted, and a cut is idempotent" });
1188
+ continue;
1189
+ }
1190
+ if (sent === "uncertain" && !parts.idempotent) {
1191
+ // The start may still be on its way to the Harness that answers now: a kill keeps it from
1192
+ // running there (exec.ts keeps an unknown id killed). Best effort — `unknown` either way.
1193
+ void killUntilKept(port, id, parts.timeoutMs).catch(() => {});
1194
+ }
1195
+ settled({
1196
+ outcome: "unknown",
1197
+ reason:
1198
+ sent === "uncertain"
1199
+ ? "its start may have reached the Harness, which no longer knows it (it restarted); not run again"
1200
+ : "the Harness lost it after it started (it restarted); not run again",
1201
+ });
1202
+ return;
981
1203
  }
982
- settle({
983
- outcome: "unknown",
984
- reason:
985
- sent === "uncertain"
986
- ? "its start may have reached the Harness, which no longer knows it (it restarted); not run again"
987
- : "the Harness lost it after it started (it restarted); not run again",
988
- });
989
- return;
1204
+ const lost = await follow(port, id, first, signal, mark, settled, !last);
1205
+ if (!lost) return;
1206
+ mark({ stage: "started", text: "sent again: the Harness lost it, and a cut is idempotent" });
990
1207
  }
991
- await follow(port, id, first, signal, mark, settle);
992
1208
  }
993
1209
 
994
1210
  /**
@@ -1018,7 +1234,8 @@ async function startOnce(
1018
1234
  }
1019
1235
  }
1020
1236
 
1021
- /** Read the exec to its end: each chunk of stream to the feed, the output to the Machine. */
1237
+ /** Read the exec to its end: each chunk of stream to the feed, the output to the Machine. With
1238
+ * `again`, a Harness that lost it settles nothing: the answer is true, and the caller sends it. */
1022
1239
  async function follow(
1023
1240
  port: ExecPort,
1024
1241
  id: string,
@@ -1026,22 +1243,24 @@ async function follow(
1026
1243
  signal: AbortSignal,
1027
1244
  mark: (marker: Omit<EffectMarker, "kind" | "slot" | "effect">) => void,
1028
1245
  settle: (output: CommandOutput) => void,
1029
- ): Promise<void> {
1246
+ again = false,
1247
+ ): Promise<boolean> {
1030
1248
  let read: ExecRead | undefined = first;
1031
1249
  for (;;) {
1032
1250
  if (read === undefined) {
1251
+ if (again) return true;
1033
1252
  settle({ outcome: "unknown", reason: "the Harness lost it while it ran (it restarted); not run again" });
1034
- return;
1253
+ return false;
1035
1254
  }
1036
1255
  if (read.stream) mark({ stage: "output", text: read.stream });
1037
1256
  if (read.state === "settled" && read.output) {
1038
1257
  settle(read.output);
1039
1258
  void port.forget(id).catch(() => {});
1040
- return;
1259
+ return false;
1041
1260
  }
1042
1261
  const offset: number = read.next;
1043
1262
  read = await retrying(() => port.read(id, offset, signal), signal);
1044
- if (signal.aborted) return;
1263
+ if (signal.aborted) return false;
1045
1264
  }
1046
1265
  }
1047
1266
 
@@ -1146,30 +1365,27 @@ function argvOf(slot: string, parts: CommandParts, input: unknown): string[] {
1146
1365
  }
1147
1366
 
1148
1367
  /**
1149
- * The cwd: a Repo Slot's Worktree (ADR-0051). Named by `slot`, or the one slot where the Workspace
1150
- * carries one; a Workspace with none runs it in `/work`, which its steps share. Named, never left to
1151
- * the Harness: a spawn with no cwd runs in the Sandbox Image's own `WORKDIR`, which is the image's.
1152
- * Several and none named is refused, as a Turn's Frame is (ADR-0057): the kit gives no slot a
1153
- * meaning.
1368
+ * Why `path` is not a Worktree of this Workspace, or undefined when it is. Exactly
1369
+ * `/work/<slot>/<name>`, of one of its Repo Slots, one segment that starts with neither `.` nor `-`.
1370
+ * `default` is a Worktree (the clone), and with `cut` it is not one a cut makes.
1154
1371
  */
1155
- function cwdOf(slot: string, parts: CommandParts, ambient: AmbientHandles): { cwd?: string } {
1372
+ export function worktreePathFault(
1373
+ path: unknown,
1374
+ ambient: AmbientHandles,
1375
+ opts: { cut?: boolean } = {},
1376
+ ): string | undefined {
1377
+ const match = typeof path === "string" ? path.match(/^\/work\/([^/]+)\/([^/]+)$/) : null;
1378
+ if (match === null) {
1379
+ return `${JSON.stringify(path)} is not a Worktree path: /work/<slot>/<name>, with one segment in <name>`;
1380
+ }
1381
+ const [, slot, name] = match as unknown as [string, string, string];
1382
+ if (name.startsWith(".") || name.startsWith("-")) return `"${path}": a Worktree's name starts with neither . nor -`;
1383
+ if (opts.cut && name === "default") return `"${path}" is the clone: it stays as the attach left it; cut a sibling`;
1156
1384
  const slots = Object.keys(ambient.repos);
1157
- if (parts.slot !== undefined) {
1158
- const cwd = ambient.repos[parts.slot];
1159
- if (cwd === undefined) {
1160
- throw new Error(
1161
- `command "${slot}" names Repo Slot "${parts.slot}", which its Workspace does not carry ` +
1162
- `(${slots.join(", ") || "none"})`,
1163
- );
1164
- }
1165
- return { cwd };
1385
+ if (!slots.includes(slot)) {
1386
+ return `"${path}" names Repo Slot "${slot}", which its Workspace does not carry (${slots.join(", ") || "none"})`;
1166
1387
  }
1167
- if (slots.length === 0) return { cwd: WORK_ROOT };
1168
- if (slots.length === 1) return { cwd: ambient.repos[slots[0]!]! };
1169
- throw new Error(
1170
- `command "${slot}" runs under a Workspace carrying more than one Repo Slot (${slots.join(", ")}) and names ` +
1171
- `none — say which Worktree it runs in: command(argv, { slot: "<slot>" }) (ADR-0051)`,
1172
- );
1388
+ return undefined;
1173
1389
  }
1174
1390
 
1175
1391
  /** Ask again on a transport failure, with backoff, until it answers or the Effect stops. For a