reactor-effect-client 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +16 -2
  2. package/dist/_internal/queue.d.ts +6 -0
  3. package/dist/_internal/queue.d.ts.map +1 -0
  4. package/dist/_internal/queue.js +30 -0
  5. package/dist/_internal/queue.js.map +1 -0
  6. package/dist/host.d.ts +1 -0
  7. package/dist/host.d.ts.map +1 -1
  8. package/dist/host.js +1 -0
  9. package/dist/host.js.map +1 -1
  10. package/dist/observation.d.ts +3 -0
  11. package/dist/observation.d.ts.map +1 -1
  12. package/dist/observation.js +9 -4
  13. package/dist/observation.js.map +1 -1
  14. package/dist/orchestration/index.d.ts +4 -4
  15. package/dist/orchestration/index.d.ts.map +1 -1
  16. package/dist/orchestration/index.js +2 -2
  17. package/dist/orchestration/index.js.map +1 -1
  18. package/dist/orchestration/media-buffer.d.ts +2 -1
  19. package/dist/orchestration/media-buffer.d.ts.map +1 -1
  20. package/dist/orchestration/media-buffer.js +5 -4
  21. package/dist/orchestration/media-buffer.js.map +1 -1
  22. package/dist/orchestration/renewal-state.d.ts +14 -2
  23. package/dist/orchestration/renewal-state.d.ts.map +1 -1
  24. package/dist/orchestration/renewal-state.js +34 -4
  25. package/dist/orchestration/renewal-state.js.map +1 -1
  26. package/dist/orchestration/renewal.d.ts +27 -3
  27. package/dist/orchestration/renewal.d.ts.map +1 -1
  28. package/dist/orchestration/renewal.js +396 -95
  29. package/dist/orchestration/renewal.js.map +1 -1
  30. package/dist/orchestration/retention.d.ts +100 -0
  31. package/dist/orchestration/retention.d.ts.map +1 -0
  32. package/dist/orchestration/retention.js +134 -0
  33. package/dist/orchestration/retention.js.map +1 -0
  34. package/dist/orchestration/scheduler-policy.d.ts +2 -2
  35. package/dist/orchestration/scheduler-policy.d.ts.map +1 -1
  36. package/dist/orchestration/scheduler-policy.js +5 -8
  37. package/dist/orchestration/scheduler-policy.js.map +1 -1
  38. package/dist/orchestration/scheduler.d.ts +5 -3
  39. package/dist/orchestration/scheduler.d.ts.map +1 -1
  40. package/dist/orchestration/scheduler.js +359 -109
  41. package/dist/orchestration/scheduler.js.map +1 -1
  42. package/dist/orchestration/source-slot.d.ts +16 -4
  43. package/dist/orchestration/source-slot.d.ts.map +1 -1
  44. package/dist/orchestration/source-slot.js +122 -30
  45. package/dist/orchestration/source-slot.js.map +1 -1
  46. package/dist/orchestration/types.d.ts +483 -1
  47. package/dist/orchestration/types.d.ts.map +1 -1
  48. package/dist/orchestration/types.js +105 -1
  49. package/dist/orchestration/types.js.map +1 -1
  50. package/dist/session.d.ts.map +1 -1
  51. package/dist/session.js +2 -1
  52. package/dist/session.js.map +1 -1
  53. package/dist/simulation/_internal/source.d.ts.map +1 -1
  54. package/dist/simulation/_internal/source.js +5 -4
  55. package/dist/simulation/_internal/source.js.map +1 -1
  56. package/package.json +2 -2
@@ -10,8 +10,10 @@ import * as PubSub from "effect/PubSub";
10
10
  import * as Queue from "effect/Queue";
11
11
  import * as Result from "effect/Result";
12
12
  import * as Schema from "effect/Schema";
13
+ import * as Scope from "effect/Scope";
13
14
  import * as Stream from "effect/Stream";
14
15
  import * as SubscriptionRef from "effect/SubscriptionRef";
16
+ import { take as takeQueue } from "../_internal/queue.js";
15
17
  import { duration } from "../duration.js";
16
18
  import { parsedInput, ReactorError } from "../errors.js";
17
19
  import { monotonicMillis } from "./elapsed.js";
@@ -51,9 +53,8 @@ const ownedData = (input, fields, name) => {
51
53
  return Result.fail(invalid(`${name} has unsupported fields or accessors`));
52
54
  return Result.succeed(Object.fromEntries(Object.entries(descriptors).map(([field, descriptor]) => [field, descriptor.value])));
53
55
  };
