@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/notices.ts CHANGED
@@ -1,66 +1,155 @@
1
1
  // The Orchestrator's notices (ADR-0062): what happened that the next Turn cannot find out for
2
2
  // itself — a memory kill in its Workspace, a conversation that is new — kept as plain data in the
3
3
  // run's persisted state (ADR-0007), beside the admission and epoch ledgers, and handed to the next
4
- // admission in scope. The Harness writes every word; this is only the keeping.
4
+ // admission in scope, or to an open Turn at a step boundary. The Harness writes every word; this is
5
+ // only the keeping.
5
6
  //
6
- // Delivered ONCE, and "delivered" means LEDGERED: a notice leaves the pending set in the same save
7
- // that records the admission it rode. A restart before that save finds it still pending and the
8
- // restored Turn's admission carries it again; a restart after it does not. Between `take` and that
9
- // save the notice is RESERVED, in memory only, so a second admission in the same scope cannot carry
10
- // it too — and a restart forgets reservations along with everything else in memory.
7
+ // A conversation notice reaches one conversation, so it leaves the pending set when it is heard. A
8
+ // Workspace notice goes to an AUDIENCE that the ledger fixes when it raises it — "an Agent hears a
9
+ // kill if it was there for it":
10
+ //
11
+ // 1. each conversation admitted in that Workspace before the kill that can still take a Turn: an
12
+ // open Turn's, or a continued conversation's (`members`);
13
+ // 2. when the kill cut specific Turns, each one's Agent's NEXT Turn, fresh or continued (`retry`:
14
+ // one kill can cut several Turns, and each Agent retries its own work);
15
+ // 3. minus each conversation whose own `bash` answer returned the kill's verdict (`told`). A
16
+ // retry in a told conversation — the next Turn of a continued one — already has the verdict
17
+ // in its context: it is the retry, and hears nothing.
18
+ //
19
+ // Each member hears it once (`heardBy`), and it RETIRES when every member and every retry has
20
+ // heard it. A conversation that starts after the kill, other than a retry, does not hear it. A
21
+ // fresh Turn that ends leaves the audience, because it can take no Turn again. "Before the kill"
22
+ // allows for the clocks (`AUDIENCE_SKEW_MS`): a kernel kill's time is the kubelet's, to the second.
23
+ //
24
+ // "Heard" means LEDGERED: a notice is heard by a conversation in the same save that records the
25
+ // admission it rode there, or the Harness's acknowledgment of a step boundary that appended it.
26
+ // Between `take` and that save the notice is RESERVED for the taking conversation, in memory only, so
27
+ // a second read in the same conversation cannot carry it too — and a restart forgets reservations
28
+ // along with everything else in memory. jr2 keeps no "handled" state: whether an Agent acted on a
29
+ // notice is not the ledger's concern.
11
30
 
12
31
  import type { Notice } from "./wire.ts";
13
32
 
14
- /** Where an admission may hear notices: its continued conversation's base Instance ID (ADR-0057)
15
- * and its Workspace's Sandbox name. Either may be absent — a fresh Turn has no conversation to be
16
- * told about, and a Menu-only Agent works in no Workspace. `only`, when set, narrows the take to
17
- * the notices raised by these sources: what a nudge or a wake carries (ADR-0062). `except` leaves
18
- * out the notices raised by these: what a re-admission of the same Turn was told already;
33
+ /** Where an admission, or a step boundary, may hear notices: its continued conversation's base
34
+ * Instance ID (ADR-0057) and its Workspace's Sandbox name. Either may be absent — a fresh Turn has no
35
+ * conversation to be told about, and a Menu-only Agent works in no Workspace. `taker` is the
36
+ * conversation that takes a Workspace notice — the Instance ID the admission rides, which a fresh
37
+ * Turn has too, as a new conversation; absent, `conversation` is the taker, and a scope with neither
38
+ * takes no Workspace notice. `slot` is the taker's Agent (ADR-0076), and `turn` says this admission
39
+ * starts a Turn: together they let a cut Turn's retry hear its kill. `only`, when set, narrows the
40
+ * take to the notices raised by these sources: what a nudge or a wake carries (ADR-0062). `except`
41
+ * leaves out the notices raised by these: what a re-admission of the same Turn was told already;
19
42
  * `exceptPrefix` every notice whose source starts so — all guard kills, when that is not known. */
