@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/actor.ts CHANGED
@@ -50,11 +50,12 @@ import {
50
50
  type ThinkingLevel,
51
51
  type WorkspaceAccess,
52
52
  } from "./agent.ts";
53
+ import type { AnyFramePath } from "./worktree.ts";
53
54
  import { ambientHandlesFor, type AmbientHandles } from "./ambient.ts";
54
55
  import type { HarnessRestarts, MemoryKill } from "./workspace.ts";
55
56
  import { INSTANCE_HARNESS_SERVICE } from "./names.ts";
56
57
  import { SENDER_KEY } from "@jr2/agent-protocol";
57
- import { agentAddress, continuedIid, allowedPicks, resolveAccepts, runBindingOf } from "./registration.ts";
58
+ import { agentAddress, agentSlot, continuedIid, allowedPicks, resolveAccepts, runBindingOf } from "./registration.ts";
58
59
  import type { NoticeScope } from "./notices.ts";
59
60
  import { restOf } from "./rest.ts";
60
61
  import { SUBMISSION_HARNESS_RESTARTED, SUBMISSION_RUNAWAY, type Notice, type PodMemoryKill } from "./wire.ts";
@@ -143,15 +144,15 @@ export type AgentTurnInput = {
143
144
  /**
144
145
  * The other half of the FRAME (ADR-0057) — WHERE this Turn works: the absolute directory the
145
146
  * Harness roots the Working tools at. Per Turn by nature, which is why no definition names one:
146
- * a Worktree path (`/work/<slot>/<branch>`) exists only once a run has a branch.
147
+ * a Worktree path (`/work/<slot>/<name>`) exists only once a Machine has cut it (ADR-0075).
147
148
  *
148
- * Absent, the actor resolves it before admission: a `workspace: "none"` Agent frames no
149
- * directory (it has no Working tools to root — ADR-0028), a Workspace with ONE Repo Slot frames
150
- * that slot's Worktree, a Workspace with more REFUSES the Turn (the kit gives no slot a meaning
151
- * — ADR-0051 — so the state must say which), and a run with no Workspace at all frames nothing
152
- * and the Harness keeps `/work`. A relative path is refused at admission, by the Harness.
149
+ * Required under Repo Slots: a Turn with no `cwd` there is REFUSED before admission, and a Turn
150
+ * that wants `/work` states it. A `workspace: "none"` Agent frames no directory (it has no Working
151
+ * tools to root — ADR-0028), and a Workspace with no Repo Slot, or a run with no Workspace at all,
152
+ * frames nothing and the Harness keeps `/work`. A relative path is refused at admission, by the
153
+ * Harness.
153
154
  */
154
- cwd?: string;
155
+ cwd?: AnyFramePath;
155
156
  /**
156
157
  * This turn's DIALS (ADR-0018) — how hard to run, layered over the definition's own
157
158
  * values. One definition value may be carried by several Machines, so the same persona
@@ -358,6 +359,10 @@ export interface AgentRunPort {
358
359
  * Machine edit that changed it. */
359
360
  export type AgentAdmitOptions = {
360
361
  definition: AgentDefinition;
362
+ /** This Agent's slot (ADR-0076): the actor path of its Machine and its agent name — the
363
+ * registration's. The Harness keeps it with the conversation and states it on each control
364
+ * request; it never derives it from an Instance ID, whose actor ids may hold a `/`. */
365
+ slot: string;
361
366
  signal?: AbortSignal;
362
367
  /** The pending notices in this admission's scope (ADR-0062), for the Briefing's Turn part. Not
363
368
  * on the persisted input either: they are the Orchestrator's, and delivered once. */
@@ -555,20 +560,15 @@ const READMIT_PROMPT =
555
560
  * an ABSENT one, and a directory a state named is never second-guessed — a Menu-only Agent handed
556
561
  * one simply has no Working tools to root there (ADR-0028/0031).
557
562
  *
558
- * Absent, a Menu-only Agent frames no directory; a Workspace with ONE Repo Slot frames that
559
- * slot's Worktree, which privileges nothing because there is nothing to choose between (ADR-0051);
560
- * a Workspace with more REFUSES the Turn, naming the slots in declaration order and the line that
561
- * ends it, because the kit gives no slot a meaning and a wrong guess is the silent failure
562
- * ADR-0057 was written for; and a Workspace with no Repo Slot, or a run outside any Workspace,
563
- * frames nothing, so the Harness keeps `/work`, the folder a slotless Workspace's steps share.
563
+ * Absent, a Menu-only Agent frames no directory, and a Workspace with no Repo Slot, or a run outside
564
+ * any Workspace, frames nothing, so the Harness keeps `/work`, the folder a slotless Workspace's
565
+ * steps share. Under Repo Slots the Workspace only clones, so no Worktree exists for a default to
566
+ * name, and the kit gives no slot a meaning: an absent `cwd` is REFUSED, because a wrong guess is
567
+ * the silent failure ADR-0057 was written for (ADR-0075).
564
568
  *
565
- * Resolution counts SLOTS, and a slot's Worktree is the writable branch checkout. The detached
566
- * review Worktree (`AmbientHandles.review`, ADR-0028's reviewer seat) is never resolved to: it is
567
- * per review round, so only the reviewing state knows which one, and it frames it (ADR-0028 hands
568
- * the reviewer its path as "cwd and prompt"). A `workspace: "read"` Agent that frames nothing
569
- * under a one-slot Workspace therefore works in the branch checkout with `write`/`edit` withheld
570
- * and `bash` kept — the tool layer's hint, not containment (ADR-0028): containment is the
571
- * detached review Worktree, and only a reviewing state that requested one can frame it.
569
+ * `workspace: "read"` withholds `write`/`edit` and keeps `bash`: the tool layer's hint, not
570
+ * containment (ADR-0028). The containment is a detached Worktree that the state cuts and names as
571
+ * its `cwd`.
572
572
  *
573
573
  * The refusal throws at invoke, like the Open-model fence: loudly, at the first Turn, before a
574
574
  * surface is registered or a pod is spent.
@@ -583,12 +583,11 @@ function frameCwd(
583
583
  if (workspace === "none") return undefined;
584
584
  const slots = Object.keys(ambient?.repos ?? {});
585
585
  if (slots.length === 0) return undefined;
586
- if (slots.length === 1) return ambient!.repos[slots[0]!];
586
+ // A default here is ADR-0057's incident: an Agent's search hits `default/`, and a relative write
587
+ // lands outside each Worktree. An author who wants `/work` states it.
587
588
  throw new Error(
588
- `turn ${instanceId}: agent "${input.agentName}" works under a Workspace carrying more than one ` +
589
- `Repo Slot (${slots.join(", ")}) and its Turn framed no \`cwd\` — say which Worktree it works ` +
590
- `in (\`cwd: context.workspace.repos.<slot>\`, one of those), because the kit gives no slot a ` +
591
- `meaning (ADR-0051) and a Turn's Frame is where it works (ADR-0057)`,
589
+ `turn ${instanceId}: agent "${input.agentName}" works under a Workspace with Repo Slots (${slots.join(", ")}) ` +
590
+ 'and its Turn framed no `cwd`: say where it works, a Worktree, or `cwd: "/work"` above them (ADR-0057)',
592
591
  );
593
592
  }
594
593
 
@@ -701,7 +700,7 @@ export function agentActorWith(
701
700
 
702
701
  // The Frame's other half (ADR-0057): WHERE this Turn works. Resolved HERE, before any surface
703
702
  // is registered and before a pod is spent, because the answer is a fact about the run — a
704
- // Worktree path exists only once a run has a branch, so no definition could have named it.
703
+ // Worktree exists only once a run has cut it, so no definition could have named it.
705
704
  const cwd = frameCwd(instanceId, input, workspace, ambient);
706
705
  const framed: AgentRunInput = cwd === undefined ? input : { ...input, cwd };
707
706
 
@@ -732,8 +731,15 @@ export function agentActorWith(
732
731
  // This Agent's continued conversation in this Machine (ADR-0057) — the base id the epoch ledger
733
732
  // burns. This Turn is PART of it only when it continues it: a fresh Turn is new by definition.
734
733
  const conversation = continuedIid(self._parent, binding.runId, input.agentName);
734
+ // This Agent's slot (ADR-0076): the same identity without the run id. The Harness states it on
735
+ // every control request, and the Orchestrator speaks here only for it.
736
+ const slot = agentSlot(self._parent, input.agentName);
735
737
  // The iids whose surface the Harness has read (see `carried` below).
736
738
  const served = new Set<string>();
739
+ // Per iid, the notices a step boundary's surface read reserved and the Harness has not yet
740
+ // acknowledged (ADR-0062): by the id the read named, every id it stands for — equal notices
741
+ // are said once — and what each was raised as, for `reraiseUnheard`.
742
+ const boundary = new Map<string, Map<string, Array<{ id: string; raised: { notice: Notice; source?: string } }>>>();
737
743
  const registerSurface = (iid: string) =>
738
744
  disposers.push(
739
745
  binding.table.register({
@@ -744,6 +750,9 @@ export function agentActorWith(
744
750
  defs,
745
751
  menu,
746
752
  sandbox,
753
+ slot,
754
+ boundaryNotices: () => readBoundary(iid),
755
+ noticesHeard: (ids) => boundaryHeard(iid, ids),
747
756
  deliver: (event) => {
748
757
  signaled = true;
749
758
  // The settlement-pick marker (ADR-0023), BEFORE the delivery moves the Machine: the pick
@@ -824,7 +833,8 @@ export function agentActorWith(
824
833
  // LIVE conversation stamped on the record, so a restore settle-follows the live submission
825
834
  // AND re-addresses it (see `currentIid` above). The notices the admission carried are
826
835
  // delivered by this same write (ADR-0062).
827
- const ledger = (admission: AgentAdmission, delivered: readonly string[]) =>
836
+ // The conversation is a member of its Workspace's audiences from this save on (ADR-0062).
837
+ const ledger = (admission: AgentAdmission, delivered: readonly string[], taker: string) =>
828
838
  binding.recordAdmission?.(
829
839
  instanceId,
830
840
  {
@@ -835,6 +845,10 @@ export function agentActorWith(
835
845
  ...(input.turnId === undefined ? {} : { turnId: input.turnId }),
836
846
  },
837
847
  delivered,
848
+ taker,
849
+ noticeWorkspace === undefined
850
+ ? undefined
851
+ : { workspace: noticeWorkspace, slot, continued: Boolean(input.continuation) && taker === conversationIid() },
838
852
  );
839
853
 
840
854
  // Where this Turn hears notices (ADR-0062). Its continued conversation's — the base id the
@@ -845,7 +859,11 @@ export function agentActorWith(
845
859
  const noticeScope: NoticeScope = {
846
860
  ...(input.continuation ? { conversation } : {}),
847
861
  ...(noticeWorkspace !== undefined ? { workspace: noticeWorkspace } : {}),
862
+ slot,
848
863
  };
864
+ // The Instance ID of the continued conversation this Turn rides — its epoch's spelling, which
865
+ // the minted `instanceId` is. A fresh Turn has none.
866
+ const conversationIid = (): string | undefined => (input.continuation ? instanceId : undefined);
849
867
  // No `agent`: the notice names no Turn — what the pod's list says (below).
850
868
  // No `limit`: the sizes read were not sizes, and the notice says none.
851
869
  const memoryLimit = ({ limit, peak, agent }: { limit?: string; peak?: string; agent?: string }): Notice => ({
@@ -859,12 +877,15 @@ export function agentActorWith(
859
877
  // Turn's Agent. A stopped actor's last poll names nothing: the state that asked for the Turn has
860
878
  // exited, and the pod's list carries the kill. Keyed by the kill's id, so a second running
861
879
  // conversation's stream, or the pod's list, adds nothing.
880
+ // The kill cut this Turn (D4 rule 2): its Agent's next Turn retries the broken work, and hears it.
862
881
  const onMemoryLimit = (kill: StreamedKill): void => {
863
882
  if (stopped || noticeWorkspace === undefined) return;
864
883
  binding.raiseNotice?.(
865
884
  memoryLimit({ limit: kill.limit, peak: kill.peak, agent: input.agentName }),
866
885
  noticeWorkspace,
867
886
  kill.legacy ? kill.id : guardSource(noticeWorkspace, kill.id),
887
+ [],
888
+ { retry: { slot, cut: currentIid }, at: new Date().toISOString() },
868
889
  );
869
890
  };
870
891
  // …but a stream is not every kill's path. A kill while no Submission ran — a process an earlier
@@ -906,6 +927,8 @@ export function agentActorWith(
906
927
  memoryLimit({ limit: kill.limit }),
907
928
  noticeWorkspace,
908
929
  kernelSource(noticeWorkspace, kill.at),
930
+ [],
931
+ { at: kill.at },
909
932
  );
910
933
  };
911
934
  let killMark = input.attach?.killMark;
@@ -937,7 +960,9 @@ export function agentActorWith(
937
960
  noticeWorkspace,
938
961
  guardSource(noticeWorkspace, newest.id),
939
962
  kills.slice(0, -1).map((kill) => guardSource(noticeWorkspace, kill.id)),
963
+ { at: newest.at, told: newest.told.map((t) => t.instanceId) },
940
964
  );
965
+ applyTold(kills);
941
966
  const toldHere = (kill: ListedKill): boolean =>
942
967
  kill.told.some((t) => t.agent === input.agentName && t.instanceId === currentIid);
943
968
  // The first kill after the Turn's mark. A mark no longer in the bounded list is older than
@@ -956,6 +981,86 @@ export function agentActorWith(
956
981
  };
957
982
  };
958
983
 
984
+ // The conversations each listed kill's `bash` answer told leave its audience (D4 rule 3) — a
985
+ // kill raised off a stream before its answer returned is found here too.
986
+ const applyTold = (kills: readonly ListedKill[]): void => {
987
+ if (noticeWorkspace === undefined) return;
988
+ const told = kills
989
+ .filter((kill) => kill.told.length > 0)
990
+ .map((kill) => ({ source: guardSource(noticeWorkspace, kill.id), takers: kill.told.map((t) => t.instanceId) }));
991
+ if (told.length > 0) binding.noticesTold?.(told);
992
+ };
993
+ // The step boundary's read (ADR-0062): this conversation's pending notices in their audience,
994
+ // reserved for this Turn until the Harness acknowledges the append. A guard kill's audience
995
+ // leaves out the conversations its `bash` answer told, which only the pod's list says — so when
996
+ // one is among them, the list is read first, and a read that could not tell takes no guard kill.
997
+ const readBoundary = async (iid: string): Promise<Array<{ id: string; notice: Notice }>> => {
998
+ if (stopped || iid !== currentIid || binding.takeNotices === undefined) return [];
999
+ const scope: NoticeScope = { ...noticeScope, taker: iid };
1000
+ if (noticeWorkspace !== undefined) {
1001
+ const guard = guardSource(noticeWorkspace, "");
1002
+ if ((binding.peekNotices?.(scope) ?? []).some((p) => p.source?.startsWith(guard))) {
1003
+ let kills: ListedKill[] | undefined;
1004
+ try {
1005
+ kills = await client.memoryKills({
1006
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(memoryKillsReadMs)]),
1007
+ });
1008
+ } catch {
1009
+ kills = undefined;
1010
+ }
1011
+ if (kills === undefined) scope.exceptPrefix = guard;
1012
+ else applyTold(kills);
1013
+ }
1014
+ }
1015
+ if (stopped || iid !== currentIid) return [];
1016
+ const taken = binding.takeNotices(scope);
1017
+ const held = boundary.get(iid) ?? new Map();
1018
+ const said = new Map<string, string>();
1019
+ const out: Array<{ id: string; notice: Notice }> = [];
1020
+ taken.ids.forEach((id, i) => {
1021
+ const raised = taken.raised?.[i] ?? { notice: taken.notices[i]! };
1022
+ const key = JSON.stringify(raised.notice);
1023
+ const first = said.get(key);
1024
+ if (first === undefined) {
1025
+ said.set(key, id);
1026
+ held.set(id, [{ id, raised }]);
1027
+ out.push({ id, notice: raised.notice });
1028
+ } else held.get(first)!.push({ id, raised });
1029
+ });
1030
+ if (held.size > 0) boundary.set(iid, held);
1031
+ return out;
1032
+ };
1033
+ // A step boundary whose notices the Harness did not append — a stopped model with no Allowed
1034
+ // picks, or a Turn ending as it read (`between-steps.ts`) — holds them until the next admission
1035
+ // of the conversation, which takes them again: a wake or a nudge carries them (ADR-0062).
1036
+ const releaseBoundary = (iid: string): void => {
1037
+ const held = boundary.get(iid);
1038
+ if (held === undefined) return;
1039
+ boundary.delete(iid);
1040
+ binding.releaseNotices?.(
1041
+ [...held.values()].flatMap((hs) => hs.map((h) => h.id)),
1042
+ iid,
1043
+ );
1044
+ };
1045
+ // The Harness appended them: heard in this conversation, in one save. An id this read did not
1046
+ // name changes nothing.
1047
+ const boundaryHeard = (iid: string, ids: readonly string[]): number => {
1048
+ const held = boundary.get(iid);
1049
+ if (held === undefined) return 0;
1050
+ const heard = ids.flatMap((id) => held.get(id) ?? []);
1051
+ for (const id of ids) held.delete(id);
1052
+ if (held.size === 0) boundary.delete(iid);
1053
+ if (heard.length === 0) return 0;
1054
+ binding.acknowledgeNotices?.(
1055
+ heard.map((h) => h.id),
1056
+ iid,
1057
+ );
1058
+ const bucket = carriedBy.get(iid) ?? { heard: [], unheard: [], task: false, fresh: [] };
1059
+ bucket.heard.push(...heard.map((h) => h.raised));
1060
+ carriedBy.set(iid, bucket);
1061
+ return ids.filter((id) => heard.some((h) => h.id === id)).length;
1062
+ };
1063
+
959
1064
  // The Turn's admission and a reroll's carry the pending notices in scope — a reroll's
960
1065
  // conversation is fresh, and has heard nothing. A nudge's and a wake's carry only the guard
961
1066
  // kills of this Turn that came after its own `bash` call's last kill verdict (`hearPodKills`):
@@ -996,26 +1101,34 @@ export function agentActorWith(
996
1101
  ) => {
997
1102
  await hearKernelKill();
998
1103
  const { known, unsaid, said } = await hearPodKills();
1104
+ releaseBoundary(turn.instanceId);
1105
+ // The conversation that takes a Workspace notice (ADR-0062): the one this admission rides, its
1106
+ // Instance ID. A continued Turn's is the continued conversation's; a fresh Turn's, a reroll's
1107
+ // and a burned conversation's successor's are each a new conversation, which has heard none.
1108
+ // A Workspace notice is delivered once in each, so every conversation in the Workspace hears
1109
+ // it — each child of a Pool has its own — and no conversation hears it twice.
1110
+ const taker = turn.instanceId;
999
1111
  // A read that could not tell leaves the same Turn no way to know which kills its own `bash`
1000
1112
  // reported: it takes none, as a nudge takes none then, and they stay for the next Turn.
1001
1113
  const sameTurn: NoticeScope = known
1002
- ? { ...noticeScope, except: said }
1114
+ ? { ...noticeScope, taker, except: said }
1003
1115
  : {
1004
1116
  ...noticeScope,
1117
+ taker,
1005
1118
  ...(noticeWorkspace === undefined ? {} : { exceptPrefix: guardSource(noticeWorkspace, "") }),
1006
1119
  };
1007
1120
  const scope: NoticeScope | undefined =
1008
1121
  opts.notices === "turn"
1009
- ? noticeScope
1122
+ ? { ...noticeScope, taker, turn: true }
1010
1123
  : opts.notices === "same-turn"
1011
1124
  ? sameTurn
1012
1125
  : unsaid.length > 0
1013
- ? { workspace: noticeWorkspace, only: unsaid }
1126
+ ? { workspace: noticeWorkspace, taker, only: unsaid }
1014
1127
  : undefined;
1015
1128
  const none = { ids: [] as string[], notices: [] as Notice[], raised: [] as Raised[] };
1016
1129
  const restored =
1017
1130
  opts.include && opts.include.length > 0
1018
- ? (binding.takeNotices?.({ ...noticeScope, only: opts.include }) ?? none)
1131
+ ? (binding.takeNotices?.({ ...noticeScope, taker, only: opts.include }) ?? none)
1019
1132
  : none;
1020
1133
  const main = (scope ? binding.takeNotices?.(scope) : undefined) ?? none;
1021
1134
  const taken = {
@@ -1045,12 +1158,13 @@ export function agentActorWith(
1045
1158
  try {
1046
1159
  admission = await client.admit(turn, {
1047
1160
  definition,
1161
+ slot,
1048
1162
  signal: controller.signal,
1049
1163
  ...(notices.length ? { notices } : {}),
1050
1164
  ...(opts.kept ? { kept: true } : {}),
1051
1165
  });
1052
1166
  } catch (err) {
1053
- binding.releaseNotices?.(taken.ids);
1167
+ binding.releaseNotices?.(taken.ids, taker);
1054
1168
  if (heard) served.add(turn.instanceId);
1055
1169
  throw err;
1056
1170
  }
@@ -1071,7 +1185,7 @@ export function agentActorWith(
1071
1185
  ...(killMark === undefined ? {} : { killMark }),
1072
1186
  };
1073
1187
  }
1074
- ledger(admission, taken.ids);
1188
+ ledger(admission, taken.ids, taker);
1075
1189
  const bucket = carriedBy.get(turn.instanceId) ?? { heard: [], unheard: [], task: false, fresh: [] };
1076
1190
  if (heard) {
1077
1191
  bucket.heard.push(...bucket.unheard);
@@ -1101,7 +1215,9 @@ export function agentActorWith(
1101
1215
  for (const { notice, source } of again) {
1102
1216
  const to = notice.scope === "conversation" ? conversation : noticeWorkspace;
1103
1217
  if (to === undefined) continue;
1104
- if (binding.restoreNotice) binding.restoreNotice(notice, to, source);
1218
+ // A Workspace notice is pending again for the conversation that carried it, which did not
1219
+ // hear it; the others it reached keep theirs.
1220
+ if (binding.restoreNotice) binding.restoreNotice(notice, to, source, iid);
1105
1221
  else binding.raiseNotice?.(notice, to);
1106
1222
  if (source !== undefined) restored.push(source);
1107
1223
  }
@@ -1122,7 +1238,14 @@ export function agentActorWith(
1122
1238
  // Pool cancelling a child. The one exception is the host stopping the run for its own
1123
1239
  // reasons, which ADR-0007's restore re-attaches to.
1124
1240
  if (binding.hostStopping) return;
1241
+ // A step boundary's notices the Harness never acknowledged are pending again (ADR-0062).
1242
+ for (const iid of [...boundary.keys()]) releaseBoundary(iid);
1125
1243
  reraiseUnheard();
1244
+ // The Turn ended: a fresh conversation leaves every audience in its Workspace, a continued one
1245
+ // waits for its next Turn (ADR-0062).
1246
+ if (noticeWorkspace !== undefined) {
1247
+ binding.noticeTurnEnded?.(noticeWorkspace, [...new Set([...carriedBy.keys(), currentIid])]);
1248
+ }
1126
1249
  // Fire-and-forget, and unreportable BY CONSTRUCTION: this actor is stopped, so there is no
1127
1250
  // `agent.fault` left to raise. An orphan that survives a failed abort 404s on every tool
1128
1251
  // call and settles on its own.
@@ -1548,10 +1671,13 @@ export function agentActorWith(
1548
1671
  const kill = lostHarness && sandbox !== undefined ? await memoryFault(sandbox) : undefined;
1549
1672
  if (stopped) return;
1550
1673
  if (kill !== undefined && noticeWorkspace !== undefined) {
1674
+ // The kill cut this Turn (D4 rule 2): its Agent's next Turn retries the work, and hears it.
1551
1675
  binding.raiseNotice?.(
1552
1676
  memoryLimit({ limit: kill.limit, agent: input.agentName }),
1553
1677
  noticeWorkspace,
1554
1678
  kill.at === undefined ? undefined : kernelSource(noticeWorkspace, kill.at),
1679
+ [],
1680
+ { retry: { slot, cut: currentIid }, ...(kill.at === undefined ? {} : { at: kill.at }) },
1555
1681
  );
1556
1682
  }
1557
1683
  // A restart the Harness came back from keeps the conversation (ADR-0031); a 404 does not.
package/src/agent.ts CHANGED
@@ -59,8 +59,8 @@ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhi
59
59
  export type WorkspaceAccess = "write" | "read" | "none";
60
60
 
61
61
  // The plain-data Agent definition (ADR-0018): IDENTITY plus one default. A Turn's Frame — what it
62
- // is about and where it works — is not here and cannot be (ADR-0057): a Worktree path exists only
63
- // once a run has a branch, so no definition written before the run could name one.
62
+ // is about and where it works — is not here and cannot be (ADR-0057): a Worktree exists only
63
+ // once a run has cut it, so no definition written before the run could name one.
64
64
  //
65
65
  // Must stay JSON-serializable: the definition rides the Turn to the Harness (ADR-0049), so
66
66
  // anything non-serializable would be silently lost — grow this contract deliberately. ADR-0028
@@ -119,14 +119,14 @@ export type AgentDefinition = {
119
119
  * level in the invoke's `input`. */
120
120
  thinkingLevel?: ThinkingLevel;
121
121
  // The persona in one word (ADR-0028), deliberately not `tools` (that names the control-plane
122
- // Menu, what it may SAY). `"read"` states intent and stops the honest path: bash stays, and the
123
- // detached review worktree is the containment. `"none"` frames no directory — only Working tools
122
+ // Menu, what it may SAY). `"read"` states intent and stops the honest path: bash stays, and a
123
+ // detached Worktree is the containment. `"none"` frames no directory — only Working tools
124
124
  // consume one — and places the Turn on the Instance Harness (ADR-0031).
125
125
  /** What the Agent may do to the Workspace. Default: `"write"`.
126
126
  * - `"write"`: all the Working tools (read, write, edit, bash, grep, glob).
127
127
  * - `"read"`: no write or edit tool. The Agent keeps bash, so `"read"` does not stop a command
128
- * that writes. To keep the Agent from changing the branch, frame its Turn in the review
129
- * Worktree: `reviewSha` in the spec, then `cwd: context.workspace.review?.<slot>`.
128
+ * that writes. To keep the Agent from changing the branch, frame its Turn in a detached
129
+ * Worktree: cut it with `{ path, detach: sha }`, then give that `path` as `cwd`.
130
130
  * - `"none"`: no Working tools. The Agent only reads its prompt and picks from its Menu. Its Turn
131
131
  * runs on the Instance Harness, not in a Sandbox, so it needs no `workspace()` around it. */
132
132
  workspace?: WorkspaceAccess;
package/src/ambient.ts CHANGED
@@ -21,14 +21,12 @@ import type { AnyActorRef } from "xstate";
21
21
  export type AmbientHandles = {
22
22
  endpoint: string;
23
23
  sandbox: string;
24
- /** Every Repo Slot's branch-worktree path, by slot (ADR-0051). */
24
+ /** Every Repo Slot's folder, `/work/<slot>`, by slot (ADR-0051). */
25
25
  repos: Record<string, string>;
26
26
  /** Every Repo Slot's Binding url, by slot (ADR-0051): where a push goes. */
27
27
  bindings?: Record<string, string>;
28
- /** The branch the Worktrees are on. Absent for a Workspace with no Repo Slot (ADR-0051). */
29
- branch?: string;
30
- /** Detached review-worktree paths, when the spec carried `reviewSha` (ADR-0028). */
31
- review?: Record<string, string>;
28
+ /** Each Repo Slot's Binding base, where the Binding names one: a cut with no `from` starts here. */
29
+ bases?: Record<string, string>;
32
30
  };
33
31
 
34
32
  const byRef = new WeakMap<AnyActorRef, AmbientHandles>();