@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,4 +1,7 @@
1
+ import {pgClient} from '@shipfox/node-postgres';
2
+ import {vi} from '@shipfox/vitest/vi';
1
3
  import {and, eq, inArray, or, sql, sum} from 'drizzle-orm';
4
+ import type {RunnerInstanceState} from '#core/entities/runner-instance.js';
2
5
  import {db} from '#db/db.js';
3
6
  import {
4
7
  deleteExpiredReservations,
@@ -7,10 +10,12 @@ import {
7
10
  pollInstallationDemandAndReserve,
8
11
  releaseReservationUnits,
9
12
  } from '#db/reservations.js';
13
+ import {pendingJobExecutions} from '#db/schema/pending-job-executions.js';
10
14
  import {reservations} from '#db/schema/reservations.js';
11
15
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
12
16
  import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
13
17
  import {providerRunners} from '#db/schema/runner-instances.js';
18
+ import {providerRunnerActivationOutcomeCount} from '#metrics/instance.js';
14
19
  import {pendingJobFactory, reservationFactory} from '#test/index.js';
15
20
 
16
21
  describe('pollDemandAndReserve', () => {
@@ -38,18 +43,656 @@ describe('pollDemandAndReserve', () => {
38
43
  expect(result.stats[0]).toMatchObject({labels: ['linux'], queued: 50, reserved: 20});
39
44
  });
40
45
 
41
- it('binds the oldest matching enrolled runners to a new reservation', async () => {
46
+ it('does not create a bound reservation when no idle runner can be rebound', async () => {
47
+ await createPendingJobs(1, ['linux']);
48
+
49
+ const result = await pollDemandAndReserve({
50
+ workspaceId,
51
+ provisionerId,
52
+ maxReservations: 1,
53
+ ttlSeconds: 60,
54
+ templates: [template('linux', ['linux'], 1)],
55
+ });
56
+
57
+ const storedReservations = await reservationsForTest();
58
+ expect(result.reservations).toHaveLength(1);
59
+ expect(storedReservations).toHaveLength(1);
60
+ expect(storedReservations[0]).toMatchObject({kind: 'launch', count: 1});
61
+ });
62
+
63
+ it('binds the oldest matching enrolled runners and returns only the launch remainder', async () => {
42
64
  const olderRunner = await createIdleRunner({
43
65
  labels: ['linux'],
44
66
  createdAt: new Date('2026-01-01T00:00:00.000Z'),
45
67
  });
46
- const newerRunner = await createIdleRunner({
68
+ const newerRunner = await createIdleRunner({
69
+ labels: ['linux'],
70
+ createdAt: new Date('2026-01-02T00:00:00.000Z'),
71
+ });
72
+ const unmatchedRunner = await createIdleRunner({
73
+ labels: ['macos'],
74
+ createdAt: new Date('2026-01-03T00:00:00.000Z'),
75
+ });
76
+ await createPendingJobs(3, ['linux']);
77
+
78
+ const result = await pollDemandAndReserve({
79
+ workspaceId,
80
+ provisionerId,
81
+ maxReservations: 3,
82
+ ttlSeconds: 60,
83
+ templates: [template('linux', ['linux'], 3)],
84
+ });
85
+
86
+ const rows = await db()
87
+ .select()
88
+ .from(providerRunners)
89
+ .where(inArray(providerRunners.id, [olderRunner.id, newerRunner.id, unmatchedRunner.id]));
90
+ const rowsById = new Map(rows.map((row) => [row.id, row]));
91
+ const [boundReservation] = await db()
92
+ .select()
93
+ .from(reservations)
94
+ .where(and(eq(reservations.workspaceId, workspaceId), eq(reservations.kind, 'bound')));
95
+ const [launchReservation] = await db()
96
+ .select()
97
+ .from(reservations)
98
+ .where(and(eq(reservations.workspaceId, workspaceId), eq(reservations.kind, 'launch')));
99
+ const reservationId = boundReservation?.id;
100
+
101
+ expect(reservationId).toEqual(expect.any(String));
102
+ expect(result.reservations).toEqual([
103
+ expect.objectContaining({reservationId: launchReservation?.id, count: 1}),
104
+ ]);
105
+ expect(boundReservation).toMatchObject({kind: 'bound', count: 2});
106
+ expect(rowsById.get(olderRunner.id)).toMatchObject({
107
+ workspaceId,
108
+ reservationId,
109
+ assignedAt: expect.any(Date),
110
+ });
111
+ expect(rowsById.get(newerRunner.id)).toMatchObject({
112
+ workspaceId,
113
+ reservationId,
114
+ assignedAt: expect.any(Date),
115
+ });
116
+ expect(rowsById.get(unmatchedRunner.id)).toMatchObject({
117
+ workspaceId: null,
118
+ reservationId: null,
119
+ assignedAt: null,
120
+ });
121
+ });
122
+
123
+ it('does not deadlock polling with expired-reservation cleanup', async () => {
124
+ const oldestRunnerId = `ffffffff-${crypto.randomUUID().slice(9)}`;
125
+ const lowestIdRunnerId = `00000000-${crypto.randomUUID().slice(9)}`;
126
+ const [expiredReservation] = await db()
127
+ .insert(reservations)
128
+ .values({
129
+ workspaceId,
130
+ provisionerId,
131
+ requiredLabels: ['linux'],
132
+ count: 2,
133
+ expiresAt: new Date('2000-01-01T00:00:00.000Z'),
134
+ })
135
+ .returning({id: reservations.id});
136
+ if (!expiredReservation) throw new Error('Expected expired reservation');
137
+
138
+ const oldestRunner = await createIdleRunner({
139
+ id: oldestRunnerId,
140
+ labels: ['linux'],
141
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
142
+ workspaceId,
143
+ reservationId: expiredReservation.id,
144
+ });
145
+ const lowestIdRunner = await createIdleRunner({
146
+ id: lowestIdRunnerId,
147
+ labels: ['linux'],
148
+ createdAt: new Date('2026-01-02T00:00:00.000Z'),
149
+ workspaceId,
150
+ reservationId: expiredReservation.id,
151
+ });
152
+ await createPendingJobs(2, ['linux']);
153
+
154
+ const lockClient = await pgClient().connect();
155
+ let transactionOpen = false;
156
+ let cleanupPromise: Promise<number> | undefined;
157
+ let pollPromise: ReturnType<typeof pollDemandAndReserve> | undefined;
158
+ try {
159
+ await lockClient.query('BEGIN');
160
+ transactionOpen = true;
161
+ const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
162
+ const lockPid = lockPidResult.rows[0]?.pid;
163
+ if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
164
+ await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
165
+ lowestIdRunner.id,
166
+ ]);
167
+
168
+ cleanupPromise = deleteExpiredReservations({limit: 1});
169
+ // Attach a handler before waiting for the second transaction so a deadlock loser cannot
170
+ // become an unhandled rejection while the test is coordinating the lock holders.
171
+ cleanupPromise.catch(() => undefined);
172
+ await waitForLockWait({blockingPid: lockPid});
173
+
174
+ pollPromise = pollDemandAndReserve({
175
+ workspaceId,
176
+ provisionerId,
177
+ maxReservations: 2,
178
+ ttlSeconds: 60,
179
+ templates: [template('linux', ['linux'], 2)],
180
+ });
181
+ pollPromise.catch(() => undefined);
182
+ await waitForLockWait({blockingPid: lockPid, minWaiters: 2});
183
+
184
+ await lockClient.query('COMMIT');
185
+ transactionOpen = false;
186
+
187
+ if (!cleanupPromise || !pollPromise) throw new Error('Expected concurrent operations');
188
+ const [deleted, result] = await Promise.all([cleanupPromise, pollPromise]);
189
+
190
+ expect(deleted).toBe(1);
191
+ expect(result.reservations).toEqual([]);
192
+ const rows = await db()
193
+ .select({id: providerRunners.id, reservationId: providerRunners.reservationId})
194
+ .from(providerRunners)
195
+ .where(inArray(providerRunners.id, [oldestRunner.id, lowestIdRunner.id]));
196
+ expect(rows).toHaveLength(2);
197
+ expect(rows.every((row) => row.reservationId !== expiredReservation.id)).toBe(true);
198
+ } finally {
199
+ if (transactionOpen) await lockClient.query('ROLLBACK');
200
+ if (cleanupPromise) await cleanupPromise.catch(() => undefined);
201
+ if (pollPromise) await pollPromise.catch(() => undefined);
202
+ lockClient.release();
203
+ }
204
+ }, 10_000);
205
+
206
+ it('refills with the next eligible runner when the oldest becomes ineligible', async () => {
207
+ const runner = await createIdleRunner({
208
+ labels: ['linux'],
209
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
210
+ });
211
+ const replacementRunner = await createIdleRunner({
212
+ labels: ['linux'],
213
+ createdAt: new Date('2026-01-02T00:00:00.000Z'),
214
+ });
215
+ await createPendingJobs(1, ['linux']);
216
+
217
+ const lockClient = await pgClient().connect();
218
+ const eligibilityClient = await pgClient().connect();
219
+ let lockTransactionOpen = false;
220
+ let eligibilityTransactionOpen = false;
221
+ let eligibilityLockPromise: Promise<unknown> | undefined;
222
+ let pollPromise: ReturnType<typeof pollDemandAndReserve> | undefined;
223
+ try {
224
+ await lockClient.query('BEGIN');
225
+ lockTransactionOpen = true;
226
+ const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
227
+ const lockPid = lockPidResult.rows[0]?.pid;
228
+ if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
229
+ await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
230
+ runner.id,
231
+ ]);
232
+
233
+ await eligibilityClient.query('BEGIN');
234
+ eligibilityTransactionOpen = true;
235
+ const eligibilityPidResult = await eligibilityClient.query<{pid: number}>(
236
+ 'SELECT pg_backend_pid() AS pid',
237
+ );
238
+ const eligibilityPid = eligibilityPidResult.rows[0]?.pid;
239
+ if (eligibilityPid === undefined) throw new Error('Expected eligibility backend pid');
240
+ eligibilityLockPromise = eligibilityClient.query(
241
+ 'SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE',
242
+ [runner.id],
243
+ );
244
+ // Attach a handler before starting the poll so cleanup can safely await a failed lock query.
245
+ eligibilityLockPromise.catch(() => undefined);
246
+ await waitForLockWait({blockingPid: lockPid});
247
+
248
+ pollPromise = pollDemandAndReserve({
249
+ workspaceId,
250
+ provisionerId,
251
+ maxReservations: 1,
252
+ ttlSeconds: 60,
253
+ templates: [template('linux', ['linux'], 1)],
254
+ });
255
+ pollPromise.catch(() => undefined);
256
+ await waitForLockWait({blockingPid: eligibilityPid});
257
+
258
+ await lockClient.query('COMMIT');
259
+ lockTransactionOpen = false;
260
+ await eligibilityLockPromise;
261
+ await eligibilityClient.query(
262
+ "UPDATE runners_runner_instances SET state = 'terminated', terminated_at = now(), updated_at = now() WHERE id = $1",
263
+ [runner.id],
264
+ );
265
+ await eligibilityClient.query(
266
+ "UPDATE runners_runner_control_sessions SET closed_at = now(), close_reason = 'test' WHERE runner_instance_id = $1 AND closed_at IS NULL",
267
+ [runner.id],
268
+ );
269
+ await eligibilityClient.query('COMMIT');
270
+ eligibilityTransactionOpen = false;
271
+
272
+ if (!pollPromise) throw new Error('Expected concurrent poll');
273
+ const result = await pollPromise;
274
+
275
+ expect(result.reservations).toEqual([]);
276
+ const rows = await db()
277
+ .select({
278
+ id: providerRunners.id,
279
+ state: providerRunners.state,
280
+ workspaceId: providerRunners.workspaceId,
281
+ reservationId: providerRunners.reservationId,
282
+ })
283
+ .from(providerRunners)
284
+ .where(inArray(providerRunners.id, [runner.id, replacementRunner.id]));
285
+ const rowsById = new Map(rows.map((row) => [row.id, row]));
286
+ expect(rowsById.get(runner.id)).toMatchObject({
287
+ state: 'terminated',
288
+ workspaceId: null,
289
+ reservationId: null,
290
+ });
291
+ expect(rowsById.get(replacementRunner.id)).toMatchObject({
292
+ state: 'running',
293
+ workspaceId,
294
+ reservationId: expect.any(String),
295
+ });
296
+ } finally {
297
+ if (lockTransactionOpen) await lockClient.query('ROLLBACK');
298
+ if (eligibilityTransactionOpen) await eligibilityClient.query('ROLLBACK');
299
+ if (eligibilityLockPromise) await eligibilityLockPromise.catch(() => undefined);
300
+ if (pollPromise) await pollPromise.catch(() => undefined);
301
+ lockClient.release();
302
+ eligibilityClient.release();
303
+ }
304
+ }, 10_000);
305
+
306
+ it('splits rebound and launch capacity with separate reservation lifetimes', async () => {
307
+ const runner = await createIdleRunner({labels: ['linux']});
308
+ await createPendingJobs(2, ['linux']);
309
+
310
+ const before = Date.now();
311
+ const result = await pollDemandAndReserve({
312
+ workspaceId,
313
+ provisionerId,
314
+ maxReservations: 2,
315
+ ttlSeconds: 120,
316
+ activationGraceSeconds: 5,
317
+ templates: [template('linux', ['linux'], 2)],
318
+ });
319
+
320
+ expect(result.reservations).toHaveLength(1);
321
+ expect(result.reservations[0]).toMatchObject({count: 1});
322
+ const rows = await db()
323
+ .select()
324
+ .from(reservations)
325
+ .where(
326
+ and(
327
+ eq(reservations.workspaceId, workspaceId),
328
+ eq(reservations.provisionerId, provisionerId),
329
+ ),
330
+ );
331
+ const boundReservation = rows.find((row) => row.kind === 'bound');
332
+ const launchReservation = rows.find((row) => row.kind === 'launch');
333
+ expect(boundReservation).toMatchObject({count: 1});
334
+ expect(launchReservation).toMatchObject({count: 1});
335
+ expect(boundReservation?.expiresAt.getTime()).toBeGreaterThan(before + 4_000);
336
+ expect(boundReservation?.expiresAt.getTime()).toBeLessThan(
337
+ launchReservation?.expiresAt.getTime() ?? 0,
338
+ );
339
+ expect(launchReservation?.id).toBe(result.reservations[0]?.reservationId);
340
+ const [storedRunner] = await db()
341
+ .select()
342
+ .from(providerRunners)
343
+ .where(eq(providerRunners.id, runner.id));
344
+ expect(storedRunner?.reservationId).toBe(boundReservation?.id);
345
+ });
346
+
347
+ it('records a rebound outcome for a demand-backed idle runner', async () => {
348
+ const outcomeSpy = vi.spyOn(providerRunnerActivationOutcomeCount, 'add');
349
+ await createIdleRunner({labels: ['linux'], launchKind: 'demand'});
350
+ await createPendingJobs(1, ['linux']);
351
+ const callsBefore = outcomeSpy.mock.calls.length;
352
+
353
+ await pollDemandAndReserve({
354
+ workspaceId,
355
+ provisionerId,
356
+ maxReservations: 1,
357
+ ttlSeconds: 60,
358
+ templates: [template('linux', ['linux'], 1)],
359
+ });
360
+
361
+ expect(
362
+ outcomeSpy.mock.calls
363
+ .slice(callsBefore)
364
+ .filter(
365
+ ([value, attributes]) =>
366
+ value === 1 && JSON.stringify(attributes) === JSON.stringify({outcome: 'rebound'}),
367
+ ),
368
+ ).toHaveLength(1);
369
+ });
370
+
371
+ it('does not charge adopted runners against later overlapping demand', async () => {
372
+ const adoptedRunner = await createIdleRunner({labels: ['linux', 'gpu']});
373
+ await createPendingJobs(1, ['linux', 'gpu']);
374
+
375
+ const firstPoll = await pollDemandAndReserve({
376
+ workspaceId,
377
+ provisionerId,
378
+ maxReservations: 1,
379
+ ttlSeconds: 60,
380
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
381
+ });
382
+ expect(firstPoll.reservations).toEqual([]);
383
+
384
+ await createPendingJobs(1, ['linux']);
385
+ const result = await pollDemandAndReserve({
386
+ workspaceId,
387
+ provisionerId,
388
+ maxReservations: 1,
389
+ ttlSeconds: 60,
390
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
391
+ });
392
+
393
+ const storedReservations = await reservationsForTest();
394
+ const boundReservation = storedReservations.find(
395
+ (reservation) => reservation.requiredLabels.includes('gpu') && reservation.kind === 'bound',
396
+ );
397
+ const launchReservation = storedReservations.find(
398
+ (reservation) => !reservation.requiredLabels.includes('gpu') && reservation.kind === 'launch',
399
+ );
400
+ const [storedRunner] = await db()
401
+ .select()
402
+ .from(providerRunners)
403
+ .where(eq(providerRunners.id, adoptedRunner.id));
404
+
405
+ expect(result.reservations).toEqual([
406
+ expect.objectContaining({
407
+ reservationId: launchReservation?.id,
408
+ labels: ['linux'],
409
+ count: 1,
410
+ }),
411
+ ]);
412
+ expect(boundReservation).toMatchObject({kind: 'bound', count: 1});
413
+ expect(storedRunner?.reservationId).toBe(boundReservation?.id);
414
+ });
415
+
416
+ it('deducts only pending launch units from a partially adopted reservation', async () => {
417
+ await createIdleRunner({labels: ['linux', 'gpu']});
418
+ await createIdleRunner({labels: ['linux', 'gpu']});
419
+ await createPendingJobs(3, ['linux', 'gpu']);
420
+
421
+ const firstPoll = await pollDemandAndReserve({
422
+ workspaceId,
423
+ provisionerId,
424
+ maxReservations: 3,
425
+ ttlSeconds: 60,
426
+ templates: [template('linux-gpu', ['linux', 'gpu'], 3)],
427
+ });
428
+ expect(firstPoll.reservations).toEqual([expect.objectContaining({count: 1})]);
429
+
430
+ await createPendingJobs(1, ['linux']);
431
+ const secondPoll = await pollDemandAndReserve({
432
+ workspaceId,
433
+ provisionerId,
434
+ maxReservations: 1,
435
+ ttlSeconds: 60,
436
+ templates: [template('linux-gpu', ['linux', 'gpu'], 2)],
437
+ });
438
+
439
+ expect(secondPoll.reservations).toEqual([
440
+ expect.objectContaining({labels: ['linux'], count: 1}),
441
+ ]);
442
+ });
443
+
444
+ it('deducts a launch reservation with no adopted units in full', async () => {
445
+ await createPendingJobs(1, ['linux', 'gpu']);
446
+
447
+ const firstPoll = await pollDemandAndReserve({
448
+ workspaceId,
449
+ provisionerId,
450
+ maxReservations: 1,
451
+ ttlSeconds: 60,
452
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
453
+ });
454
+ expect(firstPoll.reservations).toEqual([expect.objectContaining({count: 1})]);
455
+
456
+ await createPendingJobs(1, ['linux']);
457
+ const secondPoll = await pollDemandAndReserve({
458
+ workspaceId,
459
+ provisionerId,
460
+ maxReservations: 1,
461
+ ttlSeconds: 60,
462
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
463
+ });
464
+
465
+ expect(secondPoll.reservations).toEqual([]);
466
+ });
467
+
468
+ it('counts an intended runner while its reservation assignment is in flight', async () => {
469
+ const intendedReservation = await createIntendedReservation({
470
+ workspaceId,
471
+ expiresAt: new Date(Date.now() + 60_000),
472
+ });
473
+ await createIdleRunner({
474
+ labels: ['linux', 'gpu'],
475
+ intendedReservationId: intendedReservation.id,
476
+ });
477
+ await createPendingJobs(1, ['linux', 'gpu']);
478
+
479
+ const result = await pollDemandAndReserve({
480
+ workspaceId,
481
+ provisionerId,
482
+ maxReservations: 1,
483
+ ttlSeconds: 60,
484
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
485
+ });
486
+
487
+ expect(result.reservations).toEqual([
488
+ expect.objectContaining({labels: ['gpu', 'linux'], count: 1}),
489
+ ]);
490
+ });
491
+
492
+ it('deducts a released runner reservation unit as pending', async () => {
493
+ const reservation = await createIntendedReservation({
494
+ workspaceId,
495
+ expiresAt: new Date(Date.now() + 60_000),
496
+ });
497
+ await createIdleRunner({
498
+ labels: ['linux'],
499
+ reservationId: reservation.id,
500
+ reservationReleasedAt: new Date(Date.now() - 1_000),
501
+ });
502
+ await createPendingJobs(1, ['linux', 'gpu']);
503
+
504
+ const result = await pollDemandAndReserve({
505
+ workspaceId,
506
+ provisionerId,
507
+ maxReservations: 1,
508
+ ttlSeconds: 60,
509
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
510
+ });
511
+
512
+ expect(result.reservations).toEqual([]);
513
+ });
514
+
515
+ it('deducts a terminal intended runner reservation unit as pending', async () => {
516
+ const reservation = await createIntendedReservation({
517
+ workspaceId,
518
+ expiresAt: new Date(Date.now() + 60_000),
519
+ });
520
+ await createIdleRunner({
521
+ labels: ['linux'],
522
+ intendedReservationId: reservation.id,
523
+ state: 'terminated',
524
+ });
525
+ await createPendingJobs(1, ['linux', 'gpu']);
526
+
527
+ const result = await pollDemandAndReserve({
528
+ workspaceId,
529
+ provisionerId,
530
+ maxReservations: 1,
531
+ ttlSeconds: 60,
532
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
533
+ });
534
+
535
+ expect(result.reservations).toEqual([]);
536
+ });
537
+
538
+ it('binds a runner whose intended reservation has passed its activation grace period', async () => {
539
+ const intendedReservation = await createIntendedReservation({
540
+ workspaceId,
541
+ expiresAt: new Date(Date.now() - 60_000),
542
+ });
543
+ const runner = await createIdleRunner({
544
+ labels: ['linux'],
545
+ intendedReservationId: intendedReservation.id,
546
+ });
547
+ await createPendingJobs(1, ['linux']);
548
+
549
+ const result = await pollDemandAndReserve({
550
+ workspaceId,
551
+ provisionerId,
552
+ maxReservations: 1,
553
+ ttlSeconds: 60,
554
+ templates: [template('linux', ['linux'], 1)],
555
+ });
556
+
557
+ const [storedRunner] = await db()
558
+ .select()
559
+ .from(providerRunners)
560
+ .where(eq(providerRunners.id, runner.id));
561
+ expect(result.reservations).toEqual([]);
562
+ expect(storedRunner).toMatchObject({
563
+ workspaceId,
564
+ reservationId: expect.any(String),
565
+ intendedReservationId: null,
566
+ assignedAt: expect.any(Date),
567
+ });
568
+ });
569
+
570
+ it('binds a runner whose intended reservation was deleted after its grace period', async () => {
571
+ const intendedReservation = await createIntendedReservation({
572
+ workspaceId,
573
+ expiresAt: new Date(Date.now() + 60_000),
574
+ });
575
+ await db().delete(reservations).where(eq(reservations.id, intendedReservation.id));
576
+ const runner = await createIdleRunner({
577
+ labels: ['linux'],
578
+ intendedReservationId: intendedReservation.id,
579
+ });
580
+ await createPendingJobs(1, ['linux']);
581
+
582
+ const result = await pollDemandAndReserve({
583
+ workspaceId,
584
+ provisionerId,
585
+ maxReservations: 1,
586
+ ttlSeconds: 60,
587
+ templates: [template('linux', ['linux'], 1)],
588
+ });
589
+
590
+ const [storedRunner] = await db()
591
+ .select()
592
+ .from(providerRunners)
593
+ .where(eq(providerRunners.id, runner.id));
594
+ expect(result.reservations).toEqual([]);
595
+ expect(storedRunner).toMatchObject({
596
+ workspaceId,
597
+ reservationId: expect.any(String),
598
+ intendedReservationId: null,
599
+ assignedAt: expect.any(Date),
600
+ });
601
+ });
602
+
603
+ it('binds an assigned runner after its reservation grace period expires', async () => {
604
+ const staleReservation = await createIntendedReservation({
605
+ workspaceId,
606
+ expiresAt: new Date(Date.now() - 60_000),
607
+ });
608
+ const runner = await createIdleRunner({
609
+ labels: ['linux'],
610
+ workspaceId,
611
+ reservationId: staleReservation.id,
612
+ intendedReservationId: staleReservation.id,
613
+ });
614
+ const [activationToken] = await db()
615
+ .insert(runnerActivationTokens)
616
+ .values({
617
+ runnerInstanceId: runner.id,
618
+ hashedToken: crypto.randomUUID(),
619
+ prefix: 'test',
620
+ expiresAt: new Date(Date.now() + 60_000),
621
+ })
622
+ .returning();
623
+ if (!activationToken) throw new Error('Expected activation token');
624
+ await createPendingJobs(1, ['linux']);
625
+
626
+ const result = await pollDemandAndReserve({
627
+ workspaceId,
628
+ provisionerId,
629
+ maxReservations: 1,
630
+ ttlSeconds: 60,
631
+ templates: [template('linux', ['linux'], 1)],
632
+ });
633
+
634
+ const [storedRunner] = await db()
635
+ .select()
636
+ .from(providerRunners)
637
+ .where(eq(providerRunners.id, runner.id));
638
+ const [storedToken] = await db()
639
+ .select()
640
+ .from(runnerActivationTokens)
641
+ .where(eq(runnerActivationTokens.id, activationToken.id));
642
+ expect(result.reservations).toEqual([]);
643
+ expect(storedRunner).toMatchObject({
644
+ workspaceId,
645
+ reservationId: expect.any(String),
646
+ intendedReservationId: null,
647
+ assignedAt: expect.any(Date),
648
+ });
649
+ expect(storedToken?.revokedAt).toBeInstanceOf(Date);
650
+ });
651
+
652
+ it('binds an assigned runner whose reservation was deleted', async () => {
653
+ const staleReservation = await createIntendedReservation({
654
+ workspaceId,
655
+ expiresAt: new Date(Date.now() + 60_000),
656
+ });
657
+ await db().delete(reservations).where(eq(reservations.id, staleReservation.id));
658
+ const runner = await createIdleRunner({
659
+ labels: ['linux'],
660
+ workspaceId,
661
+ reservationId: staleReservation.id,
662
+ intendedReservationId: staleReservation.id,
663
+ });
664
+ await createPendingJobs(1, ['linux']);
665
+
666
+ const result = await pollDemandAndReserve({
667
+ workspaceId,
668
+ provisionerId,
669
+ maxReservations: 1,
670
+ ttlSeconds: 60,
671
+ templates: [template('linux', ['linux'], 1)],
672
+ });
673
+
674
+ const [storedRunner] = await db()
675
+ .select()
676
+ .from(providerRunners)
677
+ .where(eq(providerRunners.id, runner.id));
678
+ expect(result.reservations).toEqual([]);
679
+ expect(storedRunner).toMatchObject({
680
+ workspaceId,
681
+ reservationId: expect.any(String),
682
+ intendedReservationId: null,
683
+ assignedAt: expect.any(Date),
684
+ });
685
+ });
686
+
687
+ it('does not bind an assigned runner within its activation grace period', async () => {
688
+ const liveReservation = await createIntendedReservation({
689
+ workspaceId,
690
+ expiresAt: new Date(Date.now() + 60_000),
691
+ });
692
+ const runner = await createIdleRunner({
47
693
  labels: ['linux'],
48
- createdAt: new Date('2026-01-02T00:00:00.000Z'),
49
- });
50
- const unmatchedRunner = await createIdleRunner({
51
- labels: ['macos'],
52
- createdAt: new Date('2026-01-03T00:00:00.000Z'),
694
+ workspaceId,
695
+ reservationId: liveReservation.id,
53
696
  });
54
697
  await createPendingJobs(2, ['linux']);
55
698
 
@@ -61,31 +704,336 @@ describe('pollDemandAndReserve', () => {
61
704
  templates: [template('linux', ['linux'], 2)],
62
705
  });
63
706
 
64
- const rows = await db()
707
+ const [storedRunner] = await db()
65
708
  .select()
66
709
  .from(providerRunners)
67
- .where(inArray(providerRunners.id, [olderRunner.id, newerRunner.id, unmatchedRunner.id]));
68
- const rowsById = new Map(rows.map((row) => [row.id, row]));
69
- const reservationId = result.reservations[0]?.reservationId;
70
-
71
- expect(reservationId).toEqual(expect.any(String));
72
- expect(rowsById.get(olderRunner.id)).toMatchObject({
710
+ .where(eq(providerRunners.id, runner.id));
711
+ expect(result.reservations[0]?.count).toBe(1);
712
+ expect(storedRunner).toMatchObject({
73
713
  workspaceId,
74
- reservationId,
75
- assignedAt: expect.any(Date),
714
+ reservationId: liveReservation.id,
715
+ intendedReservationId: null,
716
+ assignedAt: null,
76
717
  });
77
- expect(rowsById.get(newerRunner.id)).toMatchObject({
718
+ });
719
+
720
+ it('does not bind a runner whose intended reservation is within its activation grace period', async () => {
721
+ const intendedReservation = await createIntendedReservation({
722
+ workspaceId: crypto.randomUUID(),
723
+ expiresAt: new Date(Date.now() + 60_000),
724
+ });
725
+ const runner = await createIdleRunner({
726
+ labels: ['linux'],
727
+ intendedReservationId: intendedReservation.id,
728
+ });
729
+ await createPendingJobs(1, ['linux']);
730
+
731
+ const result = await pollDemandAndReserve({
78
732
  workspaceId,
79
- reservationId,
80
- assignedAt: expect.any(Date),
733
+ provisionerId,
734
+ maxReservations: 1,
735
+ ttlSeconds: 60,
736
+ templates: [template('linux', ['linux'], 1)],
81
737
  });
82
- expect(rowsById.get(unmatchedRunner.id)).toMatchObject({
738
+
739
+ const [storedRunner] = await db()
740
+ .select()
741
+ .from(providerRunners)
742
+ .where(eq(providerRunners.id, runner.id));
743
+ expect(result.reservations[0]?.count).toBe(1);
744
+ expect(storedRunner).toMatchObject({
83
745
  workspaceId: null,
84
746
  reservationId: null,
747
+ intendedReservationId: intendedReservation.id,
85
748
  assignedAt: null,
86
749
  });
87
750
  });
88
751
 
752
+ it('does not rebind a stale runner from another workspace in a workspace-scoped poll', async () => {
753
+ const previousWorkspaceId = crypto.randomUUID();
754
+ const staleReservation = await createIntendedReservation({
755
+ workspaceId: previousWorkspaceId,
756
+ expiresAt: new Date(Date.now() - 60_000),
757
+ });
758
+ const runner = await createIdleRunner({
759
+ labels: ['linux'],
760
+ workspaceId: previousWorkspaceId,
761
+ reservationId: staleReservation.id,
762
+ intendedReservationId: staleReservation.id,
763
+ });
764
+ await createPendingJobs(1, ['linux']);
765
+
766
+ const result = await pollDemandAndReserve({
767
+ workspaceId,
768
+ provisionerId,
769
+ maxReservations: 1,
770
+ ttlSeconds: 60,
771
+ templates: [template('linux', ['linux'], 1)],
772
+ });
773
+
774
+ const [storedRunner] = await db()
775
+ .select()
776
+ .from(providerRunners)
777
+ .where(eq(providerRunners.id, runner.id));
778
+ expect(result.reservations[0]?.count).toBe(1);
779
+ expect(storedRunner).toMatchObject({
780
+ workspaceId: previousWorkspaceId,
781
+ reservationId: staleReservation.id,
782
+ intendedReservationId: staleReservation.id,
783
+ });
784
+ });
785
+
786
+ it('rebinds an unowned runner whose reservation was deleted', async () => {
787
+ const runner = await createIdleRunner({
788
+ labels: ['linux'],
789
+ reservationId: crypto.randomUUID(),
790
+ });
791
+ await createPendingJobs(1, ['linux']);
792
+
793
+ const result = await pollDemandAndReserve({
794
+ workspaceId,
795
+ provisionerId,
796
+ maxReservations: 1,
797
+ ttlSeconds: 60,
798
+ templates: [template('linux', ['linux'], 1)],
799
+ });
800
+
801
+ const [storedRunner] = await db()
802
+ .select()
803
+ .from(providerRunners)
804
+ .where(eq(providerRunners.id, runner.id));
805
+ expect(result.reservations).toEqual([]);
806
+ expect(storedRunner).toMatchObject({
807
+ workspaceId,
808
+ reservationId: expect.any(String),
809
+ intendedReservationId: null,
810
+ assignedAt: expect.any(Date),
811
+ });
812
+ });
813
+
814
+ it('rebinds a stale runner across workspaces for an installation-scoped poll', async () => {
815
+ const previousWorkspaceId = crypto.randomUUID();
816
+ const targetWorkspaceId = crypto.randomUUID();
817
+ const staleReservation = await createIntendedReservation({
818
+ workspaceId: previousWorkspaceId,
819
+ expiresAt: new Date(Date.now() - 60_000),
820
+ });
821
+ const runner = await createIdleRunner({
822
+ labels: ['linux'],
823
+ workspaceId: previousWorkspaceId,
824
+ reservationId: staleReservation.id,
825
+ intendedReservationId: staleReservation.id,
826
+ });
827
+ await pendingJobFactory.create({workspaceId: targetWorkspaceId, requiredLabels: ['linux']});
828
+
829
+ const result = await pollInstallationDemandAndReserve({
830
+ provisionerId,
831
+ maxReservations: 1,
832
+ ttlSeconds: 60,
833
+ templates: [template('linux', ['linux'], 1)],
834
+ capabilityWindowSeconds: 60,
835
+ eligibleWorkspaceIds: new Set([targetWorkspaceId]),
836
+ });
837
+
838
+ const [storedRunner] = await db()
839
+ .select()
840
+ .from(providerRunners)
841
+ .where(eq(providerRunners.id, runner.id));
842
+ const [storedReservation] = await db()
843
+ .select()
844
+ .from(reservations)
845
+ .where(
846
+ and(
847
+ eq(reservations.workspaceId, targetWorkspaceId),
848
+ eq(reservations.provisionerId, provisionerId),
849
+ eq(reservations.kind, 'bound'),
850
+ ),
851
+ );
852
+ expect(result.reservations).toEqual([]);
853
+ expect(storedReservation).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
854
+ expect(storedRunner).toMatchObject({
855
+ workspaceId: targetWorkspaceId,
856
+ reservationId: storedReservation?.id,
857
+ intendedReservationId: null,
858
+ assignedAt: expect.any(Date),
859
+ });
860
+ });
861
+
862
+ it('does not rebind a runner reserved by a concurrent installation poll', async () => {
863
+ const previousWorkspaceId = crypto.randomUUID();
864
+ const targetWorkspaceId = crypto.randomUUID();
865
+ const runner = await createIdleRunner({labels: ['linux']});
866
+ await pendingJobFactory.create({workspaceId: targetWorkspaceId, requiredLabels: ['linux']});
867
+
868
+ const lockClient = await pgClient().connect();
869
+ let transactionOpen = false;
870
+ let pollPromise: ReturnType<typeof pollInstallationDemandAndReserve> | undefined;
871
+ try {
872
+ await lockClient.query('BEGIN');
873
+ transactionOpen = true;
874
+ const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
875
+ const lockPid = lockPidResult.rows[0]?.pid;
876
+ if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
877
+ await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
878
+ runner.id,
879
+ ]);
880
+
881
+ const reservationId = crypto.randomUUID();
882
+ await lockClient.query(
883
+ `INSERT INTO runners_reservations
884
+ (id, workspace_id, provisioner_id, required_labels, count, kind, expires_at)
885
+ VALUES ($1, $2, $3, $4, $5, 'bound', $6)`,
886
+ [
887
+ reservationId,
888
+ previousWorkspaceId,
889
+ provisionerId,
890
+ ['linux'],
891
+ 1,
892
+ new Date(Date.now() + 60_000),
893
+ ],
894
+ );
895
+ await lockClient.query(
896
+ `UPDATE runners_runner_instances
897
+ SET workspace_id = $1, reservation_id = $2, updated_at = now()
898
+ WHERE id = $3`,
899
+ [previousWorkspaceId, reservationId, runner.id],
900
+ );
901
+
902
+ pollPromise = pollInstallationDemandAndReserve({
903
+ provisionerId,
904
+ maxReservations: 1,
905
+ ttlSeconds: 60,
906
+ templates: [template('linux', ['linux'], 1)],
907
+ capabilityWindowSeconds: 60,
908
+ eligibleWorkspaceIds: new Set([targetWorkspaceId]),
909
+ });
910
+ // waitForLockWait yields to the macrotask queue, so an early poll rejection would
911
+ // surface as an unhandled rejection before the finally block attaches its handler.
912
+ pollPromise.catch(() => undefined);
913
+ await waitForLockWait({blockingPid: lockPid});
914
+
915
+ await lockClient.query('COMMIT');
916
+ transactionOpen = false;
917
+ const result = await pollPromise;
918
+ const [storedRunner] = await db()
919
+ .select()
920
+ .from(providerRunners)
921
+ .where(eq(providerRunners.id, runner.id));
922
+ const storedReservations = await db()
923
+ .select()
924
+ .from(reservations)
925
+ .where(eq(reservations.provisionerId, provisionerId));
926
+
927
+ expect(result.reservations).toHaveLength(1);
928
+ expect(result.reservations[0]).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
929
+ expect(storedRunner).toMatchObject({
930
+ workspaceId: previousWorkspaceId,
931
+ reservationId,
932
+ });
933
+ expect(storedReservations).toHaveLength(2);
934
+ expect(
935
+ storedReservations.find((reservation) => reservation.workspaceId === targetWorkspaceId),
936
+ ).toMatchObject({kind: 'launch', count: 1});
937
+ } finally {
938
+ if (transactionOpen) await lockClient.query('ROLLBACK');
939
+ if (pollPromise) await pollPromise.catch(() => undefined);
940
+ lockClient.release();
941
+ }
942
+ });
943
+
944
+ it.each([
945
+ ['template capacity', 5, 2, 2],
946
+ ['global reservation budget', 2, 5, 1],
947
+ ])('does not overcount rebound units for the %s case', async (_case, maxReservations, availableSlots, expectedLaunchCount) => {
948
+ const reboundWorkspaceId = crypto.randomUUID();
949
+ const launchWorkspaceId = crypto.randomUUID();
950
+ await pendingJobFactory.create({
951
+ workspaceId: reboundWorkspaceId,
952
+ requiredLabels: ['linux'],
953
+ });
954
+ await pendingJobFactory.create({
955
+ workspaceId: launchWorkspaceId,
956
+ requiredLabels: ['linux'],
957
+ });
958
+ await pendingJobFactory.create({
959
+ workspaceId: launchWorkspaceId,
960
+ requiredLabels: ['linux'],
961
+ });
962
+ await db()
963
+ .update(pendingJobExecutions)
964
+ .set({createdAt: new Date('2026-01-01T00:00:00.000Z')})
965
+ .where(eq(pendingJobExecutions.workspaceId, reboundWorkspaceId));
966
+ await db()
967
+ .update(pendingJobExecutions)
968
+ .set({createdAt: new Date('2026-01-02T00:00:00.000Z')})
969
+ .where(eq(pendingJobExecutions.workspaceId, launchWorkspaceId));
970
+ await createIdleRunner({labels: ['linux']});
971
+
972
+ const result = await pollInstallationDemandAndReserve({
973
+ provisionerId,
974
+ maxReservations,
975
+ ttlSeconds: 60,
976
+ templates: [template('linux', ['linux'], availableSlots)],
977
+ capabilityWindowSeconds: 60,
978
+ eligibleWorkspaceIds: new Set([reboundWorkspaceId, launchWorkspaceId]),
979
+ });
980
+
981
+ const storedReservations = await db()
982
+ .select()
983
+ .from(reservations)
984
+ .where(eq(reservations.provisionerId, provisionerId));
985
+ expect(result.reservations).toEqual([
986
+ expect.objectContaining({workspaceId: launchWorkspaceId, count: expectedLaunchCount}),
987
+ ]);
988
+ expect(storedReservations).toEqual(
989
+ expect.arrayContaining([
990
+ expect.objectContaining({workspaceId: reboundWorkspaceId, kind: 'bound', count: 1}),
991
+ expect.objectContaining({
992
+ workspaceId: launchWorkspaceId,
993
+ kind: 'launch',
994
+ count: expectedLaunchCount,
995
+ }),
996
+ ]),
997
+ );
998
+ });
999
+
1000
+ it('does not rebind a released runner after its reservation expires', async () => {
1001
+ const staleReservation = await createIntendedReservation({
1002
+ workspaceId,
1003
+ expiresAt: new Date(Date.now() - 60_000),
1004
+ });
1005
+ const releasedAt = new Date(Date.now() - 30_000);
1006
+ const runner = await createIdleRunner({
1007
+ labels: ['linux'],
1008
+ launchKind: 'demand',
1009
+ workspaceId,
1010
+ reservationId: staleReservation.id,
1011
+ intendedReservationId: staleReservation.id,
1012
+ reservationReleasedAt: releasedAt,
1013
+ });
1014
+ await createPendingJobs(1, ['linux']);
1015
+
1016
+ const result = await pollDemandAndReserve({
1017
+ workspaceId,
1018
+ provisionerId,
1019
+ maxReservations: 1,
1020
+ ttlSeconds: 60,
1021
+ templates: [template('linux', ['linux'], 1)],
1022
+ });
1023
+
1024
+ const [storedRunner] = await db()
1025
+ .select()
1026
+ .from(providerRunners)
1027
+ .where(eq(providerRunners.id, runner.id));
1028
+ expect(result.reservations[0]?.count).toBe(1);
1029
+ expect(storedRunner).toMatchObject({
1030
+ workspaceId,
1031
+ reservationId: staleReservation.id,
1032
+ intendedReservationId: staleReservation.id,
1033
+ reservationReleasedAt: releasedAt,
1034
+ });
1035
+ });
1036
+
89
1037
  it('does not bind a running runner without an active control session', async () => {
90
1038
  const [runner] = await db()
91
1039
  .insert(providerRunners)
@@ -158,9 +1106,10 @@ describe('pollDemandAndReserve', () => {
158
1106
  templates: [template('linux', ['linux'], 1)],
159
1107
  });
160
1108
 
161
- expect(firstResult.reservations).toHaveLength(1);
1109
+ expect(firstResult.reservations).toEqual([]);
162
1110
  expect(secondResult.reservations).toEqual([]);
163
1111
  expect(secondResult.stats[0]).toMatchObject({queued: 1, reserved: 1});
1112
+ expect(await activeReservedCount()).toBe(1);
164
1113
  });
165
1114
 
166
1115
  it('allocates overlapping label sets most-specific-first', async () => {
@@ -225,6 +1174,53 @@ describe('pollDemandAndReserve', () => {
225
1174
  ]);
226
1175
  });
227
1176
 
1177
+ it('does not charge adopted installation runners against later launch capacity', async () => {
1178
+ const olderWorkspaceId = crypto.randomUUID();
1179
+ const newerWorkspaceId = crypto.randomUUID();
1180
+ const runner = await createIdleRunner({labels: ['linux', 'gpu']});
1181
+ await pendingJobFactory.create({
1182
+ workspaceId: olderWorkspaceId,
1183
+ requiredLabels: ['linux', 'gpu'],
1184
+ });
1185
+ await pendingJobFactory.create({workspaceId: newerWorkspaceId, requiredLabels: ['linux']});
1186
+
1187
+ const result = await pollInstallationDemandAndReserve({
1188
+ provisionerId,
1189
+ maxReservations: 2,
1190
+ ttlSeconds: 60,
1191
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
1192
+ capabilityWindowSeconds: 60,
1193
+ eligibleWorkspaceIds: new Set([olderWorkspaceId, newerWorkspaceId]),
1194
+ });
1195
+
1196
+ const storedReservations = await db()
1197
+ .select()
1198
+ .from(reservations)
1199
+ .where(eq(reservations.provisionerId, provisionerId));
1200
+ const boundReservation = storedReservations.find(
1201
+ (reservation) => reservation.workspaceId === olderWorkspaceId,
1202
+ );
1203
+ const launchReservation = storedReservations.find(
1204
+ (reservation) => reservation.workspaceId === newerWorkspaceId,
1205
+ );
1206
+ const [storedRunner] = await db()
1207
+ .select()
1208
+ .from(providerRunners)
1209
+ .where(eq(providerRunners.id, runner.id));
1210
+
1211
+ expect(result.reservations).toEqual([
1212
+ expect.objectContaining({
1213
+ reservationId: launchReservation?.id,
1214
+ workspaceId: newerWorkspaceId,
1215
+ labels: ['linux'],
1216
+ count: 1,
1217
+ }),
1218
+ ]);
1219
+ expect(boundReservation?.count).toBe(1);
1220
+ expect(storedRunner?.workspaceId).toBe(olderWorkspaceId);
1221
+ expect(storedRunner?.reservationId).toBe(boundReservation?.id);
1222
+ });
1223
+
228
1224
  it('reports committed installation grants before an aborted poll stops allocating', async () => {
229
1225
  const firstWorkspaceId = crypto.randomUUID();
230
1226
  const secondWorkspaceId = crypto.randomUUID();
@@ -421,15 +1417,25 @@ describe('pollDemandAndReserve', () => {
421
1417
  const runner = await createIdleRunner({labels: ['linux']});
422
1418
  await createPendingJobs(1, ['linux']);
423
1419
 
424
- const result = await pollDemandAndReserve({
1420
+ await pollDemandAndReserve({
425
1421
  workspaceId,
426
1422
  provisionerId,
427
1423
  maxReservations: 1,
428
1424
  ttlSeconds: 60,
429
1425
  templates: [template('linux', ['linux'], 1)],
430
1426
  });
431
- const reservationId = result.reservations[0]?.reservationId;
432
- if (!reservationId) throw new Error('Expected reservation');
1427
+ const [boundReservation] = await db()
1428
+ .select()
1429
+ .from(reservations)
1430
+ .where(
1431
+ and(
1432
+ eq(reservations.workspaceId, workspaceId),
1433
+ eq(reservations.provisionerId, provisionerId),
1434
+ eq(reservations.kind, 'bound'),
1435
+ ),
1436
+ );
1437
+ const reservationId = boundReservation?.id;
1438
+ if (!reservationId) throw new Error('Expected bound reservation');
433
1439
 
434
1440
  const [activationToken] = await db()
435
1441
  .insert(runnerActivationTokens)
@@ -523,15 +1529,25 @@ describe('pollDemandAndReserve', () => {
523
1529
  const runner = await createIdleRunner({labels: ['linux']});
524
1530
  await createPendingJobs(1, ['linux']);
525
1531
 
526
- const result = await pollDemandAndReserve({
1532
+ await pollDemandAndReserve({
527
1533
  workspaceId,
528
1534
  provisionerId,
529
1535
  maxReservations: 1,
530
1536
  ttlSeconds: 60,
531
1537
  templates: [template('linux', ['linux'], 1)],
532
1538
  });
533
- const reservationId = result.reservations[0]?.reservationId;
534
- if (!reservationId) throw new Error('Expected reservation');
1539
+ const [boundReservation] = await db()
1540
+ .select()
1541
+ .from(reservations)
1542
+ .where(
1543
+ and(
1544
+ eq(reservations.workspaceId, workspaceId),
1545
+ eq(reservations.provisionerId, provisionerId),
1546
+ eq(reservations.kind, 'bound'),
1547
+ ),
1548
+ );
1549
+ const reservationId = boundReservation?.id;
1550
+ if (!reservationId) throw new Error('Expected bound reservation');
535
1551
 
536
1552
  await db()
537
1553
  .update(reservations)
@@ -619,18 +1635,18 @@ describe('pollDemandAndReserve', () => {
619
1635
  expect(storedSurvivingRunner?.intendedReservationId).toBe(survivingReservation.id);
620
1636
 
621
1637
  await createPendingJobs(1, ['linux']);
622
- const result = await pollDemandAndReserve({
1638
+ await pollDemandAndReserve({
623
1639
  workspaceId,
624
1640
  provisionerId,
625
1641
  maxReservations: 1,
626
1642
  ttlSeconds: 60,
627
1643
  templates: [template('linux', ['linux'], 1)],
628
1644
  });
629
- const reboundReservationId = result.reservations[0]?.reservationId;
630
1645
  const [reboundRunner] = await db()
631
1646
  .select()
632
1647
  .from(providerRunners)
633
1648
  .where(eq(providerRunners.id, runner.id));
1649
+ const reboundReservationId = reboundRunner?.reservationId;
634
1650
  expect(reboundReservationId).toEqual(expect.any(String));
635
1651
  expect(reboundRunner).toMatchObject({
636
1652
  intendedReservationId: null,
@@ -808,21 +1824,50 @@ describe('pollDemandAndReserve', () => {
808
1824
  }
809
1825
  }
810
1826
 
1827
+ async function createIntendedReservation(params: {
1828
+ workspaceId: string;
1829
+ expiresAt: Date;
1830
+ }): Promise<{id: string}> {
1831
+ const [reservation] = await db()
1832
+ .insert(reservations)
1833
+ .values({
1834
+ workspaceId: params.workspaceId,
1835
+ provisionerId,
1836
+ requiredLabels: ['linux'],
1837
+ count: 1,
1838
+ expiresAt: params.expiresAt,
1839
+ })
1840
+ .returning({id: reservations.id});
1841
+ if (!reservation) throw new Error('Expected reservation');
1842
+ return reservation;
1843
+ }
1844
+
811
1845
  async function createIdleRunner(params: {
1846
+ id?: string;
812
1847
  labels: string[];
813
1848
  createdAt?: Date;
814
1849
  controlSessionExpiresAt?: Date;
815
- intendedReservationId?: string;
1850
+ launchKind?: 'demand' | 'warm' | 'manual';
1851
+ workspaceId?: string | null;
1852
+ reservationId?: string | null;
1853
+ intendedReservationId?: string | null;
1854
+ reservationReleasedAt?: Date | null;
1855
+ state?: RunnerInstanceState;
816
1856
  }) {
817
1857
  const createdAt = params.createdAt ?? new Date();
818
1858
  const [runner] = await db()
819
1859
  .insert(providerRunners)
820
1860
  .values({
1861
+ ...(params.id ? {id: params.id} : {}),
821
1862
  provisionerId,
1863
+ workspaceId: params.workspaceId,
1864
+ reservationId: params.reservationId,
822
1865
  providerRunnerId: crypto.randomUUID(),
1866
+ launchKind: params.launchKind ?? 'manual',
823
1867
  intendedReservationId: params.intendedReservationId,
1868
+ reservationReleasedAt: params.reservationReleasedAt,
824
1869
  labels: params.labels,
825
- state: 'running',
1870
+ state: params.state ?? 'running',
826
1871
  reportedAt: createdAt,
827
1872
  createdAt,
828
1873
  updatedAt: createdAt,
@@ -868,3 +1913,37 @@ describe('pollDemandAndReserve', () => {
868
1913
  return {templateKey, labels, availableSlots, starting: 0, running: 0};
869
1914
  }
870
1915
  });
1916
+
1917
+ async function waitForLockWait(params: {blockingPid: number; minWaiters?: number}) {
1918
+ const minWaiters = params.minWaiters ?? 1;
1919
+ const deadline = Date.now() + 5_000;
1920
+ while (Date.now() < deadline) {
1921
+ const result = await pgClient().query<{count: number}>(
1922
+ `
1923
+ WITH RECURSIVE lock_waiters(waiter_pid, blocker_pid, path) AS (
1924
+ SELECT activity.pid, blockers.blocker_pid, ARRAY[activity.pid, blockers.blocker_pid]
1925
+ FROM pg_stat_activity AS activity
1926
+ CROSS JOIN LATERAL unnest(pg_blocking_pids(activity.pid)) AS blockers(blocker_pid)
1927
+ WHERE activity.datname = current_database()
1928
+ AND activity.pid <> pg_backend_pid()
1929
+ AND activity.state = 'active'
1930
+ AND activity.wait_event_type = 'Lock'
1931
+ UNION ALL
1932
+ SELECT waiters.waiter_pid, blockers.blocker_pid, waiters.path || blockers.blocker_pid
1933
+ FROM lock_waiters AS waiters
1934
+ CROSS JOIN LATERAL unnest(pg_blocking_pids(waiters.blocker_pid)) AS blockers(blocker_pid)
1935
+ WHERE NOT (blockers.blocker_pid = ANY(waiters.path))
1936
+ )
1937
+ SELECT count(DISTINCT waiter_pid)::int AS count
1938
+ FROM lock_waiters
1939
+ WHERE blocker_pid = $1::int
1940
+ `,
1941
+ [params.blockingPid],
1942
+ );
1943
+ if ((result.rows[0]?.count ?? 0) >= minWaiters) return;
1944
+ await new Promise((resolve) => setTimeout(resolve, 10));
1945
+ }
1946
+ throw new Error(
1947
+ `Timed out waiting for ${minWaiters} backend(s) blocked by pid ${params.blockingPid}`,
1948
+ );
1949
+ }