reactor-effect-client 0.6.0 → 0.7.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.
@@ -17,10 +17,11 @@ import { take as takeQueue } from "../_internal/queue.js";
17
17
  import { duration } from "../duration.js";
18
18
  import { parsedInput, ReactorError } from "../errors.js";
19
19
  import { monotonicMillis } from "./elapsed.js";
20
+ import { requestSeconds as h3RequestSeconds } from "../h3/profile.js";
20
21
  import { captureRequest, PolicyFailure } from "./request.js";
21
22
  import { activeIds } from "./routing.js";
22
23
  import { fillerIndexFromKey, fillerKey, isReservedSchedulerKey, keyedRequest, keyFromProviderMetadata, } from "./scheduler-key.js";
23
- import { plan, runwaySeconds } from "./scheduler-policy.js";
24
+ import { dueCues, estimatesFrom, plan, projectedStartMs, runwaySeconds, } from "./scheduler-policy.js";
24
25
  import { Engine } from "./types.js";
25
26
  /** Stable caller identity for a scheduled item. */
26
27
  export const ItemKey = Schema.NonEmptyString.pipe(Schema.brand("ItemKey"));
@@ -37,6 +38,14 @@ export class KeyMismatch extends Schema.TaggedError()("KeyMismatch", {
37
38
  return new KeyMismatch({ key });
38
39
  }
39
40
  }
