@usine/orchestrator 0.3.0 → 0.4.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 (148) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +32 -9
  3. package/dist/board/items.d.ts +14 -3
  4. package/dist/board/items.d.ts.map +1 -1
  5. package/dist/board/items.js +29 -1
  6. package/dist/board/items.js.map +1 -1
  7. package/dist/board/projection.d.ts +2 -1
  8. package/dist/board/projection.d.ts.map +1 -1
  9. package/dist/board/projection.js +20 -10
  10. package/dist/board/projection.js.map +1 -1
  11. package/dist/board/tasks.d.ts +1 -1
  12. package/dist/board/tasks.d.ts.map +1 -1
  13. package/dist/board/tasks.js +7 -3
  14. package/dist/board/tasks.js.map +1 -1
  15. package/dist/cloudflare.d.ts +32 -0
  16. package/dist/cloudflare.d.ts.map +1 -0
  17. package/dist/cloudflare.js +68 -0
  18. package/dist/cloudflare.js.map +1 -0
  19. package/dist/current-instance/changes.d.ts +3 -3
  20. package/dist/current-instance/changes.d.ts.map +1 -1
  21. package/dist/current-instance/changes.js +6 -3
  22. package/dist/current-instance/changes.js.map +1 -1
  23. package/dist/current-instance/current-instance.d.ts +3 -3
  24. package/dist/exe-dev.d.ts +1 -1
  25. package/dist/exe-dev.d.ts.map +1 -1
  26. package/dist/exe-dev.js +3 -1
  27. package/dist/exe-dev.js.map +1 -1
  28. package/dist/http-client.d.ts +8 -0
  29. package/dist/http-client.d.ts.map +1 -0
  30. package/dist/http-client.js +8 -0
  31. package/dist/http-client.js.map +1 -0
  32. package/dist/lease-order.d.ts +60 -0
  33. package/dist/lease-order.d.ts.map +1 -0
  34. package/dist/lease-order.js +76 -0
  35. package/dist/lease-order.js.map +1 -0
  36. package/dist/local.d.ts +1 -1
  37. package/dist/local.d.ts.map +1 -1
  38. package/dist/local.js +3 -1
  39. package/dist/local.js.map +1 -1
  40. package/dist/operator/attempt.d.ts +22 -3
  41. package/dist/operator/attempt.d.ts.map +1 -1
  42. package/dist/operator/attempt.js +22 -3
  43. package/dist/operator/attempt.js.map +1 -1
  44. package/dist/operator/board.d.ts +8 -3
  45. package/dist/operator/board.d.ts.map +1 -1
  46. package/dist/operator/board.js +8 -4
  47. package/dist/operator/board.js.map +1 -1
  48. package/dist/operator/configuration.d.ts +65 -9
  49. package/dist/operator/configuration.d.ts.map +1 -1
  50. package/dist/operator/configuration.js +90 -10
  51. package/dist/operator/configuration.js.map +1 -1
  52. package/dist/operator/desk.d.ts +33 -8
  53. package/dist/operator/desk.d.ts.map +1 -1
  54. package/dist/operator/desk.js +73 -11
  55. package/dist/operator/desk.js.map +1 -1
  56. package/dist/operator/http.d.ts +14 -79
  57. package/dist/operator/http.d.ts.map +1 -1
  58. package/dist/operator/http.js +20 -14
  59. package/dist/operator/http.js.map +1 -1
  60. package/dist/operator/machines.d.ts +71 -0
  61. package/dist/operator/machines.d.ts.map +1 -0
  62. package/dist/operator/machines.js +103 -0
  63. package/dist/operator/machines.js.map +1 -0
  64. package/dist/operator/need.d.ts +15 -1
  65. package/dist/operator/need.d.ts.map +1 -1
  66. package/dist/operator/need.js +15 -0
  67. package/dist/operator/need.js.map +1 -1
  68. package/dist/operator/operator.d.ts +36 -10
  69. package/dist/operator/operator.d.ts.map +1 -1
  70. package/dist/operator/operator.js +136 -31
  71. package/dist/operator/operator.js.map +1 -1
  72. package/dist/operator/overview.d.ts +2 -2
  73. package/dist/operator/projection.d.ts +2 -0
  74. package/dist/operator/projection.d.ts.map +1 -1
  75. package/dist/operator/projection.js +12 -4
  76. package/dist/operator/projection.js.map +1 -1
  77. package/dist/operator/run.d.ts +30 -0
  78. package/dist/operator/run.d.ts.map +1 -1
  79. package/dist/operator/run.js +31 -1
  80. package/dist/operator/run.js.map +1 -1
  81. package/dist/operator/statistics/cache.d.ts +7 -0
  82. package/dist/operator/statistics/cache.d.ts.map +1 -0
  83. package/dist/operator/statistics/cache.js +40 -0
  84. package/dist/operator/statistics/cache.js.map +1 -0
  85. package/dist/operator/statistics/runs.d.ts +36 -0
  86. package/dist/operator/statistics/runs.d.ts.map +1 -0
  87. package/dist/operator/statistics/runs.js +284 -0
  88. package/dist/operator/statistics/runs.js.map +1 -0
  89. package/dist/operator/statistics/statistics.d.ts +20 -0
  90. package/dist/operator/statistics/statistics.d.ts.map +1 -0
  91. package/dist/operator/statistics/statistics.js +138 -0
  92. package/dist/operator/statistics/statistics.js.map +1 -0
  93. package/dist/operator/statistics/tasks.d.ts +30 -0
  94. package/dist/operator/statistics/tasks.d.ts.map +1 -0
  95. package/dist/operator/statistics/tasks.js +244 -0
  96. package/dist/operator/statistics/tasks.js.map +1 -0
  97. package/dist/operator/statistics/view.d.ts +302 -0
  98. package/dist/operator/statistics/view.d.ts.map +1 -0
  99. package/dist/operator/statistics/view.js +243 -0
  100. package/dist/operator/statistics/view.js.map +1 -0
  101. package/dist/operator/statistics/window.d.ts +24 -0
  102. package/dist/operator/statistics/window.d.ts.map +1 -0
  103. package/dist/operator/statistics/window.js +47 -0
  104. package/dist/operator/statistics/window.js.map +1 -0
  105. package/dist/operator/task.d.ts +2 -1
  106. package/dist/operator/task.d.ts.map +1 -1
  107. package/dist/operator/task.js +3 -3
  108. package/dist/operator/task.js.map +1 -1
  109. package/dist/operator/usage.d.ts +10 -18
  110. package/dist/operator/usage.d.ts.map +1 -1
  111. package/dist/operator/usage.js +57 -42
  112. package/dist/operator/usage.js.map +1 -1
  113. package/dist/polling/closed-item-asks.d.ts +8 -0
  114. package/dist/polling/closed-item-asks.d.ts.map +1 -0
  115. package/dist/polling/closed-item-asks.js +28 -0
  116. package/dist/polling/closed-item-asks.js.map +1 -0
  117. package/dist/polling/open-pull-requests.d.ts +28 -0
  118. package/dist/polling/open-pull-requests.d.ts.map +1 -0
  119. package/dist/polling/open-pull-requests.js +131 -0
  120. package/dist/polling/open-pull-requests.js.map +1 -0
  121. package/dist/polling/polling.d.ts.map +1 -1
  122. package/dist/polling/polling.js +31 -18
  123. package/dist/polling/polling.js.map +1 -1
  124. package/dist/polling/repository-sync.d.ts +1 -0
  125. package/dist/polling/repository-sync.d.ts.map +1 -1
  126. package/dist/polling/repository-sync.js +1 -1
  127. package/dist/polling/repository-sync.js.map +1 -1
  128. package/dist/runtime.d.ts +2 -2
  129. package/dist/runtime.d.ts.map +1 -1
  130. package/dist/runtime.js +10 -10
  131. package/dist/runtime.js.map +1 -1
  132. package/dist/scheduler.d.ts +13 -4
  133. package/dist/scheduler.d.ts.map +1 -1
  134. package/dist/scheduler.js +312 -55
  135. package/dist/scheduler.js.map +1 -1
  136. package/dist/ssh.d.ts +71 -7
  137. package/dist/ssh.d.ts.map +1 -1
  138. package/dist/ssh.js +84 -15
  139. package/dist/ssh.js.map +1 -1
  140. package/dist/worker-engine.d.ts +7 -4
  141. package/dist/worker-engine.d.ts.map +1 -1
  142. package/dist/worker-engine.js +36 -5
  143. package/dist/worker-engine.js.map +1 -1
  144. package/dist/worker-status.d.ts +80 -10
  145. package/dist/worker-status.d.ts.map +1 -1
  146. package/dist/worker-status.js +31 -10
  147. package/dist/worker-status.js.map +1 -1
  148. package/package.json +29 -25
