@shipfox/api-runners 12.2.0 → 12.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 (149) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +34 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +10 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/demand.d.ts +4 -2
  8. package/dist/core/demand.d.ts.map +1 -1
  9. package/dist/core/demand.js +22 -6
  10. package/dist/core/demand.js.map +1 -1
  11. package/dist/core/entities/reservation.d.ts +1 -0
  12. package/dist/core/entities/reservation.d.ts.map +1 -1
  13. package/dist/core/entities/reservation.js.map +1 -1
  14. package/dist/core/entities/runner-instance.d.ts +3 -0
  15. package/dist/core/entities/runner-instance.d.ts.map +1 -1
  16. package/dist/core/entities/runner-instance.js.map +1 -1
  17. package/dist/core/runner-activation.d.ts +4 -1
  18. package/dist/core/runner-activation.d.ts.map +1 -1
  19. package/dist/core/runner-activation.js +32 -3
  20. package/dist/core/runner-activation.js.map +1 -1
  21. package/dist/core/runner-control-sessions.d.ts +8 -4
  22. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  23. package/dist/core/runner-control-sessions.js +69 -11
  24. package/dist/core/runner-control-sessions.js.map +1 -1
  25. package/dist/core/runner-instances.d.ts +1 -0
  26. package/dist/core/runner-instances.d.ts.map +1 -1
  27. package/dist/core/runner-instances.js +1 -0
  28. package/dist/core/runner-instances.js.map +1 -1
  29. package/dist/db/db.d.ts +68 -0
  30. package/dist/db/db.d.ts.map +1 -1
  31. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  32. package/dist/db/ephemeral-registration-tokens.js +1 -1
  33. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  34. package/dist/db/index.d.ts +1 -1
  35. package/dist/db/index.d.ts.map +1 -1
  36. package/dist/db/index.js +1 -1
  37. package/dist/db/index.js.map +1 -1
  38. package/dist/db/reservation-locks.d.ts +6 -0
  39. package/dist/db/reservation-locks.d.ts.map +1 -0
  40. package/dist/db/reservation-locks.js +15 -0
  41. package/dist/db/reservation-locks.js.map +1 -0
  42. package/dist/db/reservations.d.ts +17 -3
  43. package/dist/db/reservations.d.ts.map +1 -1
  44. package/dist/db/reservations.js +256 -45
  45. package/dist/db/reservations.js.map +1 -1
  46. package/dist/db/runner-assignments.d.ts +17 -0
  47. package/dist/db/runner-assignments.d.ts.map +1 -1
  48. package/dist/db/runner-assignments.js +103 -8
  49. package/dist/db/runner-assignments.js.map +1 -1
  50. package/dist/db/runner-instances.d.ts +5 -2
  51. package/dist/db/runner-instances.d.ts.map +1 -1
  52. package/dist/db/runner-instances.js +134 -25
  53. package/dist/db/runner-instances.js.map +1 -1
  54. package/dist/db/runner-sessions.d.ts.map +1 -1
  55. package/dist/db/runner-sessions.js +22 -15
  56. package/dist/db/runner-sessions.js.map +1 -1
  57. package/dist/db/runner-states.d.ts +2 -0
  58. package/dist/db/runner-states.d.ts.map +1 -0
  59. package/dist/db/runner-states.js +7 -0
  60. package/dist/db/runner-states.js.map +1 -0
  61. package/dist/db/schema/reservations.d.ts +18 -0
  62. package/dist/db/schema/reservations.d.ts.map +1 -1
  63. package/dist/db/schema/reservations.js +7 -1
  64. package/dist/db/schema/reservations.js.map +1 -1
  65. package/dist/db/schema/runner-instances.d.ts +18 -0
  66. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  67. package/dist/db/schema/runner-instances.js +8 -0
  68. package/dist/db/schema/runner-instances.js.map +1 -1
  69. package/dist/metrics/index.d.ts +2 -2
  70. package/dist/metrics/index.d.ts.map +1 -1
  71. package/dist/metrics/index.js +1 -1
  72. package/dist/metrics/index.js.map +1 -1
  73. package/dist/metrics/instance.d.ts +24 -2
  74. package/dist/metrics/instance.d.ts.map +1 -1
  75. package/dist/metrics/instance.js +23 -0
  76. package/dist/metrics/instance.js.map +1 -1
  77. package/dist/metrics/service.d.ts.map +1 -1
  78. package/dist/metrics/service.js +20 -4
  79. package/dist/metrics/service.js.map +1 -1
  80. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  81. package/dist/presentation/dto/poll-demand.js +3 -0
  82. package/dist/presentation/dto/poll-demand.js.map +1 -1
  83. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  84. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  85. package/dist/presentation/dto/runner-instances.js +4 -1
  86. package/dist/presentation/dto/runner-instances.js.map +1 -1
  87. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/routes/assign-runner-instances.js +3 -3
  89. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  91. package/dist/presentation/routes/poll-demand.js +1 -0
  92. package/dist/presentation/routes/poll-demand.js.map +1 -1
  93. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  94. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  95. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  96. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  97. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  98. package/dist/presentation/routes/runner-enrollment.js +11 -6
  99. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  100. package/dist/tsconfig.test.tsbuildinfo +1 -1
  101. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  102. package/drizzle/meta/0005_snapshot.json +2220 -0
  103. package/drizzle/meta/_journal.json +7 -0
  104. package/package.json +4 -4
  105. package/src/config.test.ts +24 -2
  106. package/src/config.ts +16 -3
  107. package/src/core/demand.test.ts +192 -3
  108. package/src/core/demand.ts +44 -4
  109. package/src/core/entities/reservation.ts +1 -0
  110. package/src/core/entities/runner-instance.ts +4 -0
  111. package/src/core/runner-activation.ts +48 -2
  112. package/src/core/runner-control-sessions.test.ts +230 -4
  113. package/src/core/runner-control-sessions.ts +84 -10
  114. package/src/core/runner-instances.test.ts +19 -0
  115. package/src/core/runner-instances.ts +2 -0
  116. package/src/core/runner-sessions.test.ts +121 -1
  117. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  118. package/src/db/ephemeral-registration-tokens.ts +1 -0
  119. package/src/db/index.ts +1 -0
  120. package/src/db/reservation-locks.ts +22 -0
  121. package/src/db/reservations.test.ts +1110 -31
  122. package/src/db/reservations.ts +434 -105
  123. package/src/db/runner-assignments.test.ts +105 -1
  124. package/src/db/runner-assignments.ts +178 -9
  125. package/src/db/runner-instances.test.ts +496 -2
  126. package/src/db/runner-instances.ts +247 -56
  127. package/src/db/runner-sessions.ts +26 -15
  128. package/src/db/runner-states.ts +7 -0
  129. package/src/db/schema/reservations.ts +5 -1
  130. package/src/db/schema/runner-instances.ts +9 -0
  131. package/src/metrics/index.ts +10 -1
  132. package/src/metrics/instance.ts +59 -2
  133. package/src/metrics/service.test.ts +106 -0
  134. package/src/metrics/service.ts +24 -4
  135. package/src/presentation/dto/poll-demand.ts +3 -0
  136. package/src/presentation/dto/runner-instances.ts +8 -1
  137. package/src/presentation/routes/assign-runner-instances.ts +6 -2
  138. package/src/presentation/routes/late-runner-enrollment.test.ts +447 -0
  139. package/src/presentation/routes/poll-demand.test.ts +79 -6
  140. package/src/presentation/routes/poll-demand.ts +1 -0
  141. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  142. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  143. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  144. package/src/presentation/routes/runner-enrollment.ts +8 -5
  145. package/test/factories/reservation.ts +2 -0
  146. package/test/factories/runner-instance.ts +6 -0
  147. package/test/fixtures/late-runner-enrollment.ts +103 -0
  148. package/test/index.ts +5 -0
  149. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,6 +1,7 @@
