@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
@@ -0,0 +1,447 @@
1
+ import {AUTH_PROVISIONER_TOKEN, AUTH_USER, setProvisionerContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ type AuthMethod,
4
+ ClientError,
5
+ closeApp,
6
+ createApp,
7
+ extractBearerToken,
8
+ } from '@shipfox/node-fastify';
9
+ import {vi} from '@shipfox/vitest/vi';
10
+ import {and, eq} from 'drizzle-orm';
11
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
12
+ import {db} from '#db/db.js';
13
+ import {reservations} from '#db/schema/reservations.js';
14
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
15
+ import {providerRunners} from '#db/schema/runner-instances.js';
16
+ import {runnerSessions} from '#db/schema/runner-sessions.js';
17
+ import {runnerReservationPromotionFailureCount} from '#metrics/instance.js';
18
+ import {
19
+ createRunnerControlSessionAuthMethod,
20
+ createRunnerRegistrationTokenAuthMethod,
21
+ } from '#presentation/auth/index.js';
22
+ import {
23
+ arrangeDeletedRunnerEnrollment,
24
+ arrangeExpiredRunnerEnrollment,
25
+ fakeLeaseTokenAuthMethod,
26
+ fakeRunnerSessionAuthMethod,
27
+ pendingJobFactory,
28
+ provisionerTokenFactory,
29
+ runnersTestAuthClient,
30
+ } from '#test/index.js';
31
+ import {createRunnerRoutes} from './index.js';
32
+
33
+ let workspaceToken: string;
34
+ const fakeUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
35
+
36
+ describe('late runner enrollment recovery', () => {
37
+ let app: FastifyInstance;
38
+ let provisionerId: string;
39
+ let workspaceId: string;
40
+
41
+ const provisionerAuth: AuthMethod = {
42
+ name: AUTH_PROVISIONER_TOKEN,
43
+ authenticate: (request: FastifyRequest) => {
44
+ if (extractBearerToken(request.headers.authorization) !== workspaceToken)
45
+ throw new ClientError('Invalid provisioner token', 'unauthorized', {status: 401});
46
+ setProvisionerContext(request, {
47
+ scope: 'workspace',
48
+ workspaceId,
49
+ provisionerTokenId: provisionerId,
50
+ });
51
+ return Promise.resolve();
52
+ },
53
+ };
54
+
55
+ beforeAll(async () => {
56
+ app = await createApp({
57
+ auth: [
58
+ fakeUserAuth,
59
+ provisionerAuth,
60
+ createRunnerRegistrationTokenAuthMethod(),
61
+ createRunnerControlSessionAuthMethod(),
62
+ fakeRunnerSessionAuthMethod,
63
+ fakeLeaseTokenAuthMethod,
64
+ ],
65
+ routes: createRunnerRoutes(runnersTestAuthClient),
66
+ swagger: false,
67
+ });
68
+ await app.ready();
69
+ });
70
+
71
+ afterAll(async () => {
72
+ await closeApp();
73
+ });
74
+
75
+ beforeEach(async () => {
76
+ workspaceId = crypto.randomUUID();
77
+ workspaceToken = `late-runner-workspace-provisioner-${crypto.randomUUID()}`;
78
+ const provisioner = await provisionerTokenFactory.create(
79
+ {scope: 'workspace', workspaceId},
80
+ {transient: {rawToken: workspaceToken}},
81
+ );
82
+ provisionerId = provisioner.id;
83
+ });
84
+
85
+ it('keeps an enrolled runner recoverable when its reservation expires first', async () => {
86
+ const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
87
+ const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
88
+
89
+ try {
90
+ await enrollRunner(arrangement.controlSessionToken);
91
+
92
+ expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-expired'});
93
+ } finally {
94
+ failureSpy.mockRestore();
95
+ }
96
+
97
+ const [runner] = await db()
98
+ .select()
99
+ .from(providerRunners)
100
+ .where(eq(providerRunners.id, arrangement.runnerInstanceId));
101
+ expect(runner).toMatchObject({
102
+ workspaceId: null,
103
+ reservationId: null,
104
+ intendedReservationId: arrangement.reservationId,
105
+ assignedAt: null,
106
+ runnerSessionId: null,
107
+ state: 'running',
108
+ providerRunnerId: expect.any(String),
109
+ });
110
+ });
111
+
112
+ it('keeps an enrolled runner recoverable when its reservation was swept away', async () => {
113
+ const arrangement = await arrangeDeletedRunnerEnrollment({provisionerId, workspaceId});
114
+ const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
115
+
116
+ try {
117
+ await enrollRunner(arrangement.controlSessionToken);
118
+
119
+ expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-not-found'});
120
+ } finally {
121
+ failureSpy.mockRestore();
122
+ }
123
+
124
+ const [runner] = await db()
125
+ .select()
126
+ .from(providerRunners)
127
+ .where(eq(providerRunners.id, arrangement.runnerInstanceId));
128
+ expect(runner).toMatchObject({
129
+ workspaceId: null,
130
+ reservationId: null,
131
+ intendedReservationId: arrangement.reservationId,
132
+ assignedAt: null,
133
+ runnerSessionId: null,
134
+ state: 'running',
135
+ providerRunnerId: expect.any(String),
136
+ });
137
+ });
138
+
139
+ it('rebinds a stranded runner through activation and claims its pending job', async () => {
140
+ const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
141
+ await enrollRunner(arrangement.controlSessionToken);
142
+ const pendingJob = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
143
+
144
+ const demand = await app.inject({
145
+ method: 'POST',
146
+ url: '/provisioners/demand/poll',
147
+ headers: {authorization: `Bearer ${workspaceToken}`},
148
+ payload: {
149
+ wait_seconds: 0,
150
+ max_reservations: 1,
151
+ reservation_ttl_seconds: 60,
152
+ templates: [
153
+ {
154
+ template_key: 'linux',
155
+ labels: ['linux'],
156
+ available_slots: 1,
157
+ starting: 0,
158
+ running: 1,
159
+ },
160
+ ],
161
+ },
162
+ });
163
+
164
+ expect(demand.statusCode).toBe(200);
165
+ expect(demand.json().reservations).toEqual([]);
166
+ const reboundReservations = await db()
167
+ .select()
168
+ .from(reservations)
169
+ .where(
170
+ and(
171
+ eq(reservations.workspaceId, workspaceId),
172
+ eq(reservations.provisionerId, provisionerId),
173
+ eq(reservations.kind, 'bound'),
174
+ ),
175
+ );
176
+ const reboundReservation = reboundReservations.find(
177
+ (reservation) => reservation.id !== arrangement.reservationId,
178
+ );
179
+ const reboundReservationId = reboundReservation?.id;
180
+ if (!reboundReservationId) throw new Error('Expected rebound reservation');
181
+ expect(reboundReservationId).toEqual(expect.any(String));
182
+
183
+ const [reboundRunner] = await db()
184
+ .select()
185
+ .from(providerRunners)
186
+ .where(eq(providerRunners.id, arrangement.runnerInstanceId));
187
+ expect(reboundRunner).toMatchObject({
188
+ workspaceId,
189
+ reservationId: reboundReservationId,
190
+ intendedReservationId: null,
191
+ assignedAt: expect.any(Date),
192
+ runnerSessionId: null,
193
+ state: 'running',
194
+ });
195
+
196
+ const assignment = await app.inject({
197
+ method: 'GET',
198
+ url: '/runner-control/assignment?wait_seconds=1',
199
+ headers: {authorization: `Bearer ${arrangement.controlSessionToken}`},
200
+ });
201
+ expect(assignment.statusCode).toBe(200);
202
+ const activationToken = assignment.json().activation_token;
203
+ expect(activationToken).toEqual(expect.any(String));
204
+
205
+ const registered = await app.inject({
206
+ method: 'POST',
207
+ url: '/runners/register',
208
+ headers: {authorization: `Bearer ${activationToken}`},
209
+ payload: {labels: ['linux']},
210
+ });
211
+ expect(registered.statusCode).toBe(200);
212
+ expect(registered.json()).toMatchObject({mode: 'activation', max_claims: 1});
213
+
214
+ const claimed = await app.inject({
215
+ method: 'POST',
216
+ url: '/runners/jobs/request',
217
+ headers: {authorization: `Bearer ${registered.json().session_token}`},
218
+ });
219
+ expect(claimed.statusCode).toBe(200);
220
+ expect(claimed.json()).toMatchObject({job_id: pendingJob.jobId});
221
+
222
+ const [session] = await db()
223
+ .select()
224
+ .from(runnerSessions)
225
+ .where(eq(runnerSessions.id, registered.json().session_id));
226
+ expect(session).toMatchObject({
227
+ registrationTokenKind: 'activation',
228
+ runnerInstanceId: arrangement.runnerInstanceId,
229
+ provisionerId,
230
+ providerRunnerId: reboundRunner?.providerRunnerId,
231
+ maxClaims: 1,
232
+ claimsUsed: 1,
233
+ });
234
+ });
235
+
236
+ it('preserves a rebound assignment across stale provider reports and releases it once', async () => {
237
+ const providerRunnerId = `stale-assignment-${crypto.randomUUID()}`;
238
+ const firstReportAt = new Date(Date.now() - 2_000);
239
+ const [staleReservation] = await db()
240
+ .insert(reservations)
241
+ .values({
242
+ workspaceId,
243
+ provisionerId,
244
+ requiredLabels: ['linux'],
245
+ count: 1,
246
+ expiresAt: new Date(Date.now() + 60_000),
247
+ })
248
+ .returning({id: reservations.id});
249
+ if (!staleReservation) throw new Error('Expected stale reservation');
250
+
251
+ const initialReport = await app.inject({
252
+ method: 'POST',
253
+ url: '/provisioners/runner-instances/report',
254
+ headers: {authorization: `Bearer ${workspaceToken}`},
255
+ payload: {
256
+ events: [
257
+ {
258
+ provider_runner_id: providerRunnerId,
259
+ reservation_id: staleReservation.id,
260
+ template_key: 'linux',
261
+ labels: ['linux'],
262
+ state: 'running',
263
+ reported_at: firstReportAt.toISOString(),
264
+ provider_kind: 'docker',
265
+ },
266
+ ],
267
+ },
268
+ });
269
+
270
+ expect(initialReport.statusCode).toBe(200);
271
+ expect(initialReport.json()).toEqual({accepted: 1, reservations_released: 0});
272
+
273
+ const [reportedRunner] = await db()
274
+ .select()
275
+ .from(providerRunners)
276
+ .where(
277
+ and(
278
+ eq(providerRunners.provisionerId, provisionerId),
279
+ eq(providerRunners.providerRunnerId, providerRunnerId),
280
+ ),
281
+ );
282
+ if (!reportedRunner) throw new Error('Expected reported runner');
283
+ expect(reportedRunner).toMatchObject({
284
+ workspaceId,
285
+ reservationId: staleReservation.id,
286
+ state: 'running',
287
+ });
288
+
289
+ await db()
290
+ .insert(runnerControlSessions)
291
+ .values({
292
+ runnerInstanceId: reportedRunner.id,
293
+ provisionerId,
294
+ hashedToken: crypto.randomUUID(),
295
+ prefix: 'test',
296
+ expiresAt: new Date(Date.now() + 60_000),
297
+ });
298
+ await db()
299
+ .update(reservations)
300
+ .set({expiresAt: new Date(Date.now() - 1_000)})
301
+ .where(eq(reservations.id, staleReservation.id));
302
+ await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
303
+
304
+ const demand = await app.inject({
305
+ method: 'POST',
306
+ url: '/provisioners/demand/poll',
307
+ headers: {authorization: `Bearer ${workspaceToken}`},
308
+ payload: {
309
+ wait_seconds: 0,
310
+ max_reservations: 1,
311
+ reservation_ttl_seconds: 60,
312
+ templates: [
313
+ {
314
+ template_key: 'linux',
315
+ labels: ['linux'],
316
+ available_slots: 1,
317
+ starting: 0,
318
+ running: 1,
319
+ },
320
+ ],
321
+ },
322
+ });
323
+
324
+ expect(demand.statusCode).toBe(200);
325
+ expect(demand.json().reservations).toEqual([]);
326
+ const reboundReservations = await db()
327
+ .select()
328
+ .from(reservations)
329
+ .where(
330
+ and(
331
+ eq(reservations.workspaceId, workspaceId),
332
+ eq(reservations.provisionerId, provisionerId),
333
+ eq(reservations.kind, 'bound'),
334
+ ),
335
+ );
336
+ const reboundReservation = reboundReservations.find(
337
+ (reservation) => reservation.id !== staleReservation.id,
338
+ );
339
+ const reboundReservationId = reboundReservation?.id;
340
+ if (!reboundReservationId) throw new Error('Expected rebound reservation');
341
+ expect(reboundReservationId).toEqual(expect.any(String));
342
+ expect(reboundReservationId).not.toBe(staleReservation.id);
343
+
344
+ const staleReport = await app.inject({
345
+ method: 'POST',
346
+ url: '/provisioners/runner-instances/report',
347
+ headers: {authorization: `Bearer ${workspaceToken}`},
348
+ payload: {
349
+ events: [
350
+ {
351
+ provider_runner_id: providerRunnerId,
352
+ reservation_id: staleReservation.id,
353
+ template_key: 'linux',
354
+ labels: ['linux'],
355
+ state: 'running',
356
+ reported_at: new Date(firstReportAt.getTime() + 1_000).toISOString(),
357
+ provider_kind: 'docker',
358
+ },
359
+ ],
360
+ },
361
+ });
362
+
363
+ expect(staleReport.statusCode).toBe(200);
364
+ expect(staleReport.json()).toEqual({accepted: 1, reservations_released: 0});
365
+
366
+ const [reboundRunner] = await db()
367
+ .select()
368
+ .from(providerRunners)
369
+ .where(eq(providerRunners.id, reportedRunner.id));
370
+ expect(reboundRunner).toMatchObject({
371
+ workspaceId,
372
+ reservationId: reboundReservationId,
373
+ state: 'running',
374
+ intendedReservationId: null,
375
+ });
376
+
377
+ const terminalReport = await app.inject({
378
+ method: 'POST',
379
+ url: '/provisioners/runner-instances/report',
380
+ headers: {authorization: `Bearer ${workspaceToken}`},
381
+ payload: {
382
+ events: [
383
+ {
384
+ provider_runner_id: providerRunnerId,
385
+ reservation_id: staleReservation.id,
386
+ template_key: 'linux',
387
+ labels: ['linux'],
388
+ state: 'terminated',
389
+ reported_at: new Date(firstReportAt.getTime() + 2_000).toISOString(),
390
+ provider_kind: 'docker',
391
+ },
392
+ ],
393
+ },
394
+ });
395
+
396
+ expect(terminalReport.statusCode).toBe(200);
397
+ expect(terminalReport.json()).toEqual({accepted: 1, reservations_released: 1});
398
+
399
+ const [staleReservationAfterTerminal] = await db()
400
+ .select()
401
+ .from(reservations)
402
+ .where(eq(reservations.id, staleReservation.id));
403
+ const [reboundReservationAfterTerminal] = await db()
404
+ .select()
405
+ .from(reservations)
406
+ .where(eq(reservations.id, reboundReservationId));
407
+ expect(staleReservationAfterTerminal).toMatchObject({
408
+ id: staleReservation.id,
409
+ count: 1,
410
+ });
411
+ expect(reboundReservationAfterTerminal).toBeUndefined();
412
+
413
+ const repeatedTerminalReport = await app.inject({
414
+ method: 'POST',
415
+ url: '/provisioners/runner-instances/report',
416
+ headers: {authorization: `Bearer ${workspaceToken}`},
417
+ payload: {
418
+ events: [
419
+ {
420
+ provider_runner_id: providerRunnerId,
421
+ reservation_id: staleReservation.id,
422
+ template_key: 'linux',
423
+ labels: ['linux'],
424
+ state: 'terminated',
425
+ reported_at: new Date(firstReportAt.getTime() + 3_000).toISOString(),
426
+ provider_kind: 'docker',
427
+ },
428
+ ],
429
+ },
430
+ });
431
+
432
+ expect(repeatedTerminalReport.statusCode).toBe(200);
433
+ expect(repeatedTerminalReport.json()).toEqual({accepted: 1, reservations_released: 0});
434
+ });
435
+
436
+ async function enrollRunner(controlSessionToken: string): Promise<void> {
437
+ const enrolled = await app.inject({
438
+ method: 'POST',
439
+ url: '/runner-control/enrollment',
440
+ headers: {authorization: `Bearer ${controlSessionToken}`},
441
+ payload: {labels: ['linux'], provider_kind: 'docker', protocol_version: '1'},
442
+ });
443
+
444
+ expect(enrolled.statusCode).toBe(200);
445
+ expect(enrolled.json()).toEqual({activation_token: null});
446
+ }
447
+ });
@@ -20,6 +20,7 @@ import {db} from '#db/db.js';
20
20
  import {provisionerCapabilitySnapshots} from '#db/schema/provisioner-capability-snapshots.js';