package/dist/scheduler.js CHANGED
@@ -1,26 +1,27 @@
1
1
  import { resolveParentEffort } from "@usine/core/agent";
2
2
  import { AgentFailure, InfraFailure } from "@usine/core/attempt/failure";
3
3
  import { salvageRefFor } from "@usine/core/attempt/salvage";
4
+ import { canonicalRepositoryName } from "@usine/core/repository";
4
5
  import { BotLogin, WorkerName } from "@usine/core/worker";
5
- import { CheckoutRequest, Executor, ExecutorError, ExecutorOperationError } from "@usine/executor";
6
+ import { CheckoutRequest, Executor, ExecutorError, ExecutorOperationError, fullPreflight, } from "@usine/executor";
6
7
  import { StepExecutor } from "@usine/engine/step-executor";
7
- import { GitHubApp } from "@usine/github/github-app";
8
- import { Array as EffectArray, Context, DateTime, Deferred, Effect, Exit, Layer, Match, Option, Order, Ref, Schema, Semaphore, TxPriorityQueue, TxRef, } from "effect";
8
+ import { GitHubApp, isRefusal } from "@usine/github/github-app";
9
+ import { Array as EffectArray, Context, DateTime, Deferred, Duration, Effect, Exit, FiberMap, Layer, Match, Option, Ref, Result, Schema, Semaphore, TxPriorityQueue, TxRef, } from "effect";
9
10
  import { agentsWithNewBrains, workersChange, } from "./current-instance/changes.js";
10
11
  import { CurrentInstance, reloadPreflightFailed, restartRequired, } from "./current-instance/current-instance.js";
