@cotal-ai/lang 0.24.0 → 0.26.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 (75) hide show
  1. package/README.md +11 -0
  2. package/dist/engine/bridge.d.ts +71 -0
  3. package/dist/engine/bridge.d.ts.map +1 -0
  4. package/dist/engine/bridge.js +277 -0
  5. package/dist/engine/bridge.js.map +1 -0
  6. package/dist/engine/ctx.d.ts +140 -0
  7. package/dist/engine/ctx.d.ts.map +1 -0
  8. package/dist/engine/ctx.js +834 -0
  9. package/dist/engine/ctx.js.map +1 -0
  10. package/dist/engine/frame.d.ts +69 -0
  11. package/dist/engine/frame.d.ts.map +1 -0
  12. package/dist/engine/frame.js +105 -0
  13. package/dist/engine/frame.js.map +1 -0
  14. package/dist/engine/host.d.ts +77 -0
  15. package/dist/engine/host.d.ts.map +1 -0
  16. package/dist/engine/host.js +134 -0
  17. package/dist/engine/host.js.map +1 -0
  18. package/dist/engine/worker-entry.d.ts +26 -0
  19. package/dist/engine/worker-entry.d.ts.map +1 -0
  20. package/dist/engine/worker-entry.js +175 -0
  21. package/dist/engine/worker-entry.js.map +1 -0
  22. package/dist/engine/worker.d.ts +156 -0
  23. package/dist/engine/worker.d.ts.map +1 -0
  24. package/dist/engine/worker.js +123 -0
  25. package/dist/engine/worker.js.map +1 -0
  26. package/dist/errors.d.ts +44 -0
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +97 -0
  29. package/dist/errors.js.map +1 -1
  30. package/dist/index.d.ts +10 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +10 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/interpret.d.ts +7 -38
  35. package/dist/interpret.d.ts.map +1 -1
  36. package/dist/interpret.js +45 -1003
  37. package/dist/interpret.js.map +1 -1
  38. package/dist/journal.d.ts.map +1 -1
  39. package/dist/journal.js +75 -1
  40. package/dist/journal.js.map +1 -1
  41. package/dist/library.d.ts.map +1 -1
  42. package/dist/library.js +13 -1
  43. package/dist/library.js.map +1 -1
  44. package/dist/perform.d.ts +138 -0
  45. package/dist/perform.d.ts.map +1 -0
  46. package/dist/perform.js +1052 -0
  47. package/dist/perform.js.map +1 -0
  48. package/dist/pins.d.ts +28 -8
  49. package/dist/pins.d.ts.map +1 -1
  50. package/dist/pins.js +31 -11
  51. package/dist/pins.js.map +1 -1
  52. package/dist/sim.d.ts +15 -1
  53. package/dist/sim.d.ts.map +1 -1
  54. package/dist/sim.js.map +1 -1
  55. package/dist/transform/emit.d.ts +23 -0
  56. package/dist/transform/emit.d.ts.map +1 -0
  57. package/dist/transform/emit.js +934 -0
  58. package/dist/transform/emit.js.map +1 -0
  59. package/dist/transform/index.d.ts +34 -0
  60. package/dist/transform/index.d.ts.map +1 -0
  61. package/dist/transform/index.js +31 -0
  62. package/dist/transform/index.js.map +1 -0
  63. package/dist/transform/scope.d.ts +58 -0
  64. package/dist/transform/scope.d.ts.map +1 -0
  65. package/dist/transform/scope.js +500 -0
  66. package/dist/transform/scope.js.map +1 -0
  67. package/dist/transform/seam.d.ts +78 -0
  68. package/dist/transform/seam.d.ts.map +1 -0
  69. package/dist/transform/seam.js +111 -0
  70. package/dist/transform/seam.js.map +1 -0
  71. package/dist/values.d.ts +15 -0
  72. package/dist/values.d.ts.map +1 -1
  73. package/dist/values.js +79 -0
  74. package/dist/values.js.map +1 -1
  75. package/package.json +9 -4
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * The effect seam, shared by both engines.
3
+ *
4
+ * {@link performEffect} is the durable core (key allocation, journal lookup and replay, the
5
+ * pending protocol, the two failure domains), and {@link dispatchPrimitive} is the per-primitive
6
+ * wiring from evaluated argument VALUES to the handler, with the hashed projections that decide
7
+ * each effect's identity. The tree-walker (`interpret.ts`) delegates here; the v2 engine host
8
+ * calls it directly. ONE function over ONE table: a second copy of a projection would be a
9
+ * divergence the differential suite could only find program-by-program.
10
+ */
11
+ import { RunDivergence, RuntimeFault, ScopeBranchMissing, UnwalkableScope, messageOf } from "./errors.js";
12
+ import { digest, requestId, stepKeyString } from "./keys.js";
13
+ import { Journal, JournalAppendRejected, RunClock } from "./journal.js";
14
+ import { NotCrossable, assertCrossable, assertScopeValueCrossable, deepFreeze } from "./values.js";
15
+ import { PRIMITIVES } from "./primitives.js";
16
+ import { parseDuration } from "./duration.js";
17
+ import { notifyFactViolation } from "./notify-fact.js";
18
+ import { Cancelled, RunReleased, EffectError, applyCheckpointPolicy, } from "./effects.js";
19
+ export function option(bag, key) {
20
+ return bag === null || typeof bag !== "object" ? undefined : bag[key];
21
+ }
22
+ /**
23
+ * Perform one effect, or replay it.
24
+ *
25
+ * Everything durable happens here. A handler is called only in the `miss` and `pending` cases,
26
+ * and in `pending` it is told to re-bind rather than re-issue.
27
+ */
28
+ /**
29
+ * An `EffectError`'s `detail` is a RECORDED VALUE, and until this function it was the last one with
30
+ * no domain check on it.
31
+ *
32
+ * Measured, with the binding guard already in place: a handler throwing
33
+ * `new EffectError("L6002", …, { cb: () => 1 })` from a step the program CATCHES leaves a run that
34
+ * SUCCEEDS while its journal carries a function. In-process that is merely wrong; through the worker
35
+ * the whole journal is structured-cloned back to the host, so the same run dies on a DataCloneError.
36
+ * A failing run never posts its entries, because the worker hand-builds `{ok,code,name,message}` on
37
+ * that path, so a caught failure on the success path is the route, and it is a real program, not a
38
+ * contrived one.
39
+ *
40
+ * WHAT A REFUSAL COSTS, said out loud because it is a real cost: the handler's own classification is
41
+ * dropped. An error whose report cannot be recorded is recorded as what it is, a fault of the
42
+ * handler's, L4000, naming both the original message and why the detail could not be kept. Keeping
43
+ * `code` while silently dropping `detail` was the other option and it is the fallback this repo does
44
+ * not do: the program would catch an L6002 whose recorded form is missing the field the handler sent
45
+ * it to explain itself.
46
+ */
47
+ function recordableError(e, kind) {
48
+ if (e.detail === undefined)
49
+ return { error: { code: e.code, kind: e.kind, message: e.message }, faithful: true };
50
+ try {
51
+ assertCrossable(e.detail, "the detail of this failure");
52
+ return { error: { code: e.code, kind: e.kind, message: e.message, detail: e.detail }, faithful: true };
53
+ }
54
+ catch (cause) {
55
+ if (!(cause instanceof NotCrossable))
56
+ throw cause;
57
+ return {
58
+ error: { code: "L4000", kind, message: `${e.message} (and its detail could not be recorded: ${cause.message})` },
59
+ faithful: false,
60
+ };
61
+ }
62
+ }
63
+ export async function performEffect(host, kind, name, hashedInput, perform, frame) {
64
+ const key = frame.keys.nextEffect(kind, name);
65
+ const inputHash = digest(hashedInput ?? null);
66
+ const verdict = host.journal.lookup(key, inputHash);
67
+ switch (verdict.verdict) {
68
+ case "replay":
69
+ if (verdict.entry.endedAt !== undefined)
70
+ frame.clock.advance(verdict.entry.endedAt);
71
+ return verdict.entry.result;
72
+ case "replay-failed": {
73
+ if (verdict.entry.endedAt !== undefined)
74
+ frame.clock.advance(verdict.entry.endedAt);
75
+ const e = verdict.entry.error;
76
+ throw new EffectError(e.code, e.kind, e.message, e.detail);
77
+ }
78
+ case "replay-cancelled":
79
+ throw new Cancelled("this branch was cancelled on the recorded run");
80
+ case "diverged":
81
+ throw new RunDivergence(stepKeyString(key), verdict.recordedHash, verdict.programHash);
82
+ case "pending":
83
+ case "miss":
84
+ break;
85
+ }
86
+ // A cancelled branch performs no NEW effects. That is the whole of the cancellation law on
87
+ // this side: work already in flight is another matter, and the handler owns it.
88
+ if (frame.signal.cancelled) {
89
+ throw new Cancelled(frame.signal.reason ?? "cancelled");
90
+ }
91
+ // THE HOST'S STOP, asked before anything is begun and after every replay has been served. A
92
+ // driver holds its run under an absolute work horizon and may be asked to hand it back, and
93
+ // neither is a fact about the program, so the place to stop is here, where no entry has been
94
+ // written and no handler dispatched. One step later would mean a pending entry for work nobody
95
+ // performed; inside the handler would mean settling a failure for work that really happened.
96
+ // Replays above are deliberately unaffected: replaying a recorded prefix performs nothing, and
97
+ // a run that stopped mid-journal has to be able to walk back to where it stopped.
98
+ const stop = host.options.shouldStop?.();
99
+ if (stop !== undefined) {
100
+ throw new RunReleased(stop);
101
+ }
102
+ host.effectCount += 1;
103
+ if (host.effectCount > host.ceiling) {
104
+ throw new RuntimeFault("L4009", `this run has performed more than ${host.ceiling} effects, which means a loop is not terminating. Add an exit condition or a permit.`);
105
+ }
106
+ const resume = verdict.verdict === "pending" ? verdict.entry.external : undefined;
107
+ // RECOVERY SUBMITS UNDER THE RECORDED IDENTITY. Re-deriving happens to agree whenever nothing
108
+ // moved, which is exactly why it read as correct: the whole point of writing the id down is
109
+ // the case where it does NOT agree, and a resumed run that re-derives is reissuing under an
110
+ // identity the far side may never have seen. An entry with no recorded id predates this rule.
111
+ const recorded = verdict.verdict === "pending" && verdict.entry.requestId !== undefined ? verdict.entry : undefined;
112
+ const reqId = recorded?.requestId ?? requestId(host.options.runId, key, inputHash);
113
+ // WHICH attempt is open, not merely which id. An id alone cannot say how much of an escalation
114
+ // chain is already spent, and a recovery that cannot tell replays the hop: it mints again under
115
+ // the id the far side already holds and reads that mint's cached expiry back as a fresh
116
+ // observation. An entry written before the index existed reads as attempt 0, which is what it
117
+ // is for every effect that never hops.
118
+ const attempt = recorded?.attempt ?? 0;
119
+ if (verdict.verdict === "miss") {
120
+ // AWAITED, and the await is the point: the request id the handler is about to submit under
121
+ // has to be durable BEFORE the work is issued, or a crash in the gap leaves real work that
122
+ // nothing in the journal names.
123
+ await host.journal.begin(key, inputHash, host.options.handler.now(), reqId);
124
+ // THE AWAIT ABOVE IS A GAP, and the cancellation law has to hold on both sides of it. The
125
+ // check before `begin` sees the world as it was when this step started; while the append was
126
+ // in flight a sibling can settle the race and cancel this branch. Measured before this line:
127
+ // the loser's effect was still dispatched, performed against the world, and recorded `ok`,
128
+ // a NEW effect by a cancelled branch, which is the one thing the law forbids. The pending
129
+ // entry is real (the append happened), so it settles as what this branch now is: cancelled.
130
+ if (frame.signal.cancelled) {
131
+ await host.journal.settle(key, { status: "cancelled" }, host.options.handler.now());
132
+ throw new Cancelled(frame.signal.reason ?? "cancelled");
133
+ }
134
+ }
135
+ const ctx = {
136
+ key,
137
+ signal: frame.signal,
138
+ // Derived from the run, the step, the inputs and the attempt, and written on the pending
139
+ // entry by `begin` above BEFORE the handler runs. A handler submits under it idempotently,
140
+ // so a resumed run reissues the same id rather than creating a second goal.
141
+ requestId: reqId,
142
+ attempt,
143
+ ...(resume !== undefined ? { resume } : {}),
144
+ // THE THIRD PATH INTO AN ENTRY, and until this line the only one with no rule. The other two sit
145
+ // a few lines apart (the RESULT at the settle below, the ARGUMENTS in `dispatchPrimitive`), and
146
+ // a handler's own `ctx.bind` reached `journal.bind` with nothing in between. Measured before this
147
+ // line, on both engines: a handler binding `{ when: new Date(0), n: -0, bad: NaN, gone: undefined }`
148
+ // recorded all four, and the durable store gives them back as a string, `0`, `null` and an absent
149
+ // key, so the value a resume RE-BINDS to was not the value that was bound.
150
+ //
151
+ // CANONICAL, NOT ROUND-TRIP-EXACT, and the difference matters to whoever reads this next. `-0` is
152
+ // the one value this rule ADMITS that JSON still flattens, and the step key's own input hash
153
+ // equates it with `0` (`digest({n:-0}) === digest({n:0})`; the 1-vs-2 and -1-vs-1 controls
154
+ // differ). The promise here is that a binding HAS a canonical form, not that it survives a store
155
+ // byte for byte.
156
+ //
157
+ // Inside the handler's dispatch `try` by construction, so a refusal settles the entry FAILED
158
+ // through the existing L4000 handler-fault family and needs no code of its own.
159
+ bind: async (external) => {
160
+ assertCrossable(external, `the binding of ${stepKeyString(key)}`);
161
+ await host.journal.bind(key, external);
162
+ },
163
+ };
164
+ // TWO FAILURE DOMAINS, AND THE TERMINAL APPEND IS NOT IN THE HANDLER'S.
165
+ //
166
+ // One `try` around both the dispatch and the settle produces the worst bug a journal can have:
167
+ // the handler completes, the store refuses the settling append, the catch below records that
168
+ // refusal as a handler fault, and the durable sequence becomes `[pending, settled:failed]` for
169
+ // work the world actually did, so every later replay reports failure for a real success. The
170
+ // handler's outcome is decided first, alone, and the append that records it happens outside,
171
+ // where a rejection is a durability failure that travels as itself and settles nothing.
172
+ let result;
173
+ try {
174
+ result = await perform(ctx, inputHash);
175
+ assertCrossable(result, `the result of ${stepKeyString(key)}`);
176
+ }
177
+ catch (e) {
178
+ const endedAt = host.options.handler.now();
179
+ // A journal that just refused an append cannot be asked to record why. It leaves by its own
180
+ // door, unwrapped, before anything tries to settle on top of it.
181
+ if (e instanceof JournalAppendRejected)
182
+ throw e;
183
+ if (e instanceof Cancelled) {
184
+ await host.journal.settle(key, { status: "cancelled" }, endedAt);
185
+ throw e;
186
+ }
187
+ // A handler may raise a language code directly, and it survives. The simulator's "unscripted
188
+ // effect" is L6001, and flattening that to a generic handler fault would tell a caller acting
189
+ // on `code` that the handler broke, when what actually happened is that their script is
190
+ // incomplete. Only the L-code shape is honoured: anything else a thrown object happens to
191
+ // call `code` (an errno, an HTTP status) is a handler fault and is recorded as one.
192
+ // Read defensively: a handler is other people's code and may throw a primitive, and reading
193
+ // `.code` or `.message` off `null` would replace its failure with the recorder's own.
194
+ const raised = e?.code;
195
+ const carried = typeof raised === "string" && /^L\d{4}$/.test(raised) ? raised : null;
196
+ const recorded = e instanceof EffectError ? recordableError(e, "handler-fault") : undefined;
197
+ const error = recorded !== undefined
198
+ ? recorded.error
199
+ : { code: carried ?? "L4000", kind: "handler-fault", message: messageOf(e) };
200
+ await host.journal.settle(key, { status: "failed", error }, endedAt);
201
+ frame.clock.advance(endedAt);
202
+ // THE CALLER AND THE RECORD SAY THE SAME THING. Rethrowing the handler's own error unchanged is
203
+ // right whenever the record kept it; when the detail forced a downgrade it is not, because the
204
+ // program would then catch an L6002 the journal has no L6002 for.
205
+ throw recorded?.faithful === true ? e : new EffectError(error.code, error.kind, error.message);
206
+ }
207
+ const endedAt = host.options.handler.now();
208
+ await host.journal.settle(key, { status: "ok", result: deepFreeze(result) }, endedAt);
209
+ frame.clock.advance(endedAt);
210
+ return result;
211
+ }
212
+ /**
213
+ * Dispatch one non-scope primitive from evaluated argument VALUES: the crossability refusals, the
214
+ * freeze on share, and the per-primitive hashed projection, ending in {@link performEffect}. The
215
+ * scope-openers never come here: their branches must stay unevaluated.
216
+ */
217
+ export async function dispatchPrimitive(host, name, args, frame) {
218
+ const spec = PRIMITIVES[name];
219
+ if (spec === undefined)
220
+ throw new RuntimeFault("L2001", `${name} is not a primitive`);
221
+ // Every argument crosses the effect boundary: it is hashed, recorded, or handed to the handler,
222
+ // and a value with no canonical form can be none of those. Refused HERE, before any entry is
223
+ // written, with the argument named: `undefined`, a non-finite number and an opaque object are
224
+ // L3041, a function is L3042. The result of the effect is held to the same rule in
225
+ // {@link Interpreter.performEffect}.
226
+ args.forEach((arg, i) => {
227
+ try {
228
+ assertCrossable(arg, `argument ${i + 1} of \`${name}\``);
229
+ }
230
+ catch (e) {
231
+ if (e instanceof NotCrossable)
232
+ throw new RuntimeFault(e.why === "function" ? "L3042" : "L3041", e.message);
233
+ throw e;
234
+ }
235
+ });
236
+ // FREEZE ON SHARE, at the share. What crossed is what was hashed and recorded, so the program
237
+ // mutating it afterwards, or the HANDLER mutating it on its side, would make the run's own
238
+ // value disagree with its recorded form (measured before this line: `schema.deep.x = 2` after
239
+ // an `ask` succeeded, no L2031, and a handler's write to `req.schema` reached the program).
240
+ for (const arg of args)
241
+ deepFreeze(arg);
242
+ const bag = args[spec.optionsAt];
243
+ const stepName = (name === "checkpoint" ? args[0] : option(bag, "name"));
244
+ const handler = host.options.handler;
245
+ switch (name) {
246
+ case "spawn": {
247
+ // The first argument is a persona name, or a record carrying the persona WITH its model
248
+ // and variant. Only the persona was ever read, so the object form silently dropped model
249
+ // and variant from both the request and the hash: editing a model did not diverge, and the
250
+ // handler was never told which model to run. This was missed by an audit that exercised
251
+ // only the string form, which is the same defect one level up.
252
+ const spawnSubject = args[0];
253
+ const persona = typeof spawnSubject === "string" ? spawnSubject : String(option(spawnSubject, "persona"));
254
+ const model = typeof spawnSubject === "string" ? undefined : option(spawnSubject, "model");
255
+ const variant = typeof spawnSubject === "string" ? undefined : option(spawnSubject, "variant");
256
+ // Every accepted option is forwarded, including the three that are policy rather than
257
+ // identity. Dropping them here would be silent: the validator accepts `permits`, so an
258
+ // author who writes a budget gets no error and no budget. They are deliberately absent
259
+ // from `hashedOptions` (§5.12) because they decide the INTERPRETATION of a result, not the
260
+ // recorded fact, so they are reapplied from current source on resume rather than hashed.
261
+ const req = {
262
+ persona,
263
+ ...(model !== undefined ? { model } : {}),
264
+ ...(variant !== undefined ? { variant } : {}),
265
+ ...(option(bag, "worktree") !== undefined ? { worktree: option(bag, "worktree") } : {}),
266
+ ...(option(bag, "role") !== undefined ? { role: option(bag, "role") } : {}),
267
+ ...(option(bag, "join") !== undefined ? { join: option(bag, "join") } : {}),
268
+ ...(option(bag, "permits") !== undefined
269
+ ? { permits: option(bag, "permits") }
270
+ : {}),
271
+ ...(option(bag, "supervise") !== undefined
272
+ ? { supervise: option(bag, "supervise") }
273
+ : {}),
274
+ ...(option(bag, "onFork") !== undefined ? { onFork: option(bag, "onFork") } : {}),
275
+ };
276
+ return await performEffect(host, "spawn", stepName ?? persona,
277
+ // Model and variant are part of the IDENTITY being spawned, so they are hashed with the
278
+ // persona (design 5.12). A run that swapped the model under a recorded agent would be
279
+ // replaying a fact about a different agent.
280
+ {
281
+ persona,
282
+ model: model ?? null,
283
+ variant: variant ?? null,
284
+ worktree: req.worktree ?? null,
285
+ role: req.role ?? null,
286
+ join: (req.join ?? []).map((c) => c.channel),
287
+ }, (ctx) => handler.spawn(req, ctx), frame);
288
+ }
289
+ case "turn": {
290
+ const agent = deepFreeze(args[0]);
291
+ // The deadline STOPS OBSERVATION (design 5.12), so it belongs in the projection: a turn
292
+ // recorded under a 1m deadline cannot answer what a 10m turn would have produced, and a
293
+ // resumed run under the edited deadline replaying the old result is the silent-wrong-path
294
+ // class. Closing that for `checkpoint` and leaving it open on the siblings closed nothing.
295
+ const deadline = option(bag, "deadline");
296
+ return await performEffect(host, "turn", stepName, { agent: agent.agent, deadline: deadline ?? null }, (ctx) => handler.turn({ agent, ...(deadline !== undefined ? { deadline } : {}) }, ctx), frame);
297
+ }
298
+ case "ask": {
299
+ const agent = deepFreeze(args[0]);
300
+ const schema = option(bag, "schema");
301
+ // Both of these END THE ASKING: `deadline` is the cutoff and `attempts` is how many
302
+ // schema-failed replies are tolerated before it gives up. A record made under one attempt
303
+ // is not an answer to what five attempts would have produced.
304
+ const deadline = option(bag, "deadline");
305
+ const attempts = option(bag, "attempts");
306
+ return await performEffect(host, "ask", stepName, { agent: agent.agent, schema: schema ?? null, deadline: deadline ?? null, attempts: attempts ?? null }, (ctx) => handler.ask({
307
+ agent,
308
+ schema,
309
+ ...(deadline !== undefined ? { deadline } : {}),
310
+ ...(attempts !== undefined ? { attempts } : {}),
311
+ }, ctx), frame);
312
+ }
313
+ case "checkpoint": {
314
+ const prompt = args[1];
315
+ // The disposition is computed from TODAY's source, after the journal is consulted, on the
316
+ // live path and the replay path alike. performEffect returns the RAW outcome, which is
317
+ // what the journal holds; the policy sandwich closes here so a resumed run under an edited
318
+ // onExpiry throws even though nothing about the recorded expiry changed.
319
+ const onExpiry = option(bag, "onExpiry");
320
+ const schema = option(bag, "schema");
321
+ // The SAME projection the entry is keyed by, so an attempt's identity is a function of the
322
+ // step it belongs to rather than of anything the escalation invents.
323
+ // Design 5.12, and every field here earns its place. `timeout` STOPS OBSERVATION, so a
324
+ // record made under 1m cannot answer what a 3m wait would have seen. `escalate` and its
325
+ // `to` CREATE AN EFFECT rather than choosing a disposition, so editing them must diverge
326
+ // rather than be reapplied. `fail` versus `proceed` is the one genuine reapply and stays
327
+ // out. Hashing only prompt and schema left a timeout edit replaying clean, which is the
328
+ // silent-wrong-path class this projection exists to close.
329
+ const cpTimeout = option(bag, "timeout");
330
+ const cpTo = option(bag, "to");
331
+ const cpInput = {
332
+ prompt,
333
+ schema: schema ?? null,
334
+ timeout: cpTimeout ?? null,
335
+ ...(onExpiry === "escalate" ? { onExpiry, to: cpTo ?? null } : {}),
336
+ };
337
+ return applyCheckpointPolicy((await performEffect(host, "checkpoint", stepName, cpInput, async (ctx, inputHash) => {
338
+ // ONE hash value, threaded from what the entry is actually keyed by rather than
339
+ // re-digested from the projection here. The two agreed, which is exactly the problem:
340
+ // a second derivation that happens to match is a coincidence maintained by hand, and
341
+ // the first edit to the projection would desync attempt 1's identity from its own
342
+ // step with no type error and no failing test.
343
+ const attemptId = (n) => requestId(host.options.runId, ctx.key, inputHash, n);
344
+ const req = {
345
+ prompt,
346
+ ...(schema !== undefined ? { schema } : {}),
347
+ ...(cpTimeout !== undefined ? { timeout: cpTimeout } : {}),
348
+ ...(onExpiry !== undefined ? { onExpiry } : {}),
349
+ ...(cpTo !== undefined ? { to: cpTo } : {}),
350
+ };
351
+ // THE FINAL MINT DOES NOT ASK FOR AN ESCALATION. The interpreter owns the one-hop stop
352
+ // rule, and it can only own it if the far side is not simultaneously told to hop: a
353
+ // handler that honours `onExpiry` on the wire would mint a third attempt under an
354
+ // identity this journal never allocated, and nothing here would ever learn of it.
355
+ const finalReq = onExpiry === "escalate" ? { ...req, onExpiry: "proceed" } : req;
356
+ // RECOVERY COMPLETES THE OPEN ATTEMPT. IT DOES NOT REPLAY THE CHAIN.
357
+ //
358
+ // Arriving here with a non-zero attempt means the hop was issued before the crash, so
359
+ // the far side is already holding work under this very id. Re-running the live body
360
+ // from the top would call the handler again under it and take that call's cached
361
+ // expiry for a second observation: the stop rule would be satisfied on paper while the
362
+ // run had in fact observed one attempt twice. The chain's shape is recoverable without
363
+ // re-running it, because attempt 0's identity is derivable and its outcome is implied:
364
+ // the only path that opens attempt 1 is attempt 0 expiring.
365
+ if (ctx.attempt > 0) {
366
+ const raw = await handler.checkpoint(finalReq, ctx);
367
+ return {
368
+ ...raw,
369
+ attempts: [
370
+ { attempt: 0, requestId: attemptId(0), settled: "expired" },
371
+ { attempt: ctx.attempt, requestId: ctx.requestId, to: cpTo ?? null, settled: raw.outcome },
372
+ ],
373
+ };
374
+ }
375
+ const first = await handler.checkpoint(req, ctx);
376
+ if (first.outcome !== "expired" || onExpiry !== "escalate") {
377
+ // `ctx.attempt`, not a literal 0. Writing the literal made every recovery relabel the
378
+ // open attempt as the first one, which erased the hop from the journal and left the
379
+ // record claiming the escalated mint was the original.
380
+ return { ...first, attempts: [{ attempt: ctx.attempt, requestId: ctx.requestId, settled: first.outcome }] };
381
+ }
382
+ // ESCALATION STAYS INSIDE THIS ENTRY. The program made one call, and the interpreter
383
+ // owns key allocation, so a second mint must not become a second occurrence. What it
384
+ // does need is a second IDENTITY, derived from attempt 1 before the mint happens, or a
385
+ // crash between minting and recording leaves live work nothing in the journal names.
386
+ //
387
+ // Name the open attempt on the pending row BEFORE issuing it, index and all.
388
+ const nextId = attemptId(1);
389
+ await host.journal.reissueAs(ctx.key, nextId, 1);
390
+ const second = await handler.checkpoint(finalReq, { ...ctx, requestId: nextId, attempt: 1 });
391
+ // ONE HOP. An escalation that can escalate again never terminates, so a second expiry
392
+ // settles as expired and the program decides, exactly as `proceed` would.
393
+ return {
394
+ ...second,
395
+ attempts: [
396
+ { attempt: 0, requestId: ctx.requestId, settled: "expired" },
397
+ { attempt: 1, requestId: nextId, to: cpTo ?? null, settled: second.outcome },
398
+ ],
399
+ };
400
+ }, frame)), onExpiry);
401
+ }
402
+ case "sleep": {
403
+ const duration = args[0];
404
+ parseDuration(duration); // fail at the call, not inside the handler
405
+ // The duration IS hashed (design 5.12). It determines the recorded fact: a resumed run
406
+ // reads the elapsed time back through the run clock, so editing 1h to 1m must diverge
407
+ // rather than silently keep the path the old duration chose. This hashed `null` until
408
+ // a reviewer executed it, and the rule it violates is one written here and then only
409
+ // ever applied to the document.
410
+ return await performEffect(host, "sleep", stepName ?? "", { duration }, (ctx) => handler.sleep({ duration }, ctx), frame);
411
+ }
412
+ case "wait": {
413
+ const event = deepFreeze(args[0]);
414
+ const timeout = option(bag, "timeout");
415
+ // A `wait` that resolved null did not observe "the event never happens": it observed "the
416
+ // event did not happen WITHIN THIS TIMEOUT". Editing the timeout therefore asks a different
417
+ // question, and replaying the recorded null answers the old one. This is the same hole the
418
+ // checkpoint projection closed, and leaving it open here left `?? recovery` steering off a
419
+ // stale cutoff.
420
+ return await performEffect(host, "wait", stepName ?? "", { event, timeout: timeout ?? null }, (ctx) => handler.wait({ event, ...(timeout !== undefined ? { timeout } : {}) }, ctx), frame);
421
+ }
422
+ case "notify": {
423
+ const agents = deepFreeze(args[0]);
424
+ const fact = deepFreeze(args[1]);
425
+ // THE BOUND, WHERE THE VALUE EXISTS. The validator checks a literal fact exactly and says
426
+ // so about the computed one; this is the computed one. It is checked BEFORE the entry is
427
+ // written, so a fact that breaks the bound never reaches a journal, a record, or a
428
+ // handler: an out-of-bound notice recorded as performed would be laundered bytes with a
429
+ // durable receipt. An error, never a truncation: a shortened notice still delivers.
430
+ const violation = notifyFactViolation(fact);
431
+ if (violation !== null)
432
+ throw new RuntimeFault("L3043", violation);
433
+ return await performEffect(host, "notify", stepName ?? "", { agents: agents.map((a) => a.agent), fact }, (ctx) => handler.notify({ agents, fact }, ctx), frame);
434
+ }
435
+ case "monitor": {
436
+ const agent = deepFreeze(args[0]);
437
+ return await performEffect(host, "monitor", stepName ?? "", { agent: agent.agent }, (ctx) => handler.monitor({ agent }, ctx), frame);
438
+ }
439
+ default:
440
+ throw new RuntimeFault("L1000", `${name} is not implemented in this interpreter`);
441
+ }
442
+ }
443
+ /**
444
+ * The free VALUE constructors: the two pure primitives and the four event constructors, in
445
+ * program convention (plain args in, frozen descriptor out). The walker's installGlobals wraps
446
+ * them into its (frame, args) convention; the engine's `free()` serves them directly. One table,
447
+ * both engines: the shapes on the wire may not fork.
448
+ */
449
+ export function freeConstructors(run) {
450
+ return [
451
+ ["channel", (a) => deepFreeze({ channel: a[0] })],
452
+ ["run", () => deepFreeze({ id: run.runId, programHash: run.programHash, startedAt: run.startedAt })],
453
+ ["replied", (a) => deepFreeze({ event: "replied", agent: a[0].agent })],
454
+ [
455
+ "message",
456
+ (a) => {
457
+ const ch = a[0].channel;
458
+ const opts = (a[1] ?? {});
459
+ return deepFreeze({
460
+ event: "message",
461
+ channel: ch,
462
+ ...(opts.from !== undefined ? { from: opts.from.agent } : {}),
463
+ ...(opts.matches !== undefined ? { matches: opts.matches } : {}),
464
+ });
465
+ },
466
+ ],
467
+ ["idle", (a) => deepFreeze({ event: "idle", channel: a[0].channel, duration: a[1] })],
468
+ ["down", (a) => deepFreeze({ event: "down", agent: a[0].agent })],
469
+ ];
470
+ }
471
+ /**
472
+ * A `conclave` whose close did not acknowledge.
473
+ *
474
+ * It exists so the scope is NOT settled: the pending entry is the durable record that a close is
475
+ * still owed, and re-entry retries it. Settling on a close rejection would have the journal state a
476
+ * disposition the world never confirmed, which is the one thing this entry is for.
477
+ */
478
+ class CloseOwed extends Error {
479
+ reason;
480
+ constructor(reason) {
481
+ super(`conclave close did not acknowledge: ${reason?.message ?? String(reason)}`);
482
+ this.reason = reason;
483
+ this.name = "CloseOwed";
484
+ }
485
+ }
486
+ /**
487
+ * A scope's failure, carrying the interpreter's OWN facts about it.
488
+ *
489
+ * Attaching them to the thrown value with `Object.assign` works exactly as long as every program
490
+ * throws an object. `throw null` is valid, and `Object.assign(null, …)` is a TypeError, so a
491
+ * conclave whose body throws a primitive loses its closure fact AND hands the caller a manufactured
492
+ * type error in place of the body's failure, while the entry records
493
+ * `closed: undefined` for a room the handler had in fact closed. The facts belong to the
494
+ * interpreter, so they travel in the interpreter's own envelope and the program's value rides
495
+ * untouched inside it. Nothing outside `performScope` ever sees this class: it unwraps before it
496
+ * rethrows.
497
+ */
498
+ class ScopeFailed extends Error {
499
+ reason;
500
+ facts;
501
+ constructor(reason, facts) {
502
+ super(`scope failed: ${messageOf(reason)}`);
503
+ this.reason = reason;
504
+ this.facts = facts;
505
+ this.name = "ScopeFailed";
506
+ }
507
+ }
508
+ function unwrapScope(e) {
509
+ return e instanceof ScopeFailed ? { reason: e.reason, facts: e.facts } : { reason: e, facts: {} };
510
+ }
511
+ /**
512
+ * The digest fact, written wherever the loser set is: a race that FAILED owes its losers exactly
513
+ * as a winning one does, so it carries the digest too, and `replay-failed` compares it.
514
+ */
515
+ function digestFacts(of, losers) {
516
+ if (of === undefined || losers === undefined)
517
+ return {};
518
+ const d = of(losers);
519
+ return d === undefined ? {} : { branchDigest: d };
520
+ }
521
+ /**
522
+ * A concurrency scope's own journal entry, and what replay does with it.
523
+ *
524
+ * The scope is journalled as ONE durable record carrying its outcome, and for a cancelling scope
525
+ * the intent to cancel its siblings. Without it a replayed `race` re-races: both branches may have
526
+ * settled before the cancellation reached the loser, so the journal holds two successful branches
527
+ * and nothing saying which one won, and a replayed run can take the other path and reach a step
528
+ * that was never recorded.
529
+ *
530
+ * A settled scope therefore ENTERS NO BRANCH, and the order below is normative rather than
531
+ * convenient: account for the subtree first, then discharge the cancellation, and only then
532
+ * deliver the outcome. Leading with the delivery is the defect, because the next program step can share
533
+ * a worktree with a loser that is still writing.
534
+ */
535
+ export async function performScope(host, scopeKey, frame, body, subject,
536
+ /** The `branchDigest` over a named loser set. Absent where there is nothing to digest. */
537
+ branchDigest) {
538
+ const inputHash = digest(subject === undefined
539
+ ? { kind: scopeKey.kind, name: scopeKey.name }
540
+ : { kind: scopeKey.kind, name: scopeKey.name, subject });
541
+ const verdict = host.journal.lookup(scopeKey, inputHash);
542
+ if (verdict.verdict === "diverged") {
543
+ throw new RunDivergence(stepKeyString(scopeKey), verdict.recordedHash, verdict.programHash);
544
+ }
545
+ if (verdict.verdict === "replay" || verdict.verdict === "replay-failed") {
546
+ const entry = verdict.entry;
547
+ const endedAt = entry.endedAt ?? host.options.handler.now();
548
+ // The comparison: `branchDigest` is checked whenever the entry carries one. The scope's own
549
+ // `inputHash` is `{kind, name}` (an arm's body is not in it), and a settled race is
550
+ // delivered from this entry without entering a branch, so without this comparison an edit
551
+ // inside a LOSING arm reaches nothing that could notice it. Both replay paths, not the
552
+ // migration path alone: a resume of edited source is exactly the case a divergence exists to
553
+ // make loud, and the run record carries no program hash to have refused it earlier.
554
+ if (entry.branchDigest !== undefined && branchDigest !== undefined) {
555
+ const now = branchDigest(entry.cancel?.losers ?? []);
556
+ if (now !== undefined && now !== entry.branchDigest) {
557
+ throw new RunDivergence(stepKeyString(scopeKey), entry.branchDigest, now);
558
+ }
559
+ }
560
+ // A MIGRATION MUST NOT TAKE THE SHORT-CIRCUIT ABOVE.
561
+ //
562
+ // Consuming the subtree wholesale is right for a resume: the program hash is unchanged, so
563
+ // nothing under this scope can have been removed, and the branches were DECIDED rather than
564
+ // deleted. Under a migration the source HAS changed, and marking every entry beneath the
565
+ // scope accounted for means an effect the new source removed never reaches `orphans()`: a
566
+ // resolved human checkpoint inside the winning branch disappears and L5004 never fires. A
567
+ // silent disappearance whose log line never fires is invisible in the artifact AND in the
568
+ // trace, which is the worst available failure.
569
+ //
570
+ // So the walk enters the RECORDED WINNING branches and runs the ordinary hash and orphan
571
+ // checks inside them, while the losers (decided, not removed) are accounted for as before.
572
+ if (host.options.migration === true) {
573
+ if (subject !== undefined)
574
+ throw new UnwalkableScope(stepKeyString(scopeKey), "conclave");
575
+ // A SETTLED SCOPE CARRIES ITS ARM NAMES IN ONE OF TWO PLACES, and reading only the first
576
+ // is what made a failed scope look like a scope with no arms. `result` holds them when the
577
+ // scope succeeded; the `branches` FACT holds them when it failed, because `settle` writes
578
+ // no `result` for a failure.
579
+ const recorded = entry.result;
580
+ const branches = recorded?.branches ?? entry.branches ?? [];
581
+ const losers = new Set(entry.cancel?.losers ?? []);
582
+ await host.journal.consumeScope(stepKeyString(scopeKey), endedAt, losers);
583
+ try {
584
+ await body({
585
+ key: scopeKey,
586
+ signal: frame.signal,
587
+ requestId: entry.requestId ?? requestId(host.options.runId, scopeKey, inputHash),
588
+ attempt: entry.attempt ?? 0,
589
+ bind: async () => {
590
+ throw new UnwalkableScope(stepKeyString(scopeKey), "bind");
591
+ },
592
+ }, new Set(branches.filter((b) => !losers.has(b))));
593
+ }
594
+ catch (e) {
595
+ // UNWRAPPED, because the caller of a migration wants the step that diverged and not the
596
+ // scope that carried it. A live scope wraps a branch's failure so it can record the
597
+ // cancellation intent with it; a walk records nothing and cancels nobody, so the wrapper
598
+ // would only hide a `RunDivergence` behind a generic scope fault.
599
+ throw unwrapScope(e).reason;
600
+ }
601
+ if (entry.endedAt !== undefined)
602
+ frame.clock.advance(entry.endedAt);
603
+ if (verdict.verdict === "replay-failed") {
604
+ const e = entry.error;
605
+ throw new EffectError(e.code, e.kind, e.message, e.detail);
606
+ }
607
+ return entry.result.value;
608
+ }
609
+ // (1) account for the subtree, settling any loser still pending as cancelled;
610
+ await host.journal.consumeScope(stepKeyString(scopeKey), endedAt);
611
+ // (2) the cancellation intent is the driver's to discharge against the world; a journal write
612
+ // cancels nothing by itself, so an undischarged intent stays visible rather than silently
613
+ // reading as done.
614
+ // (3) only now, the outcome.
615
+ if (entry.endedAt !== undefined)
616
+ frame.clock.advance(entry.endedAt);
617
+ if (verdict.verdict === "replay-failed") {
618
+ const e = entry.error;
619
+ throw new EffectError(e.code, e.kind, e.message, e.detail);
620
+ }
621
+ return entry.result.value;
622
+ }
623
+ if (verdict.verdict === "replay-cancelled") {
624
+ throw new Cancelled("this scope was cancelled on the recorded run");
625
+ }
626
+ // `miss` and `pending` alike RE-ENTER the scope: there is no recorded outcome to return, and a
627
+ // pending scope's losers were never durably cancelled. Settling is idempotent, so the arm that
628
+ // finishes first wins again, except where the journal already knows better, which is what
629
+ // `runScope`'s replayed-branch tie-break is for.
630
+ // A scope that CALLS THE HANDLER owes a durable request id exactly as an effect does, and for
631
+ // the same reason: a crash between issuing the work and recording who issued it leaves real
632
+ // work (for `conclave`, a live channel with members joined) that nothing in the journal
633
+ // names. `subject` marks that scope, because `conclave` is the only one that dispatches from
634
+ // this path; the other three launch thunks and touch no handler of their own.
635
+ const dispatches = subject !== undefined;
636
+ const resume = verdict.verdict === "pending" ? verdict.entry.external : undefined;
637
+ const recorded = verdict.verdict === "pending" && verdict.entry.requestId !== undefined ? verdict.entry : undefined;
638
+ const reqId = recorded?.requestId ?? requestId(host.options.runId, scopeKey, inputHash);
639
+ if (verdict.verdict === "miss") {
640
+ await host.journal.begin(scopeKey, inputHash, host.options.handler.now(), dispatches ? reqId : undefined);
641
+ // The same gap as {@link Interpreter.performEffect}'s begin, for the scope that DISPATCHES: a
642
+ // conclave cancelled while its begin was in flight must not open a channel and join members.
643
+ // The non-dispatching scopes launch no work of their own, and each branch effect re-checks its
644
+ // own signal, so only the dispatching path re-checks here.
645
+ if (dispatches && frame.signal.cancelled) {
646
+ await host.journal.settle(scopeKey, { status: "cancelled" }, frame.clock.now());
647
+ throw new Cancelled(frame.signal.reason ?? "cancelled");
648
+ }
649
+ }
650
+ const ctx = {
651
+ key: scopeKey,
652
+ signal: frame.signal,
653
+ requestId: reqId,
654
+ attempt: recorded?.attempt ?? 0,
655
+ ...(resume !== undefined ? { resume } : {}),
656
+ // BOTH WRAPPERS OR NEITHER: see {@link performEffect}'s bind for the rule and for why it is
657
+ // canonical rather than round-trip-exact. Guarding the effect path alone is the half-fence: that
658
+ // one is reached by everything (measured across the lang suites: 275 reaches, every one from the
659
+ // simulator's own binds) and THIS one was executed by nothing at all, in either direction, until
660
+ // the cell below it existed. A guard no run has executed is indistinguishable from a deleted one.
661
+ bind: async (external) => {
662
+ assertCrossable(external, `the binding of ${stepKeyString(scopeKey)}`);
663
+ await host.journal.bind(scopeKey, external);
664
+ },
665
+ };
666
+ // The same two domains as {@link Interpreter.performEffect}, for the same reason: a scope whose
667
+ // branches all succeeded and whose settling append was refused must not be recorded as failed.
668
+ let outcome;
669
+ try {
670
+ outcome = await body(ctx);
671
+ }
672
+ catch (raw) {
673
+ // The interpreter's facts come out of the envelope; the program's thrown value comes out
674
+ // whole, and is what the caller sees. A value the program threw is never written on.
675
+ const { reason, facts } = unwrapScope(raw);
676
+ // THE SCOPE'S CLOCK AT SETTLE, not the host's clock at append. `runScope` joins the branch
677
+ // clocks before the outcome leaves it, so `frame.clock.now()` here is the greatest `endedAt`
678
+ // the scope's branches awaited, which is what `now()` answers after the scope, live. Replay
679
+ // advances the parent clock from this stamp and enters no branch, so stamping anything else
680
+ // (measured: the handler's clock at append time) makes live and replay disagree on `now()`
681
+ // after every scope whose last-to-land effect was not the handler's last stamp, and a program
682
+ // that branches on `now()` takes a path on resume that the live run never took.
683
+ const endedAt = frame.clock.now();
684
+ if (reason instanceof JournalAppendRejected)
685
+ throw reason;
686
+ // A close that did not acknowledge settles NOTHING. The entry stays pending, which is exactly
687
+ // what "a close is still owed" looks like in a journal, and the underlying handler error is
688
+ // what the caller sees.
689
+ if (reason instanceof CloseOwed)
690
+ throw reason.reason;
691
+ if (reason instanceof Cancelled) {
692
+ await host.journal.settle(scopeKey, { status: "cancelled" }, endedAt, facts);
693
+ throw reason;
694
+ }
695
+ // Same rule as the effect path's. The RETHROW below is deliberately left alone: this scope
696
+ // rethrows the raw reason, so a program catching a scope fault sees no language code where the
697
+ // effect path hands it one. That asymmetry predates this rule (measured with a plain throw on
698
+ // both paths) and it is recorded as a finding rather than repaired here, because repairing it
699
+ // moves the spec, the walker and the engine together.
700
+ const err = reason instanceof EffectError
701
+ ? recordableError(reason, "scope-fault").error
702
+ : { code: "L4000", kind: "scope-fault", message: messageOf(reason) };
703
+ // A rejecting branch cancels its siblings and can crash before they hear it, so a FAILED scope
704
+ // carries the intent too, and a conclave that closed says so even when its body failed.
705
+ await host.journal.settle(scopeKey, { status: "failed", error: err }, endedAt, {
706
+ ...facts,
707
+ ...digestFacts(branchDigest, facts.cancel?.losers),
708
+ });
709
+ throw reason;
710
+ }
711
+ // THE FACTS A SETTLED SCOPE CARRIES, assembled ONCE. Both the fence below and the success settle
712
+ // write them, and a scope that fails its own value rule still owes the cancel intent it issued.
713
+ // Assembling them twice is how the two settles would drift the day a fourth fact is added.
714
+ const settledFacts = {
715
+ ...(outcome.cancel !== undefined ? { cancel: outcome.cancel } : {}),
716
+ ...(outcome.closed !== undefined ? { closed: outcome.closed } : {}),
717
+ ...digestFacts(branchDigest, outcome.cancel?.losers),
718
+ };
719
+ // THE VALUE A SCOPE SETTLES IS A VALUE, and answers to the same rule as an effect's result. The
720
+ // effect path fences its handler's result at its own settle; this one was left open, and the two
721
+ // engines then disagreed about one legal program: the walker and the in-process engine recorded a
722
+ // FUNCTION in `result.value` and completed, while the worker died on a structured-clone error
723
+ // naming a host algorithm. Worse than either divergence, the durable store encodes with
724
+ // `JSON.stringify` (core's `encodeRecord`), so `{ a: fn }` went to the wire as `{}` with NO error
725
+ // and the resume replayed a value the live run never produced. All four measured before this
726
+ // existed: walker completes with a function, engine completes with a function, worker
727
+ // DataCloneError, and live `function` against replayed `undefined` with nothing raised.
728
+ //
729
+ // RECORDED AS A FAULT RATHER THAN THROWN BARE, for the effect path's reason. A scope whose
730
+ // branches already ran has done work the world can see, and leaving its entry pending invites a
731
+ // resume to run those branches a second time. The facts the success settle would have carried
732
+ // travel with the failure, because a cancel intent this scope issued is owed whether or not its
733
+ // value could be written.
734
+ try {
735
+ assertScopeValueCrossable(outcome.value, `the value of ${stepKeyString(scopeKey)}`, scopeKey.kind);
736
+ }
737
+ catch (e) {
738
+ if (!(e instanceof NotCrossable))
739
+ throw e;
740
+ await host.journal.settle(scopeKey, { status: "failed", error: { code: "L4000", kind: "scope-fault", message: e.message } }, frame.clock.now(), settledFacts);
741
+ throw e;
742
+ }
743
+ await host.journal.settle(scopeKey, { status: "ok", result: { branches: outcome.branches, value: deepFreeze(outcome.value) } },
744
+ // The joined branch clock, for the same reason as the failure path above: this is the value
745
+ // `now()` answers after the scope, and the stamp replay hands back must be that value.
746
+ frame.clock.now(), settledFacts);
747
+ return outcome.value;
748
+ }
749
+ export async function runScope(host, name, scopeKind, scopeName, occurrence, first,
750
+ /**
751
+ * The SECOND argument, DEFERRED: `fanOut`'s body, `conclave`'s body.
752
+ *
753
+ * A thunk rather than a value, so it is evaluated exactly where it was evaluated before: after
754
+ * the scope's entry has begun, not at the call. `parallel` and `race` never call it - their
755
+ * second argument is the options bag, which the caller evaluates.
756
+ */
757
+ second, bag, frame, ctx,
758
+ /** A migration's walk: enter exactly these branches, the ones the recorded run WON with. */
759
+ only) {
760
+ if (name === "parallel" || name === "race") {
761
+ const all = Array.isArray(first)
762
+ ? first.map((fn, i) => [String(i), fn])
763
+ : Object.entries(first);
764
+ const entries = only === undefined ? all : all.filter(([k]) => only.has(k));
765
+ // THE WALK MUST FIND EVERY ARM IT WAS SENT TO ENTER.
766
+ //
767
+ // `only` is the set of RECORDED WINNING branch keys, and the whole "losers only" digest rule
768
+ // rests on the walk entering the winner: an edit there is supposed to diverge at the step it
769
+ // broke, which is a strictly better error than "some arm of this race changed". A RENAME
770
+ // removes the arm, so there is no step left to diverge at and the argument silently stops
771
+ // holding. What happened instead was worse than a silent pass. `entries` came back empty,
772
+ // `running` with it, and `Promise.race([])` NEVER SETTLES: a migration or a fork over a
773
+ // renamed winning arm hung rather than returning any verdict at all. `parallel` did not hang,
774
+ // because `Promise.all([])` resolves, and handed the program back the recorded value keyed by
775
+ // the arm the source no longer has.
776
+ //
777
+ // Narrow on purpose, and every neighbouring shape already has an answer: a renamed or deleted
778
+ // LOSER diverges through the branch digest, and an ADDED arm is not an edit to anything
779
+ // recorded, so neither reaches this.
780
+ if (only !== undefined) {
781
+ const present = new Set(all.map(([k]) => k));
782
+ const missing = [...only].filter((k) => !present.has(k));
783
+ if (missing.length > 0) {
784
+ throw new ScopeBranchMissing(stepKeyString(ctx.key), name, missing, [...only], [...present]);
785
+ }
786
+ // AND THE EMPTY CASE, which the check above cannot see: with no recorded branches at all,
787
+ // "every recorded branch is present" is vacuously true, so the guard passed and the walk
788
+ // still entered nothing and still hung. A guard over an empty set grades nothing and is
789
+ // green forever. Journals written before scopes recorded their arm names on failure are
790
+ // exactly that shape, so this refuses them by name instead of hanging on them. It cannot
791
+ // fire on a scope that has no arms in the source either, because `all` is empty then too.
792
+ if (only.size === 0 && all.length > 0) {
793
+ throw new ScopeBranchMissing(stepKeyString(ctx.key), name, [], [], all.map(([k]) => k));
794
+ }
795
+ }
796
+ const frames = entries.map(([k]) => frame.branch(scopeKind, scopeName, occurrence, k));
797
+ const running = entries.map(([, fn], i) => fn(frames[i], []));
798
+ const branches = entries.map(([k]) => k);
799
+ if (name === "parallel") {
800
+ let failed = null;
801
+ const tracked = running.map((p, i) => p.catch((e) => {
802
+ if (failed === null)
803
+ failed = entries[i]?.[0];
804
+ throw e;
805
+ }));
806
+ try {
807
+ const results = await Promise.all(tracked);
808
+ frame.clock.join(frames.map((f) => f.clock));
809
+ return {
810
+ branches,
811
+ value: Array.isArray(first) ? results : Object.fromEntries(entries.map(([k], i) => [k, results[i]])),
812
+ };
813
+ }
814
+ catch (e) {
815
+ // The first rejection cancels the rest, then rethrows. The intent travels WITH the
816
+ // failure, because a rejecting branch cancels its siblings and can crash before they
817
+ // hear it, so a failed scope owes its losers exactly as a winning one does.
818
+ for (const f of frames)
819
+ f.signal.cancel("a sibling branch failed");
820
+ await Promise.allSettled(running);
821
+ frame.clock.join(frames.map((f) => f.clock));
822
+ const losers = branches.filter((k) => k !== failed);
823
+ throw new ScopeFailed(e, { branches, cancel: { losers, issued: false } });
824
+ }
825
+ }
826
+ // race: the earliest to settle wins, and the losers are cancelled BY SEMANTICS, not by an API
827
+ // the program calls. A cancelled branch performs no new effects; an agent reply already in
828
+ // flight completes and is ignored, which is the documented answer rather than an accident.
829
+ // THE WINNER IS THE EARLIEST BRANCH, NOT THE FIRST ONE SCHEDULING HAPPENED TO WAKE.
830
+ //
831
+ // An arm's logical settlement time is its branch clock: the max endedAt of the effects it
832
+ // awaited (the scope's entry clock if it awaited none), which is recorded. The winner is the
833
+ // least clock among the arms that settled; equal clocks fall to declaration order, which is
834
+ // recorded too. So the same journal resolves the same arm on every re-entry.
835
+ //
836
+ // AND LIVE, NO SCHEDULER AND NO `yieldEvery` VALUE CAN CHOOSE. When an arm settles, every
837
+ // sibling is cancelled (no new effects, the cancellation law), and each sibling is CUT, pure
838
+ // work included, only if it can no longer win: its clock is later, or equal and it is declared
839
+ // later. A sibling that could still win runs its pure work to a settle, and a sibling that
840
+ // reaches a new effect is cut there, having proven it would end after the settled arm's clock.
841
+ // Which arms settle is therefore a function of their effects and the declaration order, and
842
+ // so is the winner. A later settle with an earlier clock re-decides the cut for the rest.
843
+ // A FAILURE IS A SETTLE, so a rejecting arm is a candidate to win: it just wins by failing
844
+ // the scope. What is NOT a candidate is a branch that rejected with `Cancelled`, because that
845
+ // is not an outcome the branch reached, it is what losing did to it. Counting those would let
846
+ // a loser cut short at an early step outrank the winner that ran longer.
847
+ // The FRONTIER: the least clock among the arms that have settled as candidates, ties to the
848
+ // earlier declaration. The cut compares against it in both places below, because it is the
849
+ // bar an unsettled arm actually has to beat.
850
+ let bestAt = -1;
851
+ let bestIndex = -1;
852
+ const behindFrontier = (j) => {
853
+ const other = frames[j].clock.now();
854
+ return !(other < bestAt || (other === bestAt && j < bestIndex));
855
+ };
856
+ const onSettle = (i, wasCancelled) => {
857
+ if (wasCancelled)
858
+ return;
859
+ const at = frames[i].clock.now();
860
+ if (bestIndex === -1 || at < bestAt || (at === bestAt && i < bestIndex)) {
861
+ bestAt = at;
862
+ bestIndex = i;
863
+ }
864
+ for (let j = 0; j < frames.length; j += 1) {
865
+ if (j === i)
866
+ continue;
867
+ frames[j].signal.cancel("a sibling branch won the race", { cutPure: behindFrontier(j) });
868
+ }
869
+ };
870
+ running.forEach((p, i) => {
871
+ p.then(() => onSettle(i, false), (e) => onSettle(i, e instanceof Cancelled));
872
+ });
873
+ // AND THE CUT IS RE-DECIDED WHEN AN ARM'S OWN CLOCK MOVES. A cancelled arm with an effect
874
+ // already in flight is allowed to see it land, since the work was issued before the
875
+ // cancellation, but landing advances the arm's clock, and an arm that lands PAST the frontier has just
876
+ // proven it cannot win. Deciding only at settles left that arm running its pure tail on a
877
+ // verdict reached from its old clock: measured, an infinite pure tail burned the whole step
878
+ // budget and killed a run whose race had already settled `ok`, while a resume of the same
879
+ // journal returned the winner, with live and replay disagreeing on the run's outcome. An arm
880
+ // that lands BEFORE the frontier keeps running, because it can still win (its own cell).
881
+ frames.forEach((f, j) => {
882
+ f.clock.onAdvance(() => {
883
+ if (f.signal.cancelled && !f.signal.cutPure && bestIndex !== -1 && behindFrontier(j)) {
884
+ f.signal.cancel("a sibling branch won the race", { cutPure: true });
885
+ }
886
+ });
887
+ });
888
+ // BOTH HANDLERS, and the rejection handler is the whole point. `p.then(() => undefined)`
889
+ // propagates a rejection, so the first arm to FAIL threw straight out of this await: past the
890
+ // cancellation, past `allSettled`, and into a scope entry recorded as failed with no losers on
891
+ // it. The run terminated while a sibling was still performing effects, which is the exact
892
+ // defect the scope entry exists to prevent. A rejection is a settle.
893
+ await Promise.race(running.map((p) => p.then(() => undefined, () => undefined)));
894
+ const settled = await Promise.allSettled(running);
895
+ // Every arm has settled, so whatever cut it did not get earlier no longer matters; the
896
+ // signal still says cancelled, which is what a nested branch that outlives this line reads.
897
+ for (const f of frames)
898
+ f.signal.cancel("a sibling branch won the race");
899
+ frame.clock.join(frames.map((f) => f.clock));
900
+ let winnerAt = -1;
901
+ let winnerIndex = -1;
902
+ for (let i = 0; i < settled.length; i += 1) {
903
+ const r = settled[i];
904
+ if (r.status === "rejected" && r.reason instanceof Cancelled)
905
+ continue;
906
+ const at = frames[i].clock.now();
907
+ if (winnerIndex === -1 || at < winnerAt) {
908
+ winnerAt = at;
909
+ winnerIndex = i;
910
+ }
911
+ }
912
+ if (winnerIndex === -1) {
913
+ // Every arm was cancelled, so the race itself was: nothing here decided anything.
914
+ const first = settled.find((r) => r.status === "rejected");
915
+ throw first === undefined ? new Cancelled("every branch was cancelled") : first.reason;
916
+ }
917
+ const index = entries[winnerIndex]?.[0];
918
+ const won = settled[winnerIndex];
919
+ if (won.status === "rejected") {
920
+ // The earliest branch to settle FAILED. The scope fails with it, carrying the siblings it
921
+ // cancelled: a losing arm can crash before the cancellation reaches it, so the intent has
922
+ // to travel with the outcome exactly as it does for a winning race.
923
+ throw new ScopeFailed(won.reason, {
924
+ branches,
925
+ cancel: { losers: branches.filter((k) => k !== index), issued: false },
926
+ });
927
+ }
928
+ return {
929
+ branches,
930
+ // BOTH the index and the value. The index alone is not enough: an edit to an arm's returned
931
+ // expression would resume as the new value with no divergence raised.
932
+ value: { index, value: settled[winnerIndex].value },
933
+ cancel: { losers: branches.filter((k) => k !== index), issued: false },
934
+ };
935
+ }
936
+ if (name === "fanOut") {
937
+ const items = first;
938
+ const fn = (await second());
939
+ const keyFn = option(bag, "key");
940
+ const branchKeys = [];
941
+ for (let i = 0; i < items.length; i += 1) {
942
+ const item = items[i];
943
+ let k;
944
+ if (keyFn !== undefined)
945
+ k = await keyFn(frame, [item]);
946
+ else if (item !== null && typeof item === "object" && typeof item.id === "string") {
947
+ k = item.id;
948
+ }
949
+ else {
950
+ throw new RuntimeFault("L3021", `fanOut needs a stable key: without one, a reordered or filtered list silently reshuffles every journal key underneath it. Pass { key: (item) => ... }, or give items a string id.`);
951
+ }
952
+ branchKeys.push(String(k));
953
+ }
954
+ if (new Set(branchKeys).size !== branchKeys.length) {
955
+ throw new RuntimeFault("L3024", `fanOut produced duplicate branch keys (${branchKeys.join(", ")}), so two branches would share one journal namespace and allocate the same step key with different inputs. Nothing has run yet: the keys are all evaluated before any branch launches, because rejecting after launch would be too late by exactly the side effects the check exists to prevent.`);
956
+ }
957
+ const frames = branchKeys.map((k) => frame.branch(scopeKind, scopeName, occurrence, k));
958
+ // A fanOut has no losers: every branch is a winner, so a migration's walk enters the ones the
959
+ // recorded run actually had. A branch the new source no longer produces is simply not walked,
960
+ // and its entries surface as orphans, which is the whole point of walking rather than
961
+ // consuming.
962
+ const walk = items
963
+ .map((item, i) => [item, i])
964
+ .filter(([, i]) => only === undefined || only.has(branchKeys[i]));
965
+ // The same failure law as `parallel`: the first rejection cancels the siblings and the scope
966
+ // fails with it, carrying the losers. Measured before this block: a rejecting branch threw out
967
+ // of `Promise.all` alone, and every sibling went on performing effects against a scope whose
968
+ // entry had already settled failed.
969
+ let failed = null;
970
+ const launched = walk.map(([item, i]) => fn(frames[i], [item, i]).catch((e) => {
971
+ if (failed === null)
972
+ failed = branchKeys[i];
973
+ throw e;
974
+ }));
975
+ try {
976
+ const results = await Promise.all(launched);
977
+ frame.clock.join(frames.map((f) => f.clock));
978
+ return { branches: branchKeys, value: results };
979
+ }
980
+ catch (e) {
981
+ for (const f of frames)
982
+ f.signal.cancel("a sibling branch failed");
983
+ await Promise.allSettled(launched);
984
+ frame.clock.join(frames.map((f) => f.clock));
985
+ const losers = branchKeys.filter((k) => k !== failed);
986
+ throw new ScopeFailed(e, { branches: branchKeys, cancel: { losers, issued: false } });
987
+ }
988
+ }
989
+ if (name === "conclave") {
990
+ // A conclave is a scope AND an effect, and it gets ONE entry, of kind `conclave`, carrying
991
+ // the durable answer to "is this sub-team still live". That answer is the explicit `closed`
992
+ // FACT, not the entry's state: a body that failed after a clean close settles `failed`
993
+ // exactly like one whose close never acknowledged, and only the fact separates them. Pending
994
+ // means a close is still owed. The migrate table reads that fact (an orphaned conclave is
995
+ // rejected unless the scope closed), so a second entry for the close would be a second thing
996
+ // to keep in agreement with the first, and nothing needs it.
997
+ const members = deepFreeze(first);
998
+ const fn = (await second());
999
+ const channel = option(bag, "channel");
1000
+ const req = { members, ...(channel !== undefined ? { channel } : {}) };
1001
+ const handler = host.options.handler;
1002
+ const handle = deepFreeze(await handler.openConclave(req, ctx));
1003
+ // One body, one branch, and the branch key is the fixed literal `in` rather than the channel
1004
+ // name. The channel is HANDLER-DERIVED (the simulator and the mesh mint different ones), so
1005
+ // keying the journal namespace by it would make a journal replayable only under the handler
1006
+ // that wrote it, which is the one thing the effect seam exists to prevent.
1007
+ // ONE constant, used for both the namespace and the recorded branch list, so the entry cannot
1008
+ // claim a key the body's steps were not actually filed under.
1009
+ const branchKey = "in";
1010
+ const branch = frame.branch(scopeKind, scopeName, occurrence, branchKey);
1011
+ // The body's outcome is decided FIRST, alone. The close is a separate act with a separate
1012
+ // failure mode, and folding it into this try is what made a close rejection retry itself and
1013
+ // then settle as an ordinary body failure, a `failed` entry indistinguishable from "the body
1014
+ // failed and the room closed cleanly", which an orphan walk reads as closed while the members
1015
+ // are still joined.
1016
+ // `threw` is a separate flag rather than `bodyError !== undefined`, because `throw undefined`
1017
+ // is a thing a program may do and "the body failed" must not depend on what it failed WITH.
1018
+ let bodyError;
1019
+ let threw = false;
1020
+ let value;
1021
+ try {
1022
+ value = await fn(branch, [handle]);
1023
+ }
1024
+ catch (e) {
1025
+ bodyError = e;
1026
+ threw = true;
1027
+ }
1028
+ frame.clock.join([branch.clock]);
1029
+ // A CANCELLED branch performs no new effects, so a cancelled conclave does not close
1030
+ // itself: releasing the membership travels the same recovery path as every other branch-local
1031
+ // resource a race loser took. A conclave whose body merely FAILED is not cancelled, because
1032
+ // this process is live and the world is reachable, and walking away from live membership on
1033
+ // an ordinary error would be the `spawn` leak in another shape.
1034
+ if (bodyError instanceof Cancelled)
1035
+ throw new ScopeFailed(bodyError, { closed: false });
1036
+ try {
1037
+ await handler.closeConclave(req, ctx);
1038
+ }
1039
+ catch (e) {
1040
+ // THE CLOSE DID NOT ACKNOWLEDGE, so the scope does not settle at all. A pending entry IS
1041
+ // the durable "a close is still owed" (re-entry retries it), and settling anything here
1042
+ // would be the journal claiming a disposition the world never confirmed. The body's own
1043
+ // error, if there was one, is subordinate: it did not leave members joined; this did.
1044
+ throw new CloseOwed(e);
1045
+ }
1046
+ if (threw)
1047
+ throw new ScopeFailed(bodyError, { closed: true });
1048
+ return { branches: [branchKey], value, closed: true };
1049
+ }
1050
+ throw new RuntimeFault("L1000", `${name} is not implemented in this interpreter`);
1051
+ }
1052
+ //# sourceMappingURL=perform.js.map