@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
@@ -4,6 +4,7 @@ import {db} from '#db/db.js';
4
4
  import {createRunnerSessionConsumingEphemeralToken} from '#db/ephemeral-registration-tokens.js';
5
5
  import {
6
6
  attachRunnerInstanceProviderId,
7
+ countStaleEnrolledRunnerInstances,
7
8
  listActiveRunnerInstanceCountsByTemplateTx,
8
9
  listActiveRunnerInstances,
9
10
  listProvisionerTerminateIntentRowsTx,
@@ -14,6 +15,7 @@ import {
14
15
  } from '#db/runner-instances.js';
15
16
  import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
16
17
  import {reservations} from '#db/schema/reservations.js';
18
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
17
19
  import {providerRunners} from '#db/schema/runner-instances.js';
18
20
  import {runnerSessions} from '#db/schema/runner-sessions.js';
19
21
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
@@ -144,6 +146,28 @@ describe('reportRunnerInstances', () => {
144
146
  expect(rows[0]?.state).toBe('running');
145
147
  });
146
148
 
149
+ it('preserves launch kind when a provider report updates the runner row', async () => {
150
+ await providerRunnerFactory.create({
151
+ workspaceId,
152
+ provisionerId,
153
+ providerRunnerId: 'demand-runner',
154
+ launchKind: 'demand',
155
+ });
156
+
157
+ await reportRunnerInstances({
158
+ scope: 'workspace',
159
+ workspaceId,
160
+ provisionerId,
161
+ events: [event({providerRunnerId: 'demand-runner', state: 'running'})],
162
+ });
163
+
164
+ const [row] = await db()
165
+ .select({launchKind: providerRunners.launchKind})
166
+ .from(providerRunners)
167
+ .where(eq(providerRunners.providerRunnerId, 'demand-runner'));
168
+ expect(row?.launchKind).toBe('demand');
169
+ });
170
+
147
171
  it('uses state progression to dedupe equal-timestamp provisioned runner reports', async () => {
148
172
  const reportedAt = new Date();
149
173
 
@@ -231,6 +255,191 @@ describe('reportRunnerInstances', () => {
231
255
  expect(rows[0]?.reason).toBe('fresh');
232
256
  });
233
257
 
258
+ it('preserves a rebound reservation through stale reports and releases it on termination', async () => {
259
+ const staleReservationId = await createReservation(1);
260
+ await db()
261
+ .update(reservations)
262
+ .set({expiresAt: new Date(Date.now() - 60_000)})
263
+ .where(eq(reservations.id, staleReservationId));
264
+ const reboundReservationId = await createReservation(2);
265
+ const initialReportedAt = new Date(Date.now() - 120_000);
266
+ const runningReportedAt = new Date(Date.now() - 60_000);
267
+ await providerRunnerFactory.create({
268
+ workspaceId,
269
+ provisionerId,
270
+ providerRunnerId: 'rebound-runner',
271
+ reservationId: reboundReservationId,
272
+ state: 'running',
273
+ reportedAt: initialReportedAt,
274
+ });
275
+
276
+ const runningReport = await reportRunnerInstances({
277
+ scope: 'workspace',
278
+ workspaceId,
279
+ provisionerId,
280
+ events: [
281
+ event({
282
+ providerRunnerId: 'rebound-runner',
283
+ reservationId: staleReservationId,
284
+ reportedAt: runningReportedAt,
285
+ }),
286
+ ],
287
+ });
288
+ const terminalReport = await reportRunnerInstances({
289
+ scope: 'workspace',
290
+ workspaceId,
291
+ provisionerId,
292
+ events: [
293
+ event({
294
+ providerRunnerId: 'rebound-runner',
295
+ reservationId: staleReservationId,
296
+ state: 'failed',
297
+ reportedAt: new Date(runningReportedAt.getTime() + 1_000),
298
+ }),
299
+ ],
300
+ });
301
+
302
+ const [runner] = await db()
303
+ .select()
304
+ .from(providerRunners)
305
+ .where(eq(providerRunners.providerRunnerId, 'rebound-runner'));
306
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
307
+ expect(runningReport).toEqual({
308
+ accepted: 1,
309
+ reservationsReleased: 0,
310
+ terminateIntentsHonored: [],
311
+ });
312
+ expect(terminalReport).toEqual({
313
+ accepted: 1,
314
+ reservationsReleased: 1,
315
+ terminateIntentsHonored: [],
316
+ });
317
+ expect(runner).toMatchObject({
318
+ reservationId: reboundReservationId,
319
+ reservationReleasedAt: expect.any(Date),
320
+ });
321
+ expect(
322
+ reservationRows.find((reservation) => reservation.id === reboundReservationId)?.count,
323
+ ).toBe(1);
324
+ expect(
325
+ reservationRows.find((reservation) => reservation.id === staleReservationId)?.count,
326
+ ).toBe(1);
327
+ });
328
+
329
+ it('keeps the stored reservation when a report carries a stale reservation id', async () => {
330
+ const staleReservationId = await createReservation(1);
331
+ const boundReservationId = await createReservation(1);
332
+ await db()
333
+ .insert(providerRunners)
334
+ .values({
335
+ workspaceId,
336
+ provisionerId,
337
+ reservationId: boundReservationId,
338
+ providerRunnerId: 'provisioned-runner-1',
339
+ state: 'running',
340
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
341
+ });
342
+
343
+ await reportRunnerInstances({
344
+ scope: 'workspace',
345
+ workspaceId,
346
+ provisionerId,
347
+ events: [
348
+ event({
349
+ providerRunnerId: 'provisioned-runner-1',
350
+ reservationId: staleReservationId,
351
+ state: 'running',
352
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
353
+ }),
354
+ ],
355
+ });
356
+
357
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
358
+ expect(rows[0]?.reservationId).toBe(boundReservationId);
359
+ });
360
+
361
+ it('adopts a reported reservation id when the runner carries none', async () => {
362
+ const reservationId = await createReservation(1);
363
+ await db()
364
+ .insert(providerRunners)
365
+ .values({
366
+ workspaceId,
367
+ provisionerId,
368
+ providerRunnerId: 'provisioned-runner-1',
369
+ state: 'starting',
370
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
371
+ });
372
+
373
+ await reportRunnerInstances({
374
+ scope: 'workspace',
375
+ workspaceId,
376
+ provisionerId,
377
+ events: [
378
+ event({
379
+ providerRunnerId: 'provisioned-runner-1',
380
+ reservationId,
381
+ state: 'running',
382
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
383
+ }),
384
+ ],
385
+ });
386
+
387
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
388
+ expect(rows[0]?.reservationId).toBe(reservationId);
389
+ });
390
+
391
+ it('does not adopt a bound reservation id from an unclaimed report', async () => {
392
+ const reservationId = await createReservation(1, {kind: 'bound'});
393
+ await db()
394
+ .insert(providerRunners)
395
+ .values({
396
+ workspaceId,
397
+ provisionerId,
398
+ providerRunnerId: 'bound-reservation-runner',
399
+ state: 'starting',
400
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
401
+ });
402
+
403
+ await reportRunnerInstances({
404
+ scope: 'workspace',
405
+ workspaceId,
406
+ provisionerId,
407
+ events: [
408
+ event({
409
+ providerRunnerId: 'bound-reservation-runner',
410
+ reservationId,
411
+ state: 'running',
412
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
413
+ }),
414
+ ],
415
+ });
416
+
417
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
418
+ expect(rows[0]?.reservationId).toBeNull();
419
+ });
420
+
421
+ it('does not let reports exceed reservation capacity when creating projection rows', async () => {
422
+ const reservationId = await createReservation(1);
423
+
424
+ await reportRunnerInstances({
425
+ scope: 'workspace',
426
+ workspaceId,
427
+ provisionerId,
428
+ events: [
429
+ event({providerRunnerId: 'provisioned-runner-1', reservationId}),
430
+ event({providerRunnerId: 'provisioned-runner-2', reservationId}),
431
+ ],
432
+ });
433
+
434
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
435
+ expect(rows.find((row) => row.providerRunnerId === 'provisioned-runner-1')?.reservationId).toBe(
436
+ reservationId,
437
+ );
438
+ expect(
439
+ rows.find((row) => row.providerRunnerId === 'provisioned-runner-2')?.reservationId,
440
+ ).toBeNull();
441
+ });
442
+
234
443
  it('does not let equal-timestamp lower-priority reports flip terminal state', async () => {
235
444
  const reservationId = await createReservation(1);
236
445
  const reportedAt = new Date('2025-01-01T00:00:00.000Z');
@@ -419,8 +628,15 @@ describe('reportRunnerInstances', () => {
419
628
  expect(active).toEqual([]);
420
629
  });
421
630
 
422
- it('releases one reservation unit for a terminal unclaimed provisioned runner', async () => {
631
+ it('releases one reservation unit for a terminal runner with an intended reservation', async () => {
423
632
  const reservationId = await createReservation(2);
633
+ await providerRunnerFactory.create({
634
+ workspaceId,
635
+ provisionerId,
636
+ providerRunnerId: 'provisioned-runner-1',
637
+ intendedReservationId: reservationId,
638
+ state: 'running',
639
+ });
424
640
 
425
641
  const result = await reportRunnerInstances({
426
642
  scope: 'workspace',
@@ -436,6 +652,30 @@ describe('reportRunnerInstances', () => {
436
652
  expect(providerRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
437
653
  });
438
654
 
655
+ it('does not let an unrecognized terminal report consume reservation capacity', async () => {
656
+ const reservationId = await createReservation(1);
657
+
658
+ const result = await reportRunnerInstances({
659
+ scope: 'workspace',
660
+ workspaceId,
661
+ provisionerId,
662
+ events: [event({providerRunnerId: 'unrecognized-runner', reservationId, state: 'failed'})],
663
+ });
664
+
665
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
666
+ const providerRunnerRows = await providerRunnerRowsFor({workspaceId, provisionerId});
667
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
668
+ expect(reservationRows[0]?.count).toBe(1);
669
+ expect(providerRunnerRows).toHaveLength(1);
670
+ expect(providerRunnerRows[0]).toMatchObject({
671
+ providerRunnerId: 'unrecognized-runner',
672
+ reservationId: null,
673
+ intendedReservationId: null,
674
+ reservationReleasedAt: null,
675
+ state: 'failed',
676
+ });
677
+ });
678
+
439
679
  it('releases an intended reservation for a runner that dies before enrollment', async () => {
440
680
  const reservationId = await createReservation(2);
441
681
  const [runner] = await db()
@@ -606,6 +846,13 @@ describe('reportRunnerInstances', () => {
606
846
 
607
847
  it('releases a reservation only once across repeated terminal reports', async () => {
608
848
  const reservationId = await createReservation(2);
849
+ await providerRunnerFactory.create({
850
+ workspaceId,
851
+ provisionerId,
852
+ providerRunnerId: 'provisioned-runner-1',
853
+ intendedReservationId: reservationId,
854
+ state: 'running',
855
+ });
609
856
 
610
857
  await reportRunnerInstances({
611
858
  scope: 'workspace',
@@ -627,6 +874,13 @@ describe('reportRunnerInstances', () => {
627
874
 
628
875
  it('does not let a newer running report revive a terminal provisioned runner', async () => {
629
876
  const reservationId = await createReservation(2);
877
+ await providerRunnerFactory.create({
878
+ workspaceId,
879
+ provisionerId,
880
+ providerRunnerId: 'provisioned-runner-1',
881
+ intendedReservationId: reservationId,
882
+ state: 'running',
883
+ });
630
884
  const failedAt = new Date();
631
885
  await reportRunnerInstances({
632
886
  scope: 'workspace',
@@ -665,6 +919,13 @@ describe('reportRunnerInstances', () => {
665
919
 
666
920
  it('tracks provider cleanup as terminated', async () => {
667
921
  const reservationId = await createReservation(2);
922
+ await providerRunnerFactory.create({
923
+ workspaceId,
924
+ provisionerId,
925
+ providerRunnerId: 'provisioned-runner-1',
926
+ intendedReservationId: reservationId,
927
+ state: 'running',
928
+ });
668
929
 
669
930
  const result = await reportRunnerInstances({
670
931
  scope: 'workspace',
@@ -686,6 +947,20 @@ describe('reportRunnerInstances', () => {
686
947
 
687
948
  it('releases multiple units from the same reservation in one batch', async () => {
688
949
  const reservationId = await createReservation(3);
950
+ await providerRunnerFactory.create({
951
+ workspaceId,
952
+ provisionerId,
953
+ providerRunnerId: 'provisioned-runner-1',
954
+ intendedReservationId: reservationId,
955
+ state: 'running',
956
+ });
957
+ await providerRunnerFactory.create({
958
+ workspaceId,
959
+ provisionerId,
960
+ providerRunnerId: 'provisioned-runner-2',
961
+ intendedReservationId: reservationId,
962
+ state: 'running',
963
+ });
689
964
 
690
965
  const result = await reportRunnerInstances({
691
966
  scope: 'workspace',
@@ -704,6 +979,13 @@ describe('reportRunnerInstances', () => {
704
979
 
705
980
  it('deletes a one-unit reservation instead of violating the positive count check', async () => {
706
981
  const reservationId = await createReservation(1);
982
+ await providerRunnerFactory.create({
983
+ workspaceId,
984
+ provisionerId,
985
+ providerRunnerId: 'provisioned-runner-1',
986
+ intendedReservationId: reservationId,
987
+ state: 'running',
988
+ });
707
989
 
708
990
  const result = await reportRunnerInstances({
709
991
  scope: 'workspace',
@@ -727,6 +1009,13 @@ describe('reportRunnerInstances', () => {
727
1009
  });
728
1010
  const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
729
1011
  if (!reservation) throw new Error('Expected reservation');
1012
+ await providerRunnerFactory.create({
1013
+ workspaceId,
1014
+ provisionerId,
1015
+ providerRunnerId: 'provisioned-runner-1',
1016
+ intendedReservationId: reservation.id,
1017
+ state: 'running',
1018
+ });
730
1019
 
731
1020
  const result = await reportRunnerInstances({
732
1021
  scope: 'workspace',
@@ -877,13 +1166,14 @@ describe('reportRunnerInstances', () => {
877
1166
 
878
1167
  async function createReservation(
879
1168
  count: number,
880
- overrides?: {workspaceId?: string; provisionerId?: string},
1169
+ overrides?: {kind?: 'bound' | 'launch'; workspaceId?: string; provisionerId?: string},
881
1170
  ): Promise<string> {
882
1171
  await reservationFactory.create({
883
1172
  workspaceId: overrides?.workspaceId ?? workspaceId,
884
1173
  provisionerId: overrides?.provisionerId ?? provisionerId,
885
1174
  requiredLabels: ['linux'],
886
1175
  count,
1176
+ kind: overrides?.kind ?? 'launch',
887
1177
  expiresAt: new Date(Date.now() + 60_000),
888
1178
  });
889
1179
  const [reservation] = await db()
@@ -1022,6 +1312,106 @@ describe('listProvisionerTerminateIntents', () => {
1022
1312
  expect(result).toEqual([{providerRunnerId: 'provisioned-runner-1', reason: 'job-cancelled'}]);
1023
1313
  });
1024
1314
 
1315
+ it('returns an activation-timeout intent for a stale demand-backed runner', async () => {
1316
+ await createRunnerInstance({
1317
+ providerRunnerId: 'stale-demand-runner',
1318
+ launchKind: 'demand',
1319
+ createdAt: new Date(Date.now() - 301_000),
1320
+ });
1321
+
1322
+ const result = await db().transaction((tx) =>
1323
+ listProvisionerTerminateIntentRowsTx(tx, {workspaceId, provisionerId, limit: 1000}),
1324
+ );
1325
+ const [runner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1326
+
1327
+ expect(result).toEqual([
1328
+ {providerRunnerId: 'stale-demand-runner', reason: 'activation-timeout'},
1329
+ ]);
1330
+ expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
1331
+ });
1332
+
1333
+ it('does not reclaim warm, manual, active, or already-activated runners', async () => {
1334
+ const [liveReservation] = await db()
1335
+ .insert(reservations)
1336
+ .values({
1337
+ workspaceId,
1338
+ provisionerId,
1339
+ requiredLabels: ['linux'],
1340
+ count: 1,
1341
+ expiresAt: new Date(Date.now() + 60_000),
1342
+ })
1343
+ .returning({id: reservations.id});
1344
+ if (!liveReservation) throw new Error('Expected live reservation');
1345
+
1346
+ const staleCreatedAt = new Date(Date.now() - 301_000);
1347
+ await createRunnerInstance({
1348
+ providerRunnerId: 'stale-warm-runner',
1349
+ launchKind: 'warm',
1350
+ createdAt: staleCreatedAt,
1351
+ });
1352
+ await createRunnerInstance({
1353
+ providerRunnerId: 'stale-manual-runner',
1354
+ launchKind: 'manual',
1355
+ createdAt: staleCreatedAt,
1356
+ });
1357
+ await createRunnerInstance({
1358
+ providerRunnerId: 'live-demand-runner',
1359
+ launchKind: 'demand',
1360
+ reservationId: liveReservation.id,
1361
+ createdAt: staleCreatedAt,
1362
+ });
1363
+ await db()
1364
+ .insert(providerRunners)
1365
+ .values({
1366
+ workspaceId,
1367
+ provisionerId,
1368
+ intendedReservationId: liveReservation.id,
1369
+ providerRunnerId: 'live-intended-demand-runner',
1370
+ launchKind: 'demand',
1371
+ state: 'running',
1372
+ labels: ['linux'],
1373
+ reportedAt: staleCreatedAt,
1374
+ createdAt: staleCreatedAt,
1375
+ updatedAt: staleCreatedAt,
1376
+ });
1377
+ await createRunnerInstance({
1378
+ providerRunnerId: 'activated-demand-runner',
1379
+ launchKind: 'demand',
1380
+ runnerSessionId: crypto.randomUUID(),
1381
+ createdAt: staleCreatedAt,
1382
+ });
1383
+
1384
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
1385
+
1386
+ expect(result).toEqual([]);
1387
+ });
1388
+
1389
+ it('marks activation-timeout retries after releasing the runner reservation', async () => {
1390
+ await createRunnerInstance({
1391
+ providerRunnerId: 'retryable-demand-runner',
1392
+ launchKind: 'demand',
1393
+ createdAt: new Date(Date.now() - 301_000),
1394
+ });
1395
+
1396
+ const first = await db().transaction((tx) =>
1397
+ listProvisionerTerminateIntentRowsTx(tx, {workspaceId, provisionerId, limit: 1000}),
1398
+ );
1399
+ const second = await db().transaction((tx) =>
1400
+ listProvisionerTerminateIntentRowsTx(tx, {workspaceId, provisionerId, limit: 1000}),
1401
+ );
1402
+
1403
+ expect(first).toEqual([
1404
+ {providerRunnerId: 'retryable-demand-runner', reason: 'activation-timeout'},
1405
+ ]);
1406
+ expect(second).toEqual([
1407
+ {
1408
+ providerRunnerId: 'retryable-demand-runner',
1409
+ reason: 'activation-timeout',
1410
+ activationTimeoutRetry: true,
1411
+ },
1412
+ ]);
1413
+ });
1414
+
1025
1415
  it('excludes active provisioned runners whose latest bound job is healthy', async () => {
1026
1416
  await createRunnerInstance({providerRunnerId: 'provisioned-runner-1'});
1027
1417
  await insertRunningJobRow({
@@ -1137,12 +1527,20 @@ describe('listProvisionerTerminateIntents', () => {
1137
1527
  providerRunnerId: string;
1138
1528
  provisionerId?: string;
1139
1529
  state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
1530
+ launchKind?: 'demand' | 'warm' | 'manual';
1531
+ createdAt?: Date;
1532
+ reservationId?: string | null;
1533
+ runnerSessionId?: string | null;
1140
1534
  }) {
1141
1535
  return await providerRunnerFactory.create({
1142
1536
  workspaceId,
1143
1537
  provisionerId: params.provisionerId ?? provisionerId,
1144
1538
  providerRunnerId: params.providerRunnerId,
1145
1539
  state: params.state ?? 'running',
1540
+ launchKind: params.launchKind ?? 'manual',
1541
+ createdAt: params.createdAt ?? new Date(),
1542
+ reservationId: params.reservationId ?? null,
1543
+ runnerSessionId: params.runnerSessionId ?? null,
1146
1544
  });
1147
1545
  }
1148
1546
  });
@@ -2046,6 +2444,102 @@ describe('reconcileRunnerInstances', () => {
2046
2444
  }
2047
2445
  });
2048
2446
 
2447
+ describe('countStaleEnrolledRunnerInstances', () => {
2448
+ let provisionerId: string;
2449
+
2450
+ beforeEach(() => {
2451
+ provisionerId = crypto.randomUUID();
2452
+ });
2453
+
2454
+ function staleAt(): Date {
2455
+ return new Date(Date.now() - 120_000);
2456
+ }
2457
+
2458
+ it('counts only stale reported runners with a live control session and no assignment', async () => {
2459
+ const baseline = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
2460
+ const stale = await createRunner({updatedAt: staleAt()});
2461
+ await createControlSession(stale);
2462
+
2463
+ const fresh = await createRunner({updatedAt: new Date()});
2464
+ await createControlSession(fresh);
2465
+
2466
+ const recentlyMutated = await createRunner({reportedAt: staleAt(), updatedAt: new Date()});
2467
+ await createControlSession(recentlyMutated);
2468
+
2469
+ const freshReport = await createRunner({reportedAt: new Date(), updatedAt: staleAt()});
2470
+ await createControlSession(freshReport);
2471
+
2472
+ const assigned = await createRunner({workspaceId: crypto.randomUUID(), updatedAt: staleAt()});
2473
+ await createControlSession(assigned);
2474
+
2475
+ const activated = await createRunner({
2476
+ runnerSessionId: crypto.randomUUID(),
2477
+ updatedAt: staleAt(),
2478
+ });
2479
+ await createControlSession(activated);
2480
+
2481
+ await createRunner({updatedAt: staleAt()});
2482
+ const expiredControlSession = await createRunner({updatedAt: staleAt()});
2483
+ await createControlSession(expiredControlSession, {
2484
+ expiresAt: new Date(Date.now() - 1_000),
2485
+ });
2486
+ const closedControlSession = await createRunner({updatedAt: staleAt()});
2487
+ await createControlSession(closedControlSession, {
2488
+ closedAt: new Date(),
2489
+ });
2490
+ const stopped = await createRunner({state: 'stopped', updatedAt: staleAt()});
2491
+ await createControlSession(stopped);
2492
+
2493
+ const count = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
2494
+
2495
+ expect(count - baseline).toBe(2);
2496
+ });
2497
+
2498
+ async function createRunner(params: {
2499
+ state?: 'running' | 'stopped';
2500
+ workspaceId?: string | null;
2501
+ runnerSessionId?: string | null;
2502
+ reportedAt?: Date;
2503
+ updatedAt?: Date;
2504
+ }) {
2505
+ const reportedAt = params.reportedAt ?? params.updatedAt ?? new Date();
2506
+ const [runner] = await db()
2507
+ .insert(providerRunners)
2508
+ .values({
2509
+ workspaceId: params.workspaceId ?? null,
2510
+ provisionerId,
2511
+ providerRunnerId: crypto.randomUUID(),
2512
+ templateKey: 'linux',
2513
+ labels: ['linux'],
2514
+ state: params.state ?? 'running',
2515
+ runnerSessionId: params.runnerSessionId ?? null,
2516
+ providerKind: 'docker',
2517
+ reportedAt,
2518
+ updatedAt: params.updatedAt ?? reportedAt,
2519
+ })
2520
+ .returning({id: providerRunners.id});
2521
+ if (!runner) throw new Error('Expected runner instance');
2522
+ return runner.id;
2523
+ }
2524
+
2525
+ async function createControlSession(
2526
+ runnerInstanceId: string,
2527
+ params: {expiresAt?: Date; closedAt?: Date} = {},
2528
+ ) {
2529
+ await db()
2530
+ .insert(runnerControlSessions)
2531
+ .values({
2532
+ runnerInstanceId,
2533
+ provisionerId,
2534
+ hashedToken: crypto.randomUUID(),
2535
+ prefix: 'test',
2536
+ expiresAt: params.expiresAt ?? new Date(Date.now() + 60_000),
2537
+ closedAt: params.closedAt,
2538
+ closeReason: params.closedAt ? 'test' : null,
2539
+ });
2540
+ }
2541
+ });
2542
+
2049
2543
  describe('runner instance provider attachment', () => {
2050
2544
  it('updates the pre-created runner instance before its first lifecycle report', async () => {
2051
2545
  const provisionerId = crypto.randomUUID();