20
43
  export type NoticeScope = {
21
44
  conversation?: string;
22
45
  workspace?: string;
46
+ taker?: string;
47
+ slot?: string;
48
+ turn?: boolean;
23
49
  only?: readonly string[];
24
50
  except?: readonly string[];
25
51
  exceptPrefix?: string;
26
52
  };
27
53
 
54
+ /** A cut Turn's retry (D4 rule 2): the next Turn of `slot` — in any conversation, the cut Turn's
55
+ * own `cut` included when it continues. `retried`: that Turn took it. */
56
+ export type NoticeRetry = { slot: string; cut?: string; retried?: true };
57
+
58
+ /** How far a member's first admission may come after a kill's time and still count as before it:
59
+ * a kernel kill's time is the kubelet's `finishedAt`, which has whole seconds, and a guard kill's is
60
+ * read on a stream; a member's is this process's clock. Two seconds holds the truncation and a
61
+ * second of skew between the clocks. Wider is a notice to a conversation that started just after
62
+ * the kill; narrower is a member that never hears it. */
63
+ export const AUDIENCE_SKEW_MS = 2_000;
64
+
28
65
  /** One pending notice: the notice, and the conversation or Workspace it goes to. `source` names
29
66
  * the event that raised it, so the same event raised twice — two Turns lost to one kernel kill, a
30
- * stream read replayed after a restore — is one notice. */
31
- export type PendingNotice = { id: string; notice: Notice; to: string; source?: string };
67
+ * stream read replayed after a restore — is one notice. On a Workspace notice: `audience`, the
68
+ * conversations fixed at the raise; `retry`, each cut Turn's next Turn; `heardBy`, the
69
+ * conversations it was delivered to, which do not take it again; `told`, the conversations whose
70
+ * own `bash` answer told them. */
71
+ export type PendingNotice = {
72
+ id: string;
73
+ notice: Notice;
74
+ to: string;
75
+ source?: string;
76
+ audience?: string[];
77
+ retry?: NoticeRetry[];
78
+ heardBy?: string[];
79
+ told?: string[];
80
+ };
81
+
82
+ /** One conversation admitted in a Workspace (ADR-0062): who can be in a Workspace notice's audience.
83
+ * `open`: a Turn of it is live. `continued`: it continues across Turns, so it can take another Turn
84
+ * after this one ends. `since`: its first admission there, which a kill must come after. */
85
+ export type NoticeMember = { taker: string; slot: string; continued: boolean; open: boolean; since: string };
32
86
 
33
87
  /** The persisted form (`RunBlob.notices`). `seen` is every source ever raised, so a delivered
34
- * notice is not raised again by a late or replayed witness of the same event. */
35
- export type NoticeLedgerState = { pending: PendingNotice[]; seen: string[] };
88
+ * notice is not raised again by a late or replayed witness of the same event. `members`: by
89
+ * Workspace, the conversations admitted there that can still take a Turn. */
90
+ export type NoticeLedgerState = {
91
+ pending: PendingNotice[];
92
+ seen: string[];
93
+ /** Each retry ever recorded, by source and slot: a replayed raise records it once. */
94
+ retriesSeen?: string[];
95
+ members?: Record<string, NoticeMember[]>;
96
+ };
36
97
 
37
98
  /** What one admission takes: the notices it carries, and their ids for the ledger write. */
38
99
  export type TakenNotices = {
39
100
  ids: string[];
40
101
  notices: Notice[];
41
- /** Every notice taken, with the source it was raised with — what `restore` puts back when no
42
- * model heard the admission. */
102
+ /** Every notice taken, in the order of `ids`, with the source it was raised with — what `restore`
103
+ * puts back when no model heard the admission. */
43
104
  raised?: Array<{ notice: Notice; source?: string }>;
44
105
  };
45
106
 