54
- const captureDuration = (input) => Effect.gen(function* () {
55
- if (input === null || typeof input !== "object" || Duration.isDuration(input))
56
- return input;
56
+ /** A Duration tuple or object read from own data properties; no accessor runs. */
57
+ const durationData = (input) => {
57
58
  if (Array.isArray(input)) {
58
59
  const descriptors = Object.getOwnPropertyDescriptors(input);
59
60
  if (Object.getOwnPropertySymbols(input).length > 0 ||
@@ -61,20 +62,44 @@ const captureDuration = (input) => Effect.gen(function* () {
61
62
  descriptors.length?.value !== 2 ||
62
63
  descriptors["0"] === undefined ||
63
64
  descriptors["1"] === undefined)
64
- return yield* invalid("Duration tuple has unsupported fields or accessors");
65
- return [descriptors["0"].value, descriptors["1"].value];
65
+ return Result.fail(invalid("Duration tuple has unsupported fields or accessors"));
66
+ const seconds = descriptors["0"].value;
67
+ const nanos = descriptors["1"].value;
68
+ return Result.succeed([seconds, nanos]);
66
69
  }
67
- return (yield* Effect.fromResult(ownedData(input, [
68
- "weeks",
69
- "days",
70
- "hours",
71
- "minutes",
72
- "seconds",
73
- "milliseconds",
74
- "microseconds",
75
- "nanoseconds",
76
- ], "Duration")));
70
+ const fields = ownedData(input, ["weeks", "days", "hours", "minutes", "seconds", "milliseconds", "microseconds", "nanoseconds"], "Duration");
71
+ return Result.isFailure(fields) ? Result.fail(fields.failure) : Result.succeed(fields.success);
72
+ };
73
+ const captureDuration = (input) => Effect.gen(function* () {
74
+ if (input === null || typeof input !== "object" || Duration.isDuration(input))
75
+ return input;
76
+ return yield* Effect.fromResult(durationData(input));
77
77
  });
78
+ /**
79
+ * A Duration option read as caller data: a data property, own or inherited,
80
+ * captured without running any accessor. Rejections throw for `parsedInput`.
81
+ */
82
+ const optionDuration = (options, field, name) => {
83
+ let descriptor;
84
+ for (let owner = options; descriptor === undefined && owner !== null; owner = Object.getPrototypeOf(owner))
85
+ descriptor = Object.getOwnPropertyDescriptor(owner, field);
86
+ if (descriptor === undefined)
87
+ return undefined;
88
+ const reject = () => ReactorError.fromCode("InvalidInput", `${name} must be a Duration of data properties`);
89
+ if (!("value" in descriptor))
90
+ throw reject();
91
+ const value = descriptor.value;
92
+ if (value === undefined)
93
+ return undefined;
94
+ if (value === null)
95
+ throw reject();
96
+ if (typeof value !== "object" || Duration.isDuration(value))
97
+ return value;
98
+ const captured = durationData(value);
99
+ if (Result.isFailure(captured))
100
+ throw reject();
101
+ return captured.success;
102
+ };
78
103
  const requestDuration = (input, name, allowZero = false) => Effect.gen(function* () {
79
104
  const captured = yield* captureDuration(input);
80
105
  return yield* parsedInput(() => Duration.toMillis(duration(captured, name, { allowZero, allowNegative: name === "startBy" })), "submit").pipe(Effect.mapError((error) => invalid(error.message)));
@@ -178,11 +203,13 @@ export const makeScheduler = (options) => Effect.gen(function* () {
178
203
  throw ReactorError.fromCode("InvalidInput", "Scheduler lanes must be unique and nonempty");
179
204
  return options.lanes.map((lane) => lane.name);
180
205
  }, "makeScheduler");
181
- const { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory } = yield* parsedInput(() => {
206
+ const { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs } = yield* parsedInput(() => {
182
207
  const floorSeconds = Duration.toMillis(duration(options.filler.runway.floor, "runway floor", { allowZero: true })) / 1000;
183
208
  const targetSeconds = Duration.toMillis(duration(options.filler.runway.target, "runway target")) / 1000;
184
209
  const maxBuildsInFlight = options.maxBuildsInFlight ?? 1;
185
210
  const maxHistory = options.maxHistory ?? 4096;
211
+ const unknownRecoveryMs = Duration.toMillis(duration(optionDuration(options, "unknownRecoveryTimeout", "Unknown recovery timeout") ??
212
+ "60 seconds", "unknown recovery timeout", { maximum: "10 minutes" }));
186
213
  if (floorSeconds > targetSeconds ||
187
214
  !Number.isSafeInteger(maxBuildsInFlight) ||
188
215
  maxBuildsInFlight < 1 ||
@@ -191,7 +218,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
191
218
  maxHistory < 0 ||
192
219
  maxHistory > 65_536)
193
220
  throw ReactorError.fromCode("InvalidInput", "Scheduler runway or build cap is invalid");
194
- return { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory };
221
+ return { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs };
195
222
  }, "makeScheduler");
196
223
  const captureFiller = (index, runway, targetSeconds) => Effect.gen(function* () {
197
224
  const request = yield* captureRequest(options.filler.clip({
@@ -209,7 +236,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
209
236
  });
210
237
  let fillerIndex = 0;
211
238
  let fillerRetryAtMs = 0;
212
- let unknownFiller;
239
+ const unknownFillers = new Map();
240
+ const maxUnknownFillers = 4096;
213
241
  let upcomingFiller = yield* captureFiller(0, 0);
214
242
  const inbox = yield* Queue.unbounded();
215
243
  const commandQueue = yield* Queue.unbounded();
@@ -229,6 +257,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
229
257
  });
230
258
  const initialized = yield* Deferred.make();
231
259
  const stopped = yield* Deferred.make();
260
+ const settled = yield* Deferred.make();
232
261
  const closedCall = Deferred.await(stopped).pipe(Effect.flatMap((cause) => PolicyFailure.refuse("SessionClosed", cause.message)));
233
262
  const items = new Map();
234
263
  const history = new Map();
@@ -248,6 +277,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
248
277
  const drainReplies = [];
249
278
  const pendingWithdrawals = new Map();
250
279
  const pendingBuilds = new Set();
280
+ // A Build whose enqueue has started and whose result the worker has not
281
+ // yet seen. The provider may still accept it after the scheduler ends.
282
+ let dispatching;
251
283
  const pendingAtDeferrals = new Set();
252
284
  const pendingItemRemovals = new Set();
253
285
  const pendingFillerRemovals = new Set();
@@ -262,7 +294,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
262
294
  let drained = false;
263
295
  let observationReady = false;
264
296
  let blockedMove;
265
- const emit = (entry, status) => Effect.gen(function* () {
297
+ const emit = (entry, status, terminal = false) => Effect.gen(function* () {
298
+ if (ended !== undefined && !terminal)
299
+ return;
266
300
  if (JSON.stringify(entry.status) === JSON.stringify(status))
267
301
  return;
268
302
  entry.status = status;
@@ -275,7 +309,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
275
309
  : status._tag === "Unknown" && status.terminal !== true
276
310
  ? "Unknown"
277
311
  : "Terminal";
278
- yield* PubSub.publish(events, {
312
+ // Publish in the step that records it (the PubSub is unbounded): a claim
313
+ // from another fiber cannot land between the two, so asRun never carries
314
+ // a settlement ahead of a recorded status, nor omits one.
315
+ PubSub.publishUnsafe(events, {
279
316
  key: entry.key,
280
317
  at: clock.currentTimeMillisUnsafe(),
281
318
  status,
@@ -293,31 +330,165 @@ export const makeScheduler = (options) => Effect.gen(function* () {
293
330
  yield* Deferred.succeed(entry.outcomeWaiter, status);
294
331
  }
295
332
  });
296
- const closeActor = (cause) => Effect.gen(function* () {
333
+ // The claim is synchronous; interruption masking alone would still allow
334
+ // two closers to publish conflicting results. Join only local bookkeeping,
335
+ // never the worker scope (a worker can itself own terminal settlement).
336
+ const terminate = (terminal) => Effect.suspend(() => {
297
337
  if (ended !== undefined)
298
- return;
299
- ended = cause;
338
+ return Deferred.await(settled);
339
+ ended = terminal;
300
340
  accepting = false;
301
- yield* Deferred.succeed(stopped, cause);
302
- for (const item of items.values()) {
303
- if (item.phase === "Terminal")
304
- continue;
305
- if (item.phase === "Unknown") {
306
- yield* emit(item, { _tag: "Unknown", terminal: true });
307
- item.phase = "Terminal";
341
+ // Wake the watchdog so an episode left armed by another owner cannot
342
+ // keep re-arming its timer.
343
+ Deferred.doneUnsafe(recoveryChanged, Exit.void);
344
+ return Effect.gen(function* () {
345
+ for (const item of items.values()) {
346
+ if (Exit.isFailure(terminal)) {
347
+ yield* Deferred.failCause(item.startedWaiter, terminal.cause);
348
+ yield* Deferred.failCause(item.outcomeWaiter, terminal.cause);
349
+ yield* Deferred.failCause(item.firstDecisiveWaiter, terminal.cause);
350
+ continue;
351
+ }
352
+ // emit records and publishes a status before completing its waits,
353
+ // and a claim can land in between: a completion can resume an
354
+ // observer that closes the owner, and another fiber can claim while
355
+ // the actor is preempted. Finish those waits from the record.
356
+ const recorded = item.status;
357
+ if (item.phase === "Started" || item.phase === "Terminal") {
358
+ if (isFirstDecisive(recorded))
359
+ yield* Deferred.succeed(item.firstDecisiveWaiter, recorded);
360
+ yield* Deferred.succeed(item.startedWaiter, recorded);
361
+ }
362
+ if (item.phase === "Terminal")
363
+ yield* Deferred.succeed(item.outcomeWaiter, recorded);
364
+ else
365
+ yield* emit(item, item.phase === "Unknown" ||
366
+ (item.clipId === undefined &&
367
+ (item.unknownAtMs !== undefined || item.key === dispatching))
368
+ ? { _tag: "Unknown", terminal: true }
369
+ : { _tag: "Failed", reason: { _tag: "Scheduler", cause: terminal.value } }, true);
308
370
  }
309
- else
310
- yield* emit(item, { _tag: "Failed", reason: { _tag: "Scheduler", cause } });
371
+ const closed = Exit.isSuccess(terminal)
372
+ ? Exit.fail(PolicyFailure.refuse("SessionClosed", terminal.value.message))
373
+ : Exit.failCause(terminal.cause);
374
+ for (const [key, pending] of pendingWithdrawals) {
375
+ // A drop recorded before the claim is the withdrawal's actual result.
376
+ const reply = Exit.isSuccess(terminal) && items.get(key)?.status._tag === "Dropped"
377
+ ? Exit.succeed("withdrawn")
378
+ : closed;
379
+ for (const waiter of pending.replies)
380
+ yield* Deferred.done(waiter, reply);
381
+ }
382
+ pendingWithdrawals.clear();
383
+ for (const reply of drainReplies.splice(0))
384
+ yield* Deferred.done(reply, closed);
385
+ yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
386
+ yield* Deferred.done(initialized, Exit.isFailure(terminal)
387
+ ? Exit.failCause(terminal.cause)
388
+ : Exit.fail(ReactorError.fromCode("Closed", terminal.value.message)));
389
+ // Supervisors may close the owner immediately when this wakes them.
390
+ yield* Deferred.done(stopped, terminal);
391
+ }).pipe(Effect.onExit((exit) => Effect.gen(function* () {
392
+ // A bookkeeping defect must release joiners with that Cause, never
393
+ // leave an unfinished latch or report a partial settlement as success.
394
+ yield* Deferred.done(settled, exit);
395
+ if (Exit.isFailure(exit)) {
396
+ yield* Deferred.failCause(initialized, exit.cause);
397
+ yield* Deferred.failCause(stopped, exit.cause);
398
+ }
399
+ })));
400
+ }).pipe(Effect.uninterruptible);
401
+ const closeActor = (cause) => terminate(Exit.succeed(cause));
402
+ let recoveryState;
403
+ let capacityRecovery;
404
+ let recoveryDeadline;
405
+ let recoveryChanged = Deferred.makeUnsafe();
406
+ // Retain the original age while Closing hides the old source and acquisition
407
+ // is still pending. Only accepted evidence or a Ready replacement clears it.
408
+ const updateRecovery = (state = recoveryState, fresh) => {
409
+ if (ended !== undefined)
410
+ return;
411
+ recoveryState = state;
412
+ const preferred = state === undefined ? undefined : Option.getOrUndefined(state.preferredSessionId);
413
+ const ready = preferred !== undefined &&
414
+ state?.sessions.some((source) => source.sessionId === preferred && source.availability === "Ready") === true;
415
+ let earliest;
416
+ let drainDeadline;
417
+ const consider = (atMs, sessionId, cause, newlyObserved = false) => {
418
+ const deadlineMs = atMs + unknownRecoveryMs;
419
+ if (((!ready && newlyObserved) || sessionId === undefined || sessionId === preferred) &&
420
+ (earliest === undefined || deadlineMs < earliest.deadlineMs))
421
+ earliest = { deadlineMs, sessionId, cause };
422
+ if (draining && (drainDeadline === undefined || deadlineMs < drainDeadline.deadlineMs))
423
+ drainDeadline = { deadlineMs, sessionId, cause };
424
+ };
425
+ for (const entry of unknownFillers.values())
426
+ consider(entry.atMs, entry.sessionId, entry.cause);
427
+ for (const item of items.values())
428
+ if (item.phase !== "Terminal" &&
429
+ item.clipId === undefined &&
430
+ item.unknownAtMs !== undefined &&
431
+ item.unknownCause !== undefined)
432
+ consider(item.unknownAtMs, item.unknownSessionId, item.unknownCause);
433
+ if (fresh !== undefined)
434
+ consider(fresh.atMs, fresh.sessionId, fresh.cause, true);
435
+ if (ready &&
436
+ (earliest === undefined ||
437
+ (capacityRecovery?.sessionId !== undefined && capacityRecovery.sessionId !== preferred)))
438
+ capacityRecovery = undefined;
439
+ if (earliest !== undefined &&
440
+ (capacityRecovery === undefined || earliest.deadlineMs < capacityRecovery.deadlineMs))
441
+ capacityRecovery = earliest;
442
+ const next = drainDeadline !== undefined &&
443
+ (capacityRecovery === undefined || drainDeadline.deadlineMs < capacityRecovery.deadlineMs)
444
+ ? drainDeadline
445
+ : capacityRecovery;
446
+ if (next?.deadlineMs !== recoveryDeadline?.deadlineMs ||
447
+ next?.cause !== recoveryDeadline?.cause) {
448
+ recoveryDeadline = next;
449
+ Deferred.doneUnsafe(recoveryChanged, Exit.void);
311
450
  }
312
- const closed = PolicyFailure.refuse("SessionClosed", cause.message);
313
- for (const [key, pending] of pendingWithdrawals) {
314
- for (const reply of pending.replies)
315
- yield* Deferred.fail(reply, closed);
316
- pendingWithdrawals.delete(key);
451
+ };
452
+ const provedUnknown = (atMs) => {
453
+ if (atMs !== undefined && capacityRecovery?.deadlineMs === atMs + unknownRecoveryMs)
454
+ capacityRecovery = undefined;
455
+ };
456
+ const watchdog = Effect.gen(function* () {
457
+ for (;;) {
458
+ // A committed result and its queued actor evidence can arrive in one
459
+ // turn. Coalesce those revisions before allocating a replacement timer;
460
+ // the original monotonic deadline remains unchanged.
461
+ yield* Effect.yieldNow;
462
+ if (ended !== undefined)
463
+ return;
464
+ recoveryChanged = Deferred.makeUnsafe();
465
+ const episode = recoveryDeadline;
466
+ if (episode === undefined) {
467
+ yield* Deferred.await(recoveryChanged);
468
+ continue;
469
+ }
470
+ yield* Deferred.await(recoveryChanged).pipe(Effect.timeoutOrElse({
471
+ duration: Math.max(0, episode.deadlineMs - monotonicMillis(clock)),
472
+ orElse: () => Effect.void,
473
+ }));
474
+ // A canceled timer may already have resumed. Recheck the retained
475
+ // identity without awaiting the actor, Engine state, or its permits.
476
+ if (ended === undefined &&
477
+ recoveryDeadline === episode &&
478
+ monotonicMillis(clock) >= episode.deadlineMs) {
479
+ const causes = new Set([episode.cause]);
480
+ for (const item of items.values())
481
+ if (item.unknownCause !== undefined)
482
+ causes.add(item.unknownCause);
483
+ for (const entry of unknownFillers.values())
484
+ causes.add(entry.cause);
485
+ yield* closeActor(ReactorError.fromCode("Timeout", "Scheduler unknown recovery deadline elapsed", {
486
+ operation: "scheduler.unknownRecovery",
487
+ detail: [...causes],
488
+ }));
489
+ return;
490
+ }
317
491
  }
318
- for (const reply of drainReplies.splice(0))
319
- yield* Deferred.fail(reply, closed);
320
- yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
321
492
  });
322
493
  const prune = (state) => {
323
494
  const active = new Set(activeIds(state));
@@ -350,7 +521,44 @@ export const makeScheduler = (options) => Effect.gen(function* () {
350
521
  while (history.size > maxHistory)
351
522
  history.delete(history.keys().next().value);
352
523
  };
353
- const publishState = (state) => {
524
+ // Capture membership and public rows in one pass. A fresh token reads every
525
+ // source on every observation, including in-place changes to Engine arrays;
526
+ // frozen empty rows can be shared by every published state without treating
527
+ // array identity as proof.
528
+ const noReady = Object.freeze([]);
529
+ const sourceRows = new Map();
530
+ const projectSources = (state) => {
531
+ const seen = Symbol();
532
+ const sessions = state.sessions.map(({ sessionId }) => {
533
+ let cached = sourceRows.get(sessionId);
534
+ if (cached === undefined) {
535
+ cached = { empty: Object.freeze({ sessionId, ready: noReady }), seen };
536
+ sourceRows.set(sessionId, cached);
537
+ }
538
+ else
539
+ cached.seen = seen;
540
+ if (state.ready.length === 0)
541
+ return cached.empty;
542
+ return {
543
+ sessionId,
544
+ ready: state.ready
545
+ .filter((clip) => clip.sessionId === sessionId)
546
+ .map((clip) => {
547
+ const owner = owned.get(clip.clipId);
548
+ return owner === undefined ? "other" : owner._tag === "Filler" ? "filler" : owner.key;
549
+ }),
550
+ };
551
+ });
552
+ // Bound cached rows by snapshot size without scanning unchanged overlap.
553
+ // Membership still requires this observation's token, even before pruning.
554
+ if (sourceRows.size > state.sessions.length)
555
+ for (const [id, cached] of sourceRows)
556
+ if (cached.seen !== seen)
557
+ sourceRows.delete(id);
558
+ // Only the serialized actor uses this membership view, before its next projection.
559
+ return { sessions, has: (id) => sourceRows.get(id)?.seen === seen };
560
+ };
561
+ const publishState = (state, sessions = projectSources(state).sessions) => {
354
562
  const playing = Option.getOrUndefined(state.playing);
355
563
  const playingOwner = playing === undefined ? undefined : owned.get(playing.clipId);
356
564
  const playingKey = playing === undefined
@@ -372,15 +580,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
372
580
  .filter((item) => item.lane === name && item.phase !== "Terminal")
373
581
  .map((item) => item.key),
374
582
  })),
375
- sessions: state.sessions.map(({ sessionId }) => ({
376
- sessionId,
377
- ready: state.ready
378
- .filter((clip) => clip.sessionId === sessionId)
379
- .map((clip) => {
380
- const owner = owned.get(clip.clipId);
381
- return owner === undefined ? "other" : owner._tag === "Filler" ? "filler" : owner.key;
382
- }),
383
- })),
583
+ sessions,
384
584
  starved,
385
585
  });
386
586
  };
@@ -454,8 +654,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
454
654
  filler.set(index, { index, clipId: record.clipId, sessionId: record.sessionId });
455
655
  const priorIndex = fillerIndex;
456
656
  fillerIndex = Math.max(fillerIndex, index + 1);
457
- if (unknownFiller?.index === index)
458
- unknownFiller = undefined;
657
+ provedUnknown(unknownFillers.get(index)?.atMs);
658
+ unknownFillers.delete(index);
459
659
  if (fillerIndex !== priorIndex)
460
660
  upcomingFiller = undefined;
461
661
  }
@@ -467,6 +667,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
467
667
  if (resumed !== undefined) {
468
668
  item.clipId = resumed.clipId;
469
669
  item.sessionId = resumed.sessionId;
670
+ provedUnknown(item.unknownAtMs);
671
+ item.unknownAtMs = undefined;
672
+ item.unknownCause = undefined;
470
673
  item.unknownSessionId = undefined;
471
674
  owned.set(resumed.clipId, { _tag: "Item", key: item.key });
472
675
  }
@@ -530,7 +733,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
530
733
  lastProcessedSnapshotSerial > item.acknowledgedAtSnapshotSerial))
531
734
  yield* emit(item, { _tag: "Unobserved" });
532
735
  }
533
- yield* publishState(state);
736
+ updateRecovery(state);
534
737
  });
535
738
  const onEvent = (event) => Effect.gen(function* () {
536
739
  if (event._tag === "Starved")
@@ -662,6 +865,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
662
865
  if (command.sessionId === undefined ||
663
866
  Option.getOrUndefined((yield* engine.state).preferredSessionId) !== command.sessionId)
664
867
  return yield* PolicyFailure.refuse("RouteChanged", "The preferred source changed before dispatch");
868
+ // Termination can be claimed while the route is read; its settlement
869
+ // must not be followed by a new dispatch.
870
+ if (ended !== undefined)
871
+ return yield* PolicyFailure.refuse("SessionClosed", "The scheduler closed");
872
+ if (command._tag === "Build")
873
+ dispatching = command.key;
665
874
  return yield* engine.enqueueOnSource === undefined
666
875
  ? engine.enqueue(command.request)
667
876
  : engine.enqueueOnSource(command.request, command.sessionId);
@@ -679,12 +888,44 @@ export const makeScheduler = (options) => Effect.gen(function* () {
679
888
  };
680
889
  const commandWorker = Effect.gen(function* () {
681
890
  for (;;) {
682
- const command = yield* Queue.take(commandQueue);
891
+ const command = yield* takeQueue(commandQueue);
892
+ if (ended !== undefined)
893
+ continue;
683
894
  const result = yield* Effect.result(executeCommand(command));
895
+ dispatching = undefined;
896
+ // Capture uncertainty before actor delivery: drain may be awaiting a
897
+ // permit in stopRenewal while this committed command finishes.
898
+ if (ended === undefined &&
899
+ Result.isFailure(result) &&
900
+ result.failure.context.outcome === "unknown") {
901
+ let fresh;
902
+ const sessionId = engine.enqueueOnSource === undefined ||
903
+ (command._tag !== "Build" && command._tag !== "BuildFiller")
904
+ ? undefined
905
+ : command.sessionId;
906
+ if (command._tag === "Build") {
907
+ const item = items.get(command.key);
908
+ if (item !== undefined && item.phase !== "Terminal" && item.clipId === undefined) {
909
+ item.unknownAtMs ??= monotonicMillis(clock);
910
+ item.unknownCause = result.failure;
911
+ item.unknownSessionId = sessionId;
912
+ fresh = { sessionId, atMs: item.unknownAtMs, cause: result.failure };
913
+ }
914
+ }
915
+ else if (command._tag === "BuildFiller" &&
916
+ !filler.has(command.index) &&
917
+ !unknownFillers.has(command.index)) {
918
+ fresh = { sessionId, atMs: monotonicMillis(clock), cause: result.failure };
919
+ unknownFillers.set(command.index, { ...fresh, index: command.index });
920
+ }
921
+ updateRecovery(recoveryState, fresh);
922
+ }
684
923
  yield* Queue.offer(inbox, { _tag: "CommandDone", command, result });
685
924
  }
686
925
  });
687
926
  const sendCommand = (command) => Effect.gen(function* () {
927
+ if (ended !== undefined)
928
+ return;
688
929
  if (command._tag === "Build")
689
930
  pendingBuilds.add(command.key);
690
931
  if (command._tag === "DeferAt")
@@ -793,6 +1034,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
793
1034
  break;
794
1035
  }
795
1036
  case "BuildFiller": {
1037
+ if (unknownFillers.size >= maxUnknownFillers) {
1038
+ yield* closeActor(ReactorError.fromCode("Overflow", "Scheduler uncertain filler ledger is full", {
1039
+ operation: "scheduler.unknownRecovery",
1040
+ }));
1041
+ break;
1042
+ }
796
1043
  const captured = upcomingFiller === undefined
797
1044
  ? yield* Effect.result(captureFiller(fillerIndex, runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
798
1045
  ...items.values(),
@@ -862,6 +1109,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
862
1109
  item.sessionId =
863
1110
  knownRecord(yield* engine.state, clipId)?.sessionId ??
864
1111
  (engine.enqueueOnSource === undefined ? undefined : command.sessionId);
1112
+ provedUnknown(item.unknownAtMs);
1113
+ item.unknownAtMs = undefined;
865
1114
  item.unknownSessionId = undefined;
866
1115
  item.unknownCause = undefined;
867
1116
  yield* replayEarlyClipEvents(clipId);
@@ -928,11 +1177,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
928
1177
  yield* replayEarlyClipEvents(clipId);
929
1178
  }
930
1179
  else if (result.failure.context.outcome === "unknown") {
931
- if (!filler.has(command.index))
932
- unknownFiller = {
933
- index: command.index,
934
- sessionId: engine.enqueueOnSource === undefined ? undefined : command.sessionId,
935
- };
1180
+ // The worker already retained this identity and its original age.
936
1181
  }
937
1182
  else {
938
1183
  fillerRetryAtMs = monotonicMillis(clock) + 1_000;
@@ -1097,28 +1342,26 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1097
1342
  };
1098
1343
  const reconcile = Effect.gen(function* () {
1099
1344
  const state = yield* engine.state;
1345
+ if (ended !== undefined)
1346
+ return;
1100
1347
  prune(state);
1101
1348
  refreshAnchors();
1349
+ const sources = projectSources(state);
1102
1350
  if (!observationReady) {
1103
- yield* publishState(state);
1351
+ yield* publishState(state, sources.sessions);
1104
1352
  return;
1105
1353
  }
1106
- if (unknownFiller !== undefined) {
1107
- const uncertain = unknownFiller;
1108
- const sourceRetired = uncertain.sessionId !== undefined &&
1109
- !state.sessions.some((session) => session.sessionId === uncertain.sessionId);
1110
- if (sourceRetired) {
1111
- unknownFiller = undefined;
1112
- fillerIndex = Math.max(fillerIndex, uncertain.index + 1);
1113
- upcomingFiller = undefined;
1114
- }
1115
- else if (uncertain.sessionId !== undefined &&
1116
- Option.getOrUndefined(state.preferredSessionId) !== uncertain.sessionId &&
1117
- fillerIndex <= uncertain.index) {
1118
- // A replacement may build independently, with a distinct key. The
1119
- // uncertain old request can still be adopted if it later appears.
1120
- fillerIndex = uncertain.index + 1;
1121
- upcomingFiller = undefined;
1354
+ for (const uncertain of unknownFillers.values()) {
1355
+ const sourceRetired = uncertain.sessionId !== undefined && !sources.has(uncertain.sessionId);
1356
+ if (sourceRetired)
1357
+ unknownFillers.delete(uncertain.index);
1358
+ if (sourceRetired ||
1359
+ (uncertain.sessionId !== undefined &&
1360
+ Option.getOrUndefined(state.preferredSessionId) !== uncertain.sessionId)) {
1361
+ if (fillerIndex <= uncertain.index) {
1362
+ fillerIndex = uncertain.index + 1;
1363
+ upcomingFiller = undefined;
1364
+ }
1122
1365
  }
1123
1366
  }
1124
1367
  for (const [key, pending] of pendingWithdrawals) {
@@ -1134,10 +1377,18 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1134
1377
  if (item.phase === "Unknown" &&
1135
1378
  item.clipId === undefined &&
1136
1379
  item.unknownSessionId !== undefined &&
1137
- !state.sessions.some((session) => session.sessionId === item.unknownSessionId))
1380
+ !sources.has(item.unknownSessionId))
1138
1381
  yield* resolveRetiredUnknown(item);
1382
+ // After retirement, so a deadline no longer outlives the uncertainty that
1383
+ // set it until the next message.
1384
+ updateRecovery(state);
1139
1385
  if (draining)
1140
1386
  yield* drainPending(state);
1387
+ const preferred = Option.getOrUndefined(state.preferredSessionId);
1388
+ let unknownFillerCount = 0;
1389
+ for (const entry of unknownFillers.values())
1390
+ if (entry.sessionId === undefined || entry.sessionId === preferred)
1391
+ unknownFillerCount++;
1141
1392
  const decision = plan({
1142
1393
  engine: state,
1143
1394
  items: [...items.values()],
@@ -1152,12 +1403,11 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1152
1403
  accepting: accepting || (draining && finishAccepted),
1153
1404
  fillerEnabled: !draining || fillerHeld(monotonicMillis(clock)),
1154
1405
  fillerRetryAtMs,
1155
- fillerUnknown: unknownFiller !== undefined,
1156
- fillerUnknownSessionId: unknownFiller?.sessionId,
1406
+ unknownFillerCount,
1157
1407
  blockedMove,
1158
1408
  });
1159
1409
  refillActive = decision.refillActive;
1160
- yield* publishState(state);
1410
+ yield* publishState(state, sources.sessions);
1161
1411
  if ((!draining || finishAccepted) && commandCount === 0 && decision.action !== undefined)
1162
1412
  yield* applyPolicy(decision.action, state);
1163
1413
  if (draining &&
@@ -1173,10 +1423,14 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1173
1423
  commandCount === 0 &&
1174
1424
  [...items.values()].every((item) => item.phase === "Terminal") &&
1175
1425
  pendingWithdrawals.size === 0 &&
1176
- unknownFiller === undefined) {
1426
+ unknownFillers.size === 0) {
1177
1427
  const after = yield* engine.state;
1178
1428
  if (Option.isNone(after.playing) && !activeIds(after).some((clipId) => owned.has(clipId))) {
1179
1429
  drained = true;
1430
+ // A truthful completed drain no longer needs a replacement to resume
1431
+ // service, even when retirement left a capacity episode behind.
1432
+ capacityRecovery = undefined;
1433
+ updateRecovery(after);
1180
1434
  const replies = drainReplies.splice(0);
1181
1435
  for (const reply of replies)
1182
1436
  yield* Deferred.succeed(reply, undefined);
@@ -1185,16 +1439,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1185
1439
  });
1186
1440
  const actor = Effect.gen(function* () {
1187
1441
  for (;;) {
1188
- const message = yield* Queue.take(inbox);
1189
- if (ended !== undefined && message._tag !== "CommandDone") {
1190
- if (message._tag === "Submit")
1191
- yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
1192
- else if (message._tag === "Withdraw")
1193
- yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
1194
- else if (message._tag === "Drain")
1195
- yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
1442
+ const message = yield* takeQueue(inbox);
1443
+ // Queued public calls observe the same terminal result through closedCall.
1444
+ if (ended !== undefined && message._tag !== "CommandDone")
1196
1445
  continue;
1197
- }
1198
1446
  switch (message._tag) {
1199
1447
  case "Submit": {
1200
1448
  if (!accepting) {
@@ -1211,6 +1459,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1211
1459
  }
1212
1460
  const nowMs = monotonicMillis(clock);
1213
1461
  const state = yield* engine.state;
1462
+ if (ended !== undefined)
1463
+ continue;
1214
1464
  const startByMs = message.item.startByOffsetMs === undefined
1215
1465
  ? undefined
1216
1466
  : nowMs + message.item.startByOffsetMs;
@@ -1219,9 +1469,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1219
1469
  yield* Deferred.fail(message.reply, WouldMissDeadline.of(message.item.key));
1220
1470
  break;
1221
1471
  }
1222
- const startedWaiter = yield* Deferred.make();
1223
- const outcomeWaiter = yield* Deferred.make();
1224
- const firstDecisiveWaiter = yield* Deferred.make();
1472
+ // No yield from the terminal check above through admission and its
1473
+ // publication below: a claim either precedes the item or settles it
1474
+ // after its Accepted evidence.
1475
+ const startedWaiter = Deferred.makeUnsafe();
1476
+ const outcomeWaiter = Deferred.makeUnsafe();
1477
+ const firstDecisiveWaiter = Deferred.makeUnsafe();
1225
1478
  const handle = {
1226
1479
  key: message.item.key,
1227
1480
  started: Deferred.await(startedWaiter),
@@ -1251,18 +1504,22 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1251
1504
  sessionId: undefined,
1252
1505
  unknownSessionId: undefined,
1253
1506
  unknownCause: undefined,
1507
+ unknownAtMs: undefined,
1254
1508
  handle,
1255
1509
  startedWaiter,
1256
1510
  outcomeWaiter,
1257
1511
  firstDecisiveWaiter,
1258
1512
  };
1259
1513
  items.set(item.key, item);
1260
- yield* PubSub.publish(events, {
1514
+ PubSub.publishUnsafe(events, {
1261
1515
  key: item.key,
1262
1516
  at: clock.currentTimeMillisUnsafe(),
1263
1517
  status: item.status,
1264
1518
  });
1265
1519
  yield* observed(state);
1520
+ // Submit returns its committed view; other messages publish once
1521
+ // through reconciliation after all their evidence is adopted.
1522
+ yield* publishState(state);
1266
1523
  yield* Deferred.succeed(message.reply, handle);
1267
1524
  break;
1268
1525
  }
@@ -1288,9 +1545,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1288
1545
  if (!draining) {
1289
1546
  draining = true;
1290
1547
  finishAccepted = message.finish === "accepted";
1548
+ updateRecovery();
1291
1549
  // Renewal admission must stop even while an earlier enqueue is
1292
1550
  // awaiting its reply in the command worker.
1293
1551
  const stoppedRenewal = yield* Effect.result(engine.stopRenewal);
1552
+ if (ended !== undefined)
1553
+ break;
1294
1554
  if (Result.isFailure(stoppedRenewal)) {
1295
1555
  drainFailure = stoppedRenewal.failure;
1296
1556
  for (const reply of drainReplies.splice(0))
@@ -1362,22 +1622,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1362
1622
  }
1363
1623
  }
1364
1624
  });
1365
- // A defect remains a defect, but it must wake waiters just as a typed
1366
- // terminal failure does. Scope interruption uses the finalizer below.
1625
+ // Orderly scope teardown has already claimed and settled Closed before
1626
+ // worker interruption; unexpected interruption retains its original Cause.
1367
1627
  const supervise = (effect) => effect.pipe(Effect.onExit((exit) => Exit.isFailure(exit) && ended === undefined
1368
- ? Effect.gen(function* () {
1369
- accepting = false;
1370
- yield* Deferred.failCause(stopped, exit.cause);
1371
- yield* Deferred.failCause(initialized, exit.cause);
1372
- for (const item of items.values()) {
1373
- yield* Deferred.failCause(item.startedWaiter, exit.cause);
1374
- yield* Deferred.failCause(item.outcomeWaiter, exit.cause);
1375
- yield* Deferred.failCause(item.firstDecisiveWaiter, exit.cause);
1376
- }
1377
- yield* Queue.shutdown(inbox);
1378
- yield* Queue.shutdown(commandQueue);
1379
- })
1628
+ ? terminate(Exit.failCause(exit.cause))
1380
1629
  : Effect.void));
1630
+ yield* Effect.forkScoped(supervise(watchdog));
1381
1631
  yield* Effect.forkScoped(supervise(commandWorker));
1382
1632
  yield* Effect.forkScoped(supervise(actor));
1383
1633
  yield* Effect.forkScoped(supervise(observe));
@@ -1425,6 +1675,6 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1425
1675
  state: SubscriptionRef.get(stateRef),
1426
1676
  asRun: Stream.fromPubSub(events),
1427
1677
  };
1428
- });
1678
+ }).pipe(Effect.provideServiceEffect(Scope.Scope, Effect.flatMap(Scope.Scope, (parent) => Scope.fork(parent, "sequential"))));
1429
1679
  export const layerScheduler = (options) => Layer.effect(Scheduler, makeScheduler(options));
1430
1680
  //# sourceMappingURL=scheduler.js.map