41
+ export class LaneBusy extends Schema.TaggedError()("LaneBusy", {
42
+ key: ItemKey,
43
+ lane: Schema.String,
44
+ }) {
45
+ static of(key, lane) {
46
+ return new LaneBusy({ key, lane });
47
+ }
48
+ }
40
49
  export class WouldMissDeadline extends Schema.TaggedError()("WouldMissDeadline", { key: ItemKey }) {
41
50
  static of(key) {
42
51
  return new WouldMissDeadline({ key });
@@ -104,33 +113,65 @@ const requestDuration = (input, name, allowZero = false) => Effect.gen(function*
104
113
  const captured = yield* captureDuration(input);
105
114
  return yield* parsedInput(() => Duration.toMillis(duration(captured, name, { allowZero, allowNegative: name === "startBy" })), "submit").pipe(Effect.mapError((error) => invalid(error.message)));
106
115
  });
107
- /** Capture scheduling fields before reading the request's placement fields. */
108
- const captureItem = (input, lanes) => Effect.gen(function* () {
109
- if (input === null || typeof input !== "object" || Array.isArray(input))
110
- return yield* invalid("Scheduled item must be an object");
116
+ /** A continuity choice, read as caller data, checked against the request it would continue. */
117
+ const captureContinuity = (input, request) => Effect.gen(function* () {
118
+ if (input === undefined)
119
+ return undefined;
120
+ if (input !== "previous")
121
+ return yield* invalid('Continuity is "previous" or absent');
122
+ if ((request.audio?.length ?? 0) > 2)
123
+ return yield* invalid("A clip that continues takes at most two audio references");
124
+ return "previous";
125
+ });
126
+ /** Enough cues for a line's overlays and actions, and a bound on one clip's timers. */
127
+ const maxCues = 32;
128
+ /** A clip's cues, read as caller data. */
129
+ const captureCues = (input) => Effect.gen(function* () {
130
+ if (input === undefined)
131
+ return [];
132
+ if (!Array.isArray(input))
133
+ return yield* invalid("Cues must be an array");
134
+ // Read as a plain object: a mapped array type would hide the descriptors.
111
135
  const descriptors = Object.getOwnPropertyDescriptors(input);
112
- if (Object.getOwnPropertySymbols(input).length > 0 ||
113
- Object.keys(descriptors).some((field) => !["key", "lane", "request", "window", "start"].includes(field) ||
114
- !("value" in descriptors[field])))
115
- return yield* invalid("Scheduled item has unsupported fields or accessors");
116
- const key = yield* Schema.decodeUnknownEffect(ItemKey)(descriptors.key?.value).pipe(Effect.mapError(() => invalid("Scheduled item key must be nonempty")));
117
- if (isReservedSchedulerKey(key))
118
- return yield* invalid("Scheduled item key is reserved");
119
- const lane = descriptors.lane?.value;
120
- if (typeof lane !== "string" || !lanes.has(lane))
121
- return yield* invalid("Scheduled item lane is not configured");
122
- const rawRequest = descriptors.request?.value;
123
- const request = yield* captureRequest(rawRequest);
136
+ const length = descriptors.length?.value;
137
+ if (typeof length !== "number" || length > maxCues)
138
+ return yield* invalid(`A clip takes at most ${maxCues} cues`);
139
+ const cues = [];
140
+ for (let index = 0; index < length; index++) {
141
+ const descriptor = descriptors[String(index)];
142
+ if (descriptor === undefined || !("value" in descriptor))
143
+ return yield* invalid("Cues must be data");
144
+ const cue = yield* Effect.fromResult(ownedData(descriptor.value, ["name", "at"], "Cue"));
145
+ const at = yield* Effect.fromResult(ownedData(cue.at, ["from", "offset"], "Cue time"));
146
+ if (typeof cue.name !== "string" || cue.name.length === 0)
147
+ return yield* invalid("A cue needs a nonempty name");
148
+ if (at.from !== "start" && at.from !== "end")
149
+ return yield* invalid("A cue is timed from the start or the end");
150
+ cues.push({
151
+ name: cue.name,
152
+ from: at.from,
153
+ offsetMs: yield* requestDuration(at.offset, "cue offset", true),
154
+ });
155
+ }
156
+ return cues;
157
+ });
158
+ /** Capture scheduling fields before reading the request's placement fields. */
159
+ /** A caller's clip request, without the placement and source fields the scheduler owns. */
160
+ const captureClip = (input) => Effect.gen(function* () {
161
+ const request = yield* captureRequest(input);
124
162
  if (request.position !== undefined ||
125
163
  request.before !== undefined ||
126
164
  request.sameSessionAs !== undefined ||
127
165
  request.continueFrom !== undefined ||
128
166
  request.sequence !== undefined)
129
167
  return yield* invalid("Scheduler owns placement and source selection");
130
- const rawWindow = descriptors.window?.value;
131
- const window = rawWindow === undefined
168
+ return request;
169
+ });
170
+ /** A window's offsets from admission, read as caller data. */
171
+ const captureWindow = (input) => Effect.gen(function* () {
172
+ const window = input === undefined
132
173
  ? undefined
133
- : yield* Effect.fromResult(ownedData(rawWindow, ["notBefore", "startBy", "firm"], "Scheduled window"));
174
+ : yield* Effect.fromResult(ownedData(input, ["notBefore", "startBy", "firm"], "Scheduled window"));
134
175
  const notBeforeOffsetMs = window?.notBefore === undefined
135
176
  ? undefined
136
177
  : yield* requestDuration(window.notBefore, "notBefore", true);
@@ -144,15 +185,37 @@ const captureItem = (input, lanes) => Effect.gen(function* () {
144
185
  startByOffsetMs >= 0 &&
145
186
  notBeforeOffsetMs > startByOffsetMs)))
146
187
  return yield* invalid("Scheduled window is inconsistent");
188
+ return { notBeforeOffsetMs, startByOffsetMs, firm: window?.firm === true };
189
+ });
190
+ const captureItem = (input, lanes) => Effect.gen(function* () {
191
+ if (input === null || typeof input !== "object" || Array.isArray(input))
192
+ return yield* invalid("Scheduled item must be an object");
193
+ const descriptors = Object.getOwnPropertyDescriptors(input);
194
+ if (Object.getOwnPropertySymbols(input).length > 0 ||
195
+ Object.keys(descriptors).some((field) => !["key", "lane", "request", "window", "start", "cues", "continuity"].includes(field) ||
196
+ !("value" in descriptors[field])))
197
+ return yield* invalid("Scheduled item has unsupported fields or accessors");
198
+ const key = yield* Schema.decodeUnknownEffect(ItemKey)(descriptors.key?.value).pipe(Effect.mapError(() => invalid("Scheduled item key must be nonempty")));
199
+ if (isReservedSchedulerKey(key))
200
+ return yield* invalid("Scheduled item key is reserved");
201
+ const lane = descriptors.lane?.value;
202
+ if (typeof lane !== "string" || !lanes.has(lane))
203
+ return yield* invalid("Scheduled item lane is not configured");
204
+ const request = yield* captureClip(descriptors.request?.value);
205
+ const { notBeforeOffsetMs, startByOffsetMs, firm } = yield* captureWindow(descriptors.window?.value);
206
+ const cues = yield* captureCues(descriptors.cues?.value);
207
+ const continuity = yield* captureContinuity(descriptors.continuity?.value, request);
147
208
  const rawStart = descriptors.start?.value;
148
209
  const start = rawStart === undefined
149
210
  ? undefined
150
211
  : yield* Effect.fromResult(ownedData(rawStart, ["_tag", "time", "late"], "Scheduled start mode"));
151
212
  let atWallMs;
152
213
  let late = "nextBoundary";
153
- if (start?._tag === "Follow" && (start.time !== undefined || start.late !== undefined))
154
- return yield* invalid("Follow cannot contain an anchor or lateness policy");
155
- if (start !== undefined && start._tag !== "Follow") {
214
+ const mode = start?._tag === "Asap" ? "asap" : start?._tag === "Manual" ? "manual" : undefined;
215
+ if ((start?._tag === "Follow" || mode !== undefined) &&
216
+ (start?.time !== undefined || start?.late !== undefined))
217
+ return yield* invalid(`${String(start?._tag)} cannot contain an anchor or lateness policy`);
218
+ if (start !== undefined && start._tag !== "Follow" && mode === undefined) {
156
219
  if (start._tag !== "At" || !Number.isFinite(start.time))
157
220
  return yield* invalid("Scheduled start mode is invalid");
158
221
  atWallMs = start.time;
@@ -172,20 +235,166 @@ const captureItem = (input, lanes) => Effect.gen(function* () {
172
235
  request,
173
236
  notBeforeOffsetMs,
174
237
  startByOffsetMs,
175
- firm: window?.firm === true,
238
+ firm,
176
239
  atWallMs,
177
240
  late,
241
+ ...(mode === undefined ? {} : { mode }),
242
+ ...(cues.length === 0 ? {} : { cues }),
243
+ ...(continuity === undefined ? {} : { continuity }),
178
244
  fingerprint: JSON.stringify({
179
245
  lane,
180
246
  request,
181
247
  notBeforeOffsetMs,
182
248
  startByOffsetMs,
183
- firm: window?.firm === true,
249
+ firm,
184
250
  atWallMs,
185
251
  late,
252
+ mode,
253
+ ...(cues.length === 0 ? {} : { cues }),
254
+ ...(continuity === undefined ? {} : { continuity }),
255
+ }),
256
+ };
257
+ });
258
+ /** Enough parts for a spoken line of beats, and a bound on one submission's work. */
259
+ const maxGroupParts = 64;
260
+ const captureGroup = (input, lanes) => Effect.gen(function* () {
261
+ const group = yield* Effect.fromResult(ownedData(input, ["key", "lane", "parts", "window"], "Scheduled group"));
262
+ const key = yield* Schema.decodeUnknownEffect(ItemKey)(group.key).pipe(Effect.mapError(() => invalid("Scheduled group key must be nonempty")));
263
+ if (isReservedSchedulerKey(key))
264
+ return yield* invalid("Scheduled group key is reserved");
265
+ if (!Array.isArray(group.parts))
266
+ return yield* invalid("Scheduled group parts must be an array");
267
+ // Read as a plain object: a mapped array type would hide the descriptors.
268
+ const descriptors = Object.getOwnPropertyDescriptors(group.parts);
269
+ const length = descriptors.length?.value;
270
+ if (typeof length !== "number" || length < 1 || length > maxGroupParts)
271
+ return yield* invalid(`Scheduled group needs 1 to ${maxGroupParts} parts`);
272
+ const parts = [];
273
+ for (let index = 0; index < length; index++) {
274
+ const descriptor = descriptors[String(index)];
275
+ if (descriptor === undefined || !("value" in descriptor))
276
+ return yield* invalid("Scheduled group parts must be data");
277
+ const part = yield* Effect.fromResult(ownedData(descriptor.value, ["key", "request", "cues", "continuity"], "Scheduled group part"));
278
+ parts.push(yield* captureItem({
279
+ key: part.key,
280
+ lane: group.lane,
281
+ request: part.request,
282
+ ...(part.cues === undefined ? {} : { cues: part.cues }),
283
+ ...(part.continuity === undefined ? {} : { continuity: part.continuity }),
284
+ ...(index === 0 && group.window !== undefined ? { window: group.window } : {}),
285
+ }, lanes));
286
+ }
287
+ const [first, ...rest] = parts;
288
+ if (first === undefined || new Set([key, ...parts.map((part) => part.key)]).size !== length + 1)
289
+ return yield* invalid("Scheduled group and part keys must be distinct");
290
+ return {
291
+ key,
292
+ parts: [first, ...rest],
293
+ fingerprint: JSON.stringify(parts.map((part) => [part.key, part.fingerprint])),
294
+ };
295
+ });
296
+ const captureReplacement = (input) => Effect.gen(function* () {
297
+ const next = yield* Effect.fromResult(ownedData(input, ["key", "request", "cues", "continuity"], "Replacement"));
298
+ const key = yield* Schema.decodeUnknownEffect(ItemKey)(next.key).pipe(Effect.mapError(() => invalid("Replacement key must be nonempty")));
299
+ if (isReservedSchedulerKey(key))
300
+ return yield* invalid("Replacement key is reserved");
301
+ const request = yield* captureClip(next.request);
302
+ return {
303
+ key,
304
+ request,
305
+ cues: yield* captureCues(next.cues),
306
+ continuity: yield* captureContinuity(next.continuity, request),
307
+ };
308
+ });
309
+ const captureInsert = (input) => Effect.gen(function* () {
310
+ const spec = yield* Effect.fromResult(ownedData(input, ["key", "request", "before", "after", "window", "cues", "continuity"], "Insert"));
311
+ const key = yield* Schema.decodeUnknownEffect(ItemKey)(spec.key).pipe(Effect.mapError(() => invalid("Insert key must be nonempty")));
312
+ if (isReservedSchedulerKey(key))
313
+ return yield* invalid("Insert key is reserved");
314
+ if ((spec.before === undefined) === (spec.after === undefined))
315
+ return yield* invalid("Insert needs exactly one of before or after");
316
+ const side = spec.before === undefined ? "after" : "before";
317
+ const anchor = yield* Schema.decodeUnknownEffect(ItemKey)(spec.before ?? spec.after).pipe(Effect.mapError(() => invalid("Insert anchor must be a nonempty key")));
318
+ const request = yield* captureClip(spec.request);
319
+ const window = yield* captureWindow(spec.window);
320
+ const cues = yield* captureCues(spec.cues);
321
+ const continuity = yield* captureContinuity(spec.continuity, request);
322
+ return {
323
+ key,
324
+ request,
325
+ side,
326
+ anchor,
327
+ ...window,
328
+ cues,
329
+ continuity,
330
+ fingerprint: JSON.stringify({
331
+ [side]: anchor,
332
+ request,
333
+ ...window,
334
+ ...(cues.length === 0 ? {} : { cues }),
335
+ ...(continuity === undefined ? {} : { continuity }),
186
336
  }),
187
337
  };
188
338
  });
339
+ /** A bound on one batch's work, generous for a spoken plan of several lines. */
340
+ const maxEdits = 256;
341
+ const editKey = (input, name) => Schema.decodeUnknownEffect(ItemKey)(input).pipe(Effect.mapError(() => invalid(`${name} must be a nonempty key`)));
342
+ const captureEdits = (input, lanes) => Effect.gen(function* () {
343
+ if (!Array.isArray(input))
344
+ return yield* invalid("Edits must be an array");
345
+ // Read as a plain object: a mapped array type would hide the descriptors.
346
+ const descriptors = Object.getOwnPropertyDescriptors(input);
347
+ const length = descriptors.length?.value;
348
+ if (typeof length !== "number" || length < 1 || length > maxEdits)
349
+ return yield* invalid(`An edit batch needs 1 to ${maxEdits} edits`);
350
+ const edits = [];
351
+ for (let index = 0; index < length; index++) {
352
+ const descriptor = descriptors[String(index)];
353
+ if (descriptor === undefined || !("value" in descriptor))
354
+ return yield* invalid("Edits must be data");
355
+ const raw = descriptor.value;
356
+ const tag = raw !== null && typeof raw === "object"
357
+ ? Object.getOwnPropertyDescriptor(raw, "_tag")?.value
358
+ : undefined;
359
+ switch (tag) {
360
+ case "Submit": {
361
+ const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "item"], "Edit"));
362
+ edits.push({ _tag: "Submit", item: yield* captureItem(edit.item, lanes) });
363
+ break;
364
+ }
365
+ case "SubmitGroup": {
366
+ const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "group"], "Edit"));
367
+ edits.push({
368
+ _tag: "SubmitGroup",
369
+ group: yield* captureGroup(edit.group, lanes),
370
+ });
371
+ break;
372
+ }
373
+ case "Insert": {
374
+ const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "insert"], "Edit"));
375
+ edits.push({ _tag: "Insert", insert: yield* captureInsert(edit.insert) });
376
+ break;
377
+ }
378
+ case "Replace": {
379
+ const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "key", "next"], "Edit"));
380
+ edits.push({
381
+ _tag: "Replace",
382
+ key: yield* editKey(edit.key, "A replaced key"),
383
+ next: yield* captureReplacement(edit.next),
384
+ });
385
+ break;
386
+ }
387
+ case "Withdraw": {
388
+ const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "key"], "Edit"));
389
+ edits.push({ _tag: "Withdraw", key: yield* editKey(edit.key, "A withdrawn key") });
390
+ break;
391
+ }
392
+ default:
393
+ return yield* invalid("An edit's _tag is not one the scheduler knows");
394
+ }
395
+ }
396
+ return edits;
397
+ });
189
398
  export const lineup = (filler) => ({
190
399
  lanes: [{ name: "line" }],
191
400
  filler,
@@ -201,9 +410,15 @@ export const makeScheduler = (options) => Effect.gen(function* () {
201
410
  options.lanes.some((lane) => typeof lane.name !== "string" || lane.name.length === 0) ||
202
411
  new Set(options.lanes.map((lane) => lane.name)).size !== options.lanes.length)
203
412
  throw ReactorError.fromCode("InvalidInput", "Scheduler lanes must be unique and nonempty");
413
+ if (options.lanes.some((lane) => (lane.conflict !== undefined &&
414
+ !["queue", "replace", "skip"].includes(lane.conflict)) ||
415
+ (lane.cut !== undefined && typeof lane.cut !== "boolean")))
416
+ throw ReactorError.fromCode("InvalidInput", "A lane's conflict or cut is invalid");
204
417
  return options.lanes.map((lane) => lane.name);
205
418
  }, "makeScheduler");
206
- const { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs } = yield* parsedInput(() => {
419
+ const conflicts = new Map(options.lanes.map((lane) => [lane.name, lane.conflict ?? "queue"]));
420
+ const cutLanes = new Set(options.lanes.filter((lane) => lane.cut === true).map((lane) => lane.name));
421
+ const { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs, fillLengths, } = yield* parsedInput(() => {
207
422
  const floorSeconds = Duration.toMillis(duration(options.filler.runway.floor, "runway floor", { allowZero: true })) / 1000;
208
423
  const targetSeconds = Duration.toMillis(duration(options.filler.runway.target, "runway target")) / 1000;
209
424
  const maxBuildsInFlight = options.maxBuildsInFlight ?? 1;
@@ -218,12 +433,31 @@ export const makeScheduler = (options) => Effect.gen(function* () {
218
433
  maxHistory < 0 ||
219
434
  maxHistory > 65_536)
220
435
  throw ReactorError.fromCode("InvalidInput", "Scheduler runway or build cap is invalid");
221
- return { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs };
436
+ const lengths = options.filler.lengths;
437
+ const fillLengths = {
438
+ min: lengths === undefined
439
+ ? h3RequestSeconds.min
440
+ : Duration.toMillis(duration(lengths.min, "filler minimum length")) / 1000,
441
+ max: lengths === undefined
442
+ ? h3RequestSeconds.max
443
+ : Duration.toMillis(duration(lengths.max, "filler maximum length")) / 1000,
444
+ };
445
+ if (fillLengths.min > fillLengths.max)
446
+ throw ReactorError.fromCode("InvalidInput", "Filler lengths are inconsistent");
447
+ return {
448
+ floorSeconds,
449
+ targetSeconds,
450
+ maxBuildsInFlight,
451
+ maxHistory,
452
+ unknownRecoveryMs,
453
+ fillLengths,
454
+ };
222
455
  }, "makeScheduler");
223
- const captureFiller = (index, runway, targetSeconds) => Effect.gen(function* () {
456
+ const captureFiller = (index, runway, durationSeconds, targetSeconds) => Effect.gen(function* () {
224
457
  const request = yield* captureRequest(options.filler.clip({
225
458
  index,
226
459
  runwaySeconds: runway,
460
+ durationSeconds,
227
461
  ...(targetSeconds === undefined ? {} : { targetSeconds }),
228
462
  }));
229
463
  if (request.position !== undefined ||
@@ -238,14 +472,17 @@ export const makeScheduler = (options) => Effect.gen(function* () {
238
472
  let fillerRetryAtMs = 0;
239
473
  const unknownFillers = new Map();
240
474
  const maxUnknownFillers = 4096;
241
- let upcomingFiller = yield* captureFiller(0, 0);
475
+ let upcomingFiller = yield* captureFiller(0, 0, fillLengths.min);
476
+ const actorScope = yield* Effect.scope;
242
477
  const inbox = yield* Queue.unbounded();
243
478
  const commandQueue = yield* Queue.unbounded();
244
479
  const events = yield* PubSub.unbounded();
480
+ const cueEvents = yield* PubSub.unbounded();
245
481
  yield* Effect.addFinalizer(() => Effect.gen(function* () {
246
482
  yield* Queue.shutdown(commandQueue);
247
483
  yield* Queue.shutdown(inbox);
248
484
  yield* PubSub.shutdown(events);
485
+ yield* PubSub.shutdown(cueEvents);
249
486
  }));
250
487
  const stateRef = yield* SubscriptionRef.make({
251
488
  accepting: true,
@@ -254,6 +491,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
254
491
  lanes: lanes.map((name) => ({ name, keys: [] })),
255
492
  sessions: [],
256
493
  starved: 0,
494
+ estimates: { build: undefined, length: 1 },
257
495
  });
258
496
  const initialized = yield* Deferred.make();
259
497
  const stopped = yield* Deferred.make();
@@ -261,6 +499,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
261
499
  const closedCall = Deferred.await(stopped).pipe(Effect.flatMap((cause) => PolicyFailure.refuse("SessionClosed", cause.message)));
262
500
  const items = new Map();
263
501
  const history = new Map();
502
+ /** Kept while any part is active or in history, for idempotency and withdrawal. */
503
+ const groups = new Map();
264
504
  const owned = new Map();
265
505
  // An event can overtake its enqueue reply. Keep only the evidence needed to
266
506
  // decide that clip's fate until the one in-flight command returns its ID.
@@ -276,6 +516,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
276
516
  let drainFailure;
277
517
  const drainReplies = [];
278
518
  const pendingWithdrawals = new Map();
519
+ /** Edit batches that have not taken effect yet. */
520
+ const batches = new Map();
521
+ let batchCount = 0;
279
522
  const pendingBuilds = new Set();
280
523
  // A Build whose enqueue has started and whose result the worker has not
281
524
  // yet seen. The provider may still accept it after the scheduler ends.
@@ -294,11 +537,56 @@ export const makeScheduler = (options) => Effect.gen(function* () {
294
537
  let drained = false;
295
538
  let observationReady = false;
296
539
  let blockedMove;
540
+ let blockedCut;
541
+ // Recent measured builds: build seconds per requested second, and actual over requested length.
542
+ const buildSamples = [];
543
+ const lengthSamples = [];
544
+ const maxSamples = 32;
545
+ let lastFillerSeconds;
546
+ /** Filler builds sent and not yet Ready, by filler index. */
547
+ const fillerDispatch = new Map();
548
+ const measure = (atMs, requested, actual) => {
549
+ if (!(requested > 0))
550
+ return;
551
+ buildSamples.push((monotonicMillis(clock) - atMs) / 1000 / requested);
552
+ lengthSamples.push(actual / requested);
553
+ if (buildSamples.length > maxSamples)
554
+ buildSamples.shift();
555
+ if (lengthSamples.length > maxSamples)
556
+ lengthSamples.shift();
557
+ };
558
+ /** An item's build is measured once, when its clip is first seen Ready. */
559
+ const measureItem = (item, actual) => {
560
+ if (item.dispatchedAtMs === undefined)
561
+ return;
562
+ measure(item.dispatchedAtMs, item.request.durationSeconds, actual);
563
+ delete item.dispatchedAtMs;
564
+ };
565
+ /** Fires the cues of a playing item that are due by `untilMs`, in the order they fall. */
566
+ const fireCues = (entry, untilMs) => {
567
+ if (entry.status._tag !== "Started" || entry.startedAtMonoMs === undefined)
568
+ return;
569
+ for (const due of dueCues(entry.cues, entry.firedCues, entry.startedAtMonoMs, entry.status.durationSeconds, untilMs)) {
570
+ entry.firedCues.add(due);
571
+ PubSub.publishUnsafe(cueEvents, {
572
+ key: entry.key,
573
+ name: entry.cues[due].name,
574
+ at: clock.currentTimeMillisUnsafe(),
575
+ });
576
+ }
577
+ };
297
578
  const emit = (entry, status, terminal = false) => Effect.gen(function* () {
298
579
  if (ended !== undefined && !terminal)
299
580
  return;
300
581
  if (JSON.stringify(entry.status) === JSON.stringify(status))
301
582
  return;
583
+ // A clip that ends fires only the cues that fell within the airtime it had.
584
+ if (status._tag === "Ended" && entry.startedAtMonoMs !== undefined)
585
+ fireCues(entry, entry.startedAtMonoMs +
586
+ 1000 *
587
+ (status.termination === "finished" && entry.status._tag === "Started"
588
+ ? Math.max(status.airedSeconds, entry.status.durationSeconds)
589
+ : status.airedSeconds));
302
590
  entry.status = status;
303
591
  entry.phase =
304
592
  status._tag === "Accepted" ||
@@ -309,6 +597,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
309
597
  : status._tag === "Unknown" && status.terminal !== true
310
598
  ? "Unknown"
311
599
  : "Terminal";
600
+ const replaced = entry.replaces === undefined ? undefined : items.get(entry.replaces);
601
+ if (entry.group !== undefined &&
602
+ entry.inserted !== true &&
603
+ (status._tag === "Failed" || status._tag === "Dropped") &&
604
+ !(status._tag === "Dropped" && status.reason === "replaced") &&
605
+ (replaced === undefined || replaced.phase === "Terminal")) {
606
+ const group = groups.get(entry.group.key);
607
+ if (group !== undefined)
608
+ group.brokenAt = Math.min(group.brokenAt ?? entry.group.index, entry.group.index);
609
+ }
312
610
  // Publish in the step that records it (the PubSub is unbounded): a claim
313
611
  // from another fiber cannot land between the two, so asRun never carries
314
612
  // a settlement ahead of a recorded status, nor omits one.
@@ -380,6 +678,13 @@ export const makeScheduler = (options) => Effect.gen(function* () {
380
678
  yield* Deferred.done(waiter, reply);
381
679
  }
382
680
  pendingWithdrawals.clear();
681
+ for (const batch of batches.values()) {
682
+ for (const target of batch.targets.values())
683
+ if (target.reply !== undefined)
684
+ yield* Deferred.done(target.reply, closed);
685
+ yield* Deferred.done(batch.committed, closed);
686
+ }
687
+ batches.clear();
383
688
  for (const reply of drainReplies.splice(0))
384
689
  yield* Deferred.done(reply, closed);
385
690
  yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
@@ -509,6 +814,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
509
814
  pendingWithdrawals.has(key) ||
510
815
  pendingAtDeferrals.has(key) ||
511
816
  pendingItemRemovals.has(key) ||
817
+ [...batches.values()].some((batch) => batch.targets.has(key)) ||
818
+ // A replacement dropped before it was built still links its own replacement to
819
+ // the item in the place.
820
+ [...items.values()].some((other) => other.replaces === key && other.phase !== "Terminal") ||
512
821
  (item.clipId !== undefined && active.has(item.clipId)))
513
822
  continue;
514
823
  if (item.clipId !== undefined) {
@@ -520,6 +829,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
520
829
  }
521
830
  while (history.size > maxHistory)
522
831
  history.delete(history.keys().next().value);
832
+ for (const [key, group] of groups)
833
+ if (!group.parts.some((part) => items.has(part) || history.has(part)))
834
+ groups.delete(key);
523
835
  };
524
836
  // Capture membership and public rows in one pass. A fresh token reads every
525
837
  // source on every observation, including in-place changes to Engine arrays;
@@ -582,8 +894,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
582
894
  })),
583
895
  sessions,
584
896
  starved,
897
+ estimates: estimatesFrom(buildSamples, lengthSamples),
585
898
  });
586
899
  };
900
+ /** When the filler build in flight was sent, and the next filler clip's requested length. */
901
+ const fillerView = () => ({
902
+ fillerDispatchedAtMs: fillerDispatch.size === 0
903
+ ? undefined
904
+ : Math.min(...[...fillerDispatch.values()].map((sent) => sent.atMs)),
905
+ fillerSeconds: upcomingFiller?.durationSeconds ?? lastFillerSeconds,
906
+ });
587
907
  const activeRecords = (state) => [
588
908
  ...state.queued,
589
909
  ...state.ready,
@@ -708,6 +1028,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
708
1028
  }
709
1029
  else if (state.ready.some((record) => record.clipId === clipId)) {
710
1030
  const record = knownRecord(state, clipId);
1031
+ measureItem(item, record.durationSeconds);
711
1032
  item.sessionId = record.sessionId;
712
1033
  if (item.phase !== "Started")
713
1034
  yield* emit(item, { _tag: "Ready", sessionId: record.sessionId });
@@ -771,6 +1092,11 @@ export const makeScheduler = (options) => Effect.gen(function* () {
771
1092
  if (owner === undefined)
772
1093
  return;
773
1094
  if (owner._tag === "Filler") {
1095
+ const sent = fillerDispatch.get(owner.index);
1096
+ if (event._tag === "Ready" && sent !== undefined)
1097
+ measure(sent.atMs, sent.seconds, event.durationSeconds);
1098
+ if (event._tag === "Ready" || event._tag === "Ended" || event._tag === "Failed")
1099
+ fillerDispatch.delete(owner.index);
774
1100
  if (event._tag === "Started")
775
1101
  playingStartedMs.set(event.clipId, event.atMonotonicMillis ?? monotonicMillis(clock));
776
1102
  if (event._tag === "Ended" || event._tag === "Failed") {
@@ -791,6 +1117,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
791
1117
  yield* emit(item, { _tag: "Building" });
792
1118
  break;
793
1119
  case "Ready": {
1120
+ measureItem(item, event.durationSeconds);
794
1121
  const record = knownRecord(yield* engine.state, event.clipId);
795
1122
  if (record !== undefined) {
796
1123
  item.sessionId = record.sessionId;
@@ -842,9 +1169,33 @@ export const makeScheduler = (options) => Effect.gen(function* () {
842
1169
  });
843
1170
  playingStartedMs.delete(event.clipId);
844
1171
  break;
845
- case "Failed":
1172
+ case "Failed": {
1173
+ // A clip lost with a replaced session before it aired is rebuilt from the plan.
1174
+ if (event.lost === true &&
1175
+ (item.phase === "Accepted" || item.phase === "Building" || item.phase === "Ready")) {
1176
+ const sessionId = event.sessionId ?? item.sessionId;
1177
+ // Its session may still list the clip until it closes; it is not adopted again.
1178
+ removedIds.add(event.clipId);
1179
+ owned.delete(event.clipId);
1180
+ playingStartedMs.delete(event.clipId);
1181
+ delete item.clipId;
1182
+ delete item.dispatchedAtMs;
1183
+ delete item.acknowledgedAtSnapshotSerial;
1184
+ item.sessionId = undefined;
1185
+ const pending = pendingWithdrawals.get(item.key);
1186
+ if (pending !== undefined) {
1187
+ yield* emit(item, { _tag: "Dropped", reason: pending.reason });
1188
+ yield* finishWithdrawal(item.key, Result.succeed("withdrawn"));
1189
+ }
1190
+ else
1191
+ yield* emit(item, sessionId === undefined
1192
+ ? { _tag: "Accepted" }
1193
+ : { _tag: "Accepted", carried: { sessionId } });
1194
+ break;
1195
+ }
846
1196
  yield* emit(item, { _tag: "Failed", reason: { _tag: "Clip", message: event.reason } });
847
1197
  break;
1198
+ }
848
1199
  default:
849
1200
  break;
850
1201
  }
@@ -884,6 +1235,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
884
1235
  return engine.move(command.clipId, command.position, "playout");
885
1236
  case "PauseAutoplay":
886
1237
  return engine.setAutoplay(false);
1238
+ case "Cut":
1239
+ return engine.cut(command.clipId);
887
1240
  }
888
1241
  };
889
1242
  const commandWorker = Effect.gen(function* () {
@@ -926,8 +1279,23 @@ export const makeScheduler = (options) => Effect.gen(function* () {
926
1279
  const sendCommand = (command) => Effect.gen(function* () {
927
1280
  if (ended !== undefined)
928
1281
  return;
929
- if (command._tag === "Build")
1282
+ if (command._tag === "Build") {
930
1283
  pendingBuilds.add(command.key);
1284
+ const item = items.get(command.key);
1285
+ if (item !== undefined)
1286
+ item.dispatchedAtMs = monotonicMillis(clock);
1287
+ }
1288
+ if (command._tag === "BuildFiller") {
1289
+ lastFillerSeconds = command.request.durationSeconds;
1290
+ fillerDispatch.set(command.index, {
1291
+ atMs: monotonicMillis(clock),
1292
+ seconds: command.request.durationSeconds,
1293
+ });
1294
+ // In-flight filler is bounded like the samples it feeds.
1295
+ for (const index of fillerDispatch.keys())
1296
+ if (fillerDispatch.size > maxSamples)
1297
+ fillerDispatch.delete(index);
1298
+ }
931
1299
  if (command._tag === "DeferAt")
932
1300
  pendingAtDeferrals.add(command.key);
933
1301
  commandCount++;
@@ -955,10 +1323,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
955
1323
  pendingItemRemovals.add(item.key);
956
1324
  yield* sendCommand({ _tag: "RemoveItem", key: item.key, clipId: item.clipId, reason });
957
1325
  });
1326
+ /** A withdrawal's answer for an item that has started or settled another way. */
1327
+ const settledOutcome = (item) => item.phase === "Started" || item.status._tag === "Ended" || item.status._tag === "Unobserved"
1328
+ ? "already-started"
1329
+ : item.status._tag === "Dropped"
1330
+ ? "withdrawn"
1331
+ : "not-found";
958
1332
  const requestWithdrawal = (item, reason, reply) => Effect.gen(function* () {
959
1333
  if (item.phase === "Started" || item.phase === "Terminal") {
960
1334
  if (reply !== undefined)
961
- yield* Deferred.succeed(reply, item.phase === "Started" ? "already-started" : "not-found");
1335
+ yield* Deferred.succeed(reply, settledOutcome(item));
962
1336
  return;
963
1337
  }
964
1338
  const pending = pendingWithdrawals.get(item.key);
@@ -981,22 +1355,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
981
1355
  });
982
1356
  const applyPolicy = (action, state) => Effect.gen(function* () {
983
1357
  switch (action._tag) {
984
- case "Withdraw": {
985
- const item = items.get(action.key);
986
- if (item !== undefined)
987
- yield* requestWithdrawal(item, action.reason);
988
- break;
989
- }
990
1358
  case "DeferAt":
991
1359
  if (canRetryRemoval(action.clipId))
992
1360
  yield* sendCommand({ _tag: "DeferAt", key: action.key, clipId: action.clipId });
993
1361
  break;
994
- case "WithdrawFiller":
995
- for (const clipId of action.clipIds)
996
- if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
997
- pendingFillerRemovals.add(clipId);
998
- yield* sendCommand({ _tag: "RemoveFiller", clipId });
999
- }
1362
+ case "Cut":
1363
+ yield* sendCommand({ _tag: "Cut", clipId: action.clipId });
1000
1364
  break;
1001
1365
  case "Order": {
1002
1366
  const signature = state.ready.map((record) => record.clipId).join(",") +
@@ -1017,7 +1381,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1017
1381
  const item = items.get(action.key);
1018
1382
  if (item?.phase !== "Accepted")
1019
1383
  break;
1020
- const prepared = yield* Effect.result(keyedRequest(item.request, item.key));
1384
+ const prepared = yield* Effect.result(keyedRequest(item.request, item.key, action.continueFrom));
1021
1385
  if (Result.isFailure(prepared)) {
1022
1386
  yield* emit(item, {
1023
1387
  _tag: "Failed",
@@ -1043,7 +1407,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1043
1407
  const captured = upcomingFiller === undefined
1044
1408
  ? yield* Effect.result(captureFiller(fillerIndex, runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
1045
1409
  ...items.values(),
1046
- ]), action.targetSeconds))
1410
+ ]), action.durationSeconds, action.targetSeconds))
1047
1411
  : Result.succeed(upcomingFiller);
1048
1412
  if (Result.isFailure(captured)) {
1049
1413
  yield* closeActor(captured.failure);
@@ -1114,14 +1478,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1114
1478
  item.unknownSessionId = undefined;
1115
1479
  item.unknownCause = undefined;
1116
1480
  yield* replayEarlyClipEvents(clipId);
1481
+ // Evidence that overtook the reply may already have carried the clip away
1482
+ // with a lost session; the item then waits to be rebuilt, not Building.
1483
+ const carried = item.clipId === undefined;
1117
1484
  const pending = pendingWithdrawals.get(item.key);
1118
- if (pending !== undefined) {
1119
- if (item.phase === "Started" ||
1120
- item.status._tag === "Ended" ||
1121
- item.status._tag === "Unobserved")
1122
- yield* finishWithdrawal(item.key, Result.succeed("already-started"));
1123
- else if (item.phase === "Terminal")
1124
- yield* finishWithdrawal(item.key, Result.succeed("not-found"));
1485
+ if (carried) {
1486
+ // The carry settled any withdrawal that was waiting.
1487
+ }
1488
+ else if (pending !== undefined) {
1489
+ if (item.phase === "Started" || item.phase === "Terminal")
1490
+ yield* finishWithdrawal(item.key, Result.succeed(settledOutcome(item)));
1125
1491
  else
1126
1492
  yield* sendItemRemoval(item, pending.reason);
1127
1493
  }
@@ -1130,6 +1496,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1130
1496
  yield* observed(yield* engine.state);
1131
1497
  }
1132
1498
  else if (result.failure.context.outcome === "unknown") {
1499
+ // Its time to Ready spans the uncertainty, so it is never measured.
1500
+ delete item.dispatchedAtMs;
1133
1501
  if (item.clipId === undefined) {
1134
1502
  // A generic engine may re-route between the state read and dispatch.
1135
1503
  // Only an owner-fenced enqueue can attribute its unknown result.
@@ -1153,6 +1521,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1153
1521
  });
1154
1522
  else
1155
1523
  item.retryAtMs = monotonicMillis(clock) + 1_000;
1524
+ delete item.dispatchedAtMs;
1156
1525
  }
1157
1526
  break;
1158
1527
  }
@@ -1180,6 +1549,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1180
1549
  // The worker already retained this identity and its original age.
1181
1550
  }
1182
1551
  else {
1552
+ fillerDispatch.delete(command.index);
1183
1553
  fillerRetryAtMs = monotonicMillis(clock) + 1_000;
1184
1554
  if (result.failure.context.outcome === "replied") {
1185
1555
  fillerIndex = Math.max(fillerIndex, command.index + 1);
@@ -1212,7 +1582,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1212
1582
  removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
1213
1583
  if (item?.phase === "Started")
1214
1584
  yield* finishWithdrawal(command.key, Result.succeed("already-started"));
1215
- else
1585
+ // A refusal is not an outcome: the withdrawal stays pending and is retried.
1586
+ // An unknown removal may already have taken effect, so it ends the wait.
1587
+ else if (result.failure.context.outcome === "unknown")
1216
1588
  yield* finishWithdrawal(command.key, Result.fail(result.failure));
1217
1589
  }
1218
1590
  break;
@@ -1270,6 +1642,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1270
1642
  case "Order":
1271
1643
  blockedMove = Result.isFailure(result) ? command.signature : undefined;
1272
1644
  break;
1645
+ case "Cut":
1646
+ if (Result.isFailure(result))
1647
+ blockedCut = command.clipId;
1648
+ break;
1273
1649
  case "PauseAutoplay":
1274
1650
  if (Result.isFailure(result)) {
1275
1651
  drainFailure = result.failure;
@@ -1280,66 +1656,37 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1280
1656
  break;
1281
1657
  }
1282
1658
  });
1283
- // An accepted drain still owes air to every line not yet Ready, and to a
1284
- // Ready line held for a future At anchor, which runway must reach. Filler
1285
- // is the only material that can cover either, so it keeps playing and
1286
- // refilling until nothing accepted can still leave the host frozen. An
1287
- // Unknown item does not count: without a fenced source it can stay open
1288
- // until the scheduler closes, and filler for it would never stop.
1289
- const fillerHeld = (nowMs) => draining &&
1290
- finishAccepted &&
1291
- [...items.values()].some((item) => item.phase === "Accepted" ||
1292
- item.phase === "Building" ||
1293
- (item.phase === "Ready" && item.atMs !== undefined && item.atMs > nowMs));
1294
- const drainPending = (state) => Effect.gen(function* () {
1295
- if (!draining)
1296
- return;
1297
- const playingId = Option.getOrUndefined(state.playing)?.clipId;
1298
- for (const item of items.values()) {
1299
- if (finishAccepted)
1300
- break;
1301
- if (item.phase === "Terminal" || item.phase === "Started")
1302
- continue;
1303
- if (item.clipId === playingId)
1659
+ const pendingBatches = () => [...batches.values()].map((batch) => ({
1660
+ id: batch.id,
1661
+ waitFor: batch.waitFor,
1662
+ targets: [...batch.targets].map(([key, target]) => ({ key, reason: target.reason })),
1663
+ }));
1664
+ /** The reply a batch keeps for its withdrawal of `key`, taken once. */
1665
+ const takeBatchReply = (key) => {
1666
+ for (const batch of batches.values()) {
1667
+ const target = batch.targets.get(key);
1668
+ if (target === undefined)
1304
1669
  continue;
1305
- yield* requestWithdrawal(item, "withdrawn");
1670
+ batch.targets.delete(key);
1671
+ return target.reply;
1306
1672
  }
1307
- if (fillerHeld(monotonicMillis(clock)))
1673
+ return undefined;
1674
+ };
1675
+ /** A batch takes effect: what it withdraws goes at once, and its replacements take over. */
1676
+ const commitBatch = (id) => Effect.gen(function* () {
1677
+ const batch = batches.get(id);
1678
+ if (batch === undefined)
1308
1679
  return;
1309
- for (const entry of filler.values()) {
1310
- if (entry.clipId !== undefined &&
1311
- entry.clipId !== playingId &&
1312
- !pendingFillerRemovals.has(entry.clipId) &&
1313
- canRetryRemoval(entry.clipId)) {
1314
- pendingFillerRemovals.add(entry.clipId);
1315
- yield* sendCommand({ _tag: "RemoveFiller", clipId: entry.clipId });
1316
- }
1680
+ batches.delete(id);
1681
+ for (const [key, target] of batch.targets) {
1682
+ const item = items.get(key);
1683
+ if (item !== undefined)
1684
+ yield* requestWithdrawal(item, target.reason, target.reply);
1685
+ else if (target.reply !== undefined)
1686
+ yield* Deferred.succeed(target.reply, "not-found");
1317
1687
  }
1688
+ yield* Deferred.succeed(batch.committed, undefined);
1318
1689
  });
1319
- const projectedStartMs = (state, lane, nowMs) => {
1320
- const playing = Option.getOrUndefined(state.playing);
1321
- const playingRecord = playing === undefined ? undefined : Option.getOrUndefined(playing.record);
1322
- const started = playing === undefined ? undefined : playingStartedMs.get(playing.clipId);
1323
- const restMs = playingRecord === undefined || started === undefined
1324
- ? 0
1325
- : Math.max(0, playingRecord.durationSeconds * 1000 - (nowMs - started));
1326
- const laneRank = lanes.indexOf(lane);
1327
- const preferred = Option.getOrUndefined(state.preferredSessionId);
1328
- const aheadMs = state.ready.reduce((total, record) => {
1329
- if (record.sessionId !== preferred)
1330
- return total;
1331
- const owner = owned.get(record.clipId);
1332
- if (owner?._tag === "Filler")
1333
- return total;
1334
- if (owner?._tag === "Item") {
1335
- const prior = items.get(owner.key);
1336
- if (prior !== undefined && lanes.indexOf(prior.lane) > laneRank)
1337
- return total;
1338
- }
1339
- return total + record.durationSeconds * 1000;
1340
- }, 0);
1341
- return nowMs + restMs + aheadMs;
1342
- };
1343
1690
  const reconcile = Effect.gen(function* () {
1344
1691
  const state = yield* engine.state;
1345
1692
  if (ended !== undefined)
@@ -1351,6 +1698,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1351
1698
  yield* publishState(state, sources.sessions);
1352
1699
  return;
1353
1700
  }
1701
+ const nowMs = monotonicMillis(clock);
1702
+ for (const item of items.values())
1703
+ if (item.phase === "Started")
1704
+ fireCues(item, nowMs);
1354
1705
  for (const uncertain of unknownFillers.values()) {
1355
1706
  const sourceRetired = uncertain.sessionId !== undefined && !sources.has(uncertain.sessionId);
1356
1707
  if (sourceRetired)
@@ -1370,6 +1721,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1370
1721
  continue;
1371
1722
  if (item.phase === "Started")
1372
1723
  yield* finishWithdrawal(key, Result.succeed("already-started"));
1724
+ // A withdrawal still being retried when its item settles another way. Terminal
1725
+ // Unknown keeps its uncertain result from the retirement or terminal settlement.
1726
+ else if (item.phase === "Terminal" && item.status._tag !== "Unknown")
1727
+ yield* finishWithdrawal(key, Result.succeed(settledOutcome(item)));
1373
1728
  else if (item.clipId !== undefined)
1374
1729
  yield* sendItemRemoval(item, pending.reason);
1375
1730
  }
@@ -1382,14 +1737,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1382
1737
  // After retirement, so a deadline no longer outlives the uncertainty that
1383
1738
  // set it until the next message.
1384
1739
  updateRecovery(state);
1385
- if (draining)
1386
- yield* drainPending(state);
1387
1740
  const preferred = Option.getOrUndefined(state.preferredSessionId);
1388
1741
  let unknownFillerCount = 0;
1389
1742
  for (const entry of unknownFillers.values())
1390
1743
  if (entry.sessionId === undefined || entry.sessionId === preferred)
1391
1744
  unknownFillerCount++;
1392
- const decision = plan({
1745
+ const brokenGroups = new Map();
1746
+ for (const [key, group] of groups)
1747
+ if (group.brokenAt !== undefined)
1748
+ brokenGroups.set(key, group.brokenAt);
1749
+ const decide = () => plan({
1393
1750
  engine: state,
1394
1751
  items: [...items.values()],
1395
1752
  owned,
@@ -1400,13 +1757,39 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1400
1757
  targetSeconds,
1401
1758
  refillActive,
1402
1759
  maxBuildsInFlight,
1403
- accepting: accepting || (draining && finishAccepted),
1404
- fillerEnabled: !draining || fillerHeld(monotonicMillis(clock)),
1760
+ accepting,
1761
+ drain: draining ? (finishAccepted ? "accepted" : "playing") : undefined,
1405
1762
  fillerRetryAtMs,
1406
1763
  unknownFillerCount,
1407
1764
  blockedMove,
1765
+ withdrawing: new Set(pendingWithdrawals.keys()),
1766
+ dispatched: pendingBuilds,
1767
+ brokenGroups,
1768
+ batches: pendingBatches(),
1769
+ estimates: estimatesFrom(buildSamples, lengthSamples),
1770
+ ...fillerView(),
1771
+ cutLanes,
1772
+ fillLengths,
1773
+ blockedCut,
1408
1774
  });
1775
+ let decision = decide();
1776
+ // A commit changes what is withdrawn and what takes over, so the plan is read again.
1777
+ if (decision.commit.length > 0) {
1778
+ for (const id of decision.commit)
1779
+ yield* commitBatch(id);
1780
+ decision = decide();
1781
+ }
1409
1782
  refillActive = decision.refillActive;
1783
+ for (const { key, reason } of decision.withdraw) {
1784
+ const item = items.get(key);
1785
+ if (item !== undefined)
1786
+ yield* requestWithdrawal(item, reason, takeBatchReply(key));
1787
+ }
1788
+ for (const clipId of decision.withdrawFiller)
1789
+ if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
1790
+ pendingFillerRemovals.add(clipId);
1791
+ yield* sendCommand({ _tag: "RemoveFiller", clipId });
1792
+ }
1410
1793
  yield* publishState(state, sources.sessions);
1411
1794
  if ((!draining || finishAccepted) && commandCount === 0 && decision.action !== undefined)
1412
1795
  yield* applyPolicy(decision.action, state);
@@ -1437,6 +1820,504 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1437
1820
  }
1438
1821
  }
1439
1822
  });
1823
+ /** Registers a captured item, without yielding; its caller publishes the Accepted evidence. */
1824
+ const accept = (captured, nowMs, startByMs, group, place) => {
1825
+ const startedWaiter = Deferred.makeUnsafe();
1826
+ const outcomeWaiter = Deferred.makeUnsafe();
1827
+ const firstDecisiveWaiter = Deferred.makeUnsafe();
1828
+ const item = {
1829
+ key: captured.key,
1830
+ lane: captured.lane,
1831
+ request: captured.request,
1832
+ fingerprint: captured.fingerprint,
1833
+ admission: place === undefined ? ++admission : place.admission,
1834
+ seconds: captured.request.durationSeconds,
1835
+ phase: "Accepted",
1836
+ status: { _tag: "Accepted" },
1837
+ ...(place === undefined
1838
+ ? captured.notBeforeOffsetMs === undefined
1839
+ ? {}
1840
+ : { notBeforeMs: nowMs + captured.notBeforeOffsetMs }
1841
+ : place.notBeforeMs === undefined
1842
+ ? {}
1843
+ : { notBeforeMs: place.notBeforeMs }),
1844
+ ...(place?.replaces === undefined
1845
+ ? {}
1846
+ : { replaces: place.replaces, generation: place.generation ?? 1 }),
1847
+ ...(place?.inserted === true ? { inserted: true } : {}),
1848
+ ...(startByMs === undefined ? {} : { startByMs }),
1849
+ firm: captured.firm,
1850
+ ...(captured.atWallMs === undefined
1851
+ ? {}
1852
+ : {
1853
+ atWallMs: captured.atWallMs,
1854
+ atMs: nowMs + captured.atWallMs - clock.currentTimeMillisUnsafe(),
1855
+ }),
1856
+ late: captured.late,
1857
+ cues: captured.cues ?? [],
1858
+ firedCues: new Set(),
1859
+ ...(captured.continuity === undefined ? {} : { continuity: captured.continuity }),
1860
+ ...(captured.mode === "asap" ? { asap: true } : {}),
1861
+ ...(captured.mode === "manual" ? { held: true } : {}),
1862
+ ...(group === undefined ? {} : { group }),
1863
+ sessionId: undefined,
1864
+ unknownSessionId: undefined,
1865
+ unknownCause: undefined,
1866
+ unknownAtMs: undefined,
1867
+ handle: {
1868
+ key: captured.key,
1869
+ started: Deferred.await(startedWaiter),
1870
+ outcome: Deferred.await(outcomeWaiter),
1871
+ firstDecisive: Deferred.await(firstDecisiveWaiter),
1872
+ },
1873
+ startedWaiter,
1874
+ outcomeWaiter,
1875
+ firstDecisiveWaiter,
1876
+ };
1877
+ items.set(item.key, item);
1878
+ return item;
1879
+ };
1880
+ /**
1881
+ * One reply for withdrawals across a group's parts. A group key reports
1882
+ * `withdrawn` if any part was; a part key reports that part's own outcome,
1883
+ * while the parts after it are withdrawn too.
1884
+ */
1885
+ const settleWithdrawals = (replies, reply, wholeGroup) => Effect.gen(function* () {
1886
+ const outcomes = [];
1887
+ for (const pending of replies) {
1888
+ const result = yield* Effect.result(Deferred.await(pending));
1889
+ if (Result.isFailure(result)) {
1890
+ yield* Deferred.fail(reply, result.failure);
1891
+ return;
1892
+ }
1893
+ outcomes.push(result.success);
1894
+ }
1895
+ yield* Deferred.succeed(reply, wholeGroup && outcomes.includes("withdrawn") ? "withdrawn" : (outcomes[0] ?? "not-found"));
1896
+ });
1897
+ /**
1898
+ * The place an insert takes beside its anchor, or why it cannot: the anchor's lane, an
1899
+ * admission between the anchor and its neighbour in that lane, and a group place when
1900
+ * the insert falls between two parts of a group.
1901
+ */
1902
+ const placeInsert = (insert) => {
1903
+ const live = (entry) => entry !== undefined && entry.phase !== "Terminal" && entry.replacedBy === undefined;
1904
+ const named = groups.get(insert.anchor);
1905
+ let anchor;
1906
+ if (named === undefined)
1907
+ anchor = items.get(insert.anchor);
1908
+ else {
1909
+ const parts = named.parts
1910
+ .map((key) => items.get(key))
1911
+ .filter(live)
1912
+ .sort((a, b) => a.group.index - b.group.index);
1913
+ if (insert.side === "before" &&
1914
+ (parts.some((part) => part.phase === "Started") || parts[0]?.group?.index !== 0))
1915
+ return "The anchor group already started";
1916
+ anchor = insert.side === "before" ? parts[0] : parts.at(-1);
1917
+ }
1918
+ if (!live(anchor))
1919
+ return "Nothing queued under the anchor key";
1920
+ if (anchor.phase === "Unknown")
1921
+ return "The anchor's admission is uncertain";
1922
+ if (anchor.held === true)
1923
+ return "The anchor is held until it is released";
1924
+ if (insert.side === "before" && anchor.phase === "Started")
1925
+ return "The anchor already started";
1926
+ const neighbours = [...items.values()]
1927
+ .filter((entry) => entry.lane === anchor.lane && entry.phase !== "Terminal")
1928
+ .map((entry) => entry.admission);
1929
+ const bound = insert.side === "before"
1930
+ ? Math.max(...neighbours.filter((value) => value < anchor.admission))
1931
+ : Math.min(...neighbours.filter((value) => value > anchor.admission));
1932
+ const admission = Number.isFinite(bound)
1933
+ ? (anchor.admission + bound) / 2
1934
+ : anchor.admission + (insert.side === "before" ? -0.5 : 0.5);
1935
+ if (admission === anchor.admission || admission === bound)
1936
+ return "No room is left to insert at that place";
1937
+ let group;
1938
+ if (anchor.group !== undefined) {
1939
+ const indexes = (groups.get(anchor.group.key)?.parts ?? [])
1940
+ .map((key) => items.get(key)?.group?.index)
1941
+ .filter((value) => value !== undefined);
1942
+ const index = anchor.group.index;
1943
+ const neighbour = insert.side === "before"
1944
+ ? Math.max(...indexes.filter((value) => value < index))
1945
+ : Math.min(...indexes.filter((value) => value > index));
1946
+ // Before the first part or after the last one, the insert stays outside the group.
1947
+ // Once parts have aired and settled, the place before the first remaining part is
1948
+ // still inside the group.
1949
+ const between = insert.side === "before"
1950
+ ? index > 0
1951
+ ? (Number.isFinite(neighbour) ? neighbour : index - 1) + index
1952
+ : undefined
1953
+ : Number.isFinite(neighbour)
1954
+ ? index + neighbour
1955
+ : undefined;
1956
+ if (between !== undefined) {
1957
+ if (between / 2 === index || between / 2 === neighbour)
1958
+ return "No room is left to insert at that place";
1959
+ group = { key: anchor.group.key, index: between / 2 };
1960
+ }
1961
+ }
1962
+ const timing = {
1963
+ ...(anchor.atWallMs === undefined ? {} : { atWallMs: anchor.atWallMs }),
1964
+ late: anchor.late,
1965
+ asap: anchor.asap === true,
1966
+ };
1967
+ return group === undefined
1968
+ ? { lane: anchor.lane, admission, ...timing }
1969
+ : { lane: anchor.lane, admission, group, ...timing };
1970
+ };
1971
+ /** The items a withdrawal of `key` names: a group's parts, a part and those after it, or one item. */
1972
+ const withdrawalTargets = (key) => {
1973
+ const item = items.get(key);
1974
+ const named = groups.get(key);
1975
+ const group = named ?? (item?.group === undefined ? undefined : groups.get(item.group.key));
1976
+ if (group === undefined || (named === undefined && item?.inserted === true))
1977
+ return item === undefined ? [] : [item];
1978
+ const from = named === undefined ? item.group.index : 0;
1979
+ const targets = group.parts
1980
+ .map((part) => items.get(part))
1981
+ .filter((part) => part?.group !== undefined && part.group.index >= from);
1982
+ // A part key's reply is that part's own outcome, so it goes first.
1983
+ if (named === undefined)
1984
+ targets.sort((a, b) => Number(b === item) - Number(a === item));
1985
+ return targets;
1986
+ };
1987
+ /**
1988
+ * Checks and admits a call's edits in order, without yielding. A refused edit undoes the
1989
+ * edits before it, so nothing of a refused batch is admitted or published.
1990
+ */
1991
+ const stageEdits = (edits, batched, state, nowMs) => {
1992
+ const undo = [];
1993
+ const added = [];
1994
+ const waitFor = [];
1995
+ const replaced = [];
1996
+ const results = [];
1997
+ const refuse = (error) => {
1998
+ for (const step of undo.reverse())
1999
+ step();
2000
+ return Result.fail(error);
2001
+ };
2002
+ // Clips a batch withdraws or replaces do not count ahead of the clips it adds.
2003
+ const excluding = new Set();
2004
+ const named = [];
2005
+ for (const edit of edits)
2006
+ switch (edit._tag) {
2007
+ case "Submit":
2008
+ named.push(edit.item.key);
2009
+ break;
2010
+ case "SubmitGroup":
2011
+ named.push(edit.group.key, ...edit.group.parts.map((part) => part.key));
2012
+ break;
2013
+ case "Insert":
2014
+ named.push(edit.insert.key);
2015
+ break;
2016
+ case "Replace":
2017
+ named.push(edit.key, edit.next.key);
2018
+ excluding.add(edit.key);
2019
+ break;
2020
+ case "Withdraw": {
2021
+ const targets = withdrawalTargets(edit.key);
2022
+ named.push(edit.key, ...targets.map((target) => target.key).filter((key) => key !== edit.key));
2023
+ for (const target of targets)
2024
+ excluding.add(target.key);
2025
+ break;
2026
+ }
2027
+ }
2028
+ if (new Set(named).size !== named.length)
2029
+ return refuse(invalid("An edit batch names each key once"));
2030
+ for (const edit of edits)
2031
+ if (edit._tag === "Insert" && excluding.has(edit.insert.anchor))
2032
+ return refuse(invalid("An insert's anchor is withdrawn or replaced in the same batch"));
2033
+ const view = (place) => projectedStartMs({
2034
+ engine: state,
2035
+ items: [...items.values()],
2036
+ owned,
2037
+ lanes,
2038
+ playingStartedMs,
2039
+ nowMs,
2040
+ batches: pendingBatches(),
2041
+ estimates: estimatesFrom(buildSamples, lengthSamples),
2042
+ ...fillerView(),
2043
+ withdrawing: new Set([...pendingWithdrawals.keys(), ...excluding]),
2044
+ }, place);
2045
+ /**
2046
+ * A new submission's lane policy: a skip lane refuses it while the lane has an item
2047
+ * waiting or playing; a replace lane supersedes the items still waiting there.
2048
+ */
2049
+ const conflict = (key, lane) => {
2050
+ const others = [...items.values()].filter((entry) => entry.lane === lane &&
2051
+ entry.phase !== "Terminal" &&
2052
+ !excluding.has(entry.key) &&
2053
+ !added.includes(entry));
2054
+ switch (conflicts.get(lane)) {
2055
+ case "skip":
2056
+ return others.length > 0 ? LaneBusy.of(key, lane) : undefined;
2057
+ case "replace":
2058
+ for (const entry of others)
2059
+ if (entry.phase !== "Started" && !pendingWithdrawals.has(entry.key)) {
2060
+ replaced.push(entry.key);
2061
+ excluding.add(entry.key);
2062
+ }
2063
+ return undefined;
2064
+ default:
2065
+ return undefined;
2066
+ }
2067
+ };
2068
+ const admitted = (entry) => {
2069
+ added.push(entry);
2070
+ waitFor.push(entry.key);
2071
+ undo.push(() => items.delete(entry.key));
2072
+ };
2073
+ /** An existing key: the same spec returns its handle, and the batch waits for it too. */
2074
+ const repeated = (key, fingerprint) => {
2075
+ const existing = items.get(key) ?? history.get(key);
2076
+ if (existing === undefined && !groups.has(key))
2077
+ return Result.succeed(undefined);
2078
+ if (existing?.fingerprint !== fingerprint)
2079
+ return Result.fail(KeyMismatch.of(key));
2080
+ waitFor.push(key);
2081
+ return Result.succeed(existing.handle);
2082
+ };
2083
+ for (const edit of edits) {
2084
+ switch (edit._tag) {
2085
+ case "Submit": {
2086
+ const { item } = edit;
2087
+ const known = repeated(item.key, item.fingerprint);
2088
+ if (Result.isFailure(known))
2089
+ return refuse(known.failure);
2090
+ if (known.success !== undefined) {
2091
+ results.push({ _tag: "Added", handle: known.success });
2092
+ break;
2093
+ }
2094
+ const busy = conflict(item.key, item.lane);
2095
+ if (busy !== undefined)
2096
+ return refuse(busy);
2097
+ const startByMs = item.startByOffsetMs === undefined ? undefined : nowMs + item.startByOffsetMs;
2098
+ if (startByMs !== undefined &&
2099
+ view({
2100
+ lane: item.lane,
2101
+ admission: Infinity,
2102
+ seconds: item.request.durationSeconds,
2103
+ startByMs,
2104
+ ...(item.mode === "asap" ? { asap: true } : {}),
2105
+ ...(item.mode === "manual" ? { held: true } : {}),
2106
+ }) > startByMs)
2107
+ return refuse(WouldMissDeadline.of(item.key));
2108
+ const entry = accept(item, nowMs, startByMs, undefined);
2109
+ admitted(entry);
2110
+ results.push({ _tag: "Added", handle: entry.handle });
2111
+ break;
2112
+ }
2113
+ case "SubmitGroup": {
2114
+ const { group } = edit;
2115
+ const known = groups.get(group.key);
2116
+ if (known !== undefined) {
2117
+ if (known.fingerprint !== group.fingerprint)
2118
+ return refuse(KeyMismatch.of(group.key));
2119
+ waitFor.push(...known.parts);
2120
+ results.push({ _tag: "AddedGroup", handle: known.handle });
2121
+ break;
2122
+ }
2123
+ const taken = [group.key, ...group.parts.map((part) => part.key)].find((key) => items.has(key) || history.has(key) || groups.has(key));
2124
+ if (taken !== undefined)
2125
+ return refuse(KeyMismatch.of(taken));
2126
+ const [first, ...rest] = group.parts;
2127
+ const busy = conflict(group.key, first.lane);
2128
+ if (busy !== undefined)
2129
+ return refuse(busy);
2130
+ const startByMs = first.startByOffsetMs === undefined ? undefined : nowMs + first.startByOffsetMs;
2131
+ if (startByMs !== undefined &&
2132
+ view({
2133
+ lane: first.lane,
2134
+ admission: Infinity,
2135
+ seconds: first.request.durationSeconds,
2136
+ startByMs,
2137
+ }) > startByMs)
2138
+ return refuse(WouldMissDeadline.of(group.key));
2139
+ const parts = [
2140
+ accept(first, nowMs, startByMs, { key: group.key, index: 0 }),
2141
+ ...rest.map((part, index) => accept(part, nowMs, undefined, { key: group.key, index: index + 1 })),
2142
+ ];
2143
+ for (const part of parts)
2144
+ admitted(part);
2145
+ const handle = {
2146
+ key: group.key,
2147
+ parts: [parts[0].handle, ...parts.slice(1).map((part) => part.handle)],
2148
+ };
2149
+ groups.set(group.key, {
2150
+ fingerprint: group.fingerprint,
2151
+ handle,
2152
+ parts: group.parts.map((part) => part.key),
2153
+ brokenAt: undefined,
2154
+ });
2155
+ undo.push(() => groups.delete(group.key));
2156
+ results.push({ _tag: "AddedGroup", handle });
2157
+ break;
2158
+ }
2159
+ case "Insert": {
2160
+ const { insert } = edit;
2161
+ const known = repeated(insert.key, insert.fingerprint);
2162
+ if (Result.isFailure(known))
2163
+ return refuse(known.failure);
2164
+ if (known.success !== undefined) {
2165
+ results.push({ _tag: "Added", handle: known.success });
2166
+ break;
2167
+ }
2168
+ const placed = placeInsert(insert);
2169
+ if (typeof placed === "string")
2170
+ return refuse(invalid(placed));
2171
+ const startByMs = insert.startByOffsetMs === undefined ? undefined : nowMs + insert.startByOffsetMs;
2172
+ if (startByMs !== undefined &&
2173
+ view({ ...placed, seconds: insert.request.durationSeconds, startByMs }) > startByMs)
2174
+ return refuse(WouldMissDeadline.of(insert.key));
2175
+ const entry = accept({
2176
+ key: insert.key,
2177
+ lane: placed.lane,
2178
+ request: insert.request,
2179
+ fingerprint: insert.fingerprint,
2180
+ notBeforeOffsetMs: insert.notBeforeOffsetMs,
2181
+ startByOffsetMs: insert.startByOffsetMs,
2182
+ firm: insert.firm,
2183
+ atWallMs: placed.atWallMs,
2184
+ late: placed.late,
2185
+ ...(placed.asap ? { mode: "asap" } : {}),
2186
+ cues: insert.cues,
2187
+ ...(insert.continuity === undefined ? {} : { continuity: insert.continuity }),
2188
+ }, nowMs, startByMs, placed.group, {
2189
+ admission: placed.admission,
2190
+ inserted: true,
2191
+ notBeforeMs: insert.notBeforeOffsetMs === undefined
2192
+ ? undefined
2193
+ : nowMs + insert.notBeforeOffsetMs,
2194
+ });
2195
+ admitted(entry);
2196
+ if (placed.group !== undefined) {
2197
+ const parts = groups.get(placed.group.key)?.parts;
2198
+ parts?.push(entry.key);
2199
+ undo.push(() => parts?.pop());
2200
+ }
2201
+ results.push({ _tag: "Added", handle: entry.handle });
2202
+ break;
2203
+ }
2204
+ case "Replace": {
2205
+ const { next } = edit;
2206
+ const fingerprint = JSON.stringify({
2207
+ replaces: edit.key,
2208
+ request: next.request,
2209
+ ...(next.cues.length === 0 ? {} : { cues: next.cues }),
2210
+ ...(next.continuity === undefined ? {} : { continuity: next.continuity }),
2211
+ });
2212
+ const known = repeated(next.key, fingerprint);
2213
+ if (Result.isFailure(known))
2214
+ return refuse(known.failure);
2215
+ if (known.success !== undefined) {
2216
+ results.push({ _tag: "Added", handle: known.success });
2217
+ break;
2218
+ }
2219
+ const old = items.get(edit.key);
2220
+ if (old === undefined ||
2221
+ old.replacedBy !== undefined ||
2222
+ (old.phase !== "Accepted" && old.phase !== "Building" && old.phase !== "Ready"))
2223
+ return refuse(invalid("Nothing queued to replace under that key"));
2224
+ const entry = accept({
2225
+ key: next.key,
2226
+ lane: old.lane,
2227
+ request: next.request,
2228
+ fingerprint,
2229
+ notBeforeOffsetMs: undefined,
2230
+ startByOffsetMs: undefined,
2231
+ firm: old.firm,
2232
+ atWallMs: old.atWallMs,
2233
+ late: old.late,
2234
+ ...(old.asap === true ? { mode: "asap" } : {}),
2235
+ ...(old.held === true ? { mode: "manual" } : {}),
2236
+ cues: next.cues,
2237
+ ...(next.continuity === undefined ? {} : { continuity: next.continuity }),
2238
+ }, nowMs, old.startByMs, old.group, {
2239
+ admission: old.admission,
2240
+ replaces: old.key,
2241
+ generation: (old.generation ?? 0) + 1,
2242
+ inserted: old.inserted === true,
2243
+ notBeforeMs: old.notBeforeMs,
2244
+ });
2245
+ admitted(entry);
2246
+ old.replacedBy = entry.key;
2247
+ undo.push(() => delete old.replacedBy);
2248
+ // A pending batch that waits for the replaced item waits for its replacement.
2249
+ for (const batch of batches.values())
2250
+ if (batch.waitFor.includes(old.key)) {
2251
+ batch.waitFor.push(entry.key);
2252
+ undo.push(() => batch.waitFor.pop());
2253
+ }
2254
+ if (old.group !== undefined) {
2255
+ const parts = groups.get(old.group.key)?.parts;
2256
+ parts?.push(entry.key);
2257
+ undo.push(() => parts?.pop());
2258
+ }
2259
+ results.push({ _tag: "Added", handle: entry.handle });
2260
+ break;
2261
+ }
2262
+ case "Withdraw":
2263
+ results.push({ _tag: "Withdrawal", key: edit.key });
2264
+ break;
2265
+ }
2266
+ }
2267
+ return Result.succeed({ results, added, waitFor, replaced });
2268
+ };
2269
+ /**
2270
+ * Makes a staged call's handle. A batch keeps what it withdraws as cover until it commits;
2271
+ * a single call adds one item or group and takes effect at once.
2272
+ */
2273
+ const openBatch = (staged, batched) => {
2274
+ // A single call adds one item or group and never withdraws; only a replace lane gives
2275
+ // it something to supersede.
2276
+ if (!batched && staged.replaced.length === 0)
2277
+ return {
2278
+ handle: {
2279
+ results: staged.results.filter((result) => result._tag !== "Withdrawal"),
2280
+ committed: Effect.void,
2281
+ },
2282
+ settle: [],
2283
+ };
2284
+ const settle = [];
2285
+ const batch = {
2286
+ id: ++batchCount,
2287
+ waitFor: [...staged.waitFor],
2288
+ targets: new Map(staged.replaced.map((key) => [key, { reason: "replaced", reply: undefined }])),
2289
+ committed: Deferred.makeUnsafe(),
2290
+ };
2291
+ const results = staged.results.map((result) => {
2292
+ if (result._tag !== "Withdrawal")
2293
+ return result;
2294
+ const replies = [];
2295
+ for (const target of withdrawalTargets(result.key)) {
2296
+ const reply = Deferred.makeUnsafe();
2297
+ replies.push(reply);
2298
+ batch.targets.set(target.key, { reason: "withdrawn", reply });
2299
+ }
2300
+ const outcome = Deferred.makeUnsafe();
2301
+ settle.push(settleWithdrawals(replies, outcome, groups.has(result.key)));
2302
+ return {
2303
+ _tag: "Withdrawal",
2304
+ outcome: Deferred.await(outcome).pipe(Effect.raceFirst(closedCall)),
2305
+ };
2306
+ });
2307
+ // A pending batch whose new clip this one withdraws now also waits for this one's, so
2308
+ // its own cover is not withdrawn before anything replaces it.
2309
+ for (const other of batches.values())
2310
+ if (other.waitFor.some((key) => batch.targets.has(key)))
2311
+ other.waitFor.push(...batch.waitFor);
2312
+ batches.set(batch.id, batch);
2313
+ return {
2314
+ handle: {
2315
+ results: batched ? results : results.filter((result) => result._tag !== "Withdrawal"),
2316
+ committed: batched ? Deferred.await(batch.committed) : Effect.void,
2317
+ },
2318
+ settle,
2319
+ };
2320
+ };
1440
2321
  const actor = Effect.gen(function* () {
1441
2322
  for (;;) {
1442
2323
  const message = yield* takeQueue(inbox);
@@ -1444,91 +2325,66 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1444
2325
  if (ended !== undefined && message._tag !== "CommandDone")
1445
2326
  continue;
1446
2327
  switch (message._tag) {
1447
- case "Submit": {
2328
+ case "Edit": {
1448
2329
  if (!accepting) {
1449
2330
  yield* Deferred.fail(message.reply, invalid("Scheduler is draining or closed"));
1450
2331
  break;
1451
2332
  }
1452
- const existing = items.get(message.item.key) ?? history.get(message.item.key);
1453
- if (existing !== undefined) {
1454
- if (existing.fingerprint === message.item.fingerprint)
1455
- yield* Deferred.succeed(message.reply, existing.handle);
1456
- else
1457
- yield* Deferred.fail(message.reply, KeyMismatch.of(message.item.key));
1458
- break;
1459
- }
1460
2333
  const nowMs = monotonicMillis(clock);
1461
2334
  const state = yield* engine.state;
1462
2335
  if (ended !== undefined)
1463
2336
  continue;
1464
- const startByMs = message.item.startByOffsetMs === undefined
1465
- ? undefined
1466
- : nowMs + message.item.startByOffsetMs;
1467
- const projectedMs = projectedStartMs(state, message.item.lane, nowMs);
1468
- if (startByMs !== undefined && projectedMs > startByMs) {
1469
- yield* Deferred.fail(message.reply, WouldMissDeadline.of(message.item.key));
2337
+ // No yield from the terminal check above through admission and its
2338
+ // publication below: a claim either precedes the items or settles them
2339
+ // after their Accepted evidence.
2340
+ const staged = stageEdits(message.edits, message.batched, state, nowMs);
2341
+ if (Result.isFailure(staged)) {
2342
+ yield* Deferred.fail(message.reply, staged.failure);
1470
2343
  break;
1471
2344
  }
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();
1478
- const handle = {
1479
- key: message.item.key,
1480
- started: Deferred.await(startedWaiter),
1481
- outcome: Deferred.await(outcomeWaiter),
1482
- firstDecisive: Deferred.await(firstDecisiveWaiter),
1483
- };
1484
- const item = {
1485
- key: message.item.key,
1486
- lane: message.item.lane,
1487
- request: message.item.request,
1488
- fingerprint: message.item.fingerprint,
1489
- admission: ++admission,
1490
- phase: "Accepted",
1491
- status: { _tag: "Accepted" },
1492
- ...(message.item.notBeforeOffsetMs === undefined
1493
- ? {}
1494
- : { notBeforeMs: nowMs + message.item.notBeforeOffsetMs }),
1495
- ...(startByMs === undefined ? {} : { startByMs }),
1496
- firm: message.item.firm,
1497
- ...(message.item.atWallMs === undefined
1498
- ? {}
1499
- : {
1500
- atWallMs: message.item.atWallMs,
1501
- atMs: nowMs + message.item.atWallMs - clock.currentTimeMillisUnsafe(),
1502
- }),
1503
- late: message.item.late,
1504
- sessionId: undefined,
1505
- unknownSessionId: undefined,
1506
- unknownCause: undefined,
1507
- unknownAtMs: undefined,
1508
- handle,
1509
- startedWaiter,
1510
- outcomeWaiter,
1511
- firstDecisiveWaiter,
1512
- };
1513
- items.set(item.key, item);
1514
- PubSub.publishUnsafe(events, {
1515
- key: item.key,
1516
- at: clock.currentTimeMillisUnsafe(),
1517
- status: item.status,
1518
- });
2345
+ for (const entry of staged.success.added)
2346
+ PubSub.publishUnsafe(events, {
2347
+ key: entry.key,
2348
+ at: clock.currentTimeMillisUnsafe(),
2349
+ status: entry.status,
2350
+ });
2351
+ const opened = openBatch(staged.success, message.batched);
2352
+ for (const effect of opened.settle)
2353
+ yield* Effect.forkIn(effect, actorScope);
1519
2354
  yield* observed(state);
1520
- // Submit returns its committed view; other messages publish once
2355
+ // An edit returns its committed view; other messages publish once
1521
2356
  // through reconciliation after all their evidence is adopted.
1522
2357
  yield* publishState(state);
1523
- yield* Deferred.succeed(message.reply, handle);
2358
+ yield* Deferred.succeed(message.reply, opened.handle);
1524
2359
  break;
1525
2360
  }
1526
2361
  case "Withdraw": {
2362
+ const targets = withdrawalTargets(message.key);
2363
+ if (targets.length <= 1 && !groups.has(message.key)) {
2364
+ if (targets[0] === undefined)
2365
+ yield* Deferred.succeed(message.reply, "not-found");
2366
+ else
2367
+ yield* requestWithdrawal(targets[0], "withdrawn", message.reply);
2368
+ break;
2369
+ }
2370
+ const replies = [];
2371
+ for (const part of targets) {
2372
+ const reply = Deferred.makeUnsafe();
2373
+ replies.push(reply);
2374
+ yield* requestWithdrawal(part, "withdrawn", reply);
2375
+ }
2376
+ yield* Effect.forkIn(settleWithdrawals(replies, message.reply, groups.has(message.key)), actorScope);
2377
+ break;
2378
+ }
2379
+ case "Release": {
1527
2380
  const item = items.get(message.key);
1528
- if (item === undefined)
1529
- yield* Deferred.succeed(message.reply, "not-found");
1530
- else
1531
- yield* requestWithdrawal(item, "withdrawn", message.reply);
2381
+ if (item?.held !== true || item.phase === "Started" || item.phase === "Terminal") {
2382
+ yield* Deferred.fail(message.reply, invalid("Nothing is held under that key"));
2383
+ break;
2384
+ }
2385
+ item.held = false;
2386
+ item.asap = true;
2387
+ yield* Deferred.succeed(message.reply, undefined);
1532
2388
  break;
1533
2389
  }
1534
2390
  case "Drain": {
@@ -1562,7 +2418,6 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1562
2418
  yield* sendCommand({ _tag: "PauseAutoplay" });
1563
2419
  }
1564
2420
  }
1565
- yield* drainPending(yield* engine.state);
1566
2421
  break;
1567
2422
  }
1568
2423
  case "Snapshot": {
@@ -1640,16 +2495,72 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1640
2495
  yield* Queue.offer(inbox, { _tag: "Tick" });
1641
2496
  yield* Effect.addFinalizer(() => closeActor(ReactorError.fromCode("Closed", "The scheduler closed")));
1642
2497
  yield* Deferred.await(initialized);
2498
+ const laneSet = new Set(lanes);
2499
+ const send = (edits, batched) => Effect.gen(function* () {
2500
+ const reply = yield* Deferred.make();
2501
+ if (!(yield* Queue.offer(inbox, { _tag: "Edit", edits, batched, reply })))
2502
+ return yield* closedCall;
2503
+ return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
2504
+ });
2505
+ /** The one result of a single call. */
2506
+ const only = (handle) => handle.results[0];
1643
2507
  const submit = (input) => Effect.gen(function* () {
1644
2508
  if (yield* Deferred.isDone(stopped))
1645
2509
  return yield* closedCall;
1646
- const item = yield* captureItem(input, new Set(lanes));
2510
+ const item = yield* captureItem(input, laneSet);
1647
2511
  yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", item.key);
2512
+ const result = only(yield* send([{ _tag: "Submit", item }], false));
2513
+ if (result?._tag !== "Added")
2514
+ return yield* Effect.die("A submission returned no item");
2515
+ return result.handle;
2516
+ }).pipe(Effect.withSpan("Scheduler.submit", {}, { captureStackTrace: false }));
2517
+ const submitGroup = (input) => Effect.gen(function* () {
2518
+ if (yield* Deferred.isDone(stopped))
2519
+ return yield* closedCall;
2520
+ const group = yield* captureGroup(input, laneSet);
2521
+ yield* Effect.annotateCurrentSpan("reactor.scheduler.group.key", group.key);
2522
+ const result = only(yield* send([{ _tag: "SubmitGroup", group }], false));
2523
+ if (result?._tag !== "AddedGroup")
2524
+ return yield* Effect.die("A group returned no handle");
2525
+ return result.handle;
2526
+ }).pipe(Effect.withSpan("Scheduler.submitGroup", {}, { captureStackTrace: false }));
2527
+ const replace = (key, input) => Effect.gen(function* () {
2528
+ if (yield* Deferred.isDone(stopped))
2529
+ return yield* closedCall;
2530
+ const next = yield* captureReplacement(input);
2531
+ const result = only(yield* send([{ _tag: "Replace", key, next }], false).pipe(
2532
+ // A replacement keeps the replaced item's place and window, so neither a deadline
2533
+ // nor its lane's policy is checked for it.
2534
+ Effect.catchTag(["WouldMissDeadline", "LaneBusy"], (error) => Effect.die(error))));
2535
+ if (result?._tag !== "Added")
2536
+ return yield* Effect.die("A replacement returned no item");
2537
+ return result.handle;
2538
+ }).pipe(Effect.withSpan("Scheduler.replace", { attributes: { key } }, { captureStackTrace: false }));
2539
+ const insert = (input) => Effect.gen(function* () {
2540
+ if (yield* Deferred.isDone(stopped))
2541
+ return yield* closedCall;
2542
+ const captured = yield* captureInsert(input);
2543
+ yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", captured.key);
2544
+ const result = only(yield* send([{ _tag: "Insert", insert: captured }], false).pipe(
2545
+ // An insert places itself, so its lane's policy does not apply.
2546
+ Effect.catchTag("LaneBusy", (error) => Effect.die(error))));
2547
+ if (result?._tag !== "Added")
2548
+ return yield* Effect.die("An insert returned no item");
2549
+ return result.handle;
2550
+ }).pipe(Effect.withSpan("Scheduler.insert", {}, { captureStackTrace: false }));
2551
+ const edit = (input) => Effect.gen(function* () {
2552
+ if (yield* Deferred.isDone(stopped))
2553
+ return yield* closedCall;
2554
+ const edits = yield* captureEdits(input, laneSet);
2555
+ yield* Effect.annotateCurrentSpan("reactor.scheduler.edits", edits.length);
2556
+ return yield* send(edits, true);
2557
+ }).pipe(Effect.withSpan("Scheduler.edit", {}, { captureStackTrace: false }));
2558
+ const release = (key) => Effect.gen(function* () {
1648
2559
  const reply = yield* Deferred.make();
1649
- if (!(yield* Queue.offer(inbox, { _tag: "Submit", item, reply })))
2560
+ if (!(yield* Queue.offer(inbox, { _tag: "Release", key, reply })))
1650
2561
  return yield* closedCall;
1651
2562
  return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
1652
- }).pipe(Effect.withSpan("Scheduler.submit", {}, { captureStackTrace: false }));
2563
+ }).pipe(Effect.withSpan("Scheduler.release", { attributes: { key } }, { captureStackTrace: false }));
1653
2564
  const withdraw = (key) => Effect.gen(function* () {
1654
2565
  const reply = yield* Deferred.make();
1655
2566
  if (!(yield* Queue.offer(inbox, { _tag: "Withdraw", key, reply })))
@@ -1669,11 +2580,17 @@ export const makeScheduler = (options) => Effect.gen(function* () {
1669
2580
  }).pipe(Effect.withSpan("Scheduler.drain", {}, { captureStackTrace: false }));
1670
2581
  return {
1671
2582
  submit,
2583
+ submitGroup,
2584
+ replace,
2585
+ insert,
2586
+ edit,
2587
+ release,
1672
2588
  withdraw,
1673
2589
  drain,
1674
2590
  failure: Deferred.await(stopped),
1675
2591
  state: SubscriptionRef.get(stateRef),
1676
2592
  asRun: Stream.fromPubSub(events),
2593
+ cues: Stream.fromPubSub(cueEvents),
1677
2594
  };
1678
2595
  }).pipe(Effect.provideServiceEffect(Scope.Scope, Effect.flatMap(Scope.Scope, (parent) => Scope.fork(parent, "sequential"))));
1679
2596
  export const layerScheduler = (options) => Layer.effect(Scheduler, makeScheduler(options));