@cotal-ai/lang 0.24.0 → 0.25.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.
- package/README.md +11 -0
- package/dist/engine/bridge.d.ts +71 -0
- package/dist/engine/bridge.d.ts.map +1 -0
- package/dist/engine/bridge.js +277 -0
- package/dist/engine/bridge.js.map +1 -0
- package/dist/engine/ctx.d.ts +140 -0
- package/dist/engine/ctx.d.ts.map +1 -0
- package/dist/engine/ctx.js +834 -0
- package/dist/engine/ctx.js.map +1 -0
- package/dist/engine/frame.d.ts +69 -0
- package/dist/engine/frame.d.ts.map +1 -0
- package/dist/engine/frame.js +105 -0
- package/dist/engine/frame.js.map +1 -0
- package/dist/engine/host.d.ts +77 -0
- package/dist/engine/host.d.ts.map +1 -0
- package/dist/engine/host.js +134 -0
- package/dist/engine/host.js.map +1 -0
- package/dist/engine/worker-entry.d.ts +26 -0
- package/dist/engine/worker-entry.d.ts.map +1 -0
- package/dist/engine/worker-entry.js +175 -0
- package/dist/engine/worker-entry.js.map +1 -0
- package/dist/engine/worker.d.ts +156 -0
- package/dist/engine/worker.d.ts.map +1 -0
- package/dist/engine/worker.js +123 -0
- package/dist/engine/worker.js.map +1 -0
- package/dist/errors.d.ts +44 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +97 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/interpret.d.ts +7 -38
- package/dist/interpret.d.ts.map +1 -1
- package/dist/interpret.js +45 -1003
- package/dist/interpret.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +75 -1
- package/dist/journal.js.map +1 -1
- package/dist/library.d.ts.map +1 -1
- package/dist/library.js +13 -1
- package/dist/library.js.map +1 -1
- package/dist/perform.d.ts +138 -0
- package/dist/perform.d.ts.map +1 -0
- package/dist/perform.js +1052 -0
- package/dist/perform.js.map +1 -0
- package/dist/pins.d.ts +28 -8
- package/dist/pins.d.ts.map +1 -1
- package/dist/pins.js +31 -11
- package/dist/pins.js.map +1 -1
- package/dist/sim.d.ts +15 -1
- package/dist/sim.d.ts.map +1 -1
- package/dist/sim.js.map +1 -1
- package/dist/transform/emit.d.ts +23 -0
- package/dist/transform/emit.d.ts.map +1 -0
- package/dist/transform/emit.js +934 -0
- package/dist/transform/emit.js.map +1 -0
- package/dist/transform/index.d.ts +34 -0
- package/dist/transform/index.d.ts.map +1 -0
- package/dist/transform/index.js +31 -0
- package/dist/transform/index.js.map +1 -0
- package/dist/transform/scope.d.ts +58 -0
- package/dist/transform/scope.d.ts.map +1 -0
- package/dist/transform/scope.js +500 -0
- package/dist/transform/scope.js.map +1 -0
- package/dist/transform/seam.d.ts +78 -0
- package/dist/transform/seam.d.ts.map +1 -0
- package/dist/transform/seam.js +111 -0
- package/dist/transform/seam.js.map +1 -0
- package/dist/values.d.ts +15 -0
- package/dist/values.d.ts.map +1 -1
- package/dist/values.js +79 -0
- package/dist/values.js.map +1 -1
- package/package.json +9 -4
package/dist/interpret.js
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* serialized.
|
|
16
16
|
*/
|
|
17
17
|
import { validate } from "./grammar.js";
|
|
18
|
-
import { LangError, LangErrors, RuntimeFault, } from "./errors.js";
|
|
19
|
-
export { RuntimeFault } from "./errors.js";
|
|
18
|
+
import { LangError, LangErrors, RunDivergence, RuntimeFault, ScopeBranchMissing, UnwalkableScope, messageOf, } from "./errors.js";
|
|
19
|
+
export { RunDivergence, RuntimeFault, ScopeBranchMissing, UnwalkableScope } from "./errors.js";
|
|
20
20
|
import { KeyScope, digest, programHashOf, requestId, scopePathString, stepKeyString } from "./keys.js";
|
|
21
21
|
import { Journal, JournalAppendRejected, RunClock } from "./journal.js";
|
|
22
22
|
import { NotCrossable, Prng, assertCrossable, birthDepth, born, deepFreeze, setOwn } from "./values.js";
|
|
@@ -24,22 +24,27 @@ import { parseDuration } from "./duration.js";
|
|
|
24
24
|
import { PRIMITIVES, VALUE_NAMES } from "./primitives.js";
|
|
25
25
|
import { arrayMethods, builtins, numberMethods, stringMethods } from "./library.js";
|
|
26
26
|
import { notifyFactViolation } from "./notify-fact.js";
|
|
27
|
-
import { bindPins, resolvePins } from "./pins.js";
|
|
27
|
+
import { bindPins, resolvePins, WALKER_LANGUAGE_VERSION } from "./pins.js";
|
|
28
|
+
import { dispatchPrimitive, freeConstructors, option, performEffect, performScope, runScope, } from "./perform.js";
|
|
28
29
|
import { Cancelled, RunReleased, EffectError, applyCheckpointPolicy, } from "./effects.js";
|
|
29
30
|
/**
|
|
30
|
-
*
|
|
31
|
+
* Every write of a RECORD member goes through here, whatever spelled it: a literal, a spread, a
|
|
32
|
+
* rest pattern, or `o.a = v`. The one member the language refuses is a callable `then`.
|
|
31
33
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
34
|
+
* The host's promise machinery adopts any object that carries one: resolving a promise with such a
|
|
35
|
+
* record calls the record's own `then` with the machinery's continuations instead of delivering the
|
|
36
|
+
* value. Inside this interpreter every function is async, so a program-authored `then` that throws
|
|
37
|
+
* turns that throw into a rejection of a promise nobody owns, which escapes the run as an
|
|
38
|
+
* unhandled rejection and kills the host, while the await that adopted the record never settles
|
|
39
|
+
* and the run hangs behind it (measured: a record returned from a program function did exactly
|
|
40
|
+
* this, host process and all). Refusing the member is refusing the adoption: the language carries
|
|
41
|
+
* no thenable values, the same way it carries no sparse arrays and no `__proto__` fields.
|
|
35
42
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
super(`conclave close did not acknowledge: ${reason?.message ?? String(reason)}`);
|
|
40
|
-
this.reason = reason;
|
|
41
|
-
this.name = "CloseOwed";
|
|
43
|
+
function setRecordMember(target, key, value) {
|
|
44
|
+
if (key === "then" && typeof value === "function") {
|
|
45
|
+
throw new RuntimeFault("L4021", "`then` cannot name a function here. To the host's promise machinery any object with a callable `then` is a promise waiting to be adopted, so the record would never arrive as the value this program built: its `then` runs with the machinery's own continuations, a `then` that throws or rejects escapes the run as an unhandled rejection with no owner and kills the host, and the await that adopted it never settles. Name the member something else.");
|
|
42
46
|
}
|
|
47
|
+
setOwn(target, key, value);
|
|
43
48
|
}
|
|
44
49
|
// ---- environments ------------------------------------------------------------------------------
|
|
45
50
|
class Binding {
|
|
@@ -136,31 +141,6 @@ class Env {
|
|
|
136
141
|
b.value = value;
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
|
-
/**
|
|
140
|
-
* A scope's failure, carrying the interpreter's OWN facts about it.
|
|
141
|
-
*
|
|
142
|
-
* Attaching them to the thrown value with `Object.assign` works exactly as long as every program
|
|
143
|
-
* throws an object. `throw null` is valid, and `Object.assign(null, …)` is a TypeError, so a
|
|
144
|
-
* conclave whose body throws a primitive loses its closure fact AND hands the caller a manufactured
|
|
145
|
-
* type error in place of the body's failure, while the entry records
|
|
146
|
-
* `closed: undefined` for a room the handler had in fact closed. The facts belong to the
|
|
147
|
-
* interpreter, so they travel in the interpreter's own envelope and the program's value rides
|
|
148
|
-
* untouched inside it. Nothing outside `performScope` ever sees this class: it unwraps before it
|
|
149
|
-
* rethrows.
|
|
150
|
-
*/
|
|
151
|
-
class ScopeFailed extends Error {
|
|
152
|
-
reason;
|
|
153
|
-
facts;
|
|
154
|
-
constructor(reason, facts) {
|
|
155
|
-
super(`scope failed: ${messageOf(reason)}`);
|
|
156
|
-
this.reason = reason;
|
|
157
|
-
this.facts = facts;
|
|
158
|
-
this.name = "ScopeFailed";
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
function unwrapScope(e) {
|
|
162
|
-
return e instanceof ScopeFailed ? { reason: e.reason, facts: e.facts } : { reason: e, facts: {} };
|
|
163
|
-
}
|
|
164
144
|
/**
|
|
165
145
|
* The message of an arbitrary thrown value.
|
|
166
146
|
*
|
|
@@ -168,18 +148,8 @@ function unwrapScope(e) {
|
|
|
168
148
|
* so every place that has to describe a failure it did not construct goes through here. A recorded
|
|
169
149
|
* entry saying "Cannot read properties of null" describes the recorder, not the run.
|
|
170
150
|
*/
|
|
171
|
-
/**
|
|
172
|
-
* The digest fact, written wherever the loser set is — a race that FAILED owes its losers exactly
|
|
173
|
-
* as a winning one does, so it carries the digest too, and `replay-failed` compares it.
|
|
174
|
-
*/
|
|
175
|
-
function digestFacts(of, losers) {
|
|
176
|
-
if (of === undefined || losers === undefined)
|
|
177
|
-
return {};
|
|
178
|
-
const d = of(losers);
|
|
179
|
-
return d === undefined ? {} : { branchDigest: d };
|
|
180
|
-
}
|
|
181
151
|
/** An AST subtree with its source offsets removed: what the code IS, not where it sits. */
|
|
182
|
-
function stripPositions(node) {
|
|
152
|
+
export function stripPositions(node) {
|
|
183
153
|
if (Array.isArray(node))
|
|
184
154
|
return node.map(stripPositions);
|
|
185
155
|
if (node === null || typeof node !== "object")
|
|
@@ -192,33 +162,6 @@ function stripPositions(node) {
|
|
|
192
162
|
}
|
|
193
163
|
return out;
|
|
194
164
|
}
|
|
195
|
-
function messageOf(v) {
|
|
196
|
-
if (v instanceof Error)
|
|
197
|
-
return v.message;
|
|
198
|
-
const m = v?.message;
|
|
199
|
-
return typeof m === "string" ? m : String(v);
|
|
200
|
-
}
|
|
201
|
-
/** A fault the interpreter itself raises, as opposed to one an effect handler reported. */
|
|
202
|
-
/**
|
|
203
|
-
* A migration's walk reached a settled scope it cannot enter.
|
|
204
|
-
*
|
|
205
|
-
* A `conclave` is the case that exists today: its channel handle is HANDLER-DERIVED — the mint
|
|
206
|
-
* returns it and nothing journals it — so a walk cannot re-enter the body without inventing a
|
|
207
|
-
* handle, and an invented one would re-hash every step inside that used the channel into a
|
|
208
|
-
* divergence the run never had. Refusing is the honest exit. Consuming the subtree instead would
|
|
209
|
-
* hide exactly the orphans a migration exists to find, which is a silent wrong answer in place of
|
|
210
|
-
* a loud refusal.
|
|
211
|
-
*/
|
|
212
|
-
export class UnwalkableScope extends Error {
|
|
213
|
-
scopeKey;
|
|
214
|
-
why;
|
|
215
|
-
constructor(scopeKey, why) {
|
|
216
|
-
super(`a migration cannot walk inside the settled ${why} at ${scopeKey}: its handle is handler-derived and was never journalled, so the walk would have to invent one. Fork from this step instead, or migrate a run that does not contain it.`);
|
|
217
|
-
this.scopeKey = scopeKey;
|
|
218
|
-
this.why = why;
|
|
219
|
-
this.name = "UnwalkableScope";
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
165
|
const NORMAL = { type: "normal" };
|
|
223
166
|
// ---- per-branch execution state ---------------------------------------------------------------------
|
|
224
167
|
/**
|
|
@@ -287,61 +230,6 @@ class Frame {
|
|
|
287
230
|
kind === "conclave" ? this.depth : this.depth + 1);
|
|
288
231
|
}
|
|
289
232
|
}
|
|
290
|
-
/** A recorded step's inputs changed, so its recorded result may no longer be the truth. */
|
|
291
|
-
export class RunDivergence extends Error {
|
|
292
|
-
stepKey;
|
|
293
|
-
recordedHash;
|
|
294
|
-
programHash;
|
|
295
|
-
constructor(stepKey, recordedHash, programHash) {
|
|
296
|
-
super(`L5001 Run divergence\n\n step ${stepKey} INPUT CHANGED\n recorded ${recordedHash}\n program ${programHash}\n\nThe recorded result was produced from different inputs, so replaying it would hand the program an answer to a question it is no longer asking.\n\nOptions\n fork(run, "${stepKey}") re-run from this step, keeping everything before it\n revert the inputs keep the recorded result`);
|
|
297
|
-
this.stepKey = stepKey;
|
|
298
|
-
this.recordedHash = recordedHash;
|
|
299
|
-
this.programHash = programHash;
|
|
300
|
-
this.name = "RunDivergence";
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* A migration's walk was sent into a recorded branch the new source does not have.
|
|
305
|
-
*
|
|
306
|
-
* Its own code rather than `RunDivergence`, for the reason the L5005/L5006/L5007 collision in the
|
|
307
|
-
* orphan table bought: `RunDivergence` is a HASH comparison and says so in both its fields and its
|
|
308
|
-
* message, and putting branch NAMES in fields called `recordedHash`/`programHash` would be a lie in
|
|
309
|
-
* the payload a repair loop reads. The author's repair differs too — this one is fixed by looking at
|
|
310
|
-
* an arm's NAME, not at its body.
|
|
311
|
-
*/
|
|
312
|
-
export class ScopeBranchMissing extends Error {
|
|
313
|
-
scopeKey;
|
|
314
|
-
scope;
|
|
315
|
-
missing;
|
|
316
|
-
recorded;
|
|
317
|
-
source;
|
|
318
|
-
constructor(scopeKey, scope, missing, recorded, source) {
|
|
319
|
-
super(recorded.length === 0
|
|
320
|
-
// THE EMPTY CASE IS NOT THE SAME SENTENCE. "A recorded branch is not in the source" is false
|
|
321
|
-
// here: no branch was recorded at all, and saying "missing: " with nothing after it would
|
|
322
|
-
// send a reader looking through their source for an arm that was never named. The cause is
|
|
323
|
-
// the entry, not the edit, and the repair is different too.
|
|
324
|
-
? `L5022 A settled scope recorded no branch names\n\n step ${scopeKey} BRANCH NAMES ABSENT\n`
|
|
325
|
-
+ ` source ${source.join(", ")}\n\n`
|
|
326
|
-
+ `This ${scope} settled before scopes recorded their arm names on failure, so the walk has `
|
|
327
|
-
+ `nothing to tell it which arm ran. It cannot enter one, and entering none would wait `
|
|
328
|
-
+ `forever on a scope with no branches in it.\n\nOptions\n resume(run) replay it rather `
|
|
329
|
-
+ `than walking it\n fork(run, "${scopeKey}") re-run this scope on the current arms`
|
|
330
|
-
: `L5022 A recorded branch is not in the migrated source\n\n step ${scopeKey} BRANCH MISSING\n`
|
|
331
|
-
+ ` recorded ${recorded.join(", ")}\n source ${source.join(", ")}\n`
|
|
332
|
-
+ ` missing ${missing.join(", ")}\n\n`
|
|
333
|
-
+ `This ${scope} settled on ${missing.length === 1 ? "a branch" : "branches"} the new source no longer declares, so the walk `
|
|
334
|
-
+ `cannot enter ${missing.length === 1 ? "it" : "them"} to check what ran inside. Migrating anyway would hand the program a `
|
|
335
|
-
+ `result produced by an arm it does not have.\n\nOptions\n restore the branch ${missing.map((k) => `\`${k}\``).join(", ")} `
|
|
336
|
-
+ `keep the recorded result\n fork(run, "${scopeKey}") re-run this scope on the new arms`);
|
|
337
|
-
this.scopeKey = scopeKey;
|
|
338
|
-
this.scope = scope;
|
|
339
|
-
this.missing = missing;
|
|
340
|
-
this.recorded = recorded;
|
|
341
|
-
this.source = source;
|
|
342
|
-
this.name = "ScopeBranchMissing";
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
233
|
class Interpreter {
|
|
346
234
|
ast;
|
|
347
235
|
options;
|
|
@@ -349,8 +237,7 @@ class Interpreter {
|
|
|
349
237
|
pins;
|
|
350
238
|
journal;
|
|
351
239
|
prng;
|
|
352
|
-
|
|
353
|
-
ceiling;
|
|
240
|
+
effects;
|
|
354
241
|
steps = 0;
|
|
355
242
|
nextYield;
|
|
356
243
|
stepBudget;
|
|
@@ -378,8 +265,7 @@ class Interpreter {
|
|
|
378
265
|
// periodically never reached the ceiling however much it performed against the world, and the
|
|
379
266
|
// fault text claimed a run-scoped fact from an activation-scoped counter.
|
|
380
267
|
this.prng = new Prng(pins.seed);
|
|
381
|
-
this.
|
|
382
|
-
this.ceiling = pins.effectCeiling;
|
|
268
|
+
this.effects = { journal: this.journal, options, ceiling: pins.effectCeiling, effectCount: this.journal.dispatchedEffects() };
|
|
383
269
|
this.stepBudget = pins.stepBudget;
|
|
384
270
|
this.yieldEvery = pins.yieldEvery;
|
|
385
271
|
this.nextYield = this.yieldEvery;
|
|
@@ -518,7 +404,7 @@ class Interpreter {
|
|
|
518
404
|
else if (prop === "__proto__") {
|
|
519
405
|
throw new RuntimeFault("L4014", "`__proto__` names an object's prototype, and there are no prototypes here");
|
|
520
406
|
}
|
|
521
|
-
|
|
407
|
+
setRecordMember(obj, prop, value);
|
|
522
408
|
}
|
|
523
409
|
// ---- the fuel ceiling -----------------------------------------------------------------------
|
|
524
410
|
/**
|
|
@@ -564,139 +450,11 @@ class Interpreter {
|
|
|
564
450
|
}
|
|
565
451
|
// ---- the effect seam ------------------------------------------------------------------------
|
|
566
452
|
/**
|
|
567
|
-
* Perform one effect, or replay it.
|
|
568
|
-
*
|
|
569
|
-
* Everything durable happens here. A handler is called only in the `miss` and `pending` cases,
|
|
570
|
-
* and in `pending` it is told to re-bind rather than re-issue.
|
|
453
|
+
* Perform one effect, or replay it. The machinery is {@link performEffect} in `perform.ts`,
|
|
454
|
+
* shared with the v2 engine host; this method binds it to this run.
|
|
571
455
|
*/
|
|
572
456
|
async performEffect(kind, name, hashedInput, perform, frame) {
|
|
573
|
-
|
|
574
|
-
const inputHash = digest(hashedInput ?? null);
|
|
575
|
-
const verdict = this.journal.lookup(key, inputHash);
|
|
576
|
-
switch (verdict.verdict) {
|
|
577
|
-
case "replay":
|
|
578
|
-
if (verdict.entry.endedAt !== undefined)
|
|
579
|
-
frame.clock.advance(verdict.entry.endedAt);
|
|
580
|
-
return verdict.entry.result;
|
|
581
|
-
case "replay-failed": {
|
|
582
|
-
if (verdict.entry.endedAt !== undefined)
|
|
583
|
-
frame.clock.advance(verdict.entry.endedAt);
|
|
584
|
-
const e = verdict.entry.error;
|
|
585
|
-
throw new EffectError(e.code, e.kind, e.message, e.detail);
|
|
586
|
-
}
|
|
587
|
-
case "replay-cancelled":
|
|
588
|
-
throw new Cancelled("this branch was cancelled on the recorded run");
|
|
589
|
-
case "diverged":
|
|
590
|
-
throw new RunDivergence(stepKeyString(key), verdict.recordedHash, verdict.programHash);
|
|
591
|
-
case "pending":
|
|
592
|
-
case "miss":
|
|
593
|
-
break;
|
|
594
|
-
}
|
|
595
|
-
// A cancelled branch performs no NEW effects. That is the whole of the cancellation law on
|
|
596
|
-
// this side: work already in flight is another matter, and the handler owns it.
|
|
597
|
-
if (frame.signal.cancelled) {
|
|
598
|
-
throw new Cancelled(frame.signal.reason ?? "cancelled");
|
|
599
|
-
}
|
|
600
|
-
// THE HOST'S STOP, asked before anything is begun and after every replay has been served. A
|
|
601
|
-
// driver holds its run under an absolute work horizon and may be asked to hand it back, and
|
|
602
|
-
// neither is a fact about the program — so the place to stop is here, where no entry has been
|
|
603
|
-
// written and no handler dispatched. One step later would mean a pending entry for work nobody
|
|
604
|
-
// performed; inside the handler would mean settling a failure for work that really happened.
|
|
605
|
-
// Replays above are deliberately unaffected: replaying a recorded prefix performs nothing, and
|
|
606
|
-
// a run that stopped mid-journal has to be able to walk back to where it stopped.
|
|
607
|
-
const stop = this.options.shouldStop?.();
|
|
608
|
-
if (stop !== undefined) {
|
|
609
|
-
throw new RunReleased(stop);
|
|
610
|
-
}
|
|
611
|
-
this.effectCount += 1;
|
|
612
|
-
if (this.effectCount > this.ceiling) {
|
|
613
|
-
throw new RuntimeFault("L4009", `this run has performed more than ${this.ceiling} effects, which means a loop is not terminating. Add an exit condition or a permit.`);
|
|
614
|
-
}
|
|
615
|
-
const resume = verdict.verdict === "pending" ? verdict.entry.external : undefined;
|
|
616
|
-
// RECOVERY SUBMITS UNDER THE RECORDED IDENTITY. Re-deriving happens to agree whenever nothing
|
|
617
|
-
// moved, which is exactly why it read as correct: the whole point of writing the id down is
|
|
618
|
-
// the case where it does NOT agree, and a resumed run that re-derives is reissuing under an
|
|
619
|
-
// identity the far side may never have seen. An entry with no recorded id predates this rule.
|
|
620
|
-
const recorded = verdict.verdict === "pending" && verdict.entry.requestId !== undefined ? verdict.entry : undefined;
|
|
621
|
-
const reqId = recorded?.requestId ?? requestId(this.options.runId, key, inputHash);
|
|
622
|
-
// WHICH attempt is open, not merely which id. An id alone cannot say how much of an escalation
|
|
623
|
-
// chain is already spent, and a recovery that cannot tell replays the hop: it mints again under
|
|
624
|
-
// the id the far side already holds and reads that mint's cached expiry back as a fresh
|
|
625
|
-
// observation. An entry written before the index existed reads as attempt 0, which is what it
|
|
626
|
-
// is for every effect that never hops.
|
|
627
|
-
const attempt = recorded?.attempt ?? 0;
|
|
628
|
-
if (verdict.verdict === "miss") {
|
|
629
|
-
// AWAITED, and the await is the point: the request id the handler is about to submit under
|
|
630
|
-
// has to be durable BEFORE the work is issued, or a crash in the gap leaves real work that
|
|
631
|
-
// nothing in the journal names.
|
|
632
|
-
await this.journal.begin(key, inputHash, this.options.handler.now(), reqId);
|
|
633
|
-
// THE AWAIT ABOVE IS A GAP, and the cancellation law has to hold on both sides of it. The
|
|
634
|
-
// check before `begin` sees the world as it was when this step started; while the append was
|
|
635
|
-
// in flight a sibling can settle the race and cancel this branch. Measured before this line:
|
|
636
|
-
// the loser's effect was still dispatched, performed against the world, and recorded `ok` —
|
|
637
|
-
// a NEW effect by a cancelled branch, which is the one thing the law forbids. The pending
|
|
638
|
-
// entry is real (the append happened), so it settles as what this branch now is: cancelled.
|
|
639
|
-
if (frame.signal.cancelled) {
|
|
640
|
-
await this.journal.settle(key, { status: "cancelled" }, this.options.handler.now());
|
|
641
|
-
throw new Cancelled(frame.signal.reason ?? "cancelled");
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
const ctx = {
|
|
645
|
-
key,
|
|
646
|
-
signal: frame.signal,
|
|
647
|
-
// Derived from the run, the step, the inputs and the attempt, and written on the pending
|
|
648
|
-
// entry by `begin` above BEFORE the handler runs. A handler submits under it idempotently,
|
|
649
|
-
// so a resumed run reissues the same id rather than creating a second goal.
|
|
650
|
-
requestId: reqId,
|
|
651
|
-
attempt,
|
|
652
|
-
...(resume !== undefined ? { resume } : {}),
|
|
653
|
-
bind: async (external) => {
|
|
654
|
-
await this.journal.bind(key, external);
|
|
655
|
-
},
|
|
656
|
-
};
|
|
657
|
-
// TWO FAILURE DOMAINS, AND THE TERMINAL APPEND IS NOT IN THE HANDLER'S.
|
|
658
|
-
//
|
|
659
|
-
// One `try` around both the dispatch and the settle produces the worst bug a journal can have:
|
|
660
|
-
// the handler completes, the store refuses the settling append, the catch below records that
|
|
661
|
-
// refusal as a handler fault, and the durable sequence becomes `[pending, settled:failed]` for
|
|
662
|
-
// work the world actually did, so every later replay reports failure for a real success. The
|
|
663
|
-
// handler's outcome is decided first, alone, and the append that records it happens outside,
|
|
664
|
-
// where a rejection is a durability failure that travels as itself and settles nothing.
|
|
665
|
-
let result;
|
|
666
|
-
try {
|
|
667
|
-
result = await perform(ctx, inputHash);
|
|
668
|
-
assertCrossable(result, `the result of ${stepKeyString(key)}`);
|
|
669
|
-
}
|
|
670
|
-
catch (e) {
|
|
671
|
-
const endedAt = this.options.handler.now();
|
|
672
|
-
// A journal that just refused an append cannot be asked to record why. It leaves by its own
|
|
673
|
-
// door, unwrapped, before anything tries to settle on top of it.
|
|
674
|
-
if (e instanceof JournalAppendRejected)
|
|
675
|
-
throw e;
|
|
676
|
-
if (e instanceof Cancelled) {
|
|
677
|
-
await this.journal.settle(key, { status: "cancelled" }, endedAt);
|
|
678
|
-
throw e;
|
|
679
|
-
}
|
|
680
|
-
// A handler may raise a language code directly, and it survives. The simulator's "unscripted
|
|
681
|
-
// effect" is L6001, and flattening that to a generic handler fault would tell a caller acting
|
|
682
|
-
// on `code` that the handler broke, when what actually happened is that their script is
|
|
683
|
-
// incomplete. Only the L-code shape is honoured: anything else a thrown object happens to
|
|
684
|
-
// call `code` (an errno, an HTTP status) is a handler fault and is recorded as one.
|
|
685
|
-
// Read defensively: a handler is other people's code and may throw a primitive, and reading
|
|
686
|
-
// `.code` or `.message` off `null` would replace its failure with the recorder's own.
|
|
687
|
-
const raised = e?.code;
|
|
688
|
-
const carried = typeof raised === "string" && /^L\d{4}$/.test(raised) ? raised : null;
|
|
689
|
-
const error = e instanceof EffectError
|
|
690
|
-
? { code: e.code, kind: e.kind, message: e.message, ...(e.detail !== undefined ? { detail: e.detail } : {}) }
|
|
691
|
-
: { code: carried ?? "L4000", kind: "handler-fault", message: messageOf(e) };
|
|
692
|
-
await this.journal.settle(key, { status: "failed", error }, endedAt);
|
|
693
|
-
frame.clock.advance(endedAt);
|
|
694
|
-
throw e instanceof EffectError ? e : new EffectError(error.code, error.kind, error.message);
|
|
695
|
-
}
|
|
696
|
-
const endedAt = this.options.handler.now();
|
|
697
|
-
await this.journal.settle(key, { status: "ok", result: deepFreeze(result) }, endedAt);
|
|
698
|
-
frame.clock.advance(endedAt);
|
|
699
|
-
return result;
|
|
457
|
+
return await performEffect(this.effects, kind, name, hashedInput, perform, frame);
|
|
700
458
|
}
|
|
701
459
|
// ---- expressions ------------------------------------------------------------------------------
|
|
702
460
|
async evaluate(node, env, frame) {
|
|
@@ -743,13 +501,13 @@ class Interpreter {
|
|
|
743
501
|
const src = await this.evaluate(p.argument, env, frame);
|
|
744
502
|
if (src !== null && src !== undefined) {
|
|
745
503
|
for (const [k, v] of Object.entries(src))
|
|
746
|
-
|
|
504
|
+
setRecordMember(out, k, v);
|
|
747
505
|
}
|
|
748
506
|
continue;
|
|
749
507
|
}
|
|
750
508
|
const key = p.key;
|
|
751
509
|
const name = key.type === "Identifier" ? key.name : String(key.value);
|
|
752
|
-
|
|
510
|
+
setRecordMember(out, name, await this.evaluate(p.value, env, frame));
|
|
753
511
|
}
|
|
754
512
|
return born(out, frame.depth);
|
|
755
513
|
}
|
|
@@ -959,7 +717,7 @@ class Interpreter {
|
|
|
959
717
|
const rest = {};
|
|
960
718
|
for (const [k, v] of Object.entries(src))
|
|
961
719
|
if (!taken.includes(k))
|
|
962
|
-
|
|
720
|
+
setRecordMember(rest, k, v);
|
|
963
721
|
await this.bindPattern(p.argument, born(rest, frame.depth), env, frame, mode);
|
|
964
722
|
continue;
|
|
965
723
|
}
|
|
@@ -1028,9 +786,6 @@ class Interpreter {
|
|
|
1028
786
|
}
|
|
1029
787
|
return await fn(frame, args);
|
|
1030
788
|
}
|
|
1031
|
-
option(bag, key) {
|
|
1032
|
-
return bag === null || typeof bag !== "object" ? undefined : bag[key];
|
|
1033
|
-
}
|
|
1034
789
|
async callPrimitive(name, argNodes, env, frame) {
|
|
1035
790
|
const spec = PRIMITIVES[name];
|
|
1036
791
|
if (spec === undefined)
|
|
@@ -1042,227 +797,7 @@ class Interpreter {
|
|
|
1042
797
|
const args = [];
|
|
1043
798
|
for (const a of argNodes)
|
|
1044
799
|
args.push(await this.evaluate(a, env, frame));
|
|
1045
|
-
|
|
1046
|
-
// and a value with no canonical form can be none of those. Refused HERE, before any entry is
|
|
1047
|
-
// written, with the argument named: `undefined`, a non-finite number and an opaque object are
|
|
1048
|
-
// L3041, a function is L3042. The result of the effect is held to the same rule in
|
|
1049
|
-
// {@link Interpreter.performEffect}.
|
|
1050
|
-
args.forEach((arg, i) => {
|
|
1051
|
-
try {
|
|
1052
|
-
assertCrossable(arg, `argument ${i + 1} of \`${name}\``);
|
|
1053
|
-
}
|
|
1054
|
-
catch (e) {
|
|
1055
|
-
if (e instanceof NotCrossable)
|
|
1056
|
-
throw new RuntimeFault(e.why === "function" ? "L3042" : "L3041", e.message);
|
|
1057
|
-
throw e;
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
// FREEZE ON SHARE, at the share. What crossed is what was hashed and recorded, so the program
|
|
1061
|
-
// mutating it afterwards — or the HANDLER mutating it on its side — would make the run's own
|
|
1062
|
-
// value disagree with its recorded form (measured before this line: `schema.deep.x = 2` after
|
|
1063
|
-
// an `ask` succeeded, no L2031, and a handler's write to `req.schema` reached the program).
|
|
1064
|
-
for (const arg of args)
|
|
1065
|
-
deepFreeze(arg);
|
|
1066
|
-
const bag = args[spec.optionsAt];
|
|
1067
|
-
const stepName = (name === "checkpoint" ? args[0] : this.option(bag, "name"));
|
|
1068
|
-
const handler = this.options.handler;
|
|
1069
|
-
switch (name) {
|
|
1070
|
-
case "spawn": {
|
|
1071
|
-
// The first argument is a persona name, or a record carrying the persona WITH its model
|
|
1072
|
-
// and variant. Only the persona was ever read, so the object form silently dropped model
|
|
1073
|
-
// and variant from both the request and the hash: editing a model did not diverge, and the
|
|
1074
|
-
// handler was never told which model to run. This was missed by an audit that exercised
|
|
1075
|
-
// only the string form, which is the same defect one level up.
|
|
1076
|
-
const spawnSubject = args[0];
|
|
1077
|
-
const persona = typeof spawnSubject === "string" ? spawnSubject : String(this.option(spawnSubject, "persona"));
|
|
1078
|
-
const model = typeof spawnSubject === "string" ? undefined : this.option(spawnSubject, "model");
|
|
1079
|
-
const variant = typeof spawnSubject === "string" ? undefined : this.option(spawnSubject, "variant");
|
|
1080
|
-
// Every accepted option is forwarded, including the three that are policy rather than
|
|
1081
|
-
// identity. Dropping them here would be silent: the validator accepts `permits`, so an
|
|
1082
|
-
// author who writes a budget gets no error and no budget. They are deliberately absent
|
|
1083
|
-
// from `hashedOptions` (§5.12) because they decide the INTERPRETATION of a result, not the
|
|
1084
|
-
// recorded fact, so they are reapplied from current source on resume rather than hashed.
|
|
1085
|
-
const req = {
|
|
1086
|
-
persona,
|
|
1087
|
-
...(model !== undefined ? { model } : {}),
|
|
1088
|
-
...(variant !== undefined ? { variant } : {}),
|
|
1089
|
-
...(this.option(bag, "worktree") !== undefined ? { worktree: this.option(bag, "worktree") } : {}),
|
|
1090
|
-
...(this.option(bag, "role") !== undefined ? { role: this.option(bag, "role") } : {}),
|
|
1091
|
-
...(this.option(bag, "join") !== undefined ? { join: this.option(bag, "join") } : {}),
|
|
1092
|
-
...(this.option(bag, "permits") !== undefined
|
|
1093
|
-
? { permits: this.option(bag, "permits") }
|
|
1094
|
-
: {}),
|
|
1095
|
-
...(this.option(bag, "supervise") !== undefined
|
|
1096
|
-
? { supervise: this.option(bag, "supervise") }
|
|
1097
|
-
: {}),
|
|
1098
|
-
...(this.option(bag, "onFork") !== undefined ? { onFork: this.option(bag, "onFork") } : {}),
|
|
1099
|
-
};
|
|
1100
|
-
return await this.performEffect("spawn", stepName ?? persona,
|
|
1101
|
-
// Model and variant are part of the IDENTITY being spawned, so they are hashed with the
|
|
1102
|
-
// persona (design 5.12). A run that swapped the model under a recorded agent would be
|
|
1103
|
-
// replaying a fact about a different agent.
|
|
1104
|
-
{
|
|
1105
|
-
persona,
|
|
1106
|
-
model: model ?? null,
|
|
1107
|
-
variant: variant ?? null,
|
|
1108
|
-
worktree: req.worktree ?? null,
|
|
1109
|
-
role: req.role ?? null,
|
|
1110
|
-
join: (req.join ?? []).map((c) => c.channel),
|
|
1111
|
-
}, (ctx) => handler.spawn(req, ctx), frame);
|
|
1112
|
-
}
|
|
1113
|
-
case "turn": {
|
|
1114
|
-
const agent = deepFreeze(args[0]);
|
|
1115
|
-
// The deadline STOPS OBSERVATION (design 5.12), so it belongs in the projection: a turn
|
|
1116
|
-
// recorded under a 1m deadline cannot answer what a 10m turn would have produced, and a
|
|
1117
|
-
// resumed run under the edited deadline replaying the old result is the silent-wrong-path
|
|
1118
|
-
// class. Closing that for `checkpoint` and leaving it open on the siblings closed nothing.
|
|
1119
|
-
const deadline = this.option(bag, "deadline");
|
|
1120
|
-
return await this.performEffect("turn", stepName, { agent: agent.agent, deadline: deadline ?? null }, (ctx) => handler.turn({ agent, ...(deadline !== undefined ? { deadline } : {}) }, ctx), frame);
|
|
1121
|
-
}
|
|
1122
|
-
case "ask": {
|
|
1123
|
-
const agent = deepFreeze(args[0]);
|
|
1124
|
-
const schema = this.option(bag, "schema");
|
|
1125
|
-
// Both of these END THE ASKING: `deadline` is the cutoff and `attempts` is how many
|
|
1126
|
-
// schema-failed replies are tolerated before it gives up. A record made under one attempt
|
|
1127
|
-
// is not an answer to what five attempts would have produced.
|
|
1128
|
-
const deadline = this.option(bag, "deadline");
|
|
1129
|
-
const attempts = this.option(bag, "attempts");
|
|
1130
|
-
return await this.performEffect("ask", stepName, { agent: agent.agent, schema: schema ?? null, deadline: deadline ?? null, attempts: attempts ?? null }, (ctx) => handler.ask({
|
|
1131
|
-
agent,
|
|
1132
|
-
schema,
|
|
1133
|
-
...(deadline !== undefined ? { deadline } : {}),
|
|
1134
|
-
...(attempts !== undefined ? { attempts } : {}),
|
|
1135
|
-
}, ctx), frame);
|
|
1136
|
-
}
|
|
1137
|
-
case "checkpoint": {
|
|
1138
|
-
const prompt = args[1];
|
|
1139
|
-
// The disposition is computed from TODAY's source, after the journal is consulted, on the
|
|
1140
|
-
// live path and the replay path alike. performEffect returns the RAW outcome, which is
|
|
1141
|
-
// what the journal holds; the policy sandwich closes here so a resumed run under an edited
|
|
1142
|
-
// onExpiry throws even though nothing about the recorded expiry changed.
|
|
1143
|
-
const onExpiry = this.option(bag, "onExpiry");
|
|
1144
|
-
const schema = this.option(bag, "schema");
|
|
1145
|
-
// The SAME projection the entry is keyed by, so an attempt's identity is a function of the
|
|
1146
|
-
// step it belongs to rather than of anything the escalation invents.
|
|
1147
|
-
// Design 5.12, and every field here earns its place. `timeout` STOPS OBSERVATION, so a
|
|
1148
|
-
// record made under 1m cannot answer what a 3m wait would have seen. `escalate` and its
|
|
1149
|
-
// `to` CREATE AN EFFECT rather than choosing a disposition, so editing them must diverge
|
|
1150
|
-
// rather than be reapplied. `fail` versus `proceed` is the one genuine reapply and stays
|
|
1151
|
-
// out. Hashing only prompt and schema left a timeout edit replaying clean, which is the
|
|
1152
|
-
// silent-wrong-path class this projection exists to close.
|
|
1153
|
-
const cpTimeout = this.option(bag, "timeout");
|
|
1154
|
-
const cpTo = this.option(bag, "to");
|
|
1155
|
-
const cpInput = {
|
|
1156
|
-
prompt,
|
|
1157
|
-
schema: schema ?? null,
|
|
1158
|
-
timeout: cpTimeout ?? null,
|
|
1159
|
-
...(onExpiry === "escalate" ? { onExpiry, to: cpTo ?? null } : {}),
|
|
1160
|
-
};
|
|
1161
|
-
return applyCheckpointPolicy((await this.performEffect("checkpoint", stepName, cpInput, async (ctx, inputHash) => {
|
|
1162
|
-
// ONE hash value, threaded from what the entry is actually keyed by rather than
|
|
1163
|
-
// re-digested from the projection here. The two agreed, which is exactly the problem:
|
|
1164
|
-
// a second derivation that happens to match is a coincidence maintained by hand, and
|
|
1165
|
-
// the first edit to the projection would desync attempt 1's identity from its own
|
|
1166
|
-
// step with no type error and no failing test.
|
|
1167
|
-
const attemptId = (n) => requestId(this.options.runId, ctx.key, inputHash, n);
|
|
1168
|
-
const req = {
|
|
1169
|
-
prompt,
|
|
1170
|
-
...(schema !== undefined ? { schema } : {}),
|
|
1171
|
-
...(cpTimeout !== undefined ? { timeout: cpTimeout } : {}),
|
|
1172
|
-
...(onExpiry !== undefined ? { onExpiry } : {}),
|
|
1173
|
-
...(cpTo !== undefined ? { to: cpTo } : {}),
|
|
1174
|
-
};
|
|
1175
|
-
// THE FINAL MINT DOES NOT ASK FOR AN ESCALATION. The interpreter owns the one-hop stop
|
|
1176
|
-
// rule, and it can only own it if the far side is not simultaneously told to hop: a
|
|
1177
|
-
// handler that honours `onExpiry` on the wire would mint a third attempt under an
|
|
1178
|
-
// identity this journal never allocated, and nothing here would ever learn of it.
|
|
1179
|
-
const finalReq = onExpiry === "escalate" ? { ...req, onExpiry: "proceed" } : req;
|
|
1180
|
-
// RECOVERY COMPLETES THE OPEN ATTEMPT. IT DOES NOT REPLAY THE CHAIN.
|
|
1181
|
-
//
|
|
1182
|
-
// Arriving here with a non-zero attempt means the hop was issued before the crash, so
|
|
1183
|
-
// the far side is already holding work under this very id. Re-running the live body
|
|
1184
|
-
// from the top would call the handler again under it and take that call's cached
|
|
1185
|
-
// expiry for a second observation: the stop rule would be satisfied on paper while the
|
|
1186
|
-
// run had in fact observed one attempt twice. The chain's shape is recoverable without
|
|
1187
|
-
// re-running it, because attempt 0's identity is derivable and its outcome is implied:
|
|
1188
|
-
// the only path that opens attempt 1 is attempt 0 expiring.
|
|
1189
|
-
if (ctx.attempt > 0) {
|
|
1190
|
-
const raw = await handler.checkpoint(finalReq, ctx);
|
|
1191
|
-
return {
|
|
1192
|
-
...raw,
|
|
1193
|
-
attempts: [
|
|
1194
|
-
{ attempt: 0, requestId: attemptId(0), settled: "expired" },
|
|
1195
|
-
{ attempt: ctx.attempt, requestId: ctx.requestId, to: cpTo ?? null, settled: raw.outcome },
|
|
1196
|
-
],
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
const first = await handler.checkpoint(req, ctx);
|
|
1200
|
-
if (first.outcome !== "expired" || onExpiry !== "escalate") {
|
|
1201
|
-
// `ctx.attempt`, not a literal 0. Writing the literal made every recovery relabel the
|
|
1202
|
-
// open attempt as the first one, which erased the hop from the journal and left the
|
|
1203
|
-
// record claiming the escalated mint was the original.
|
|
1204
|
-
return { ...first, attempts: [{ attempt: ctx.attempt, requestId: ctx.requestId, settled: first.outcome }] };
|
|
1205
|
-
}
|
|
1206
|
-
// ESCALATION STAYS INSIDE THIS ENTRY. The program made one call, and the interpreter
|
|
1207
|
-
// owns key allocation, so a second mint must not become a second occurrence. What it
|
|
1208
|
-
// does need is a second IDENTITY, derived from attempt 1 before the mint happens, or a
|
|
1209
|
-
// crash between minting and recording leaves live work nothing in the journal names.
|
|
1210
|
-
//
|
|
1211
|
-
// Name the open attempt on the pending row BEFORE issuing it, index and all.
|
|
1212
|
-
const nextId = attemptId(1);
|
|
1213
|
-
await this.journal.reissueAs(ctx.key, nextId, 1);
|
|
1214
|
-
const second = await handler.checkpoint(finalReq, { ...ctx, requestId: nextId, attempt: 1 });
|
|
1215
|
-
// ONE HOP. An escalation that can escalate again never terminates, so a second expiry
|
|
1216
|
-
// settles as expired and the program decides, exactly as `proceed` would.
|
|
1217
|
-
return {
|
|
1218
|
-
...second,
|
|
1219
|
-
attempts: [
|
|
1220
|
-
{ attempt: 0, requestId: ctx.requestId, settled: "expired" },
|
|
1221
|
-
{ attempt: 1, requestId: nextId, to: cpTo ?? null, settled: second.outcome },
|
|
1222
|
-
],
|
|
1223
|
-
};
|
|
1224
|
-
}, frame)), onExpiry);
|
|
1225
|
-
}
|
|
1226
|
-
case "sleep": {
|
|
1227
|
-
const duration = args[0];
|
|
1228
|
-
parseDuration(duration); // fail at the call, not inside the handler
|
|
1229
|
-
// The duration IS hashed (design 5.12). It determines the recorded fact: a resumed run
|
|
1230
|
-
// reads the elapsed time back through the run clock, so editing 1h to 1m must diverge
|
|
1231
|
-
// rather than silently keep the path the old duration chose. This hashed `null` until
|
|
1232
|
-
// critic2 executed it, and the rule it violates is one this lane wrote and then only
|
|
1233
|
-
// ever applied to the document.
|
|
1234
|
-
return await this.performEffect("sleep", stepName ?? "", { duration }, (ctx) => handler.sleep({ duration }, ctx), frame);
|
|
1235
|
-
}
|
|
1236
|
-
case "wait": {
|
|
1237
|
-
const event = deepFreeze(args[0]);
|
|
1238
|
-
const timeout = this.option(bag, "timeout");
|
|
1239
|
-
// A `wait` that resolved null did not observe "the event never happens": it observed "the
|
|
1240
|
-
// event did not happen WITHIN THIS TIMEOUT". Editing the timeout therefore asks a different
|
|
1241
|
-
// question, and replaying the recorded null answers the old one. This is the same hole the
|
|
1242
|
-
// checkpoint projection closed, and leaving it open here left `?? recovery` steering off a
|
|
1243
|
-
// stale cutoff.
|
|
1244
|
-
return await this.performEffect("wait", stepName ?? "", { event, timeout: timeout ?? null }, (ctx) => handler.wait({ event, ...(timeout !== undefined ? { timeout } : {}) }, ctx), frame);
|
|
1245
|
-
}
|
|
1246
|
-
case "notify": {
|
|
1247
|
-
const agents = deepFreeze(args[0]);
|
|
1248
|
-
const fact = deepFreeze(args[1]);
|
|
1249
|
-
// THE BOUND, WHERE THE VALUE EXISTS. The validator checks a literal fact exactly and says
|
|
1250
|
-
// so about the computed one; this is the computed one. It is checked BEFORE the entry is
|
|
1251
|
-
// written, so a fact that breaks the bound never reaches a journal, a record, or a
|
|
1252
|
-
// handler — an out-of-bound notice recorded as performed would be laundered bytes with a
|
|
1253
|
-
// durable receipt. An error, never a truncation: a shortened notice still delivers.
|
|
1254
|
-
const violation = notifyFactViolation(fact);
|
|
1255
|
-
if (violation !== null)
|
|
1256
|
-
throw new RuntimeFault("L3043", violation);
|
|
1257
|
-
return await this.performEffect("notify", stepName ?? "", { agents: agents.map((a) => a.agent), fact }, (ctx) => handler.notify({ agents, fact }, ctx), frame);
|
|
1258
|
-
}
|
|
1259
|
-
case "monitor": {
|
|
1260
|
-
const agent = deepFreeze(args[0]);
|
|
1261
|
-
return await this.performEffect("monitor", stepName ?? "", { agent: agent.agent }, (ctx) => handler.monitor({ agent }, ctx), frame);
|
|
1262
|
-
}
|
|
1263
|
-
default:
|
|
1264
|
-
throw new RuntimeFault("L1000", `${name} is not implemented in this interpreter`);
|
|
1265
|
-
}
|
|
800
|
+
return await dispatchPrimitive(this.effects, name, args, frame);
|
|
1266
801
|
}
|
|
1267
802
|
/**
|
|
1268
803
|
* The `branchDigest`, over the arms a settled `race` will never be walked into.
|
|
@@ -1301,6 +836,10 @@ class Interpreter {
|
|
|
1301
836
|
* the same named effect cannot race for a counter, and replay reproduces both regardless of
|
|
1302
837
|
* which one finished first.
|
|
1303
838
|
*/
|
|
839
|
+
/** An options-bag field, read the way the shared scope machinery reads one. */
|
|
840
|
+
option(bag, key) {
|
|
841
|
+
return option(bag, key);
|
|
842
|
+
}
|
|
1304
843
|
async callScope(name, argNodes, env, frame) {
|
|
1305
844
|
const spec = PRIMITIVES[name];
|
|
1306
845
|
if (spec === undefined)
|
|
@@ -1321,501 +860,15 @@ class Interpreter {
|
|
|
1321
860
|
channel: this.option(bag, "channel") ?? null,
|
|
1322
861
|
}
|
|
1323
862
|
: undefined;
|
|
1324
|
-
return await this.
|
|
863
|
+
return await performScope(this.effects, scopeKey, frame, async (ctx, only) => await runScope(this.effects, name, scopeKind, scopeName, occurrence, first,
|
|
864
|
+
// Deferred, so the body is evaluated where it was evaluated before: after the scope's
|
|
865
|
+
// entry has begun, not at this call. `parallel` and `race` never ask for it.
|
|
866
|
+
async () => await this.evaluate(argNodes[1], env, frame), bag, frame, ctx, only), subject,
|
|
1325
867
|
// `race` alone. `parallel` and `fanOut` have no losers — every branch is a winner and the
|
|
1326
868
|
// walk enters all of them — and a `conclave` cannot be walked into at all, so a digest there
|
|
1327
869
|
// would bind arms nothing was ever going to miss.
|
|
1328
870
|
name === "race" ? this.branchDigester(argNodes[0]) : undefined);
|
|
1329
871
|
}
|
|
1330
|
-
/**
|
|
1331
|
-
* A concurrency scope's own journal entry, and what replay does with it.
|
|
1332
|
-
*
|
|
1333
|
-
* The scope is journalled as ONE durable record carrying its outcome, and for a cancelling scope
|
|
1334
|
-
* the intent to cancel its siblings. Without it a replayed `race` re-races: both branches may have
|
|
1335
|
-
* settled before the cancellation reached the loser, so the journal holds two successful branches
|
|
1336
|
-
* and nothing saying which one won, and a replayed run can take the other path and reach a step
|
|
1337
|
-
* that was never recorded.
|
|
1338
|
-
*
|
|
1339
|
-
* A settled scope therefore ENTERS NO BRANCH, and the order below is normative rather than
|
|
1340
|
-
* convenient: account for the subtree first, then discharge the cancellation, and only then
|
|
1341
|
-
* deliver the outcome. Leading with the delivery is the defect — the next program step can share
|
|
1342
|
-
* a worktree with a loser that is still writing.
|
|
1343
|
-
*/
|
|
1344
|
-
async performScope(scopeKey, frame, body, subject,
|
|
1345
|
-
/** The `branchDigest` over a named loser set. Absent where there is nothing to digest. */
|
|
1346
|
-
branchDigest) {
|
|
1347
|
-
const inputHash = digest(subject === undefined
|
|
1348
|
-
? { kind: scopeKey.kind, name: scopeKey.name }
|
|
1349
|
-
: { kind: scopeKey.kind, name: scopeKey.name, subject });
|
|
1350
|
-
const verdict = this.journal.lookup(scopeKey, inputHash);
|
|
1351
|
-
if (verdict.verdict === "diverged") {
|
|
1352
|
-
throw new RunDivergence(stepKeyString(scopeKey), verdict.recordedHash, verdict.programHash);
|
|
1353
|
-
}
|
|
1354
|
-
if (verdict.verdict === "replay" || verdict.verdict === "replay-failed") {
|
|
1355
|
-
const entry = verdict.entry;
|
|
1356
|
-
const endedAt = entry.endedAt ?? this.options.handler.now();
|
|
1357
|
-
// The comparison: `branchDigest` is checked whenever the entry carries one. The scope's own
|
|
1358
|
-
// `inputHash` is `{kind, name}` — an arm's body is not in it — and a settled race is
|
|
1359
|
-
// delivered from this entry without entering a branch, so without this comparison an edit
|
|
1360
|
-
// inside a LOSING arm reaches nothing that could notice it. Both replay paths, not the
|
|
1361
|
-
// migration path alone: a resume of edited source is exactly the case a divergence exists to
|
|
1362
|
-
// make loud, and the run record carries no program hash to have refused it earlier.
|
|
1363
|
-
if (entry.branchDigest !== undefined && branchDigest !== undefined) {
|
|
1364
|
-
const now = branchDigest(entry.cancel?.losers ?? []);
|
|
1365
|
-
if (now !== undefined && now !== entry.branchDigest) {
|
|
1366
|
-
throw new RunDivergence(stepKeyString(scopeKey), entry.branchDigest, now);
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
// A MIGRATION MUST NOT TAKE THE SHORT-CIRCUIT ABOVE.
|
|
1370
|
-
//
|
|
1371
|
-
// Consuming the subtree wholesale is right for a resume — the program hash is unchanged, so
|
|
1372
|
-
// nothing under this scope can have been removed, and the branches were DECIDED rather than
|
|
1373
|
-
// deleted. Under a migration the source HAS changed, and marking every entry beneath the
|
|
1374
|
-
// scope accounted for means an effect the new source removed never reaches `orphans()`: a
|
|
1375
|
-
// resolved human checkpoint inside the winning branch disappears and L5004 never fires. A
|
|
1376
|
-
// silent disappearance whose log line never fires is invisible in the artifact AND in the
|
|
1377
|
-
// trace, which is the worst available failure.
|
|
1378
|
-
//
|
|
1379
|
-
// So the walk enters the RECORDED WINNING branches and runs the ordinary hash and orphan
|
|
1380
|
-
// checks inside them, while the losers — decided, not removed — are accounted for as before.
|
|
1381
|
-
if (this.options.migration === true) {
|
|
1382
|
-
if (subject !== undefined)
|
|
1383
|
-
throw new UnwalkableScope(stepKeyString(scopeKey), "conclave");
|
|
1384
|
-
// A SETTLED SCOPE CARRIES ITS ARM NAMES IN ONE OF TWO PLACES, and reading only the first
|
|
1385
|
-
// is what made a failed scope look like a scope with no arms. `result` holds them when the
|
|
1386
|
-
// scope succeeded; the `branches` FACT holds them when it failed, because `settle` writes
|
|
1387
|
-
// no `result` for a failure.
|
|
1388
|
-
const recorded = entry.result;
|
|
1389
|
-
const branches = recorded?.branches ?? entry.branches ?? [];
|
|
1390
|
-
const losers = new Set(entry.cancel?.losers ?? []);
|
|
1391
|
-
await this.journal.consumeScope(stepKeyString(scopeKey), endedAt, losers);
|
|
1392
|
-
try {
|
|
1393
|
-
await body({
|
|
1394
|
-
key: scopeKey,
|
|
1395
|
-
signal: frame.signal,
|
|
1396
|
-
requestId: entry.requestId ?? requestId(this.options.runId, scopeKey, inputHash),
|
|
1397
|
-
attempt: entry.attempt ?? 0,
|
|
1398
|
-
bind: async () => {
|
|
1399
|
-
throw new UnwalkableScope(stepKeyString(scopeKey), "bind");
|
|
1400
|
-
},
|
|
1401
|
-
}, new Set(branches.filter((b) => !losers.has(b))));
|
|
1402
|
-
}
|
|
1403
|
-
catch (e) {
|
|
1404
|
-
// UNWRAPPED, because the caller of a migration wants the step that diverged and not the
|
|
1405
|
-
// scope that carried it. A live scope wraps a branch's failure so it can record the
|
|
1406
|
-
// cancellation intent with it; a walk records nothing and cancels nobody, so the wrapper
|
|
1407
|
-
// would only hide a `RunDivergence` behind a generic scope fault.
|
|
1408
|
-
throw unwrapScope(e).reason;
|
|
1409
|
-
}
|
|
1410
|
-
if (entry.endedAt !== undefined)
|
|
1411
|
-
frame.clock.advance(entry.endedAt);
|
|
1412
|
-
if (verdict.verdict === "replay-failed") {
|
|
1413
|
-
const e = entry.error;
|
|
1414
|
-
throw new EffectError(e.code, e.kind, e.message, e.detail);
|
|
1415
|
-
}
|
|
1416
|
-
return entry.result.value;
|
|
1417
|
-
}
|
|
1418
|
-
// (1) account for the subtree, settling any loser still pending as cancelled;
|
|
1419
|
-
await this.journal.consumeScope(stepKeyString(scopeKey), endedAt);
|
|
1420
|
-
// (2) the cancellation intent is the driver's to discharge against the world; a journal write
|
|
1421
|
-
// cancels nothing by itself, so an undischarged intent stays visible rather than silently
|
|
1422
|
-
// reading as done.
|
|
1423
|
-
// (3) only now, the outcome.
|
|
1424
|
-
if (entry.endedAt !== undefined)
|
|
1425
|
-
frame.clock.advance(entry.endedAt);
|
|
1426
|
-
if (verdict.verdict === "replay-failed") {
|
|
1427
|
-
const e = entry.error;
|
|
1428
|
-
throw new EffectError(e.code, e.kind, e.message, e.detail);
|
|
1429
|
-
}
|
|
1430
|
-
return entry.result.value;
|
|
1431
|
-
}
|
|
1432
|
-
if (verdict.verdict === "replay-cancelled") {
|
|
1433
|
-
throw new Cancelled("this scope was cancelled on the recorded run");
|
|
1434
|
-
}
|
|
1435
|
-
// `miss` and `pending` alike RE-ENTER the scope: there is no recorded outcome to return, and a
|
|
1436
|
-
// pending scope's losers were never durably cancelled. Settling is idempotent, so the arm that
|
|
1437
|
-
// finishes first wins again — except where the journal already knows better, which is what
|
|
1438
|
-
// `runScope`'s replayed-branch tie-break is for.
|
|
1439
|
-
// A scope that CALLS THE HANDLER owes a durable request id exactly as an effect does, and for
|
|
1440
|
-
// the same reason: a crash between issuing the work and recording who issued it leaves real
|
|
1441
|
-
// work — for `conclave`, a live channel with members joined — that nothing in the journal
|
|
1442
|
-
// names. `subject` marks that scope, because `conclave` is the only one that dispatches from
|
|
1443
|
-
// this path; the other three launch thunks and touch no handler of their own.
|
|
1444
|
-
const dispatches = subject !== undefined;
|
|
1445
|
-
const resume = verdict.verdict === "pending" ? verdict.entry.external : undefined;
|
|
1446
|
-
const recorded = verdict.verdict === "pending" && verdict.entry.requestId !== undefined ? verdict.entry : undefined;
|
|
1447
|
-
const reqId = recorded?.requestId ?? requestId(this.options.runId, scopeKey, inputHash);
|
|
1448
|
-
if (verdict.verdict === "miss") {
|
|
1449
|
-
await this.journal.begin(scopeKey, inputHash, this.options.handler.now(), dispatches ? reqId : undefined);
|
|
1450
|
-
// The same gap as {@link Interpreter.performEffect}'s begin, for the scope that DISPATCHES: a
|
|
1451
|
-
// conclave cancelled while its begin was in flight must not open a channel and join members.
|
|
1452
|
-
// The non-dispatching scopes launch no work of their own — each branch effect re-checks its
|
|
1453
|
-
// own signal — so only the dispatching path re-checks here.
|
|
1454
|
-
if (dispatches && frame.signal.cancelled) {
|
|
1455
|
-
await this.journal.settle(scopeKey, { status: "cancelled" }, frame.clock.now());
|
|
1456
|
-
throw new Cancelled(frame.signal.reason ?? "cancelled");
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
const ctx = {
|
|
1460
|
-
key: scopeKey,
|
|
1461
|
-
signal: frame.signal,
|
|
1462
|
-
requestId: reqId,
|
|
1463
|
-
attempt: recorded?.attempt ?? 0,
|
|
1464
|
-
...(resume !== undefined ? { resume } : {}),
|
|
1465
|
-
bind: async (external) => {
|
|
1466
|
-
await this.journal.bind(scopeKey, external);
|
|
1467
|
-
},
|
|
1468
|
-
};
|
|
1469
|
-
// The same two domains as {@link Interpreter.performEffect}, for the same reason: a scope whose
|
|
1470
|
-
// branches all succeeded and whose settling append was refused must not be recorded as failed.
|
|
1471
|
-
let outcome;
|
|
1472
|
-
try {
|
|
1473
|
-
outcome = await body(ctx);
|
|
1474
|
-
}
|
|
1475
|
-
catch (raw) {
|
|
1476
|
-
// The interpreter's facts come out of the envelope; the program's thrown value comes out
|
|
1477
|
-
// whole, and is what the caller sees. A value the program threw is never written on.
|
|
1478
|
-
const { reason, facts } = unwrapScope(raw);
|
|
1479
|
-
// THE SCOPE'S CLOCK AT SETTLE, not the host's clock at append. `runScope` joins the branch
|
|
1480
|
-
// clocks before the outcome leaves it, so `frame.clock.now()` here is the greatest `endedAt`
|
|
1481
|
-
// the scope's branches awaited — which is what `now()` answers after the scope, live. Replay
|
|
1482
|
-
// advances the parent clock from this stamp and enters no branch, so stamping anything else
|
|
1483
|
-
// (measured: the handler's clock at append time) makes live and replay disagree on `now()`
|
|
1484
|
-
// after every scope whose last-to-land effect was not the handler's last stamp, and a program
|
|
1485
|
-
// that branches on `now()` takes a path on resume that the live run never took.
|
|
1486
|
-
const endedAt = frame.clock.now();
|
|
1487
|
-
if (reason instanceof JournalAppendRejected)
|
|
1488
|
-
throw reason;
|
|
1489
|
-
// A close that did not acknowledge settles NOTHING. The entry stays pending, which is exactly
|
|
1490
|
-
// what "a close is still owed" looks like in a journal, and the underlying handler error is
|
|
1491
|
-
// what the caller sees.
|
|
1492
|
-
if (reason instanceof CloseOwed)
|
|
1493
|
-
throw reason.reason;
|
|
1494
|
-
if (reason instanceof Cancelled) {
|
|
1495
|
-
await this.journal.settle(scopeKey, { status: "cancelled" }, endedAt, facts);
|
|
1496
|
-
throw reason;
|
|
1497
|
-
}
|
|
1498
|
-
const err = reason instanceof EffectError
|
|
1499
|
-
? {
|
|
1500
|
-
code: reason.code,
|
|
1501
|
-
kind: reason.kind,
|
|
1502
|
-
message: reason.message,
|
|
1503
|
-
...(reason.detail !== undefined ? { detail: reason.detail } : {}),
|
|
1504
|
-
}
|
|
1505
|
-
: { code: "L4000", kind: "scope-fault", message: messageOf(reason) };
|
|
1506
|
-
// A rejecting branch cancels its siblings and can crash before they hear it, so a FAILED scope
|
|
1507
|
-
// carries the intent too — and a conclave that closed says so even when its body failed.
|
|
1508
|
-
await this.journal.settle(scopeKey, { status: "failed", error: err }, endedAt, {
|
|
1509
|
-
...facts,
|
|
1510
|
-
...digestFacts(branchDigest, facts.cancel?.losers),
|
|
1511
|
-
});
|
|
1512
|
-
throw reason;
|
|
1513
|
-
}
|
|
1514
|
-
await this.journal.settle(scopeKey, { status: "ok", result: { branches: outcome.branches, value: deepFreeze(outcome.value) } },
|
|
1515
|
-
// The joined branch clock, for the same reason as the failure path above: this is the value
|
|
1516
|
-
// `now()` answers after the scope, and the stamp replay hands back must be that value.
|
|
1517
|
-
frame.clock.now(), {
|
|
1518
|
-
...(outcome.cancel !== undefined ? { cancel: outcome.cancel } : {}),
|
|
1519
|
-
...(outcome.closed !== undefined ? { closed: outcome.closed } : {}),
|
|
1520
|
-
...digestFacts(branchDigest, outcome.cancel?.losers),
|
|
1521
|
-
});
|
|
1522
|
-
return outcome.value;
|
|
1523
|
-
}
|
|
1524
|
-
async runScope(name, scopeKind, scopeName, occurrence, first, argNodes, bag, env, frame, ctx,
|
|
1525
|
-
/** A migration's walk: enter exactly these branches, the ones the recorded run WON with. */
|
|
1526
|
-
only) {
|
|
1527
|
-
if (name === "parallel" || name === "race") {
|
|
1528
|
-
const all = Array.isArray(first)
|
|
1529
|
-
? first.map((fn, i) => [String(i), fn])
|
|
1530
|
-
: Object.entries(first);
|
|
1531
|
-
const entries = only === undefined ? all : all.filter(([k]) => only.has(k));
|
|
1532
|
-
// THE WALK MUST FIND EVERY ARM IT WAS SENT TO ENTER.
|
|
1533
|
-
//
|
|
1534
|
-
// `only` is the set of RECORDED WINNING branch keys, and the whole "losers only" digest rule
|
|
1535
|
-
// rests on the walk entering the winner: an edit there is supposed to diverge at the step it
|
|
1536
|
-
// broke, which is a strictly better error than "some arm of this race changed". A RENAME
|
|
1537
|
-
// removes the arm, so there is no step left to diverge at and the argument silently stops
|
|
1538
|
-
// holding. What happened instead was worse than a silent pass. `entries` came back empty,
|
|
1539
|
-
// `running` with it, and `Promise.race([])` NEVER SETTLES — a migration or a fork over a
|
|
1540
|
-
// renamed winning arm hung rather than returning any verdict at all. `parallel` did not hang,
|
|
1541
|
-
// because `Promise.all([])` resolves, and handed the program back the recorded value keyed by
|
|
1542
|
-
// the arm the source no longer has.
|
|
1543
|
-
//
|
|
1544
|
-
// Narrow on purpose, and every neighbouring shape already has an answer: a renamed or deleted
|
|
1545
|
-
// LOSER diverges through the branch digest, and an ADDED arm is not an edit to anything
|
|
1546
|
-
// recorded, so neither reaches this.
|
|
1547
|
-
if (only !== undefined) {
|
|
1548
|
-
const present = new Set(all.map(([k]) => k));
|
|
1549
|
-
const missing = [...only].filter((k) => !present.has(k));
|
|
1550
|
-
if (missing.length > 0) {
|
|
1551
|
-
throw new ScopeBranchMissing(stepKeyString(ctx.key), name, missing, [...only], [...present]);
|
|
1552
|
-
}
|
|
1553
|
-
// AND THE EMPTY CASE, which the check above cannot see: with no recorded branches at all,
|
|
1554
|
-
// "every recorded branch is present" is vacuously true, so the guard passed and the walk
|
|
1555
|
-
// still entered nothing and still hung. A guard over an empty set grades nothing and is
|
|
1556
|
-
// green forever. Journals written before scopes recorded their arm names on failure are
|
|
1557
|
-
// exactly that shape, so this refuses them by name instead of hanging on them. It cannot
|
|
1558
|
-
// fire on a scope that has no arms in the source either, because `all` is empty then too.
|
|
1559
|
-
if (only.size === 0 && all.length > 0) {
|
|
1560
|
-
throw new ScopeBranchMissing(stepKeyString(ctx.key), name, [], [], all.map(([k]) => k));
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
const frames = entries.map(([k]) => frame.branch(scopeKind, scopeName, occurrence, k));
|
|
1564
|
-
const running = entries.map(([, fn], i) => fn(frames[i], []));
|
|
1565
|
-
const branches = entries.map(([k]) => k);
|
|
1566
|
-
if (name === "parallel") {
|
|
1567
|
-
let failed = null;
|
|
1568
|
-
const tracked = running.map((p, i) => p.catch((e) => {
|
|
1569
|
-
if (failed === null)
|
|
1570
|
-
failed = entries[i]?.[0];
|
|
1571
|
-
throw e;
|
|
1572
|
-
}));
|
|
1573
|
-
try {
|
|
1574
|
-
const results = await Promise.all(tracked);
|
|
1575
|
-
frame.clock.join(frames.map((f) => f.clock));
|
|
1576
|
-
return {
|
|
1577
|
-
branches,
|
|
1578
|
-
value: Array.isArray(first) ? results : Object.fromEntries(entries.map(([k], i) => [k, results[i]])),
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
catch (e) {
|
|
1582
|
-
// The first rejection cancels the rest, then rethrows. The intent travels WITH the
|
|
1583
|
-
// failure, because a rejecting branch cancels its siblings and can crash before they
|
|
1584
|
-
// hear it, so a failed scope owes its losers exactly as a winning one does.
|
|
1585
|
-
for (const f of frames)
|
|
1586
|
-
f.signal.cancel("a sibling branch failed");
|
|
1587
|
-
await Promise.allSettled(running);
|
|
1588
|
-
frame.clock.join(frames.map((f) => f.clock));
|
|
1589
|
-
const losers = branches.filter((k) => k !== failed);
|
|
1590
|
-
throw new ScopeFailed(e, { branches, cancel: { losers, issued: false } });
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
// race: the earliest to settle wins, and the losers are cancelled BY SEMANTICS, not by an API
|
|
1594
|
-
// the program calls. A cancelled branch performs no new effects; an agent reply already in
|
|
1595
|
-
// flight completes and is ignored, which is the documented answer rather than an accident.
|
|
1596
|
-
// THE WINNER IS THE EARLIEST BRANCH, NOT THE FIRST ONE SCHEDULING HAPPENED TO WAKE.
|
|
1597
|
-
//
|
|
1598
|
-
// An arm's logical settlement time is its branch clock: the max endedAt of the effects it
|
|
1599
|
-
// awaited (the scope's entry clock if it awaited none), which is recorded. The winner is the
|
|
1600
|
-
// least clock among the arms that settled; equal clocks fall to declaration order, which is
|
|
1601
|
-
// recorded too. So the same journal resolves the same arm on every re-entry.
|
|
1602
|
-
//
|
|
1603
|
-
// AND LIVE, NO SCHEDULER AND NO `yieldEvery` VALUE CAN CHOOSE. When an arm settles, every
|
|
1604
|
-
// sibling is cancelled (no new effects, the cancellation law), and each sibling is CUT, pure
|
|
1605
|
-
// work included, only if it can no longer win: its clock is later, or equal and it is declared
|
|
1606
|
-
// later. A sibling that could still win runs its pure work to a settle, and a sibling that
|
|
1607
|
-
// reaches a new effect is cut there, having proven it would end after the settled arm's clock.
|
|
1608
|
-
// Which arms settle is therefore a function of their effects and the declaration order, and
|
|
1609
|
-
// so is the winner. A later settle with an earlier clock re-decides the cut for the rest.
|
|
1610
|
-
// A FAILURE IS A SETTLE, so a rejecting arm is a candidate to win — it just wins by failing
|
|
1611
|
-
// the scope. What is NOT a candidate is a branch that rejected with `Cancelled`, because that
|
|
1612
|
-
// is not an outcome the branch reached, it is what losing did to it. Counting those would let
|
|
1613
|
-
// a loser cut short at an early step outrank the winner that ran longer.
|
|
1614
|
-
// The FRONTIER: the least clock among the arms that have settled as candidates, ties to the
|
|
1615
|
-
// earlier declaration. The cut compares against it in both places below, because it is the
|
|
1616
|
-
// bar an unsettled arm actually has to beat.
|
|
1617
|
-
let bestAt = -1;
|
|
1618
|
-
let bestIndex = -1;
|
|
1619
|
-
const behindFrontier = (j) => {
|
|
1620
|
-
const other = frames[j].clock.now();
|
|
1621
|
-
return !(other < bestAt || (other === bestAt && j < bestIndex));
|
|
1622
|
-
};
|
|
1623
|
-
const onSettle = (i, wasCancelled) => {
|
|
1624
|
-
if (wasCancelled)
|
|
1625
|
-
return;
|
|
1626
|
-
const at = frames[i].clock.now();
|
|
1627
|
-
if (bestIndex === -1 || at < bestAt || (at === bestAt && i < bestIndex)) {
|
|
1628
|
-
bestAt = at;
|
|
1629
|
-
bestIndex = i;
|
|
1630
|
-
}
|
|
1631
|
-
for (let j = 0; j < frames.length; j += 1) {
|
|
1632
|
-
if (j === i)
|
|
1633
|
-
continue;
|
|
1634
|
-
frames[j].signal.cancel("a sibling branch won the race", { cutPure: behindFrontier(j) });
|
|
1635
|
-
}
|
|
1636
|
-
};
|
|
1637
|
-
running.forEach((p, i) => {
|
|
1638
|
-
p.then(() => onSettle(i, false), (e) => onSettle(i, e instanceof Cancelled));
|
|
1639
|
-
});
|
|
1640
|
-
// AND THE CUT IS RE-DECIDED WHEN AN ARM'S OWN CLOCK MOVES. A cancelled arm with an effect
|
|
1641
|
-
// already in flight is allowed to see it land — the work was issued before the cancellation
|
|
1642
|
-
// — but landing advances the arm's clock, and an arm that lands PAST the frontier has just
|
|
1643
|
-
// proven it cannot win. Deciding only at settles left that arm running its pure tail on a
|
|
1644
|
-
// verdict reached from its old clock: measured, an infinite pure tail burned the whole step
|
|
1645
|
-
// budget and killed a run whose race had already settled `ok`, while a resume of the same
|
|
1646
|
-
// journal returned the winner — live and replay disagreeing on the run's outcome. An arm
|
|
1647
|
-
// that lands BEFORE the frontier keeps running, because it can still win (its own cell).
|
|
1648
|
-
frames.forEach((f, j) => {
|
|
1649
|
-
f.clock.onAdvance(() => {
|
|
1650
|
-
if (f.signal.cancelled && !f.signal.cutPure && bestIndex !== -1 && behindFrontier(j)) {
|
|
1651
|
-
f.signal.cancel("a sibling branch won the race", { cutPure: true });
|
|
1652
|
-
}
|
|
1653
|
-
});
|
|
1654
|
-
});
|
|
1655
|
-
// BOTH HANDLERS, and the rejection handler is the whole point. `p.then(() => undefined)`
|
|
1656
|
-
// propagates a rejection, so the first arm to FAIL threw straight out of this await: past the
|
|
1657
|
-
// cancellation, past `allSettled`, and into a scope entry recorded as failed with no losers on
|
|
1658
|
-
// it. The run terminated while a sibling was still performing effects, which is the exact
|
|
1659
|
-
// defect the scope entry exists to prevent. A rejection is a settle.
|
|
1660
|
-
await Promise.race(running.map((p) => p.then(() => undefined, () => undefined)));
|
|
1661
|
-
const settled = await Promise.allSettled(running);
|
|
1662
|
-
// Every arm has settled, so whatever cut it did not get earlier no longer matters; the
|
|
1663
|
-
// signal still says cancelled, which is what a nested branch that outlives this line reads.
|
|
1664
|
-
for (const f of frames)
|
|
1665
|
-
f.signal.cancel("a sibling branch won the race");
|
|
1666
|
-
frame.clock.join(frames.map((f) => f.clock));
|
|
1667
|
-
let winnerAt = -1;
|
|
1668
|
-
let winnerIndex = -1;
|
|
1669
|
-
for (let i = 0; i < settled.length; i += 1) {
|
|
1670
|
-
const r = settled[i];
|
|
1671
|
-
if (r.status === "rejected" && r.reason instanceof Cancelled)
|
|
1672
|
-
continue;
|
|
1673
|
-
const at = frames[i].clock.now();
|
|
1674
|
-
if (winnerIndex === -1 || at < winnerAt) {
|
|
1675
|
-
winnerAt = at;
|
|
1676
|
-
winnerIndex = i;
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
if (winnerIndex === -1) {
|
|
1680
|
-
// Every arm was cancelled, so the race itself was: nothing here decided anything.
|
|
1681
|
-
const first = settled.find((r) => r.status === "rejected");
|
|
1682
|
-
throw first === undefined ? new Cancelled("every branch was cancelled") : first.reason;
|
|
1683
|
-
}
|
|
1684
|
-
const index = entries[winnerIndex]?.[0];
|
|
1685
|
-
const won = settled[winnerIndex];
|
|
1686
|
-
if (won.status === "rejected") {
|
|
1687
|
-
// The earliest branch to settle FAILED. The scope fails with it, carrying the siblings it
|
|
1688
|
-
// cancelled — a losing arm can crash before the cancellation reaches it, so the intent has
|
|
1689
|
-
// to travel with the outcome exactly as it does for a winning race.
|
|
1690
|
-
throw new ScopeFailed(won.reason, {
|
|
1691
|
-
branches,
|
|
1692
|
-
cancel: { losers: branches.filter((k) => k !== index), issued: false },
|
|
1693
|
-
});
|
|
1694
|
-
}
|
|
1695
|
-
return {
|
|
1696
|
-
branches,
|
|
1697
|
-
// BOTH the index and the value. The index alone is not enough: an edit to an arm's returned
|
|
1698
|
-
// expression would resume as the new value with no divergence raised.
|
|
1699
|
-
value: { index, value: settled[winnerIndex].value },
|
|
1700
|
-
cancel: { losers: branches.filter((k) => k !== index), issued: false },
|
|
1701
|
-
};
|
|
1702
|
-
}
|
|
1703
|
-
if (name === "fanOut") {
|
|
1704
|
-
const items = first;
|
|
1705
|
-
const fn = (await this.evaluate(argNodes[1], env, frame));
|
|
1706
|
-
const keyFn = this.option(bag, "key");
|
|
1707
|
-
const branchKeys = [];
|
|
1708
|
-
for (let i = 0; i < items.length; i += 1) {
|
|
1709
|
-
const item = items[i];
|
|
1710
|
-
let k;
|
|
1711
|
-
if (keyFn !== undefined)
|
|
1712
|
-
k = await keyFn(frame, [item]);
|
|
1713
|
-
else if (item !== null && typeof item === "object" && typeof item.id === "string") {
|
|
1714
|
-
k = item.id;
|
|
1715
|
-
}
|
|
1716
|
-
else {
|
|
1717
|
-
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.`);
|
|
1718
|
-
}
|
|
1719
|
-
branchKeys.push(String(k));
|
|
1720
|
-
}
|
|
1721
|
-
if (new Set(branchKeys).size !== branchKeys.length) {
|
|
1722
|
-
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.`);
|
|
1723
|
-
}
|
|
1724
|
-
const frames = branchKeys.map((k) => frame.branch(scopeKind, scopeName, occurrence, k));
|
|
1725
|
-
// A fanOut has no losers: every branch is a winner, so a migration's walk enters the ones the
|
|
1726
|
-
// recorded run actually had. A branch the new source no longer produces is simply not walked,
|
|
1727
|
-
// and its entries surface as orphans — which is the whole point of walking rather than
|
|
1728
|
-
// consuming.
|
|
1729
|
-
const walk = items
|
|
1730
|
-
.map((item, i) => [item, i])
|
|
1731
|
-
.filter(([, i]) => only === undefined || only.has(branchKeys[i]));
|
|
1732
|
-
// The same failure law as `parallel`: the first rejection cancels the siblings and the scope
|
|
1733
|
-
// fails with it, carrying the losers. Measured before this block: a rejecting branch threw out
|
|
1734
|
-
// of `Promise.all` alone, and every sibling went on performing effects against a scope whose
|
|
1735
|
-
// entry had already settled failed.
|
|
1736
|
-
let failed = null;
|
|
1737
|
-
const launched = walk.map(([item, i]) => fn(frames[i], [item, i]).catch((e) => {
|
|
1738
|
-
if (failed === null)
|
|
1739
|
-
failed = branchKeys[i];
|
|
1740
|
-
throw e;
|
|
1741
|
-
}));
|
|
1742
|
-
try {
|
|
1743
|
-
const results = await Promise.all(launched);
|
|
1744
|
-
frame.clock.join(frames.map((f) => f.clock));
|
|
1745
|
-
return { branches: branchKeys, value: results };
|
|
1746
|
-
}
|
|
1747
|
-
catch (e) {
|
|
1748
|
-
for (const f of frames)
|
|
1749
|
-
f.signal.cancel("a sibling branch failed");
|
|
1750
|
-
await Promise.allSettled(launched);
|
|
1751
|
-
frame.clock.join(frames.map((f) => f.clock));
|
|
1752
|
-
const losers = branchKeys.filter((k) => k !== failed);
|
|
1753
|
-
throw new ScopeFailed(e, { branches: branchKeys, cancel: { losers, issued: false } });
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
if (name === "conclave") {
|
|
1757
|
-
// A conclave is a scope AND an effect, and it gets ONE entry, of kind `conclave`, carrying
|
|
1758
|
-
// the durable answer to "is this sub-team still live". That answer is the explicit `closed`
|
|
1759
|
-
// FACT, not the entry's state: a body that failed after a clean close settles `failed`
|
|
1760
|
-
// exactly like one whose close never acknowledged, and only the fact separates them. Pending
|
|
1761
|
-
// means a close is still owed. The migrate table reads that fact — an orphaned conclave is
|
|
1762
|
-
// rejected unless the scope closed — so a second entry for the close would be a second thing
|
|
1763
|
-
// to keep in agreement with the first, and nothing needs it.
|
|
1764
|
-
const members = deepFreeze(first);
|
|
1765
|
-
const fn = (await this.evaluate(argNodes[1], env, frame));
|
|
1766
|
-
const channel = this.option(bag, "channel");
|
|
1767
|
-
const req = { members, ...(channel !== undefined ? { channel } : {}) };
|
|
1768
|
-
const handler = this.options.handler;
|
|
1769
|
-
const handle = deepFreeze(await handler.openConclave(req, ctx));
|
|
1770
|
-
// One body, one branch, and the branch key is the fixed literal `in` rather than the channel
|
|
1771
|
-
// name. The channel is HANDLER-DERIVED — the simulator and the mesh mint different ones — so
|
|
1772
|
-
// keying the journal namespace by it would make a journal replayable only under the handler
|
|
1773
|
-
// that wrote it, which is the one thing the effect seam exists to prevent.
|
|
1774
|
-
// ONE constant, used for both the namespace and the recorded branch list, so the entry cannot
|
|
1775
|
-
// claim a key the body's steps were not actually filed under.
|
|
1776
|
-
const branchKey = "in";
|
|
1777
|
-
const branch = frame.branch(scopeKind, scopeName, occurrence, branchKey);
|
|
1778
|
-
// The body's outcome is decided FIRST, alone. The close is a separate act with a separate
|
|
1779
|
-
// failure mode, and folding it into this try is what made a close rejection retry itself and
|
|
1780
|
-
// then settle as an ordinary body failure — a `failed` entry indistinguishable from "the body
|
|
1781
|
-
// failed and the room closed cleanly", which an orphan walk reads as closed while the members
|
|
1782
|
-
// are still joined.
|
|
1783
|
-
// `threw` is a separate flag rather than `bodyError !== undefined`, because `throw undefined`
|
|
1784
|
-
// is a thing a program may do and "the body failed" must not depend on what it failed WITH.
|
|
1785
|
-
let bodyError;
|
|
1786
|
-
let threw = false;
|
|
1787
|
-
let value;
|
|
1788
|
-
try {
|
|
1789
|
-
value = await fn(branch, [handle]);
|
|
1790
|
-
}
|
|
1791
|
-
catch (e) {
|
|
1792
|
-
bodyError = e;
|
|
1793
|
-
threw = true;
|
|
1794
|
-
}
|
|
1795
|
-
frame.clock.join([branch.clock]);
|
|
1796
|
-
// A CANCELLED branch performs no new effects, so a cancelled conclave does not close
|
|
1797
|
-
// itself: releasing the membership travels the same recovery path as every other branch-local
|
|
1798
|
-
// resource a race loser took. A conclave whose body merely FAILED is not cancelled —
|
|
1799
|
-
// this process is live and the world is reachable — and walking away from live membership on
|
|
1800
|
-
// an ordinary error would be the `spawn` leak in another shape.
|
|
1801
|
-
if (bodyError instanceof Cancelled)
|
|
1802
|
-
throw new ScopeFailed(bodyError, { closed: false });
|
|
1803
|
-
try {
|
|
1804
|
-
await handler.closeConclave(req, ctx);
|
|
1805
|
-
}
|
|
1806
|
-
catch (e) {
|
|
1807
|
-
// THE CLOSE DID NOT ACKNOWLEDGE, so the scope does not settle at all. A pending entry IS
|
|
1808
|
-
// the durable "a close is still owed" — re-entry retries it — and settling anything here
|
|
1809
|
-
// would be the journal claiming a disposition the world never confirmed. The body's own
|
|
1810
|
-
// error, if there was one, is subordinate: it did not leave members joined; this did.
|
|
1811
|
-
throw new CloseOwed(e);
|
|
1812
|
-
}
|
|
1813
|
-
if (threw)
|
|
1814
|
-
throw new ScopeFailed(bodyError, { closed: true });
|
|
1815
|
-
return { branches: [branchKey], value, closed: true };
|
|
1816
|
-
}
|
|
1817
|
-
throw new RuntimeFault("L1000", `${name} is not implemented in this interpreter`);
|
|
1818
|
-
}
|
|
1819
872
|
// ---- statements --------------------------------------------------------------------------------
|
|
1820
873
|
async executeBlock(block, env, frame) {
|
|
1821
874
|
const inner = new Env(env);
|
|
@@ -2240,7 +1293,9 @@ export async function run(source, options) {
|
|
|
2240
1293
|
+ `written for it — silently, because neither the clock nor a pure draw is a recorded fact the replay could diverge on.\n\n`
|
|
2241
1294
|
+ `Options\n pass the pins from the run record\n start a fresh run instead of resuming this journal`);
|
|
2242
1295
|
}
|
|
2243
|
-
const pins = options.pins !== undefined
|
|
1296
|
+
const pins = options.pins !== undefined
|
|
1297
|
+
? bindPins(options.pins, options, WALKER_LANGUAGE_VERSION)
|
|
1298
|
+
: resolvePins(options, options.handler.now(), WALKER_LANGUAGE_VERSION);
|
|
2244
1299
|
const interp = new Interpreter(ast, options, programHash, pins);
|
|
2245
1300
|
// The run clock starts at the run's LOGICAL epoch, not at this host's clock: a run resumed on
|
|
2246
1301
|
// another machine hours later must see the same `now()` before its first effect as the run that
|
|
@@ -2267,24 +1322,11 @@ function installGlobals(env, interp) {
|
|
|
2267
1322
|
// The value names. `undefined` is a value the runtime produces, so a program can name it.
|
|
2268
1323
|
for (const name of VALUE_NAMES)
|
|
2269
1324
|
env.declare(name, undefined, false);
|
|
2270
|
-
// Pure primitives
|
|
2271
|
-
// Handles are opaque frozen records the runtime mints
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
env.declare("replied", fn((_f, a) => deepFreeze({ event: "replied", agent: a[0].agent })), false);
|
|
2276
|
-
env.declare("message", fn((_f, a) => {
|
|
2277
|
-
const ch = a[0].channel;
|
|
2278
|
-
const opts = (a[1] ?? {});
|
|
2279
|
-
return deepFreeze({
|
|
2280
|
-
event: "message",
|
|
2281
|
-
channel: ch,
|
|
2282
|
-
...(opts.from !== undefined ? { from: opts.from.agent } : {}),
|
|
2283
|
-
...(opts.matches !== undefined ? { matches: opts.matches } : {}),
|
|
2284
|
-
});
|
|
2285
|
-
}), false);
|
|
2286
|
-
env.declare("idle", fn((_f, a) => deepFreeze({ event: "idle", channel: a[0].channel, duration: a[1] })), false);
|
|
2287
|
-
env.declare("down", fn((_f, a) => deepFreeze({ event: "down", agent: a[0].agent })), false);
|
|
1325
|
+
// Pure primitives and event constructors: ONE shared table (perform.ts), wrapped into the
|
|
1326
|
+
// walker's calling convention here. Handles are opaque frozen records the runtime mints
|
|
1327
|
+
// (design §4); event constructors are pure descriptors, and awaiting one is `wait`.
|
|
1328
|
+
for (const [name, impl] of freeConstructors({ runId: interp.options.runId, programHash: interp.programHash, startedAt: interp.pins.startedAt }))
|
|
1329
|
+
env.declare(name, fn((_f, a) => impl(a)), false);
|
|
2288
1330
|
// The builtin library (design §4), one table in library.ts.
|
|
2289
1331
|
for (const [name, value] of builtins(interp.libraryContext()))
|
|
2290
1332
|
env.declare(name, value, false);
|