1
1
  import {eq} from 'drizzle-orm';
2
2
  import {
3
3
  ReservationExpiredError,
4
+ ReservationNotFoundError,
4
5
  RunnerInstanceAlreadyAssignedError,
5
6
  RunnerInstanceNotAssignableError,
6
7
  } from '#core/errors.js';
@@ -41,6 +42,50 @@ describe('assignRunnerInstances', () => {
41
42
  });
42
43
  });
43
44
 
45
+ it('repairs a provider-reported reservation before assignment commit', async () => {
46
+ const reservation = await createReservation();
47
+ const [runner] = await db()
48
+ .insert(providerRunners)
49
+ .values({
50
+ provisionerId,
51
+ intendedReservationId: reservation.id,
52
+ reservationId: reservation.id,
53
+ providerRunnerId: crypto.randomUUID(),
54
+ labels: ['linux'],
55
+ state: 'running',
56
+ reportedAt: new Date(),
57
+ })
58
+ .returning();
59
+ if (!runner) throw new Error('Runner instance insert returned no row');
60
+ await db()
61
+ .insert(runnerControlSessions)
62
+ .values({
63
+ runnerInstanceId: runner.id,
64
+ provisionerId,
65
+ hashedToken: crypto.randomUUID(),
66
+ prefix: 'test',
67
+ expiresAt: new Date(Date.now() + 60_000),
68
+ });
69
+
70
+ const assigned = await assignRunnerInstances({
71
+ provisionerId,
72
+ reservationId: reservation.id,
73
+ runnerInstanceIds: [runner.id],
74
+ });
75
+
76
+ expect(assigned).toEqual([runner.id]);
77
+ const [stored] = await db()
78
+ .select()
79
+ .from(providerRunners)
80
+ .where(eq(providerRunners.id, runner.id));
81
+ expect(stored).toMatchObject({
82
+ workspaceId,
83
+ reservationId: reservation.id,
84
+ intendedReservationId: null,
85
+ assignedAt: expect.any(Date),
86
+ });
87
+ });
88
+
44
89
  it('is idempotent for concurrent retries of the same assignment', async () => {
45
90
  const reservation = await createReservation();
46
91
  const runner = await createEnrolledRunner();
@@ -80,6 +125,32 @@ describe('assignRunnerInstances', () => {
80
125
  expect(assigned).toEqual([runner.id]);
81
126
  });
82
127
 
128
+ it('is idempotent for a committed bound reservation assignment', async () => {
129
+ const reservation = await createReservation({kind: 'bound'});
130
+ const runner = await createEnrolledRunner();
131
+ await db()
132
+ .update(providerRunners)
133
+ .set({
134
+ workspaceId,
135
+ reservationId: reservation.id,
136
+ assignedAt: new Date(),
137
+ })
138
+ .where(eq(providerRunners.id, runner.id));
139
+
140
+ const assigned = await assignRunnerInstances({
141
+ provisionerId,
142
+ reservationId: reservation.id,
143
+ runnerInstanceIds: [runner.id],
144
+ });
145
+
146
+ expect(assigned).toEqual([runner.id]);
147
+ const [stored] = await db()
148
+ .select()
149
+ .from(providerRunners)
150
+ .where(eq(providerRunners.id, runner.id));
151
+ expect(stored?.intendedReservationId).toBeNull();
152
+ });
153
+
83
154
  it('rejects expired reservations', async () => {
84
155
  const reservation = await createReservation({expiresAt: new Date(Date.now() - 1_000)});
85
156
  const runner = await createEnrolledRunner();
@@ -93,6 +164,19 @@ describe('assignRunnerInstances', () => {
93
164
  await expect(assignment).rejects.toThrow(ReservationExpiredError);
94
165
  });
95
166
 
167
+ it('rejects bound reservations', async () => {
168
+ const reservation = await createReservation({kind: 'bound'});
169
+ const runner = await createEnrolledRunner();
170
+
171
+ const assignment = assignRunnerInstances({
172
+ provisionerId,
173
+ reservationId: reservation.id,
174
+ runnerInstanceIds: [runner.id],
175
+ });
176
+
177
+ await expect(assignment).rejects.toThrow(ReservationNotFoundError);
178
+ });
179
+
96
180
  it('rejects unenrolled or incompatible runners', async () => {
97
181
  const reservation = await createReservation({requiredLabels: ['linux', 'gpu']});
98
182
  const runner = await createEnrolledRunner({labels: ['linux']});
@@ -139,6 +223,25 @@ describe('assignRunnerInstances', () => {
139
223
  await expect(assignment).rejects.toThrow(RunnerInstanceNotAssignableError);
140
224
  });
141
225
 
226
+ it('charges capacity for a committed runner replayed alongside a new one', async () => {
227
+ const reservation = await createReservation();
228
+ const firstRunner = await createEnrolledRunner();
229
+ const secondRunner = await createEnrolledRunner();
230
+ await assignRunnerInstances({
231
+ provisionerId,
232
+ reservationId: reservation.id,
233
+ runnerInstanceIds: [firstRunner.id],
234
+ });
235
+
236
+ const assignment = assignRunnerInstances({
237
+ provisionerId,
238
+ reservationId: reservation.id,
239
+ runnerInstanceIds: [firstRunner.id, secondRunner.id],
240
+ });
241
+
242
+ await expect(assignment).rejects.toThrow(RunnerInstanceNotAssignableError);
243
+ });
244
+
142
245
  it('does not charge capacity for an already released reservation intent', async () => {
143
246
  const reservation = await createReservation();
144
247
  await db().insert(providerRunners).values({
@@ -202,7 +305,7 @@ describe('assignRunnerInstances', () => {
202
305
  });
203
306
 
204
307
  async function createReservation(
205
- overrides: Partial<{expiresAt: Date; requiredLabels: string[]}> = {},
308
+ overrides: Partial<{expiresAt: Date; requiredLabels: string[]; kind: 'bound' | 'launch'}> = {},
206
309
  ) {
207
310
  const [reservation] = await db()
208
311
  .insert(reservations)
@@ -211,6 +314,7 @@ describe('assignRunnerInstances', () => {
211
314
  provisionerId,
212
315
  requiredLabels: overrides.requiredLabels ?? ['linux'],
213
316
  count: 1,
317
+ kind: overrides.kind ?? 'launch',
214
318
  expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60_000),
215
319
  })
216
320
  .returning();
@@ -1,4 +1,4 @@
1
- import {and, eq, inArray, isNull, notInArray, or, sql} from 'drizzle-orm';
1
+ import {and, eq, inArray, isNull, ne, notInArray, or, sql} from 'drizzle-orm';
2
2
  import {
3
3
  ReservationExpiredError,
4
4
  ReservationNotFoundError,
@@ -7,7 +7,8 @@ import {
7
7
  } from '#core/errors.js';
8
8
  import type {Tx} from './db.js';
9
9
  import {db} from './db.js';
10
- import {terminalStates} from './runner-instances.js';
10
+ import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
11
+ import {terminalStates} from './runner-states.js';
11
12
  import {reservations} from './schema/reservations.js';
12
13
  import {runnerControlSessions} from './schema/runner-control-sessions.js';
13
14
  import {providerRunners} from './schema/runner-instances.js';
@@ -30,15 +31,17 @@ export async function assignRunnerInstancesTx(
30
31
  },
31
32
  ): Promise<string[]> {
32
33
  const runnerInstanceIds = [...params.runnerInstanceIds].sort();
33
- await tx.execute(
34
- sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${params.provisionerId}:${params.reservationId}`}))`,
35
- );
34
+ await lockRunnerReservationAdvisoryKeysTx(tx, {
35
+ provisionerId: params.provisionerId,
36
+ reservationIds: [params.reservationId],
37
+ });
36
38
  const runnerRows = await tx
37
39
  .select({
38
40
  id: providerRunners.id,
39
41
  reservationId: providerRunners.reservationId,
40
42
  intendedReservationId: providerRunners.intendedReservationId,
41
43
  workspaceId: providerRunners.workspaceId,
44
+ assignedAt: providerRunners.assignedAt,
42
45
  providerRunnerId: providerRunners.providerRunnerId,
43
46
  labels: providerRunners.labels,
44
47
  state: providerRunners.state,
@@ -53,11 +56,16 @@ export async function assignRunnerInstancesTx(
53
56
  .for('update');
54
57
 
55
58
  // The assignment outlives its short reservation row. A retry after maintenance
56
- // cleanup is complete when every owned runner already carries the requested assignment.
59
+ // cleanup is complete when every owned runner already carries the committed assignment.
57
60
  if (
58
61
  runnerInstanceIds.length > 0 &&
59
62
  runnerRows.length === runnerInstanceIds.length &&
60
- runnerRows.every((runner) => runner.reservationId === params.reservationId)
63
+ runnerRows.every(
64
+ (runner) =>
65
+ runner.reservationId === params.reservationId &&
66
+ runner.assignedAt !== null &&
67
+ runner.workspaceId !== null,
68
+ )
61
69
  ) {
62
70
  await tx
63
71
  .update(providerRunners)
@@ -78,6 +86,7 @@ export async function assignRunnerInstancesTx(
78
86
  and(
79
87
  eq(reservations.id, params.reservationId),
80
88
  eq(reservations.provisionerId, params.provisionerId),
89
+ eq(reservations.kind, 'launch'),
81
90
  ),
82
91
  )
83
92
  .limit(1)
@@ -110,13 +119,20 @@ export async function assignRunnerInstancesTx(
110
119
  const alreadyAssigned = runners.filter((runner) => runner.reservationId !== null);
111
120
  if (alreadyAssigned.some((runner) => runner.reservationId !== reservation.id))
112
121
  throw new RunnerInstanceAlreadyAssignedError(alreadyAssigned[0]?.id ?? '');
113
- const newRunners = runners.filter((runner) => runner.reservationId === null);
122
+ const newRunners = runners.filter(
123
+ (runner) =>
124
+ runner.reservationId === null ||
125
+ (runner.reservationId === reservation.id &&
126
+ (runner.assignedAt === null || runner.workspaceId === null)),
127
+ );
128
+ const newRunnerIds = newRunners.map((runner) => runner.id);
114
129
  const assignedCount = await tx
115
130
  .select({count: sql<number>`count(*)::int`})
116
131
  .from(providerRunners)
117
132
  .where(
118
133
  and(
119
134
  eq(providerRunners.provisionerId, params.provisionerId),
135
+ notInArray(providerRunners.id, newRunnerIds),
120
136
  or(
121
137
  and(
122
138
  eq(providerRunners.reservationId, reservation.id),
@@ -124,7 +140,6 @@ export async function assignRunnerInstancesTx(
124
140
  ),
125
141
  and(
126
142
  eq(providerRunners.intendedReservationId, reservation.id),
127
- notInArray(providerRunners.id, runnerInstanceIds),
128
143
  isNull(providerRunners.reservationReleasedAt),
129
144
  notInArray(providerRunners.state, [...terminalStates]),
130
145
  ),
@@ -161,3 +176,157 @@ export async function assignRunnerInstancesTx(
161
176
  }
162
177
  return params.runnerInstanceIds;
163
178
  }
179
+
180
+ export type RunnerReservationCapacityFailureReason =
181
+ | 'reservation-not-found'
182
+ | 'reservation-kind-mismatch'
183
+ | 'reservation-expired'
184
+ | 'capacity-exhausted';
185
+
186
+ export interface RunnerReservationCapacityValidation {
187
+ acceptedByReservation: Map<string, number>;
188
+ unavailableByReservation: Map<
189
+ string,
190
+ {reason: RunnerReservationCapacityFailureReason; count: number}
191
+ >;
192
+ }
193
+
194
+ export async function validateRunnerReservationCapacityTx(
195
+ tx: Tx,
196
+ params: {
197
+ provisionerId: string;
198
+ requests: readonly {reservationId: string; count: number}[];
199
+ },
200
+ options: {advisoryLocksHeld?: boolean} = {},
201
+ ): Promise<RunnerReservationCapacityValidation> {
202
+ const requestedByReservation = new Map<string, number>();
203
+ for (const request of params.requests) {
204
+ if (request.count <= 0) continue;
205
+ requestedByReservation.set(
206
+ request.reservationId,
207
+ (requestedByReservation.get(request.reservationId) ?? 0) + request.count,
208
+ );
209
+ }
210
+ const reservationIds = [...requestedByReservation.keys()].sort();
211
+ if (reservationIds.length === 0)
212
+ return {acceptedByReservation: new Map(), unavailableByReservation: new Map()};
213
+
214
+ if (!options.advisoryLocksHeld)
215
+ await lockRunnerReservationAdvisoryKeysTx(tx, {
216
+ provisionerId: params.provisionerId,
217
+ reservationIds,
218
+ });
219
+
220
+ const reservationRows = await tx
221
+ .select({
222
+ id: reservations.id,
223
+ count: reservations.count,
224
+ expiresAt: reservations.expiresAt,
225
+ isExpired: sql<boolean>`${reservations.expiresAt} <= now()`,
226
+ })
227
+ .from(reservations)
228
+ .where(
229
+ and(
230
+ eq(reservations.provisionerId, params.provisionerId),
231
+ inArray(reservations.id, reservationIds),
232
+ eq(reservations.kind, 'launch'),
233
+ ),
234
+ )
235
+ .for('update');
236
+ const nonLaunchReservationRows = await tx
237
+ .select({id: reservations.id})
238
+ .from(reservations)
239
+ .where(
240
+ and(
241
+ eq(reservations.provisionerId, params.provisionerId),
242
+ inArray(reservations.id, reservationIds),
243
+ ne(reservations.kind, 'launch'),
244
+ ),
245
+ )
246
+ .for('update');
247
+ const reservationsById = new Map(
248
+ reservationRows.map((reservation) => [reservation.id, reservation]),
249
+ );
250
+ const nonLaunchReservationIds = new Set(
251
+ nonLaunchReservationRows.map((reservation) => reservation.id),
252
+ );
253
+ const requestedReservationIds = new Set(reservationIds);
254
+ const usedRunnerIdsByReservation = new Map<string, Set<string>>();
255
+ const usedRunnerRows = await tx
256
+ .select({
257
+ id: providerRunners.id,
258
+ reservationId: providerRunners.reservationId,
259
+ intendedReservationId: providerRunners.intendedReservationId,
260
+ state: providerRunners.state,
261
+ })
262
+ .from(providerRunners)
263
+ .where(
264
+ and(
265
+ eq(providerRunners.provisionerId, params.provisionerId),
266
+ isNull(providerRunners.reservationReleasedAt),
267
+ or(
268
+ inArray(providerRunners.reservationId, reservationIds),
269
+ and(
270
+ inArray(providerRunners.intendedReservationId, reservationIds),
271
+ notInArray(providerRunners.state, [...terminalStates]),
272
+ ),
273
+ ),
274
+ ),
275
+ );
276
+ for (const runner of usedRunnerRows) {
277
+ if (runner.reservationId && requestedReservationIds.has(runner.reservationId)) {
278
+ const usedRunnerIds =
279
+ usedRunnerIdsByReservation.get(runner.reservationId) ?? new Set<string>();
280
+ usedRunnerIds.add(runner.id);
281
+ usedRunnerIdsByReservation.set(runner.reservationId, usedRunnerIds);
282
+ }
283
+ if (
284
+ runner.intendedReservationId &&
285
+ requestedReservationIds.has(runner.intendedReservationId) &&
286
+ !terminalStates.includes(runner.state as (typeof terminalStates)[number])
287
+ ) {
288
+ const usedRunnerIds =
289
+ usedRunnerIdsByReservation.get(runner.intendedReservationId) ?? new Set<string>();
290
+ usedRunnerIds.add(runner.id);
291
+ usedRunnerIdsByReservation.set(runner.intendedReservationId, usedRunnerIds);
292
+ }
293
+ }
294
+
295
+ const acceptedByReservation = new Map<string, number>();
296
+ const unavailableByReservation = new Map<
297
+ string,
298
+ {reason: RunnerReservationCapacityFailureReason; count: number}
299
+ >();
300
+ for (const reservationId of reservationIds) {
301
+ const requested = requestedByReservation.get(reservationId) ?? 0;
302
+ const reservation = reservationsById.get(reservationId);
303
+ if (!reservation) {
304
+ unavailableByReservation.set(reservationId, {
305
+ reason: nonLaunchReservationIds.has(reservationId)
306
+ ? 'reservation-kind-mismatch'
307
+ : 'reservation-not-found',
308
+ count: requested,
309
+ });
310
+ continue;
311
+ }
312
+ if (reservation.isExpired) {
313
+ unavailableByReservation.set(reservationId, {
314
+ reason: 'reservation-expired',
315
+ count: requested,
316
+ });
317
+ continue;
318
+ }
319
+
320
+ const used = usedRunnerIdsByReservation.get(reservationId)?.size ?? 0;
321
+ const accepted = Math.min(requested, Math.max(0, reservation.count - used));
322
+ acceptedByReservation.set(reservationId, accepted);
323
+ if (accepted < requested) {
324
+ unavailableByReservation.set(reservationId, {
325
+ reason: 'capacity-exhausted',
326
+ count: requested - accepted,
327
+ });
328
+ }
329
+ }
330
+
331
+ return {acceptedByReservation, unavailableByReservation};
332
+ }