107
+ /** What a Workspace notice's raise knows of its kill: the cut Turn's retry, the conversations its
108
+ * `bash` answer told, and when it happened — a conversation first admitted after it was not there. */
109
+ export type RaiseOptions = { retry?: NoticeRetry; told?: readonly string[]; at?: string };
110
+
111
+ /** A reservation: a conversation notice's id alone (it has one taker), a Workspace notice's id
112
+ * with the conversation that took it (each conversation takes it once). */
113
+ const reservation = (p: PendingNotice, taker: string | undefined): string =>
114
+ p.notice.scope === "workspace" ? `${p.id}\u0000${taker ?? ""}` : p.id;
115
+
116
+ /** The mark of a retry recorded for a source: a replayed raise records it once. */
117
+ const retryMark = (source: string, slot: string): string => `${source}\u0000retry\u0000${slot}`;
118
+
119
+ const copyRetry = (r: readonly NoticeRetry[] | undefined) =>
120
+ r === undefined ? {} : { retry: r.map((x) => ({ ...x })) };
121
+
46
122
  export class NoticeLedger {
47
123
  readonly #pending: PendingNotice[];
48
124
  readonly #seen: Set<string>;
125
+ readonly #retriesSeen: Set<string>;
126
+ readonly #members: Map<string, NoticeMember[]>;
49
127
  readonly #reserved = new Set<string>();
128
+ /** The reservations a cut Turn's retry holds, by the slot it retries: delivering one marks that
129
+ * retry `retried`. */
130
+ readonly #asRetry = new Map<string, string>();
50
131
  readonly #newId: () => string;
51
132
 
52
133
  constructor(state: NoticeLedgerState | undefined, newId: () => string) {
53
- this.#pending = [...(state?.pending ?? [])];
134
+ this.#pending = (state?.pending ?? []).map((p) => ({
135
+ ...p,
136
+ ...(p.audience === undefined ? {} : { audience: [...p.audience] }),
137
+ ...copyRetry(p.retry),
138
+ ...(p.heardBy === undefined ? {} : { heardBy: [...p.heardBy] }),
139
+ ...(p.told === undefined ? {} : { told: [...p.told] }),
140
+ }));
54
141
  this.#seen = new Set(state?.seen ?? []);
142
+ this.#retriesSeen = new Set(state?.retriesSeen ?? []);
143
+ this.#members = new Map(Object.entries(state?.members ?? {}).map(([ws, ms]) => [ws, ms.map((m) => ({ ...m }))]));
55
144
  this.#newId = newId;
56
145
  }
57
146
 