21
21
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
22
22
  import {
23
+ providerRunnerActivationOutcomeCount,
23
24
  providerRunnerCountDivergenceCount,
24
25
  providerRunnerTerminateIntentIssuedCount,
25
26
  } from '#metrics/instance.js';
@@ -99,6 +100,7 @@ describe('POST /provisioners/demand/poll', () => {
99
100
  expect(res.json()).toMatchObject({
100
101
  stats: [{labels: ['linux'], queued: 1, reserved: 1}],
101
102
  reservations: [{labels: ['linux'], count: 1}],
103
+ newly_reserved_count: 1,
102
104
  terminate_provider_runner_ids: [],
103
105
  });
104
106
  expect(res.json().reservations[0].reservation_id).toEqual(expect.any(String));
@@ -116,13 +118,30 @@ describe('POST /provisioners/demand/poll', () => {
116
118
  method: 'POST',
117
119
  url: '/provisioners/demand/poll',
118
120
  headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
119
- payload: body({max_reservations: 1, reservation_ttl_seconds: 600}),
121
+ payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
120
122
  });
121
123
 
122
124
  expect(res.statusCode).toBe(200);
123
125
  const expiresAt = Date.parse(res.json().reservations[0].expires_at);
124
- expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 295_000);
125
- expect(expiresAt).toBeLessThan(requestStartedAt + 305_000);
126
+ expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 595_000);
127
+ expect(expiresAt).toBeLessThan(requestStartedAt + 605_000);
128
+ });
129
+
130
+ it('accepts a slow-boot reservation TTL below the raised ceiling', async () => {
131
+ await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
132
+
133
+ const requestStartedAt = Date.now();
134
+ const res = await app.inject({
135
+ method: 'POST',
136
+ url: '/provisioners/demand/poll',
137
+ headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
138
+ payload: body({max_reservations: 1, reservation_ttl_seconds: 540}),
139
+ });
140
+
141
+ expect(res.statusCode).toBe(200);
142
+ const expiresAt = Date.parse(res.json().reservations[0].expires_at);
143
+ expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 535_000);
144
+ expect(expiresAt).toBeLessThan(requestStartedAt + 545_000);
126
145
  });