11
- import { recordPreflight, unchecked, WorkerStatus } from "./worker-status.js";
12
+ import { payloadLabels } from "./board/items.js";
13
+ import { aheadCount, leaseOrder, passedCount, pipelinePosition, } from "./lease-order.js";
14
+ import { recordPreflight, unchecked, WorkerStatus, } from "./worker-status.js";
12
15
  export class UnsupportedWorkerError extends Schema.TaggedError("@usine/orchestrator/UnsupportedWorkerError")("UnsupportedWorkerError", {
13
16
  worker: WorkerName,
14
17
  kind: Schema.String,
15
18
  }) {
16
19
  }
17
- // Reservations for a specific Worker (Attempts already running there) go first; otherwise, the Run
18
- // whose Event arrived first goes first.
19
- const reservationOrder = Order.combineAll([
20
- Order.mapInput(Order.Boolean, (reservation) => Option.isNone(reservation.worker)),
21
- Order.mapInput(Order.Number, (reservation) => reservation.eventTime),
22
- Order.mapInput(Order.Number, (reservation) => reservation.order),
23
- ]);
20
+ const positionFields = (position) => Match.valueTags(position, {
21
+ Priority: ({ priority }) => ({ positionSource: "priority", position: priority }),
22
+ Lane: ({ lane, number }) => ({ positionSource: "lane", position: number, lane }),
23
+ Unpositioned: () => ({ positionSource: "none" }),
24
+ });
24
25
  const slotPoolOf = (workers, previous) => {
25
26
  const declared = new Set(workers.map(({ name }) => name));
26
27
  return new Map([
@@ -33,18 +34,36 @@ const slotPoolOf = (workers, previous) => {
33
34
  .map(([name, { leased }]) => [name, { capacity: 0, leased }]),
34
35
  ]);
35
36
  };
37
+ /** Returns the Workers that take new Attempts: those `usine.yaml` declares with slots. */
38
+ const openWorkers = (pool) => [...pool].filter(([, { capacity }]) => capacity > 0).map(([worker]) => worker);
39
+ /** Returns the Runs of `queued`, which is in lease order, with the Runs ahead of each. */
40
+ const queuedRuns = (queued, pool) => {
41
+ const open = openWorkers(pool);
42
+ return queued.map((reservation, index) => ({
43
+ runId: reservation.runId,
44
+ loop: reservation.loop,
45
+ itemKey: reservation.itemKey,
46
+ position: reservation.position,
47
+ ahead: aheadCount(queued.slice(0, index), reservation, open),
48
+ }));
49
+ };
36
50
  const isFree = (pool, worker) => {
37
51
  const slots = pool.get(worker);
38
52
  return slots !== undefined && slots.leased < slots.capacity;
39
53
  };
40
54
  /**
41
55
  * Returns the Worker with a free slot for `reservation`: its specified Worker, or else the first
42
- * free one.
56
+ * free one that isn't `paused` from creating machines.
43
57
  */
44
- const workerFor = (pool, reservation) => Option.match(reservation.worker, {
45
- onNone: () => Option.fromNullishOr([...pool.keys()].find((worker) => isFree(pool, worker))),
58
+ const workerFor = (pool, paused, reservation) => Option.match(reservation.worker, {
59
+ onNone: () => Option.fromNullishOr([...pool.keys()].find((worker) => isFree(pool, worker) && paused.get(worker)?.blocked !== true)),
46
60
  onSome: (worker) => (isFree(pool, worker) ? Option.some(worker) : Option.none()),
47
61
  });
62
+ /** The first pause after a machine creation fails on a Worker. It doubles up to `MAX_PAUSE`. */
63
+ const FIRST_PAUSE = Duration.seconds(30);
64
+ const MAX_PAUSE = Duration.minutes(10);
65
+ /** Returns the pause after the `failures`th consecutive failed machine creation. */
66
+ const pauseAfter = (failures) => Duration.min(Duration.times(FIRST_PAUSE, 2 ** Math.min(failures - 1, 10)), MAX_PAUSE);
48
67
  const withLeased = (pool, worker, change) => {
49
68
  const slots = pool.get(worker);
50
69
  if (slots === undefined)
@@ -90,13 +109,18 @@ const validateWorkers = (kind, workers) => {
90
109
  const brainLabel = ({ harness, model, effort }) => `${harness}/${model}/${effort}`;
91
110
  /** The login that exe.dev Workers write to GitHub as, through exe.dev's integration (ADR 0011). */
92
111
  const EXE_DEV_BOT_LOGIN = BotLogin.make("exe-dev-github-integration[bot]");
93
- const decodeBotLogin = Schema.decodeUnknownOption(BotLogin);
112
+ /** Returns the Attempt's failure for an unreadable bot account. A refusal isn't retried. */
113
+ const botAccountFailure = (error) => {
114
+ const message = `Can't read the GitHub App's bot account, which authors the attempt's commits: ${error.message}`;
115
+ return isRefusal(error)
116
+ ? new AgentFailure({ message })
117
+ : new InfraFailure({ message, cause: error });
118
+ };
94
119
  /** The Worker set of an executor that runs any Worker of its kind by name. */
95
120
  const anyWorker = { add: () => Effect.void, remove: () => Effect.void };
96
121
  /**
97
- * Schedules Attempts FIFO over the Workers and adapts the Executor to the engine's `StepExecutor`
98
- * port. Reservations for a specific Worker go first (#177), and a removed Worker stays until its
99
- * last Attempt ends.
122
+ * Schedules Attempts over the Workers in `leaseOrder` and adapts the Executor to the engine's
123
+ * `StepExecutor` port. A removed Worker stays until its last Attempt ends.
100
124
  */
101
125
  export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectContext(Effect.gen(function* () {
102
126
  const executor = yield* Executor;
@@ -104,7 +128,7 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
104
128
  const app = yield* GitHubApp;
105
129
  const workers = yield* validateWorkers(executor.kind, config.workers);
106
130
  const pool = yield* TxRef.make(slotPoolOf(workers, new Map()));
107
- const pending = yield* TxPriorityQueue.empty(reservationOrder);
131
+ const pending = yield* TxPriorityQueue.empty(leaseOrder);
108
132
  const nextOrder = yield* Ref.make(0);
109
133
  const preflights = yield* Ref.make(new Map());
110
134
  const executorWorkers = options.workers ?? anyWorker;
@@ -112,13 +136,22 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
112
136
  // addition.
113
137
  const membership = yield* Semaphore.make(1);
114
138
  const adding = yield* Ref.make(new Set());
139
+ const machines = options.machines;
140
+ const paused = yield* TxRef.make(new Map());
141
+ // The machine of each Run's lease, which `execute` gives to the Run's Attempt.
142
+ const runMachines = yield* Ref.make(new Map());
143
+ // The machine creation of each reservation, by its order.
144
+ const creations = yield* FiberMap.make();
145
+ const provisions = (worker) => machines === undefined ? Effect.succeed(false) : machines.provisions(worker);
115
146
  const recordPreflightExit = Effect.fn("WorkerScheduler.recordPreflight")(function* (worker, roster, exit) {
116
- const failure = Exit.findErrorOption(exit);
147
+ const outcome = Exit.isSuccess(exit)
148
+ ? Option.some(Result.succeed(exit.value))
149
+ : Option.map(Exit.findErrorOption(exit), (error) => Result.fail(error));
117
150
  // An interrupted or defective preflight gives no information about the Worker.
118
- if (Exit.isFailure(exit) && Option.isNone(failure))
151
+ if (Option.isNone(outcome))
119
152
  return;
120
153
  const checkedAt = yield* DateTime.now;
121
- yield* Ref.update(preflights, (current) => new Map(current).set(worker, recordPreflight(current.get(worker) ?? unchecked(worker), roster, checkedAt, failure)));
154
+ yield* Ref.update(preflights, (current) => new Map(current).set(worker, recordPreflight(current.get(worker) ?? unchecked(worker), roster, checkedAt, outcome.value)));
122
155
  });
123
156
  /**
124
157
  * Runs `before`, then the Worker's preflight, and records both outcomes as the Worker's
@@ -230,42 +263,193 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
230
263
  Retired: ({ running, slots }) => Effect.logInfo("An Attempt ended on a Worker that runs more Attempts than it has slots; its slot retires", { worker, running, slots }),
231
264
  Dropped: () => Effect.logInfo("The last Attempt on a removed Worker ended; the Worker leaves the scheduler", { worker }).pipe(Effect.andThen(membership.withPermit(removeUnused(worker)))),
232
265
  })));
233
- const makeLease = Effect.fn("WorkerScheduler.makeLease")(function* (worker) {
266
+ /** Deletes the machine that `machine` holds, if any, before the slot frees. */
267
+ const releaseMachine = (worker, machine) => Ref.getAndSet(machine, Option.none()).pipe(Effect.flatMap((hold) => Option.match(hold, {
268
+ onNone: () => Effect.void,
269
+ onSome: (held) => machines === undefined
270
+ ? Effect.void
271
+ : Effect.logDebug("Deleting the machine of the released slot", {
272
+ worker,
273
+ hold: held,
274
+ }).pipe(Effect.andThen(machines.release(held))),
275
+ })));
276
+ const makeLease = Effect.fn("WorkerScheduler.makeLease")(function* (worker, hold = Option.none(), runId = Option.none()) {
234
277
  const released = yield* Ref.make(false);
278
+ const machine = yield* Ref.make(hold);
279
+ if (Option.isSome(runId))
280
+ yield* Ref.update(runMachines, (current) => new Map(current).set(runId.value, machine));
281
+ const forget = Option.match(runId, {
282
+ onNone: () => Effect.void,
283
+ onSome: (run) => Ref.update(runMachines, (current) => {
284
+ if (current.get(run) !== machine)
285
+ return current;
286
+ const next = new Map(current);
287
+ next.delete(run);
288
+ return next;
289
+ }),
290
+ });
235
291
  return {
236
292
  worker,
237
- release: Ref.modify(released, (done) => [!done, true]).pipe(Effect.flatMap((first) => (first ? releaseSlot(worker) : Effect.void))),
293
+ release: Ref.modify(released, (done) => [!done, true]).pipe(Effect.flatMap((first) => first
294
+ ? forget.pipe(Effect.andThen(releaseMachine(worker, machine)), Effect.andThen(releaseSlot(worker)))
295
+ : Effect.void)),
238
296
  };
239
297
  });
240
298
  // Retries until a free slot can take a waiting reservation, then takes both. A reservation
241
299
  // whose Worker has no free slot doesn't block the ones behind it.
242
300
  const nextAssignment = Effect.tx(Effect.gen(function* () {
243
301
  const current = yield* TxRef.get(pool);
244
- const next = EffectArray.findFirst(yield* TxPriorityQueue.toArray(pending), (reservation) => Option.map(workerFor(current, reservation), (worker) => ({ worker, reservation })));
302
+ const pauses = yield* TxRef.get(paused);
303
+ const waiting = yield* TxPriorityQueue.toArray(pending);
304
+ const next = EffectArray.findFirst(waiting, (reservation) => Option.map(workerFor(current, pauses, reservation), (worker) => ({
305
+ worker,
306
+ reservation,
307
+ })));
245
308
  if (Option.isNone(next))
246
309
  return yield* Effect.txRetry;
247
310
  const { worker, reservation } = next.value;
248
- yield* TxPriorityQueue.removeIf(pending, (waiting) => waiting === reservation);
311
+ yield* TxPriorityQueue.removeIf(pending, (queued) => queued === reservation);
249
312
  yield* TxRef.set(pool, withLeased(current, worker, 1));
250
- return next.value;
313
+ return { worker, reservation, passed: passedCount(waiting, reservation) };
251
314
  }));
315
+ /** Pauses machine creation on `worker` after `failure`, and resumes it when the pause ends. */
316
+ const pauseWorker = Effect.fn("WorkerScheduler.pauseWorker")(function* (worker, failure) {
317
+ const failedAt = yield* DateTime.now;
318
+ const pause = yield* Effect.tx(TxRef.modify(paused, (current) => {
319
+ const failures = (current.get(worker)?.failures ?? 0) + 1;
320
+ const delay = pauseAfter(failures);
321
+ const next = {
322
+ worker,
323
+ reason: failure._tag === "MachineUnavailableError" ? failure.reason : "check",
324
+ message: failure.message,
325
+ failures,
326
+ failedAt,
327
+ retryAt: DateTime.addDuration(failedAt, delay),
328
+ blocked: true,
329
+ };
330
+ return [next, new Map(current).set(worker, next)];
331
+ }));
332
+ yield* Effect.logWarning("A provisioned Worker could not create a machine; its Runs keep waiting, and it tries again after a pause", {
333
+ worker,
334
+ reason: pause.reason,
335
+ message: pause.message,
336
+ failures: pause.failures,
337
+ retryAt: DateTime.formatIso(pause.retryAt),
338
+ });
339
+ yield* Effect.sleep(DateTime.distance(failedAt, pause.retryAt)).pipe(Effect.andThen(Effect.tx(TxRef.update(paused, (current) => {
340
+ const latest = current.get(worker);
341
+ return latest === undefined || latest.failures !== pause.failures
342
+ ? current
343
+ : new Map(current).set(worker, { ...latest, blocked: false });
344
+ }))), Effect.andThen(Effect.logInfo("A provisioned Worker's pause ended; it creates machines again", {
345
+ worker,
346
+ failures: pause.failures,
347
+ })), Effect.forkScoped);
348
+ });
349
+ /**
350
+ * Creates the machine of `reservation`'s next Attempt on `worker`, whose slot it holds, and
351
+ * then assigns the lease with the machine. On failure, frees the slot, pauses the Worker,
352
+ * and puts the reservation back in the queue, so the Run keeps waiting.
353
+ */
354
+ const provision = Effect.fn("WorkerScheduler.provision")(function* (worker, reservation, passed) {
355
+ const fields = {
356
+ runId: reservation.runId,
357
+ loop: reservation.loop,
358
+ itemKey: reservation.itemKey,
359
+ worker,
360
+ };
361
+ yield* Effect.annotateCurrentSpan(fields);
362
+ if (machines === undefined)
363
+ return yield* Effect.die(new Error("no machines to create"));
364
+ const { agents } = yield* instances.get;
365
+ const roster = EffectArray.dedupe(agents.map(({ agent }) => agent.brain));
366
+ yield* Effect.logInfo("Worker slot taken in lease order; creating the Attempt's machine", {
367
+ ...fields,
368
+ ...positionFields(reservation.position),
369
+ passed,
370
+ });
371
+ const created = yield* machines.create(worker, reservation.runId, roster).pipe(Effect.result, Effect.onInterrupt(() => Effect.logInfo("The Run stopped waiting while its machine was created; the slot is free again", fields).pipe(Effect.andThen(releaseSlot(worker)))));
372
+ yield* Effect.uninterruptible(Effect.gen(function* () {
373
+ if (Result.isFailure(created)) {
374
+ const failure = created.failure;
375
+ yield* releaseSlot(worker);
376
+ if (failure._tag === "WorkerCapabilityError")
377
+ yield* recordPreflightExit(worker, roster, Exit.fail(failure));
378
+ yield* pauseWorker(worker, failure);
379
+ const requeued = yield* Ref.modify(reservation.state, (state) => state._tag === "Creating"
380
+ ? [true, { _tag: "Pending" }]
381
+ : [false, state]);
382
+ if (requeued)
383
+ yield* Effect.tx(TxPriorityQueue.offer(pending, reservation));
384
+ return;
385
+ }
386
+ const grant = created.success;
387
+ yield* Effect.tx(TxRef.update(paused, (current) => {
388
+ if (!current.has(worker))
389
+ return current;
390
+ const next = new Map(current);
391
+ next.delete(worker);
392
+ return next;
393
+ }));
394
+ if (Option.isSome(grant.checked)) {
395
+ const { brains, containerLimits } = grant.checked.value;
396
+ yield* recordPreflightExit(worker, brains, Exit.succeed({ ...fullPreflight, containerLimits }));
397
+ }
398
+ const lease = yield* makeLease(worker, Option.some(grant.hold), Option.some(reservation.runId));
399
+ const assign = yield* Ref.modify(reservation.state, (state) => state._tag === "Creating"
400
+ ? [true, { _tag: "Assigned", lease }]
401
+ : [false, state]);
402
+ if (!assign) {
403
+ yield* Effect.logInfo("The Run stopped waiting while its machine was created; the machine is deleted and the slot is free again", { ...fields, machine: grant.id });
404
+ return yield* lease.release;
405
+ }
406
+ yield* Effect.logInfo("Worker slot leased with a new machine", {
407
+ ...fields,
408
+ machine: grant.id,
409
+ provider: grant.provider,
410
+ });
411
+ yield* Deferred.succeed(reservation.deferred, lease);
412
+ }));
413
+ });
252
414
  const dispatch = Effect.forever(Effect.gen(function* () {
253
- const { worker, reservation } = yield* nextAssignment;
254
- const lease = yield* makeLease(worker);
415
+ const { worker, reservation, passed } = yield* nextAssignment;
416
+ // A reattached Attempt keeps its machine, so only a new Attempt gets one.
417
+ if (Option.isNone(reservation.worker) && (yield* provisions(worker))) {
418
+ const creating = yield* Ref.modify(reservation.state, (state) => state._tag === "Pending"
419
+ ? [true, { _tag: "Creating", worker }]
420
+ : [false, state]);
421
+ if (creating)
422
+ yield* FiberMap.run(creations, reservation.order, provision(worker, reservation, passed));
423
+ else
424
+ yield* releaseSlot(worker);
425
+ return;
426
+ }
427
+ const lease = yield* makeLease(worker, Option.none(), Option.some(reservation.runId));
255
428
  const assign = yield* Ref.modify(reservation.state, (state) => state._tag === "Pending"
256
429
  ? [true, { _tag: "Assigned", lease }]
257
430
  : [false, state]);
431
+ const fields = {
432
+ runId: reservation.runId,
433
+ loop: reservation.loop,
434
+ itemKey: reservation.itemKey,
435
+ worker,
436
+ };
258
437
  if (assign) {
259
- yield* Effect.logDebug("Worker slot assigned", { worker });
438
+ yield* Effect.logInfo("Worker slot leased in lease order", {
439
+ ...fields,
440
+ ...positionFields(reservation.position),
441
+ passed,
442
+ });
260
443
  yield* Deferred.succeed(reservation.deferred, lease);
261
444
  }
262
445
  else {
446
+ yield* Effect.logDebug("The Run stopped waiting before its lease; the Worker slot is free again", fields);
263
447
  yield* lease.release;
264
448
  }
265
449
  }));
266
450
  yield* Effect.forkScoped(dispatch);
267
- const occupy = Effect.fn("WorkerScheduler.occupy")(function* (worker) {
268
- yield* Effect.annotateCurrentSpan({ worker });
451
+ const occupy = Effect.fn("WorkerScheduler.occupy")(function* (worker, attemptId) {
452
+ yield* Effect.annotateCurrentSpan({ worker, attemptId });
269
453
  // The Attempt runs on the Worker even without a free slot, so count it now. A slot over
270
454
  // capacity retires when an Attempt there ends.
271
455
  const slots = yield* TxRef.modify(pool, (current) => [current.get(worker), withLeased(current, worker, 1)]);
@@ -278,51 +462,117 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
278
462
  yield* Effect.logInfo("An Attempt that already runs on the Worker occupies a slot", fields);
279
463
  else
280
464
  yield* Effect.logWarning("An Attempt that already runs on the Worker occupies a slot beyond its max_concurrent_attempts; the Worker takes no new Attempt until enough of its Attempts end", fields);
281
- return yield* makeLease(worker);
465
+ const hold = machines !== undefined && (yield* provisions(worker))
466
+ ? yield* machines.adopt(worker, attemptId)
467
+ : Option.none();
468
+ return yield* makeLease(worker, hold);
282
469
  });
283
- const reserve = Effect.fn("WorkerScheduler.reserve")(function* (submission, worker) {
284
- const run = { loop: submission.loop, itemKey: submission.itemKey };
470
+ /**
471
+ * Returns the pipeline position of the Run of `submission`, from the current Loop file and
472
+ * the lanes of the Run's repository.
473
+ */
474
+ const positionOf = Effect.fn("WorkerScheduler.position")(function* (runId, submission) {
475
+ yield* Effect.annotateCurrentSpan({ runId, loop: submission.loop });
476
+ const { config, loops } = yield* instances.get;
477
+ const loop = loops.find(({ name }) => name === submission.loop);
478
+ const priority = Option.flatMap(Option.fromNullishOr(loop), ({ priority }) => priority);
479
+ const lanes = config.repos.find(({ name }) => canonicalRepositoryName(name) === canonicalRepositoryName(submission.repository))?.lanes ?? [];
480
+ const stream = loop?.trigger.on;
481
+ // Only issue and pull request Events record Item labels.
482
+ if (Option.isSome(priority) ||
483
+ lanes.length === 0 ||
484
+ stream === undefined ||
485
+ stream === "schedule") {
486
+ return pipelinePosition(priority, lanes, []);
487
+ }
488
+ const labels = yield* payloadLabels(stream, submission.event.payload).pipe(Effect.catch((error) => Effect.logWarning("The Run's Event has no labels to read; the Run waits for a worker slot without a lane position", {
489
+ runId,
490
+ loop: submission.loop,
491
+ eventId: submission.event.id,
492
+ errorTag: error._tag,
493
+ message: error.message,
494
+ }).pipe(Effect.as([]))));
495
+ return pipelinePosition(priority, lanes, labels);
496
+ });
497
+ const reserve = Effect.fn("WorkerScheduler.reserve")(function* (runId, submission, worker) {
498
+ const run = { runId, loop: submission.loop, itemKey: submission.itemKey };
285
499
  yield* Effect.annotateCurrentSpan(run);
286
500
  if (Option.isSome(worker))
287
501
  yield* Effect.annotateCurrentSpan({ worker: worker.value });
288
502
  const deferred = yield* Deferred.make();
289
503
  const state = yield* Ref.make({ _tag: "Pending" });
290
504
  const reservation = {
505
+ ...run,
291
506
  deferred,
292
507
  worker,
508
+ position: yield* positionOf(runId, submission),
293
509
  eventTime: DateTime.toEpochMillis(submission.event.receivedAt),
294
510
  order: yield* Ref.getAndUpdate(nextOrder, (value) => value + 1),
295
511
  state,
296
512
  };
297
- yield* TxPriorityQueue.offer(pending, reservation);
298
- const fields = { ...run, queued: yield* TxPriorityQueue.size(pending) };
513
+ const [queue, current] = yield* Effect.tx(TxPriorityQueue.offer(pending, reservation).pipe(Effect.andThen(Effect.all([TxPriorityQueue.toArray(pending), TxRef.get(pool)]))));
514
+ const fields = {
515
+ ...run,
516
+ ...positionFields(reservation.position),
517
+ queued: queue.length,
518
+ ahead: aheadCount(queue.slice(0, queue.indexOf(reservation)), reservation, openWorkers(current)),
519
+ };
299
520
  if (Option.isNone(worker))
300
521
  yield* Effect.logInfo("Run waits for a worker slot", fields);
301
- else if (((yield* TxRef.get(pool)).get(worker.value)?.capacity ?? 0) > 0)
522
+ else if ((current.get(worker.value)?.capacity ?? 0) > 0)
302
523
  yield* Effect.logInfo("Run waits for a slot on the Worker its reattached Attempt runs on", { ...fields, worker: worker.value });
303
524
  else
304
525
  yield* Effect.logWarning("Run waits for a slot on the Worker its reattached Attempt runs on, which usine.yaml does not declare; it waits until a reload declares that Worker again or the Run is canceled", { ...fields, worker: worker.value });
305
526
  return yield* Deferred.await(deferred).pipe(Effect.onInterrupt(() => Ref.modify(state, (current) => current._tag === "Assigned"
306
527
  ? [Option.some(current.lease), { _tag: "Canceled" }]
307
528
  : [Option.none(), { _tag: "Canceled" }]).pipe(
529
+ // Stops a machine creation for the Run, which deletes the machine and frees the slot.
530
+ Effect.tap(() => FiberMap.remove(creations, reservation.order)),
308
531
  // Remove a canceled reservation from the queue, so one whose Worker never frees a
309
532
  // slot doesn't stay there.
310
533
  Effect.tap(() => TxPriorityQueue.removeIf(pending, (waiting) => waiting === reservation)), Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (lease) => lease.release })))));
311
534
  });
312
- // If the login can't be read, harvesting asks GitHub; the Attempt never fails for this.
313
- const botLoginFor = Effect.fn("WorkerScheduler.botLogin")(function* (attemptId) {
314
- if (executor.kind === "exe.dev")
315
- return Option.some(EXE_DEV_BOT_LOGIN);
316
- const installation = yield* app.installation().pipe(Effect.map(Option.some), Effect.catch((error) => Effect.logWarning("Could not read the App's installation; harvesting will ask GitHub for the login of the Attempt's credential", { attemptId, errorTag: error._tag, message: error.message }).pipe(Effect.as(Option.none()))));
317
- if (Option.isNone(installation))
318
- return Option.none();
319
- const { appSlug } = installation.value;
320
- // GitHub's REST API names an App's bot `<slug>[bot]`; its GraphQL API drops the suffix.
321
- const login = decodeBotLogin(`${appSlug}[bot]`);
322
- if (Option.isNone(login))
323
- yield* Effect.logWarning("The App's slug does not form a bot login; harvesting will ask GitHub for the login of the Attempt's credential", { attemptId, appSlug });
324
- return login;
535
+ /**
536
+ * Gives a provisioned Worker's machine to the Attempt of `execution`: the machine its Run's
537
+ * lease created, or for a reattachment the machine it ran on before the restart.
538
+ */
539
+ const machineForAttempt = Effect.fn("WorkerScheduler.machineForAttempt")(function* (execution) {
540
+ const worker = execution.attempt.worker;
541
+ if (machines === undefined || !(yield* provisions(worker)))
542
+ return;
543
+ const attemptId = execution.attempt.id;
544
+ const fields = { runId: execution.run.id, attemptId, worker };
545
+ const machine = (yield* Ref.get(runMachines)).get(execution.run.id);
546
+ if (execution.reattach) {
547
+ const adopted = yield* machines.adopt(worker, attemptId);
548
+ if (machine !== undefined && Option.isNone(yield* Ref.get(machine)))
549
+ yield* Ref.set(machine, adopted);
550
+ return yield* Effect.logDebug("The reattached Attempt keeps its machine", {
551
+ ...fields,
552
+ found: Option.isSome(adopted),
553
+ });
554
+ }
555
+ const hold = machine === undefined ? Option.none() : yield* Ref.get(machine);
556
+ if (Option.isNone(hold))
557
+ return yield* new InfraFailure({
558
+ message: `Worker "${worker}" created no machine for the attempt`,
559
+ cause: new Error("no machine in the Run's lease"),
560
+ });
561
+ yield* machines.assign(hold.value, attemptId).pipe(Effect.mapError((error) => new InfraFailure({
562
+ message: `Can't give the machine to the attempt: ${error.message}`,
563
+ cause: error,
564
+ })));
325
565
  });
566
+ // Every Worker kind commits as the App's bot account; only exe.dev writes to GitHub through
567
+ // another bot.
568
+ const writerFor = Effect.fn("WorkerScheduler.writer")(function* (attemptId) {
569
+ yield* Effect.annotateCurrentSpan({ attemptId });
570
+ const commitAuthor = yield* app.botAccount();
571
+ const botLogin = executor.kind === "exe.dev" ? EXE_DEV_BOT_LOGIN : commitAuthor.login;
572
+ return { commitAuthor, botLogin };
573
+ }, (effect, attemptId) => effect.pipe(Effect.tapError((error) => Effect.logWarning(isRefusal(error)
574
+ ? "Could not read the App's bot account, which authors the Attempt's commits; the Attempt fails without an automatic retry"
575
+ : "Could not read the App's bot account, which authors the Attempt's commits; the Attempt fails with an infrastructure failure, which is retried", { attemptId, errorTag: error._tag, message: error.message })), Effect.mapError(botAccountFailure)));
326
576
  const execute = Effect.fn("WorkerScheduler.execute")(function* (execution, io) {
327
577
  yield* Effect.annotateCurrentSpan({
328
578
  attemptId: execution.attempt.id,
@@ -333,7 +583,8 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
333
583
  reattach: execution.reattach,
334
584
  });
335
585
  const { settings } = (yield* instances.get).config;
336
- const botLogin = yield* botLoginFor(execution.attempt.id);
586
+ const { commitAuthor, botLogin } = yield* writerFor(execution.attempt.id);
587
+ yield* machineForAttempt(execution);
337
588
  const prepared = yield* executor
338
589
  .prepare(new CheckoutRequest({
339
590
  attemptId: execution.attempt.id,
@@ -353,7 +604,8 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
353
604
  salvageRef: salvageRefFor(execution.run.id, execution.attempt.number),
354
605
  resume: execution.resume,
355
606
  reattach: execution.reattach,
356
- botLogin,
607
+ botLogin: Option.some(botLogin),
608
+ commitAuthor,
357
609
  }))
358
610
  .pipe(Effect.tapError((error) => error._tag === "CredentialUnavailableError"
359
611
  ? Effect.logWarning(error.reason === "refused"
@@ -371,10 +623,13 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
371
623
  yield* io.checkoutAcquired;
372
624
  return yield* prepared.launch(io);
373
625
  }, Effect.scoped);
374
- const interrupt = (attemptId) => executor.cancel(attemptId).pipe(Effect.mapError(asAttemptFailure));
626
+ const interrupt = (attemptId, reason) => executor.cancel(attemptId, reason).pipe(Effect.mapError(asAttemptFailure));
375
627
  const status = WorkerStatus.of({
376
628
  slots: TxRef.get(pool).pipe(Effect.map((current) => [...current].map(([worker, { capacity, leased }]) => ({ worker, capacity, leased })))),
629
+ queue: Effect.tx(Effect.all([TxPriorityQueue.toArray(pending), TxRef.get(pool)])).pipe(Effect.map(([queued, current]) => queuedRuns(queued, current))),
377
630
  preflights: Ref.get(preflights).pipe(Effect.map((current) => [...current.values()])),
631
+ machines: machines === undefined ? Effect.succeed([]) : machines.machines,
632
+ provisioning: TxRef.get(paused).pipe(Effect.map((current) => [...current.values()])),
378
633
  });
379
634
  const stepExecutor = StepExecutor.of({
380
635
  workers: TxRef.get(pool).pipe(Effect.map((current) => [...current.keys()])),
@@ -384,9 +639,11 @@ export const stepExecutorLayer = (config, brains, options = {}) => Layer.effectC
384
639
  outcome: executor.outcome,
385
640
  reserve,
386
641
  occupy,
642
+ sweep: Effect.andThen(executor.sweep, machines === undefined ? Effect.void : machines.sweep),
643
+ discard: options.discard ?? (() => Effect.void),
387
644
  intervene: executor.intervene,
388
645
  recover: (attempt, record) => executor
389
- .recover(attempt, record)
646
+ .recover(attempt, record, app.botAccount().pipe(Effect.mapError(botAccountFailure)))
390
647
  .pipe(Effect.catchIf(Schema.is(ExecutorError), (error) => Effect.fail(asAttemptFailure(error)))),
391
648
  });
392
649
  return Context.make(StepExecutor, stepExecutor).pipe(Context.add(WorkerStatus, status));