58
147
  /** Raise a notice for `to` (a conversation's base id, or a Sandbox name — the notice's own
59
148
  * `scope` says which). `older` are the sources of earlier events that this one tells the same as —
60
149
  * a burst of guard kills, read at once: they are marked raised and give no notice of their own.
61
- * Answers whether anything changed — a notice pending, or a source marked — so the caller saves
62
- * only then. */
63
- raise(notice: Notice, to: string, source?: string, older: readonly string[] = []): boolean {
150
+ * A Workspace notice's audience is fixed here, from `opts`. Answers whether anything changed — a
151
+ * notice pending, or a source marked — so the caller saves only then. */
152
+ raise(notice: Notice, to: string, source?: string, older: readonly string[] = [], opts: RaiseOptions = {}): boolean {
64
153
  let changed = false;
65
154
  for (const seen of older) {
66
155
  if (this.#seen.has(seen)) continue;
@@ -68,33 +157,73 @@ export class NoticeLedger {
68
157
  changed = true;
69
158
  }
70
159
  if (source !== undefined) {
71
- if (this.#seen.has(source)) return changed;
160
+ // A second witness of one kill: a Turn it cut that the first did not name still retries.
161
+ if (this.#seen.has(source)) return this.#addRetry(notice, to, source, opts.retry) || changed;
72
162
  this.#seen.add(source);
73
- } else {
74
- // A notice with no source is one notice with a pending twin that has none either. A notice
75
- // WITH a source keeps its own entry, so a take by source (`only`, `except`) finds it; equal
76
- // ones are one notice in the admission (`take`).
77
- const same = JSON.stringify(notice);
78
- if (this.#pending.some((p) => p.source === undefined && p.to === to && JSON.stringify(p.notice) === same)) {
79
- return changed;
163
+ if (opts.retry !== undefined) this.#retriesSeen.add(retryMark(source, opts.retry.slot));
164
+ changed = true;
165
+ }
166
+ const workspace = notice.scope === "workspace";
167
+ const audience = workspace ? this.#audienceOf(to, opts) : undefined;
168
+ if (source === undefined) {
169
+ // A notice with no source is one notice with a pending twin that has none either — while no
170
+ // conversation has heard or reserved that twin: a later event is news to each conversation
171
+ // the first one reached. A notice WITH a source keeps its own entry, so a take by source
172
+ // (`only`, `except`) finds it; equal ones are one notice in the admission (`take`).
173
+ const twin = this.#twin(notice, to);
174
+ if (twin !== undefined) {
175
+ if (audience === undefined) return changed;
176
+ const before = twin.audience?.length ?? 0;
177
+ twin.audience = [...new Set([...(twin.audience ?? []), ...audience])];
178
+ const retried = opts.retry !== undefined && !(twin.retry ?? []).some((r) => r.slot === opts.retry!.slot);
179
+ if (retried) twin.retry = [...(twin.retry ?? []), { ...opts.retry! }];
180
+ return changed || twin.audience.length !== before || retried;
80
181
  }
81
182
  }
82
- this.#pending.push({ id: this.#newId(), notice, to, ...(source === undefined ? {} : { source }) });
183
+ // A Workspace notice no one was there for, and that no Turn retries, tells no one.
184
+ if (audience !== undefined && audience.length === 0 && opts.retry === undefined) return changed;
185
+ this.#pending.push({
186
+ id: this.#newId(),
187
+ notice,
188
+ to,
189
+ ...(source === undefined ? {} : { source }),
190
+ ...(audience === undefined ? {} : { audience }),
191
+ ...(opts.retry === undefined ? {} : { retry: [{ ...opts.retry }] }),
192
+ ...(workspace && opts.told !== undefined && opts.told.length > 0 ? { told: [...opts.told] } : {}),
193
+ });
83
194
  return true;
84
195
  }
85
196
 
86
- /** Reserve every unreserved notice in this admission's scope, in the order raised. Equal notices —
87
- * two kills that tell the same thing — are said once, and every id is reserved. */
197
+ /** A raise of a seen source that names a cut Turn: its retry joins the notice, or is pending on a
198
+ * notice of its own when the first witness gave none or every member has heard it. Once for each
199
+ * slot and source, so a replayed read adds nothing. */
200
+ #addRetry(notice: Notice, to: string, source: string, retry: NoticeRetry | undefined): boolean {
201
+ if (retry === undefined || notice.scope !== "workspace") return false;
202
+ const mark = retryMark(source, retry.slot);
203
+ if (this.#retriesSeen.has(mark)) return false;
204
+ this.#retriesSeen.add(mark);
205
+ const pending = this.#pending.find((p) => p.source === source && p.to === to);
206
+ if (pending !== undefined) {
207
+ pending.retry = [...(pending.retry ?? []), { ...retry }];
208
+ return true;
209
+ }
210
+ this.#pending.push({ id: this.#newId(), notice, to, source, audience: [], retry: [{ ...retry }] });
211
+ return true;
212
+ }
213
+
214
+ /** Reserve every notice in this scope that this conversation may hear and has neither heard nor
215
+ * reserved, in the order raised. Equal notices — two kills that tell the same thing — are said
216
+ * once, and every id is reserved. */
88
217
  take(scope: NoticeScope): TakenNotices {
89
- const taken = this.#pending.filter(
90
- (p) =>
91
- !this.#reserved.has(p.id) &&
92
- (scope.only === undefined || (p.source !== undefined && scope.only.includes(p.source))) &&
93
- (scope.except === undefined || p.source === undefined || !scope.except.includes(p.source)) &&
94
- (scope.exceptPrefix === undefined || p.source === undefined || !p.source.startsWith(scope.exceptPrefix)) &&
95
- (p.notice.scope === "conversation" ? p.to === scope.conversation : p.to === scope.workspace),
96
- );
97
- for (const p of taken) this.#reserved.add(p.id);
218
+ const taker = scope.taker ?? scope.conversation;
219
+ this.#retryTold(scope, taker);
220
+ const taken = this.#eligible(scope, taker);
221
+ for (const p of taken) {
222
+ const key = reservation(p, taker);
223
+ this.#reserved.add(key);
224
+ const retry = this.#retryFor(p, scope, taker);
225
+ if (retry !== undefined) this.#asRetry.set(key, retry.slot);
226
+ }
98
227
  const said = new Set<string>();
99
228
  const notices = taken.flatMap((p) => {
100
229
  const key = JSON.stringify(p.notice);
@@ -109,33 +238,293 @@ export class NoticeLedger {
109
238
  };
110
239
  }
111
240
 
241
+ /** What {@link take} would reserve in this scope, reserving nothing: a step boundary asks first
242
+ * whether a guard kill is among them, and so whether it must learn who was told. */
243
+ peek(scope: NoticeScope): Array<{ id: string; notice: Notice; source?: string }> {
244
+ return this.#eligible(scope, scope.taker ?? scope.conversation).map((p) => ({
245
+ id: p.id,
246
+ notice: p.notice,
247
+ ...(p.source === undefined ? {} : { source: p.source }),
248
+ }));
249
+ }
250
+
112
251
  /** Put back a notice an admission carried that no model heard, with the source it was raised
113
- * with — `seen` already holds it, so a raise would drop it. Nothing changes when a notice with
114
- * that source is pending already. Answers whether anything changed. */
115
- restore(notice: Notice, to: string, source?: string): boolean {
252
+ * with — `seen` already holds it, so a raise would drop it. A Workspace notice is still pending
253
+ * for the other conversations: it is pending again for `taker`, the conversation that did not hear
254
+ * it, which is a member of its audience again. Nothing changes when the notice is pending for that
255
+ * conversation already. Answers whether anything changed. */
256
+ restore(notice: Notice, to: string, source?: string, taker?: string): boolean {
257
+ const pending =
258
+ source === undefined
259
+ ? notice.scope === "workspace" && taker !== undefined
260
+ ? this.#equal(notice, to, taker)
261
+ : this.#twin(notice, to)
262
+ : this.#pending.find((p) => p.source === source && p.to === to);
263
+ if (pending !== undefined) {
264
+ if (pending.notice.scope !== "workspace" || taker === undefined) return false;
265
+ const heardBy = pending.heardBy ?? [];
266
+ const inAudience = pending.audience === undefined || pending.audience.includes(taker);
267
+ if (!heardBy.includes(taker) && inAudience) return false;
268
+ pending.heardBy = heardBy.filter((c) => c !== taker);
269
+ if (!inAudience) pending.audience = [...(pending.audience ?? []), taker];
270
+ return true;
271
+ }
272
+ if (source !== undefined) this.#seen.add(source);
273
+ if (notice.scope === "workspace") {
274
+ // Retired, or never pending for anyone else: pending again for the one that did not hear it.
275
+ if (taker === undefined) return false;
276
+ this.#pending.push({
277
+ id: this.#newId(),
278
+ notice,
279
+ to,
280
+ ...(source === undefined ? {} : { source }),
281
+ audience: [taker],
282
+ });
283
+ return true;
284
+ }
116
285
  if (source === undefined) return this.raise(notice, to);
117
- if (this.#pending.some((p) => p.source === source && p.to === to)) return false;
118
- this.#seen.add(source);
119
286
  this.#pending.push({ id: this.#newId(), notice, to, source });
120
287
  return true;
121
288
  }
122
289
 
123
- /** The admission that carried them was ledgered: they are delivered. */
124
- delivered(ids: readonly string[]): void {
290
+ /** The admission that carried them, in `taker`'s conversation, was ledgered — or the Harness
291
+ * acknowledged the step boundary that appended them: they are heard there. A conversation notice
292
+ * leaves the pending set; a Workspace notice stays for the rest of its audience, and `taker` does
293
+ * not hear it again. It retires once every member has heard it. */
294
+ delivered(ids: readonly string[], taker?: string): void {
125
295
  for (const id of ids) {
126
- this.#reserved.delete(id);
127
296
  const at = this.#pending.findIndex((p) => p.id === id);
128
- if (at !== -1) this.#pending.splice(at, 1);
297
+ const p = this.#pending[at];
298
+ if (p === undefined) {
299
+ this.#reserved.delete(id);
300
+ continue;
301
+ }
302
+ const key = reservation(p, taker);
303
+ this.#reserved.delete(key);
304
+ if (p.notice.scope !== "workspace") {
305
+ this.#pending.splice(at, 1);
306
+ continue;
307
+ }
308
+ const slot = this.#asRetry.get(key);
309
+ if (slot !== undefined) {
310
+ this.#asRetry.delete(key);
311
+ for (const r of p.retry ?? []) if (r.slot === slot) r.retried = true;
312
+ }
313
+ if (taker !== undefined && !(p.heardBy ?? []).includes(taker)) p.heardBy = [...(p.heardBy ?? []), taker];
314
+ this.#retireIfHeard(p);
315
+ }
316
+ }
317
+
318
+ /** The Harness acknowledged the step boundary that appended these (ADR-0062): each one that
319
+ * `taker` holds a reservation on is heard there. Answers how many were. */
320
+ acknowledge(ids: readonly string[], taker: string): number {
321
+ const mine = ids.filter((id) => {
322
+ const p = this.#pending.find((q) => q.id === id);
323
+ return p !== undefined && this.#reserved.has(reservation(p, taker));
324
+ });
325
+ this.delivered(mine, taker);
326
+ return mine.length;
327
+ }
328
+
329
+ /** The admission that carried them, in `taker`'s conversation, failed — or the Turn ended with a
330
+ * step boundary's notices unacknowledged: they are pending again there, for the next one. */
331
+ release(ids: readonly string[], taker?: string): void {
332
+ for (const id of ids) {
333
+ const p = this.#pending.find((q) => q.id === id);
334
+ const key = p === undefined ? id : reservation(p, taker);
335
+ this.#reserved.delete(key);
336
+ this.#asRetry.delete(key);
129
337
  }
130
338
  }
131
339
 
132
- /** The admission that carried them failed: they are pending again, for the next one. */
133
- release(ids: readonly string[]): void {
134
- for (const id of ids) this.#reserved.delete(id);
340
+ /** A conversation was admitted in `workspace` (ADR-0062): it is there for every kill from now on,
341
+ * while it can take a Turn. Answers whether anything changed. */
342
+ admitted(workspace: string, member: Omit<NoticeMember, "open">): boolean {
343
+ const members = this.#members.get(workspace) ?? [];
344
+ const known = members.find((m) => m.taker === member.taker);
345
+ if (known !== undefined) {
346
+ if (known.open && known.continued === member.continued) return false;
347
+ known.open = true;
348
+ known.continued = member.continued;
349
+ return true;
350
+ }
351
+ members.push({ ...member, open: true });
352
+ this.#members.set(workspace, members);
353
+ return true;
354
+ }
355
+
356
+ /** A Turn of these conversations in `workspace` ended. A continued one can take another Turn and
357
+ * stays a member; a fresh one cannot, so it leaves every audience it has not heard yet — which can
358
+ * retire a notice. Answers whether anything changed. */
359
+ ended(workspace: string, takers: readonly string[]): boolean {
360
+ let changed = false;
361
+ const members = this.#members.get(workspace) ?? [];
362
+ for (const taker of takers) {
363
+ const at = members.findIndex((m) => m.taker === taker);
364
+ const m = members[at];
365
+ if (m === undefined) continue;
366
+ changed = true;
367
+ if (m.continued) {
368
+ m.open = false;
369
+ continue;
370
+ }
371
+ members.splice(at, 1);
372
+ this.#leave(taker, workspace);
373
+ }
374
+ if (members.length === 0) this.#members.delete(workspace);
375
+ return changed;
376
+ }
377
+
378
+ /** A conversation that can take no Turn again — a continued one jr2 burned (ADR-0057): it leaves
379
+ * every Workspace and every audience. Answers whether anything changed. */
380
+ forget(taker: string): boolean {
381
+ let changed = false;
382
+ for (const [workspace, members] of this.#members) {
383
+ const at = members.findIndex((m) => m.taker === taker);
384
+ if (at < 0) continue;
385
+ members.splice(at, 1);
386
+ if (members.length === 0) this.#members.delete(workspace);
387
+ this.#leave(taker, workspace);
388
+ changed = true;
389
+ }
390
+ return changed;
391
+ }
392
+
393
+ /** The conversations whose own `bash` answer returned the verdict of the kill `source` raised
394
+ * (D4 rule 3): that answer told them, so they leave its audience. Answers whether anything
395
+ * changed. */
396
+ told(source: string, takers: readonly string[]): boolean {
397
+ if (takers.length === 0) return false;
398
+ const p = this.#pending.find((q) => q.source === source);
399
+ if (p === undefined || p.audience === undefined) return false;
400
+ const before = p.audience.length;
401
+ const toldBefore = p.told?.length ?? 0;
402
+ p.told = [...new Set([...(p.told ?? []), ...takers])];
403
+ p.audience = p.audience.filter((t) => !takers.includes(t) || (p.heardBy ?? []).includes(t));
404
+ if (p.audience.length === before && p.told.length === toldBefore) return false;
405
+ this.#retireIfHeard(p);
406
+ return true;
135
407
  }
136
408
 
137
409
  /** The persisted form — reserved notices included, because reserved is not delivered. */
138
410
  state(): NoticeLedgerState {
139
- return { pending: [...this.#pending], seen: [...this.#seen] };
411
+ return {
412
+ pending: this.#pending.map((p) => ({
413
+ ...p,
414
+ ...(p.audience === undefined ? {} : { audience: [...p.audience] }),
415
+ ...copyRetry(p.retry),
416
+ ...(p.heardBy === undefined ? {} : { heardBy: [...p.heardBy] }),
417
+ ...(p.told === undefined ? {} : { told: [...p.told] }),
418
+ })),
419
+ seen: [...this.#seen],
420
+ ...(this.#retriesSeen.size === 0 ? {} : { retriesSeen: [...this.#retriesSeen] }),
421
+ ...(this.#members.size === 0
422
+ ? {}
423
+ : { members: Object.fromEntries([...this.#members].map(([ws, ms]) => [ws, ms.map((m) => ({ ...m }))])) }),
424
+ };
425
+ }
426
+
427
+ /** The notices this scope may take for `taker`, unreserved and unheard, in the order raised. */
428
+ #eligible(scope: NoticeScope, taker: string | undefined): PendingNotice[] {
429
+ return this.#pending.filter(
430
+ (p) =>
431
+ !this.#reserved.has(reservation(p, taker)) &&
432
+ (scope.only === undefined || (p.source !== undefined && scope.only.includes(p.source))) &&
433
+ (scope.except === undefined || p.source === undefined || !scope.except.includes(p.source)) &&
434
+ (scope.exceptPrefix === undefined || p.source === undefined || !p.source.startsWith(scope.exceptPrefix)) &&
435
+ (p.notice.scope === "conversation"
436
+ ? p.to === scope.conversation
437
+ : taker !== undefined &&
438
+ p.to === scope.workspace &&
439
+ !(p.heardBy ?? []).includes(taker) &&
440
+ (p.audience === undefined || p.audience.includes(taker) || this.#retryFor(p, scope, taker) !== undefined)),
441
+ );
442
+ }
443
+
444
+ /** The cut Turn's retry that this take is (D4 rule 2), or undefined: a Turn's admission of the cut
445
+ * Agent's slot, in any conversation that its own `bash` answer did not tell — the cut Turn's own,
446
+ * when it continues, too. */
447
+ #retryFor(p: PendingNotice, scope: NoticeScope, taker: string | undefined): NoticeRetry | undefined {
448
+ if (scope.turn !== true || taker === undefined || (p.told ?? []).includes(taker)) return undefined;
449
+ return (p.retry ?? []).find((r) => r.retried !== true && r.slot === scope.slot);
450
+ }
451
+
452
+ /** A Turn of a retried slot in a conversation that its `bash` answer told: the next Turn of a
453
+ * continued conversation, which has the verdict in its context. It is the retry, and hears
454
+ * nothing more — so the notice can retire. */
455
+ #retryTold(scope: NoticeScope, taker: string | undefined): void {
456
+ if (scope.turn !== true || taker === undefined || scope.workspace === undefined) return;
457
+ for (const p of [...this.#pending]) {
458
+ if (p.to !== scope.workspace || !(p.told ?? []).includes(taker)) continue;
459
+ let changed = false;
460
+ for (const r of p.retry ?? []) {
461
+ if (r.retried === true || r.slot !== scope.slot) continue;
462
+ r.retried = true;
463
+ changed = true;
464
+ }
465
+ if (changed) this.#retireIfHeard(p);
466
+ }
467
+ }
468
+
469
+ /** Who of a Workspace's members is there for a kill now: each that can take a Turn, first
470
+ * admitted before the kill, and not told by its own `bash` answer. */
471
+ #audienceOf(workspace: string, opts: RaiseOptions): string[] {
472
+ const at = opts.at === undefined ? undefined : Date.parse(opts.at);
473
+ return (this.#members.get(workspace) ?? [])
474
+ .filter((m) => m.open || m.continued)
475
+ .filter((m) => at === undefined || Number.isNaN(at) || Date.parse(m.since) <= at + AUDIENCE_SKEW_MS)
476
+ .map((m) => m.taker)
477
+ .filter((t) => !(opts.told ?? []).includes(t));
478
+ }
479
+
480
+ /** `taker` can hear no more in `workspace`: it leaves each audience there it has not heard. */
481
+ #leave(taker: string, workspace: string): void {
482
+ for (const p of [...this.#pending]) {
483
+ if (p.notice.scope !== "workspace" || p.to !== workspace || p.audience === undefined) continue;
484
+ if ((p.heardBy ?? []).includes(taker) || !p.audience.includes(taker)) continue;
485
+ p.audience = p.audience.filter((t) => t !== taker);
486
+ this.#retireIfHeard(p);
487
+ }
488
+ }
489
+
490
+ /** A Workspace notice every member has heard, and every retry has, is done. */
491
+ #retireIfHeard(p: PendingNotice): void {
492
+ if (p.notice.scope !== "workspace" || p.audience === undefined) return;
493
+ const heard = p.heardBy ?? [];
494
+ if (!p.audience.every((t) => heard.includes(t))) return;
495
+ if ((p.retry ?? []).some((r) => r.retried !== true)) return;
496
+ const at = this.#pending.indexOf(p);
497
+ if (at >= 0) this.#pending.splice(at, 1);
498
+ }
499
+
500
+ /** The pending notice with no source that tells the same as `notice`, to the same place, and
501
+ * that a new event of the same words can fold into: a conversation notice always, a Workspace
502
+ * notice only while no conversation has heard or reserved it. */
503
+ #twin(notice: Notice, to: string): PendingNotice | undefined {
504
+ return this.#sameAs(notice, to).find(
505
+ (p) => p.notice.scope !== "workspace" || ((p.heardBy ?? []).length === 0 && !this.#reservedByAny(p)),
506
+ );
507
+ }
508
+
509
+ /** The pending Workspace notice with no source that tells the same as `notice` and that `taker`
510
+ * restores: one still pending for it — then nothing changes — or else the last one it heard. */
511
+ #equal(notice: Notice, to: string, taker: string): PendingNotice | undefined {
512
+ const same = this.#sameAs(notice, to);
513
+ return (
514
+ same.find(
515
+ (p) => !(p.heardBy ?? []).includes(taker) && (p.audience === undefined || p.audience.includes(taker)),
516
+ ) ?? same.at(-1)
517
+ );
518
+ }
519
+
520
+ #sameAs(notice: Notice, to: string): PendingNotice[] {
521
+ const same = JSON.stringify(notice);
522
+ return this.#pending.filter((p) => p.source === undefined && p.to === to && JSON.stringify(p.notice) === same);
523
+ }
524
+
525
+ /** Whether any conversation holds a reservation on this notice. */
526
+ #reservedByAny(p: PendingNotice): boolean {
527
+ for (const key of this.#reserved) if (key === p.id || key.startsWith(`${p.id}\u0000`)) return true;
528
+ return false;
140
529
  }
141
530
  }