127
146
 
128
147
  it('applies a requested reservation TTL below the ceiling', async () => {
@@ -350,6 +369,60 @@ describe('POST /provisioners/demand/poll', () => {
350
369
  expect(intentCalls).toHaveLength(1);
351
370
  });
352
371
 
372
+ it('counts activation-timeout reaps once across terminate-intent retries', async () => {
373
+ const intentSpy = vi.spyOn(providerRunnerTerminateIntentIssuedCount, 'add');
374
+ const outcomeSpy = vi.spyOn(providerRunnerActivationOutcomeCount, 'add');
375
+ await providerRunnerFactory.create({
376
+ workspaceId,
377
+ provisionerId: provisionerTokenId,
378
+ providerRunnerId: 'stale-demand-runner',
379
+ launchKind: 'demand',
380
+ createdAt: new Date(Date.now() - 301_000),
381
+ templateKey: 'linux',
382
+ state: 'running',
383
+ });
384
+ const intentCallsBefore = intentSpy.mock.calls.length;
385
+ const outcomeCallsBefore = outcomeSpy.mock.calls.length;
386
+
387
+ const poll = () =>
388
+ app.inject({
389
+ method: 'POST',
390
+ url: '/provisioners/demand/poll',
391
+ headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
392
+ payload: body({max_reservations: 0}),
393
+ });
394
+ const firstResponse = await poll();
395
+ const retryResponse = await poll();
396
+
397
+ expect(firstResponse.statusCode).toBe(200);
398
+ expect(retryResponse.statusCode).toBe(200);
399
+ expect(firstResponse.json()).toMatchObject({
400
+ reservations: [],
401
+ terminate_provider_runner_ids: ['stale-demand-runner'],
402
+ });
403
+ expect(retryResponse.json()).toMatchObject({
404
+ reservations: [],
405
+ terminate_provider_runner_ids: ['stale-demand-runner'],
406
+ });
407
+ const intentCalls = intentSpy.mock.calls
408
+ .slice(intentCallsBefore)
409
+ .filter(
410
+ ([value, attributes]) =>
411
+ value === 1 &&
412
+ JSON.stringify(attributes) ===
413
+ JSON.stringify({surface: 'poll-demand', reason: 'activation-timeout'}),
414
+ );
415
+ expect(intentCalls).toHaveLength(2);
416
+ expect(
417
+ outcomeSpy.mock.calls
418
+ .slice(outcomeCallsBefore)
419
+ .filter(
420
+ ([value, attributes]) =>
421
+ value === 1 && JSON.stringify(attributes) === JSON.stringify({outcome: 'reaped'}),
422
+ ),
423
+ ).toHaveLength(1);
424
+ });
425
+
353
426
  it('returns 400 for max reservations above the request bound', async () => {
354
427
  const res = await app.inject({
355
428
  method: 'POST',
@@ -528,13 +601,13 @@ describe('POST /provisioners/demand/poll with installation provisioning configur
528
601
  method: 'POST',
529
602
  url: '/provisioners/demand/poll',
530
603
  headers: {authorization: `Bearer ${INSTALLATION_PROVISIONER_TOKEN}`},
531
- payload: body({max_reservations: 1, reservation_ttl_seconds: 600}),
604
+ payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
532
605
  });
533
606
 
534
607
  expect(res.statusCode).toBe(200);
535
608
  const expiresAt = Date.parse(res.json().reservations[0].expires_at);
536
- expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 295_000);
537
- expect(expiresAt).toBeLessThan(requestStartedAt + 305_000);
609
+ expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 595_000);
610
+ expect(expiresAt).toBeLessThan(requestStartedAt + 605_000);
538
611
  });
539
612
 
540
613
  function body(params: {max_reservations: number; reservation_ttl_seconds?: number}) {
@@ -98,6 +98,7 @@ export function createPollDemandRoute(options: CreateRunnersModuleOptions = {})
98
98
  provisionerId: provisionerContext.provisionerTokenId,
99
99
  maxReservations: request.body.max_reservations,
100
100
  ttlSeconds,
101
+ activationGraceSeconds: config.RESERVATION_TTL_SECONDS,
101
102
  templates,
102
103
  capabilityWindowSeconds: config.PROVISIONER_ACTIVE_WINDOW_SECONDS,
103
104
  eligibleWorkspaceIds,
@@ -6,6 +6,10 @@ import {
6
6
  AUTH_USER,
7
7
  setProvisionerContext,
8
8
  } from '@shipfox/api-auth-context';
9
+ import {
10
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER,
11
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
12
+ } from '@shipfox/api-runners-dto';
9
13
  import {
10
14
  type AuthMethod,
11
15
  ClientError,
@@ -86,7 +90,11 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
86
90
  const jobId = crypto.randomUUID();
87
91
  const workflowRunId = crypto.randomUUID();
88
92
  const workflowRunAttemptId = crypto.randomUUID();
89
- await createRunnerInstance({providerRunnerId: 'provisioned-runner-1'});
93
+ const intendedReservationId = crypto.randomUUID();
94
+ await createRunnerInstance({
95
+ providerRunnerId: 'provisioned-runner-1',
96
+ intendedReservationId,
97
+ });
90
98
  await insertRunningJob({
91
99
  jobId,
92
100
  workflowRunId,
@@ -98,7 +106,11 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
98
106
  const res = await app.inject({
99
107
  method: 'POST',
100
108
  url: '/provisioners/runner-instances/reconcile',
101
- headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
109
+ headers: {
110
+ authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`,
111
+ [RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER]:
112
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
113
+ },
102
114
  payload: {observed_provider_runner_ids: ['provisioned-runner-1']},
103
115
  });
104
116
 
@@ -108,6 +120,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
108
120
  {
109
121
  provider_runner_id: 'provisioned-runner-1',
110
122
  state: 'running',
123
+ intended_reservation_id: intendedReservationId,
111
124
  reservation_id: null,
112
125
  runner_session_id: null,
113
126
  bound_job: {
@@ -123,6 +136,23 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
123
136
  });
124
137
  });
125
138
 
139
+ it('omits the additive reservation field for legacy provisioners', async () => {
140
+ await createRunnerInstance({
141
+ providerRunnerId: 'provisioned-runner-1',
142
+ intendedReservationId: crypto.randomUUID(),
143
+ });
144
+
145
+ const res = await app.inject({
146
+ method: 'POST',
147
+ url: '/provisioners/runner-instances/reconcile',
148
+ headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
149
+ payload: {observed_provider_runner_ids: ['provisioned-runner-1']},
150
+ });
151
+
152
+ expect(res.statusCode).toBe(200);
153
+ expect(Object.hasOwn(res.json().runners[0], 'intended_reservation_id')).toBe(false);
154
+ });
155
+
126
156
  it('returns terminate for an observed terminal runner', async () => {
127
157
  await createRunnerInstance({
128
158
  providerRunnerId: 'provisioned-runner-1',
@@ -340,6 +370,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
340
370
  async function createRunnerInstance(params: {
341
371
  providerRunnerId: string;
342
372
  state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
373
+ intendedReservationId?: string | null;
343
374
  reservationId?: string | null;
344
375
  reportedAt?: Date;
345
376
  }) {
@@ -347,6 +378,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
347
378
  workspaceId,
348
379
  provisionerId: provisionerTokenId,
349
380
  providerRunnerId: params.providerRunnerId,
381
+ intendedReservationId: params.intendedReservationId ?? null,
350
382
  reservationId: params.reservationId ?? null,
351
383
  state: params.state ?? 'running',
352
384
  reportedAt: params.reportedAt ?? new Date(),
@@ -1,5 +1,7 @@
1
1
  import {requireProvisionerContext} from '@shipfox/api-auth-context';
2
2
  import {
3
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER,
4
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
3
5
  reconcileRunnerInstancesBodySchema,
4
6
  reconcileRunnerInstancesResponseSchema,
5
7
  } from '@shipfox/api-runners-dto';
@@ -25,6 +27,10 @@ export const reconcileRunnerInstancesRoute = defineRoute({
25
27
  observedRunnerInstanceIds: request.body.observed_provider_runner_ids,
26
28
  });
27
29
 
28
- return toReconcileRunnerInstancesResponseDto(result);
30
+ return toReconcileRunnerInstancesResponseDto(result, {
31
+ includeIntendedReservationId:
32
+ request.headers[RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER] ===
33
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
34
+ });
29
35
  },
30
36
  });