@cotal-ai/lang 0.48.2 → 0.50.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/dist/dryrun.d.ts +30 -1
- package/dist/dryrun.d.ts.map +1 -1
- package/dist/dryrun.js +29 -0
- package/dist/dryrun.js.map +1 -1
- package/dist/effects.d.ts +72 -0
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js.map +1 -1
- package/dist/engine/ctx.d.ts.map +1 -1
- package/dist/engine/ctx.js +19 -0
- package/dist/engine/ctx.js.map +1 -1
- package/dist/engine/frame.d.ts +2 -2
- package/dist/engine/frame.d.ts.map +1 -1
- package/dist/engine/frame.js.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +49 -0
- package/dist/errors.js.map +1 -1
- package/dist/grammar.d.ts.map +1 -1
- package/dist/grammar.js +50 -0
- package/dist/grammar.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interpret.js.map +1 -1
- package/dist/journal.d.ts +46 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +50 -0
- package/dist/journal.js.map +1 -1
- package/dist/keys.d.ts +23 -3
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js.map +1 -1
- package/dist/perform.d.ts +7 -3
- package/dist/perform.d.ts.map +1 -1
- package/dist/perform.js +376 -4
- package/dist/perform.js.map +1 -1
- package/dist/primitives.d.ts +20 -1
- package/dist/primitives.d.ts.map +1 -1
- package/dist/primitives.js +34 -3
- package/dist/primitives.js.map +1 -1
- package/dist/sim.d.ts +16 -1
- package/dist/sim.d.ts.map +1 -1
- package/dist/sim.js +25 -0
- package/dist/sim.js.map +1 -1
- package/dist/transform/emit.d.ts.map +1 -1
- package/dist/transform/emit.js +7 -0
- package/dist/transform/emit.js.map +1 -1
- package/package.json +1 -1
package/dist/perform.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* calls it directly. ONE function over ONE table: a second copy of a projection would be a
|
|
9
9
|
* divergence the differential suite could only find program-by-program.
|
|
10
10
|
*/
|
|
11
|
-
import { InterpreterDefect, RunDivergence, RuntimeFault, ScopeBranchMissing, UnwalkableScope, messageOf } from "./errors.js";
|
|
11
|
+
import { InterpreterDefect, RunDivergence, RuntimeFault, ScopeBranchMissing, UnwalkableScope, messageOf, stackOf } from "./errors.js";
|
|
12
12
|
import { digest, requestId, stepKeyString } from "./keys.js";
|
|
13
13
|
import { Journal, JournalAppendRejected, RunClock } from "./journal.js";
|
|
14
14
|
import { NotCrossable, assertCrossable, assertScopeValueCrossable, deepFreeze } from "./values.js";
|
|
@@ -19,6 +19,54 @@ import { Cancelled, RunReleased, EffectRefused, RunHeld, EffectError, applyCheck
|
|
|
19
19
|
export function option(bag, key) {
|
|
20
20
|
return bag === null || typeof bag !== "object" ? undefined : bag[key];
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Is this value a `spawn` placement: an `{ endpoint, instanceId }` pair of non-empty strings?
|
|
24
|
+
*
|
|
25
|
+
* THE VALUE, NOT THE BAG. {@link option} answers `undefined` when the BAG is null or is not an
|
|
26
|
+
* object, which is the right guard for a missing option bag and no guard at all on what the bag
|
|
27
|
+
* holds: `{ placement: null }` is a perfectly good object whose `placement` is `null`, and it
|
|
28
|
+
* came back as `null`, passed a `!== undefined` test, and was dereferenced.
|
|
29
|
+
*
|
|
30
|
+
* An array is refused along with every other non-record: `typeof [] === "object"`, so an array
|
|
31
|
+
* reaches `.endpoint` as `undefined` rather than as an error, and `["manager", "i1"]` is exactly
|
|
32
|
+
* the shape an author writes when they have guessed the pair is positional.
|
|
33
|
+
*
|
|
34
|
+
* Emptiness is part of the shape rather than a separate rule, and it matches what the runtime
|
|
35
|
+
* already refuses at mesh-handler (L4000, "must name both an endpoint and an instanceId"): an
|
|
36
|
+
* empty string names no instance, and a placement that names no instance cannot pin a seat.
|
|
37
|
+
*/
|
|
38
|
+
function isPlacementPair(v) {
|
|
39
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
40
|
+
return false;
|
|
41
|
+
const r = v;
|
|
42
|
+
return typeof r.endpoint === "string" && r.endpoint.length > 0
|
|
43
|
+
&& typeof r.instanceId === "string" && r.instanceId.length > 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* How a malformed placement is named back to its author.
|
|
47
|
+
*
|
|
48
|
+
* `JSON.stringify` alone is not enough: it answers `undefined` for `undefined` and for a function,
|
|
49
|
+
* so the message would read "it was given undefined" for three different mistakes and would
|
|
50
|
+
* interpolate the literal word into the sentence. Each wrong shape is named as what it IS, and a
|
|
51
|
+
* record is shown with the keys it actually carried, because the commonest case is a pair with one
|
|
52
|
+
* half missing and the author needs to see which half.
|
|
53
|
+
*/
|
|
54
|
+
function describePlacement(v) {
|
|
55
|
+
if (v === null)
|
|
56
|
+
return "null";
|
|
57
|
+
if (Array.isArray(v))
|
|
58
|
+
return `an array (${JSON.stringify(v)}); placement is a record, not a positional pair`;
|
|
59
|
+
if (typeof v === "function")
|
|
60
|
+
return "a function";
|
|
61
|
+
if (typeof v !== "object")
|
|
62
|
+
return `the ${typeof v} ${JSON.stringify(v) ?? String(v)}`;
|
|
63
|
+
const r = v;
|
|
64
|
+
const names = Object.keys(r);
|
|
65
|
+
const missing = ["endpoint", "instanceId"].filter((k) => typeof r[k] !== "string" || r[k].length === 0);
|
|
66
|
+
return names.length === 0
|
|
67
|
+
? "an empty record, which names neither an endpoint nor an instanceId"
|
|
68
|
+
: `${JSON.stringify(r)}, which is missing ${missing.join(" and ")}`;
|
|
69
|
+
}
|
|
22
70
|
/**
|
|
23
71
|
* The per-run turn queues, keyed by agent identity (§6.5: one agent, one turn at a time).
|
|
24
72
|
*
|
|
@@ -231,9 +279,17 @@ export async function performEffect(host, kind, name, hashedInput, perform, fram
|
|
|
231
279
|
const raised = e?.code;
|
|
232
280
|
const carried = typeof raised === "string" && /^L\d{4}$/.test(raised) ? raised : null;
|
|
233
281
|
const recorded = e instanceof EffectError ? recordableError(e, "handler-fault") : undefined;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
282
|
+
// THE STACK IS THE ONLY FIELD THAT NAMES THE HOST CODE. A handler fault happens outside both the
|
|
283
|
+
// program and the language, so `message` alone ("timeout") is the symptom with no origin, and
|
|
284
|
+
// the durable entry is usually the only look anyone gets at it. Read defensively, by the same
|
|
285
|
+
// rule as `messageOf` one line up: a primitive throw carries no stack and none is recorded.
|
|
286
|
+
const stack = stackOf(e);
|
|
287
|
+
const error = {
|
|
288
|
+
...(recorded !== undefined
|
|
289
|
+
? recorded.error
|
|
290
|
+
: { code: carried ?? "L4000", kind: "handler-fault", message: messageOf(e) }),
|
|
291
|
+
...(stack !== undefined ? { stack } : {}),
|
|
292
|
+
};
|
|
237
293
|
await host.journal.settle(key, { status: "failed", error }, endedAt);
|
|
238
294
|
frame.clock.advance(endedAt);
|
|
239
295
|
// THE CALLER AND THE RECORD SAY THE SAME THING. Rethrowing the handler's own error unchanged is
|
|
@@ -246,10 +302,213 @@ export async function performEffect(host, kind, name, hashedInput, perform, fram
|
|
|
246
302
|
frame.clock.advance(endedAt);
|
|
247
303
|
return result;
|
|
248
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* `waitUntil`: the durable wait on something that is not a mesh event.
|
|
307
|
+
*
|
|
308
|
+
* IT DOES NOT GO THROUGH {@link performEffect}, and that is the whole point rather than an
|
|
309
|
+
* exemption. `performEffect` is built around a single dispatch whose outcome SETTLES the entry, and
|
|
310
|
+
* `lookup` serves a settled entry by handing `result` back with no handler call at all. Run a
|
|
311
|
+
* poll through that and the first observation becomes the step's answer forever: measured on this
|
|
312
|
+
* exact shape before this function existed, a run that observed `"pending"` once replayed
|
|
313
|
+
* `"pending"` on every later activation, for a resource that had since completed, and the handler
|
|
314
|
+
* was never called again. That is #1459's sharp half, and no amount of care inside a handler can
|
|
315
|
+
* reach it, because the handler is not invited.
|
|
316
|
+
*
|
|
317
|
+
* So the durable shape is different in one specific way: a NON-TERMINAL observation is appended to
|
|
318
|
+
* the entry with {@link Journal.observe} and the entry STAYS PENDING. `lookup` answers `pending`
|
|
319
|
+
* for it, which routes to the live path, so a resumed run OBSERVES AGAIN. Only the terminal
|
|
320
|
+
* observation settles, because only that one is an answer. Everything else about the entry is the
|
|
321
|
+
* ordinary contract: it begins before the first observation, it carries the request id a handler
|
|
322
|
+
* waits under, and the two failure domains stay separate.
|
|
323
|
+
*
|
|
324
|
+
* WHO OWNS WHAT. The program owns the probe and the predicate; the runtime owns the cadence and
|
|
325
|
+
* the deadline. A program that hand-rolled this with `sleep` in a loop owns all four, which is the
|
|
326
|
+
* duplication §6 complains about, and it still could not re-observe: its `sleep` and its poll are
|
|
327
|
+
* two settled steps, so the poll's recorded answer is replayed exactly as before.
|
|
328
|
+
*/
|
|
329
|
+
async function performWaitUntil(host, name, hashedInput, probe, terminal, every, deadline, frame) {
|
|
330
|
+
const key = frame.keys.nextEffect("waitUntil", name);
|
|
331
|
+
const inputHash = digest(hashedInput ?? null);
|
|
332
|
+
const verdict = host.journal.lookup(key, inputHash);
|
|
333
|
+
switch (verdict.verdict) {
|
|
334
|
+
// A SETTLED `waitUntil` replays like any other step, and it must: what settled it was the
|
|
335
|
+
// TERMINAL observation, which IS an answer, and re-observing a wait that already finished
|
|
336
|
+
// would re-ask a question the run has answered. Only the unfinished ones re-observe.
|
|
337
|
+
case "replay":
|
|
338
|
+
if (verdict.entry.endedAt !== undefined)
|
|
339
|
+
frame.clock.advance(verdict.entry.endedAt);
|
|
340
|
+
return verdict.entry.result;
|
|
341
|
+
case "replay-failed": {
|
|
342
|
+
if (verdict.entry.endedAt !== undefined)
|
|
343
|
+
frame.clock.advance(verdict.entry.endedAt);
|
|
344
|
+
const e = verdict.entry.error;
|
|
345
|
+
throw new EffectError(e.code, e.kind, e.message, e.detail);
|
|
346
|
+
}
|
|
347
|
+
case "replay-cancelled":
|
|
348
|
+
throw new Cancelled("this branch was cancelled on the recorded run");
|
|
349
|
+
case "diverged":
|
|
350
|
+
throw new RunDivergence(stepKeyString(key), verdict.recordedHash, verdict.programHash);
|
|
351
|
+
case "refused":
|
|
352
|
+
case "pending":
|
|
353
|
+
case "miss":
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
if (frame.signal.cancelled)
|
|
357
|
+
throw new Cancelled(frame.signal.reason ?? "cancelled");
|
|
358
|
+
const stop = host.options.shouldStop?.();
|
|
359
|
+
if (stop !== undefined)
|
|
360
|
+
throw new RunReleased(stop);
|
|
361
|
+
host.effectCount += 1;
|
|
362
|
+
if (host.effectCount > host.ceiling) {
|
|
363
|
+
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.`);
|
|
364
|
+
}
|
|
365
|
+
const recorded = verdict.verdict === "pending" ? verdict.entry : undefined;
|
|
366
|
+
const reqId = recorded?.requestId ?? requestId(host.options.runId, key, inputHash);
|
|
367
|
+
if (verdict.verdict === "miss" || verdict.verdict === "refused") {
|
|
368
|
+
await host.journal.begin(key, inputHash, host.options.handler.now(), reqId);
|
|
369
|
+
if (frame.signal.cancelled) {
|
|
370
|
+
await host.journal.settle(key, { status: "cancelled" }, host.options.handler.now());
|
|
371
|
+
throw new Cancelled(frame.signal.reason ?? "cancelled");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// THE DEADLINE IS ABSOLUTE AND IT IS READ BACK, never recomputed from a clock that has moved.
|
|
375
|
+
// Recomputing `now + deadline` on every activation is how an hour-long wait becomes immortal:
|
|
376
|
+
// each crash would hand it a fresh hour, and a run that should have given up at 11:00 goes on
|
|
377
|
+
// waiting forever, which is the same class of defect as the one this primitive fixes. The
|
|
378
|
+
// entry's own `startedAt` is the epoch, and it survives because the entry does.
|
|
379
|
+
const startedAt = host.journal.get(key)?.startedAt ?? host.options.handler.now();
|
|
380
|
+
const deadlineAt = startedAt + parseDuration(deadline);
|
|
381
|
+
let attempt = (recorded?.observations ?? []).length;
|
|
382
|
+
for (;;) {
|
|
383
|
+
const ctx = {
|
|
384
|
+
key,
|
|
385
|
+
signal: frame.signal,
|
|
386
|
+
requestId: reqId,
|
|
387
|
+
attempt,
|
|
388
|
+
...(recorded?.external !== undefined ? { resume: recorded.external } : {}),
|
|
389
|
+
bind: async (external) => {
|
|
390
|
+
assertCrossable(external, `the binding of ${stepKeyString(key)}`);
|
|
391
|
+
await host.journal.bind(key, external);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
// EACH OBSERVATION GETS ITS OWN KEY NAMESPACE, and this is the half of the fix that is easy
|
|
395
|
+
// to miss. The probe reaches the world by performing effects, and those effects allocate keys
|
|
396
|
+
// in the frame it runs in. Share one namespace across observations and observation 2's probe
|
|
397
|
+
// allocates the key observation 1's probe already settled: the journal answers `replay`, the
|
|
398
|
+
// probe is handed back the FIRST look's result, and the wait re-observes while its probe does
|
|
399
|
+
// not. The re-observation would be a ritual. A per-observation branch makes each look a
|
|
400
|
+
// namespace nothing has written to, so a resumed wait's next look runs live.
|
|
401
|
+
const observing = frame.branch("waitUntil", name, key.occurrence, String(attempt));
|
|
402
|
+
// THE CADENCE IS THE HANDLER'S, and the first observation skips it: a wait that sleeps before
|
|
403
|
+
// it has ever looked is a wait that cannot notice a predicate which already holds, and the
|
|
404
|
+
// canonical use ("are the checks done") is very often already true by the time the run asks.
|
|
405
|
+
// The request carries the cadence either way and `attempt` is what says to skip it, so the
|
|
406
|
+
// fact never has to go missing to be communicated.
|
|
407
|
+
let inTime;
|
|
408
|
+
try {
|
|
409
|
+
inTime = await host.options.handler.observe({ name, every, deadlineAt, deadline, attempt }, ctx);
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
const endedAt = host.options.handler.now();
|
|
413
|
+
if (e instanceof JournalAppendRejected)
|
|
414
|
+
throw e;
|
|
415
|
+
// A refusal is not a failure here either: nothing was observed, so the entry settles
|
|
416
|
+
// `refused` and a capable host picks the wait up exactly where it stands, with the
|
|
417
|
+
// observations it has already made intact.
|
|
418
|
+
if (e instanceof EffectRefused) {
|
|
419
|
+
await host.journal.settle(key, { status: "refused", error: { code: e.code, kind: "refused", message: e.message } }, endedAt);
|
|
420
|
+
throw new RunHeld(stepKeyString(key), e.message);
|
|
421
|
+
}
|
|
422
|
+
if (e instanceof Cancelled) {
|
|
423
|
+
await host.journal.settle(key, { status: "cancelled" }, endedAt);
|
|
424
|
+
throw e;
|
|
425
|
+
}
|
|
426
|
+
const raised = e?.code;
|
|
427
|
+
const carried = typeof raised === "string" && /^L\d{4}$/.test(raised) ? raised : null;
|
|
428
|
+
const rec = e instanceof EffectError ? recordableError(e, "handler-fault") : undefined;
|
|
429
|
+
// The same rule as the effect site above: the probe is other people's code too.
|
|
430
|
+
const stack = stackOf(e);
|
|
431
|
+
const error = {
|
|
432
|
+
...(rec !== undefined ? rec.error : { code: carried ?? "L4000", kind: "handler-fault", message: messageOf(e) }),
|
|
433
|
+
...(stack !== undefined ? { stack } : {}),
|
|
434
|
+
};
|
|
435
|
+
await host.journal.settle(key, { status: "failed", error }, endedAt);
|
|
436
|
+
frame.clock.advance(endedAt);
|
|
437
|
+
throw rec?.faithful === true ? e : new EffectError(error.code, error.kind, error.message);
|
|
438
|
+
}
|
|
439
|
+
if (!inTime) {
|
|
440
|
+
// THE DEADLINE, and it is CATCHABLE with its own code, exactly as `turn`'s L4003 is. A wait
|
|
441
|
+
// that gave up is a fact about the world the program asked about, so the program decides
|
|
442
|
+
// what to do next; it is not a fault of the run's. The failure settles the entry, carrying
|
|
443
|
+
// how many times it looked, because "I observed 60 times over an hour and it never held" is
|
|
444
|
+
// the sentence whoever reads this needs.
|
|
445
|
+
const endedAt = host.options.handler.now();
|
|
446
|
+
const looked = (host.journal.get(key)?.observations ?? []).length;
|
|
447
|
+
const error = {
|
|
448
|
+
code: "L4023",
|
|
449
|
+
kind: "wait-until-deadline",
|
|
450
|
+
message: `L4023 \`waitUntil\` deadline elapsed\n\n step ${stepKeyString(key)} ${looked} observation${looked === 1 ? "" : "s"} over ${deadline}\n\n`
|
|
451
|
+
+ `The predicate never held: every observation this wait made was non-terminal, and the deadline passed.\n\n`
|
|
452
|
+
+ `Options\n catch it: \`e.code === "L4023"\` and chase, escalate, or proceed degraded\n raise \`deadline\`, or widen \`terminal\` if a state you meant to accept is being read as still-pending`,
|
|
453
|
+
detail: { observations: looked, deadline, every },
|
|
454
|
+
};
|
|
455
|
+
await host.journal.settle(key, { status: "failed", error }, endedAt);
|
|
456
|
+
frame.clock.advance(endedAt);
|
|
457
|
+
throw new EffectError(error.code, error.kind, error.message, error.detail);
|
|
458
|
+
}
|
|
459
|
+
// THE PROBE IS THE PROGRAM'S OWN CODE, called by the runtime. Its answer is refused here if it
|
|
460
|
+
// has no canonical form, with its own code: it is about to be journalled AND handed back to
|
|
461
|
+
// the program, so the two things a recorded value must be able to do are exactly what it
|
|
462
|
+
// cannot do. Blamed as L4024 rather than L3041 because nothing crossed a boundary at a CALL —
|
|
463
|
+
// a function the runtime invoked answered the wrong shape, and the repair differs.
|
|
464
|
+
const observation = await probe(observing);
|
|
465
|
+
try {
|
|
466
|
+
assertCrossable(observation, `the observation of ${stepKeyString(key)}`);
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
if (!(e instanceof NotCrossable))
|
|
470
|
+
throw e;
|
|
471
|
+
throw new RuntimeFault("L4024", `this \`waitUntil\`'s probe answered with a value that cannot be recorded: ${e.message}. Every observation is journalled as history and handed to \`terminal\`, so it has to be data. Return what you read (a status string, a record of counts), not the object you read it from.`);
|
|
472
|
+
}
|
|
473
|
+
// THE PREDICATE IS THE PROGRAM'S TOO, and it decides the ONE thing the runtime cannot: whether
|
|
474
|
+
// this observation is an answer. Absent, any observation that is not `null` is terminal, which
|
|
475
|
+
// makes the no-predicate form mean "wait until there is something", and a probe that answers
|
|
476
|
+
// null while it waits is the idiom that shape serves.
|
|
477
|
+
const decided = terminal === undefined ? observation !== null : await terminal(observing, observation);
|
|
478
|
+
if (typeof decided !== "boolean") {
|
|
479
|
+
throw new RuntimeFault("L4024", `this \`waitUntil\`'s \`terminal\` answered ${JSON.stringify(decided) ?? "undefined"} rather than true or false. It decides whether an observation ENDS the wait, so a value that is merely truthy is not enough: a predicate that accidentally returns a record would end every wait on its first look. Return a comparison.`);
|
|
480
|
+
}
|
|
481
|
+
const at = host.options.handler.now();
|
|
482
|
+
// The wait's own clock carries what its observations awaited: an observation is an effect this
|
|
483
|
+
// step performed, so the time it consumed is time this step consumed.
|
|
484
|
+
frame.clock.join([observing.clock]);
|
|
485
|
+
if (decided) {
|
|
486
|
+
// THE TERMINAL OBSERVATION IS THE ANSWER, so it settles, and it is recorded in BOTH places
|
|
487
|
+
// on purpose. `result` is what a later activation replays, which is correct now that the
|
|
488
|
+
// wait is over; `observations` keeps the whole history, so the record says how long the
|
|
489
|
+
// world took to get there rather than only where it ended up.
|
|
490
|
+
await host.journal.observe(key, observation, at);
|
|
491
|
+
await host.journal.settle(key, { status: "ok", result: deepFreeze(observation) }, at);
|
|
492
|
+
frame.clock.advance(at);
|
|
493
|
+
return observation;
|
|
494
|
+
}
|
|
495
|
+
// NOT AN ANSWER: recorded as HISTORY, and the entry stays PENDING. This single line is what
|
|
496
|
+
// #1459 asks for. A resume finds a pending entry, `lookup` answers `pending`, the live path
|
|
497
|
+
// runs, and the world is observed again — instead of the recorded "pending" being handed back
|
|
498
|
+
// as though it were still true.
|
|
499
|
+
await host.journal.observe(key, observation, at);
|
|
500
|
+
frame.clock.advance(at);
|
|
501
|
+
attempt += 1;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
249
504
|
/**
|
|
250
505
|
* Dispatch one non-scope primitive from evaluated argument VALUES: the crossability refusals, the
|
|
251
506
|
* freeze on share, and the per-primitive hashed projection, ending in {@link performEffect}. The
|
|
252
507
|
* scope-openers never come here: their branches must stay unevaluated.
|
|
508
|
+
*
|
|
509
|
+
* It takes a full {@link Frame} rather than an {@link EffectFrame} because `waitUntil` gives each
|
|
510
|
+
* observation its own key namespace, which needs `branch`. Both engines already pass a real frame;
|
|
511
|
+
* declaring what is actually required is what keeps that a checked fact rather than a cast.
|
|
253
512
|
*/
|
|
254
513
|
export async function dispatchPrimitive(host, name, args, frame) {
|
|
255
514
|
const spec = PRIMITIVES[name];
|
|
@@ -260,8 +519,29 @@ export async function dispatchPrimitive(host, name, args, frame) {
|
|
|
260
519
|
// written, with the argument named: `undefined`, a non-finite number and an opaque object are
|
|
261
520
|
// L3041, a function is L3042. The result of the effect is held to the same rule in
|
|
262
521
|
// {@link Interpreter.performEffect}.
|
|
522
|
+
//
|
|
523
|
+
// THE PROBE IS THE ONE EXEMPTION, and it is an exemption from the RULE'S PREMISE rather than a
|
|
524
|
+
// hole in the rule. The premise is "this value crosses to a handler or a journal", and a probe
|
|
525
|
+
// does neither: it is program code the INTERPRETER calls, in the program's own compartment, and
|
|
526
|
+
// what crosses is the observation it returns, which is held to the full rule at every
|
|
527
|
+
// observation. A `fanOut`'s branch function is the same shape and avoids this loop only because
|
|
528
|
+
// a scope-opener never reaches it. Driven by `probeAt` from the table, so a second primitive
|
|
529
|
+
// taking a probe cannot arrive with the exemption silently missing or silently wrong.
|
|
263
530
|
args.forEach((arg, i) => {
|
|
531
|
+
if (spec.probeAt === i)
|
|
532
|
+
return;
|
|
264
533
|
try {
|
|
534
|
+
// A bag holding a declared function option is checked KEY BY KEY, so every other key in it
|
|
535
|
+
// still answers to the rule whole: exempting the bag wholesale would let a stray function
|
|
536
|
+
// anywhere inside it through, which is the loophole rather than the exemption.
|
|
537
|
+
if (i === spec.optionsAt && spec.functionOptions !== undefined && arg !== null && typeof arg === "object") {
|
|
538
|
+
for (const [k, v] of Object.entries(arg)) {
|
|
539
|
+
if (spec.functionOptions.includes(k))
|
|
540
|
+
continue;
|
|
541
|
+
assertCrossable(v, `\`${k}\` of \`${name}\``);
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
265
545
|
assertCrossable(arg, `argument ${i + 1} of \`${name}\``);
|
|
266
546
|
}
|
|
267
547
|
catch (e) {
|
|
@@ -294,6 +574,29 @@ export async function dispatchPrimitive(host, name, args, frame) {
|
|
|
294
574
|
if (typeof named !== "string" || named === "")
|
|
295
575
|
throw new RuntimeFault("L3041", `spawn names no persona: its first argument is ${JSON.stringify(spawnSubject) ?? "undefined"}, and a spawn takes a persona name or a record carrying one`);
|
|
296
576
|
const persona = named;
|
|
577
|
+
// PLACEMENT IS VALIDATED HERE, BEFORE ANYTHING READS INTO IT.
|
|
578
|
+
//
|
|
579
|
+
// `option()` guards the BAG being null, not the VALUE, so `option(bag, "placement")` answers
|
|
580
|
+
// `null` for `placement: null` and the `!== undefined` test below FORWARDS it. The identity
|
|
581
|
+
// projection further down then reads `req.placement.endpoint`, and that is a raw `TypeError`
|
|
582
|
+
// out of the interpreter: no code, no effect kind, no journal entry, and a stack trace handed
|
|
583
|
+
// to an author where the runtime's own named refusal (mesh-handler's L4000 "placement must
|
|
584
|
+
// name both an endpoint and an instanceId") was written to speak. The runtime's refusal is
|
|
585
|
+
// correct and stays; it simply never ran, because the value died one layer earlier.
|
|
586
|
+
//
|
|
587
|
+
// ALL FOUR MALFORMED SHAPES ARE REFUSED, not just the one that crashed. A primitive, an empty
|
|
588
|
+
// record and a half-filled record did NOT throw: they projected `endpoint: undefined,
|
|
589
|
+
// instanceId: undefined` into the step identity and travelled on. That is the worse half of
|
|
590
|
+
// the defect — `undefined` has no canonical form (§4.4), so what got hashed was a placement
|
|
591
|
+
// the program never named, and the crash at least stopped. Refusing the shape rather than the
|
|
592
|
+
// null is what makes the hash mean the target again.
|
|
593
|
+
//
|
|
594
|
+
// It is a call-shape refusal (L3xxx) and it is raised BEFORE the step key is minted, so a
|
|
595
|
+
// malformed placement writes nothing to the journal: there is no entry to replay, and the
|
|
596
|
+
// fix is an edit to the program rather than a migration.
|
|
597
|
+
const placementValue = option(bag, "placement");
|
|
598
|
+
if (placementValue !== undefined && !isPlacementPair(placementValue))
|
|
599
|
+
throw new RuntimeFault("L3048", `spawn(${persona}) placement must name BOTH an endpoint and an instanceId, each a non-empty string: it was given ${describePlacement(placementValue)}. A host-local seat is pinned to one manager instance, and the target is hashed into this step's identity, so a half-named target would record a placement the program never asked for. Pass placement: { endpoint: "manager", instanceId: "<the instance's id>" }.`);
|
|
297
600
|
const model = typeof spawnSubject === "string" ? undefined : option(spawnSubject, "model");
|
|
298
601
|
const variant = typeof spawnSubject === "string" ? undefined : option(spawnSubject, "variant");
|
|
299
602
|
// Every accepted option is forwarded, including the three that are policy rather than
|
|
@@ -305,6 +608,11 @@ export async function dispatchPrimitive(host, name, args, frame) {
|
|
|
305
608
|
persona,
|
|
306
609
|
...(model !== undefined ? { model } : {}),
|
|
307
610
|
...(variant !== undefined ? { variant } : {}),
|
|
611
|
+
...(option(bag, "cwd") !== undefined ? { cwd: option(bag, "cwd") } : {}),
|
|
612
|
+
// The VALIDATED value, not a second `option()` read cast into the pair type. The cast was
|
|
613
|
+
// the whole defect: it asserted the shape the projection then relied on, and TypeScript
|
|
614
|
+
// erases at run time, so the assertion was a comment that looked like a check.
|
|
615
|
+
...(placementValue !== undefined ? { placement: placementValue } : {}),
|
|
308
616
|
...(option(bag, "worktree") !== undefined ? { worktree: option(bag, "worktree") } : {}),
|
|
309
617
|
...(option(bag, "role") !== undefined ? { role: option(bag, "role") } : {}),
|
|
310
618
|
...(option(bag, "join") !== undefined ? { join: option(bag, "join") } : {}),
|
|
@@ -324,6 +632,23 @@ export async function dispatchPrimitive(host, name, args, frame) {
|
|
|
324
632
|
persona,
|
|
325
633
|
model: model ?? null,
|
|
326
634
|
variant: variant ?? null,
|
|
635
|
+
// #1616 item 3, identity half: placement and `cwd` are hashed WHEN PRESENT and contribute
|
|
636
|
+
// NO KEY AT ALL when omitted, which is the same rule `req` above already follows. The
|
|
637
|
+
// first cut of this wrote `cwd: req.cwd ?? null` and `placement: ... ?? null`
|
|
638
|
+
// unconditionally, and that is a REPLAY COMPATIBILITY BREAK, not a cosmetic difference: a
|
|
639
|
+
// legacy spawn that never mentioned either option hashed a six-key object, and hashing an
|
|
640
|
+
// eight-key object carrying two nulls it never had changes its `inputHash` — so every
|
|
641
|
+
// recorded pre-#1616 spawn step diverges on resume. `digest` canonicalizes (RFC 8785), so
|
|
642
|
+
// the key SET is what the hash is over and a present-but-null key is a different set.
|
|
643
|
+
//
|
|
644
|
+
// Absent contributes nothing; PRESENT IS HASHED. Not hashing placement at all would also
|
|
645
|
+
// restore the legacy hash, and would be wrong for the other direction: replaying a step
|
|
646
|
+
// against a different manager instance must DIVERGE as a migration rather than silently
|
|
647
|
+
// reuse the resolution that was taken against the old instance.
|
|
648
|
+
...(req.cwd !== undefined ? { cwd: req.cwd } : {}),
|
|
649
|
+
...(req.placement !== undefined
|
|
650
|
+
? { placement: { endpoint: req.placement.endpoint, instanceId: req.placement.instanceId } }
|
|
651
|
+
: {}),
|
|
327
652
|
worktree: req.worktree ?? null,
|
|
328
653
|
role: req.role ?? null,
|
|
329
654
|
join: (req.join ?? []).map((c) => c.channel),
|
|
@@ -471,6 +796,53 @@ export async function dispatchPrimitive(host, name, args, frame) {
|
|
|
471
796
|
// stale cutoff.
|
|
472
797
|
return await performEffect(host, "wait", stepName ?? "", { event, timeout: timeout ?? null }, (ctx) => handler.wait({ event, ...(timeout !== undefined ? { timeout } : {}) }, ctx), frame);
|
|
473
798
|
}
|
|
799
|
+
case "waitUntil": {
|
|
800
|
+
// THE PROBE IS A FUNCTION, so it is the one primitive argument that does NOT cross the
|
|
801
|
+
// effect boundary as data: it is program code the runtime calls, repeatedly, and the
|
|
802
|
+
// crossability loop above has already refused every other argument. It is validated
|
|
803
|
+
// statically (L3045) and again here, because a computed callee is only knowable now.
|
|
804
|
+
const probeArg = args[0];
|
|
805
|
+
if (typeof probeArg !== "function") {
|
|
806
|
+
throw new RuntimeFault("L3045", `\`waitUntil\` takes a PROBE as its first argument: a function the runtime calls on its own cadence to observe something outside the run. It was given ${JSON.stringify(probeArg) ?? "undefined"}. Pass a function: waitUntil(() => checks(sha), { name: "checks", every: "1m", deadline: "1h" }).`);
|
|
807
|
+
}
|
|
808
|
+
const every = option(bag, "every");
|
|
809
|
+
const deadline = option(bag, "deadline");
|
|
810
|
+
// BOTH ARE REQUIRED, and neither gets a default. A default cadence is a guess about how
|
|
811
|
+
// expensive someone else's resource is to poll, and a default deadline is a wait that hangs
|
|
812
|
+
// a run forever on a resource that never arrives. The validator refuses this statically too;
|
|
813
|
+
// this is the computed-bag case it cannot see.
|
|
814
|
+
if (every === undefined || deadline === undefined) {
|
|
815
|
+
throw new RuntimeFault("L3046", `\`waitUntil\` needs both \`every\` (how often to observe) and \`deadline\` (when to give up)${every === undefined ? ", and \`every\` is missing" : ""}${deadline === undefined ? ", and \`deadline\` is missing" : ""}. Neither has a default: a default cadence guesses how expensive someone else's resource is to poll, and a default deadline is a run that waits forever.`);
|
|
816
|
+
}
|
|
817
|
+
// Fail at the CALL rather than inside the wait, exactly as `sleep` does with its duration:
|
|
818
|
+
// a malformed cadence discovered an hour in is a wait that was never going to work.
|
|
819
|
+
const everyMs = parseDuration(every);
|
|
820
|
+
const deadlineMs = parseDuration(deadline);
|
|
821
|
+
// ZERO IS REFUSED BEFORE THE COMPARISON, because `0 > deadline` is false and a bare ordering
|
|
822
|
+
// test therefore ACCEPTS the one cadence that is not a cadence at all. A wait that pauses for
|
|
823
|
+
// nothing between looks is a busy loop against a resource the run does not own, and every
|
|
824
|
+
// turn of it appends to a journal that is rewritten whole. This is the computed-bag case the
|
|
825
|
+
// validator cannot see.
|
|
826
|
+
if (everyMs <= 0) {
|
|
827
|
+
throw new RuntimeFault("L3047", `this \`waitUntil\` observes every ${every}, which is no pause at all: it would poll the resource as fast as the run can turn and journal an observation each time. Give \`every\` a real interval, the slowest one that still notices in time.`);
|
|
828
|
+
}
|
|
829
|
+
if (everyMs > deadlineMs) {
|
|
830
|
+
throw new RuntimeFault("L3047", `this \`waitUntil\` observes every ${every} but gives up after ${deadline}, so it would make its first observation and then fail without ever looking again. Either shorten \`every\` below \`deadline\`, or use \`sleep\` then a single check if one look is what you meant.`);
|
|
831
|
+
}
|
|
832
|
+
const terminalArg = option(bag, "terminal");
|
|
833
|
+
if (terminalArg !== undefined && typeof terminalArg !== "function") {
|
|
834
|
+
throw new RuntimeFault("L3045", `\`waitUntil\`'s \`terminal\` decides whether an observation ENDS the wait, so it must be a function; it was given ${JSON.stringify(terminalArg)}. Pass a predicate: terminal: (o) => o.state !== "pending".`);
|
|
835
|
+
}
|
|
836
|
+
// BOTH are called in the WALKER's convention, `(frame, args)`, which is what the engine
|
|
837
|
+
// adapts its own closures into as well. Calling them any other way would hand a program
|
|
838
|
+
// function the frame as its first real argument. The frame handed over is the OBSERVATION's,
|
|
839
|
+
// not this call's: the probe's own effects belong to the look that made them.
|
|
840
|
+
const call = (fn, f, xs) => fn(f, xs);
|
|
841
|
+
return await performWaitUntil(host, stepName,
|
|
842
|
+
// The probe is a function and cannot be hashed, so what identifies this step is its name
|
|
843
|
+
// and the two durations that STOP OBSERVATION. See the table's note.
|
|
844
|
+
{ every, deadline }, async (f) => await call(probeArg, f, []), terminalArg === undefined ? undefined : async (f, o) => await call(terminalArg, f, [o]), every, deadline, frame);
|
|
845
|
+
}
|
|
474
846
|
case "notify": {
|
|
475
847
|
const agents = deepFreeze(args[0]);
|
|
476
848
|
const fact = deepFreeze(args[1]);
|