@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
@@ -36,6 +36,13 @@
36
36
  "when": 1786017247182,
37
37
  "tag": "0004_huge_skreet",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1786106547187,
44
+ "tag": "0005_previous_lenny_balinger",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-runners",
3
3
  "license": "MIT",
4
- "version": "12.2.0",
4
+ "version": "12.4.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -24,20 +24,20 @@
24
24
  "@shipfox/api-common-dto": "12.0.0",
25
25
  "@shipfox/api-auth-context": "12.2.0",
26
26
  "@shipfox/api-auth-dto": "12.0.0",
27
- "@shipfox/api-runners-dto": "12.2.0",
28
- "@shipfox/api-workflows-dto": "12.2.0",
27
+ "@shipfox/api-runners-dto": "12.4.0",
28
+ "@shipfox/api-workflows-dto": "12.3.0",
29
29
  "@shipfox/config": "1.2.4",
30
30
  "@shipfox/inter-module": "0.2.3",
31
31
  "@shipfox/node-drizzle": "0.3.5",
32
32
  "@shipfox/node-error-monitoring": "0.3.0",
33
33
  "@shipfox/node-auth-root-key": "0.2.3",
34
34
  "@shipfox/node-fastify": "0.4.2",
35
+ "@shipfox/node-module": "1.0.6",
35
36
  "@shipfox/node-opentelemetry": "0.6.4",
36
37
  "@shipfox/node-outbox": "0.2.6",
37
38
  "@shipfox/node-postgres": "0.5.0",
38
39
  "@shipfox/node-rate-limit": "0.4.0",
39
40
  "@shipfox/node-temporal": "0.4.5",
40
- "@shipfox/node-module": "1.0.6",
41
41
  "@shipfox/node-tokens": "0.3.2",
42
42
  "@shipfox/runner-labels": "0.2.0"
43
43
  },
@@ -86,6 +86,28 @@ describe('runner assignment polling defaults', () => {
86
86
  });
87
87
  });
88
88
 
89
+ describe('RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS validation', () => {
90
+ afterEach(() => {
91
+ vi.unstubAllEnvs();
92
+ vi.resetModules();
93
+ });
94
+
95
+ it('defaults to a five-minute recovery window', async () => {
96
+ vi.resetModules();
97
+
98
+ const {config} = await import('#config.js');
99
+
100
+ expect(config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS).toBe(300);
101
+ });
102
+
103
+ it.each(['0', '-5', '1.5'])('fails startup when the value is %s', async (value) => {
104
+ vi.stubEnv('RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS', value);
105
+ vi.resetModules();
106
+
107
+ await expect(import('#config.js')).rejects.toThrow('RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS');
108
+ });
109
+ });
110
+
89
111
  describe('reservation TTL ceiling validation', () => {
90
112
  afterEach(() => {
91
113
  vi.unstubAllEnvs();
@@ -104,12 +126,12 @@ describe('reservation TTL ceiling validation', () => {
104
126
  await expect(import('#config.js')).rejects.toThrow('RESERVATION_TTL_MAX_SECONDS');
105
127
  });
106
128
 
107
- it('defaults high enough for the EC2 registration deadline', async () => {
129
+ it('defaults high enough for provider registration deadlines and launch headroom', async () => {
108
130
  vi.resetModules();
109
131
 
110
132
  const {config} = await import('#config.js');
111
133
 
112
- expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(300);
134
+ expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(600);
113
135
  });
114
136
 
115
137
  it('accepts a whole-second ceiling inside the hard maximum', async () => {
package/src/config.ts CHANGED
@@ -62,12 +62,12 @@ export const config = createConfig({
62
62
  default: 250,
63
63
  }),
64
64
  RESERVATION_TTL_SECONDS: num({
65
- desc: 'Default lifetime of a count-based runner reservation, in seconds. Used when a provisioner does not request a provider-specific value. Expired reservations stop counting against queued demand.',
65
+ desc: 'Activation grace period for a rebound runner reservation, in seconds. A runner without a session can be rebound again only after this deadline. It is also the default lifetime for launch reservations when a provisioner does not request a provider-specific value.',
66
66
  default: 60,
67
67
  }),
68
68
  RESERVATION_TTL_MAX_SECONDS: num({
69
- desc: `Server-side ceiling for a count-based runner reservation lifetime, in seconds. Set it at least as high as every provider registration deadline so provider-specific values can cover boot and enrollment. Set this between 1 and ${RESERVATION_TTL_HARD_MAX_SECONDS}.`,
70
- default: 300,
69
+ desc: `Server-side ceiling for a launch reservation lifetime, in seconds. Set it at least as high as every provider registration deadline plus its launch headroom so provider-specific values can cover the launch gap, boot, and enrollment. Set this between 1 and ${RESERVATION_TTL_HARD_MAX_SECONDS}.`,
70
+ default: 600,
71
71
  }),
72
72
  RESERVATION_LONG_POLL_MAX_WAIT_SECONDS: num({
73
73
  desc: 'Maximum time the provisioner demand poll endpoint waits for reservable demand before returning, in seconds.',
@@ -85,6 +85,10 @@ export const config = createConfig({
85
85
  desc: 'Time window, in seconds, used to list active runners from recent heartbeats and provisioned runner reports.',
86
86
  default: 60,
87
87
  }),
88
+ RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS: num({
89
+ desc: 'Minimum time, in seconds, before a demand-backed runner without a runner session can be reclaimed when neither its current nor intended reservation is live. Set this above the provider registration deadline when startup reservations remain live during boot.',
90
+ default: 300,
91
+ }),
88
92
  RUNNER_TOOL_CAPABILITIES_STALE_AFTER_SECONDS: num({
89
93
  desc: 'Time window, in seconds, after which a runner tool capability report is treated as stale. Set this higher than the runner heartbeat interval so active runners keep their advertised tools fresh.',
90
94
  default: 60,
@@ -282,6 +286,15 @@ if (
282
286
  );
283
287
  }
284
288
 
289
+ if (
290
+ !Number.isInteger(config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS) ||
291
+ config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS < 1
292
+ ) {
293
+ throw new Error(
294
+ `RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS (${config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS}) must be a whole number of seconds >= 1.`,
295
+ );
296
+ }
297
+
285
298
  if (
286
299
  !Number.isInteger(config.RUNNER_TOOL_CAPABILITIES_STALE_AFTER_SECONDS) ||
287
300
  config.RUNNER_TOOL_CAPABILITIES_STALE_AFTER_SECONDS < 1
@@ -37,6 +37,18 @@ describe('shouldReturn', () => {
37
37
  expect(result).toBe(true);
38
38
  });
39
39
 
40
+ it('returns true when only a rebound reservation was created', () => {
41
+ const result = shouldReturn({...emptyResult, newlyReservedCount: 1}, 1, 1, false);
42
+
43
+ expect(result).toBe(true);
44
+ });
45
+
46
+ it('returns true when demand was fully adopted by idle runners', () => {
47
+ const result = shouldReturn({...emptyResult, newlyReservedCount: 3}, 1, 3, false);
48
+
49
+ expect(result).toBe(true);
50
+ });
51
+
40
52
  it('returns true when terminate intents exist', () => {
41
53
  const result = shouldReturn(
42
54
  {...emptyResult, terminateRunnerInstanceIds: ['provisioned-runner-1']},
@@ -48,6 +60,55 @@ describe('shouldReturn', () => {
48
60
  expect(result).toBe(true);
49
61
  });
50
62
 
63
+ it('continues waiting for a retry-only activation-timeout intent', () => {
64
+ const result = shouldReturn(
65
+ {...emptyResult, terminateRunnerInstanceIds: ['stale-demand-runner']},
66
+ 1,
67
+ 1,
68
+ false,
69
+ [
70
+ {
71
+ providerRunnerId: 'stale-demand-runner',
72
+ reason: 'activation-timeout',
73
+ activationTimeoutRetry: true,
74
+ },
75
+ ],
76
+ );
77
+
78
+ expect(result).toBe(false);
79
+ });
80
+
81
+ it('returns true for a first-delivery activation-timeout intent', () => {
82
+ const result = shouldReturn(
83
+ {...emptyResult, terminateRunnerInstanceIds: ['stale-demand-runner']},
84
+ 1,
85
+ 1,
86
+ false,
87
+ [{providerRunnerId: 'stale-demand-runner', reason: 'activation-timeout'}],
88
+ );
89
+
90
+ expect(result).toBe(true);
91
+ });
92
+
93
+ it('returns true for a job-cancelled intent alongside a retried activation timeout', () => {
94
+ const result = shouldReturn(
95
+ {...emptyResult, terminateRunnerInstanceIds: ['stale-demand-runner', 'cancelled-runner']},
96
+ 1,
97
+ 1,
98
+ false,
99
+ [
100
+ {
101
+ providerRunnerId: 'stale-demand-runner',
102
+ reason: 'activation-timeout',
103
+ activationTimeoutRetry: true,
104
+ },
105
+ {providerRunnerId: 'cancelled-runner', reason: 'job-cancelled'},
106
+ ],
107
+ );
108
+
109
+ expect(result).toBe(true);
110
+ });
111
+
51
112
  it('returns true when the deadline passed', () => {
52
113
  const result = shouldReturn(emptyResult, 1, 1, true);
53
114
 
@@ -81,7 +142,9 @@ describe('pollDemand', () => {
81
142
 
82
143
  it('returns immediately when terminate intents exist without reservation demand', async () => {
83
144
  vi.resetModules();
84
- const pollDemandAndReserveTx = vi.fn().mockResolvedValue({stats: [], reservations: []});
145
+ const pollDemandAndReserveTx = vi
146
+ .fn()
147
+ .mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
85
148
  const listProvisionerTerminateIntentRowsTx = vi
86
149
  .fn()
87
150
  .mockResolvedValue([{providerRunnerId: 'provisioned-runner-1', reason: 'job-cancelled'}]);
@@ -129,10 +192,134 @@ describe('pollDemand', () => {
129
192
  }
130
193
  });
131
194
 
195
+ it('does not return immediately for retry-only activation-timeout intents', async () => {
196
+ vi.resetModules();
197
+ const abortController = new AbortController();
198
+ let listCalls = 0;
199
+ const pollDemandAndReserveTx = vi
200
+ .fn()
201
+ .mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
202
+ const listProvisionerTerminateIntentRowsTx = vi.fn().mockImplementation(() => {
203
+ listCalls += 1;
204
+ if (listCalls === 2) abortController.abort();
205
+ return [
206
+ {
207
+ providerRunnerId: 'stale-demand-runner',
208
+ reason: 'activation-timeout',
209
+ activationTimeoutRetry: true,
210
+ },
211
+ ];
212
+ });
213
+ const listActiveRunnerInstanceCountsByTemplateTx = vi.fn().mockResolvedValue([]);
214
+ vi.doMock('#db/db.js', () => ({
215
+ db: () => ({transaction: (callback: (tx: unknown) => Promise<unknown>) => callback({})}),
216
+ }));
217
+ vi.doMock('#db/reservations.js', () => ({
218
+ deleteReservationsByIds: vi.fn(),
219
+ pollDemandAndReserveTx,
220
+ }));
221
+ vi.doMock('#db/runner-instances.js', () => ({
222
+ listActiveRunnerInstanceCountsByTemplateTx,
223
+ listProvisionerTerminateIntentRowsTx,
224
+ }));
225
+ vi.doMock('#metrics/instance.js', () => ({
226
+ providerRunnerActivationOutcomeCount: {add: vi.fn()},
227
+ providerRunnerCountDivergenceCount: {add: vi.fn()},
228
+ providerRunnerTerminateIntentIssuedCount: {add: vi.fn()},
229
+ }));
230
+ const random = vi.spyOn(Math, 'random').mockReturnValue(0);
231
+
232
+ try {
233
+ const {pollDemand: mockedPollDemand} = await import('#core/demand.js');
234
+
235
+ const result = await mockedPollDemand({
236
+ workspaceId: crypto.randomUUID(),
237
+ provisionerId: crypto.randomUUID(),
238
+ maxReservations: 1,
239
+ waitSeconds: 60,
240
+ ttlSeconds: 60,
241
+ terminateIntentLimit: 1000,
242
+ templates: [
243
+ {templateKey: 'linux', labels: ['linux'], availableSlots: 1, starting: 0, running: 0},
244
+ ],
245
+ signal: abortController.signal,
246
+ });
247
+
248
+ expect(result).toEqual({
249
+ stats: [],
250
+ reservations: [],
251
+ terminateRunnerInstanceIds: ['stale-demand-runner'],
252
+ });
253
+ expect(listProvisionerTerminateIntentRowsTx).toHaveBeenCalledTimes(2);
254
+ } finally {
255
+ random.mockRestore();
256
+ vi.doUnmock('#db/db.js');
257
+ vi.doUnmock('#db/reservations.js');
258
+ vi.doUnmock('#db/runner-instances.js');
259
+ vi.doUnmock('#metrics/instance.js');
260
+ vi.resetModules();
261
+ }
262
+ });
263
+
264
+ it('returns immediately when demand is fully adopted by idle runners', async () => {
265
+ vi.resetModules();
266
+ const pollDemandAndReserveTx = vi.fn().mockResolvedValue({
267
+ stats: [],
268
+ reservations: [],
269
+ newlyReservedUnits: [{labels: ['linux'], count: 3}],
270
+ });
271
+ const listProvisionerTerminateIntentRowsTx = vi.fn().mockResolvedValue([]);
272
+ const listActiveRunnerInstanceCountsByTemplateTx = vi.fn().mockResolvedValue([]);
273
+ vi.doMock('#db/db.js', () => ({
274
+ db: () => ({transaction: (callback: (tx: unknown) => Promise<unknown>) => callback({})}),
275
+ }));
276
+ vi.doMock('#db/reservations.js', () => ({
277
+ deleteReservationsByIds: vi.fn(),
278
+ pollDemandAndReserveTx,
279
+ }));
280
+ vi.doMock('#db/runner-instances.js', () => ({
281
+ listActiveRunnerInstanceCountsByTemplateTx,
282
+ listProvisionerTerminateIntentRowsTx,
283
+ }));
284
+
285
+ try {
286
+ const {pollDemand: mockedPollDemand} = await import('#core/demand.js');
287
+
288
+ const result = await mockedPollDemand({
289
+ workspaceId: crypto.randomUUID(),
290
+ provisionerId: crypto.randomUUID(),
291
+ maxReservations: 3,
292
+ waitSeconds: 60,
293
+ ttlSeconds: 60,
294
+ terminateIntentLimit: 1000,
295
+ templates: [
296
+ {templateKey: 'linux', labels: ['linux'], availableSlots: 3, starting: 0, running: 3},
297
+ ],
298
+ signal: new AbortController().signal,
299
+ });
300
+
301
+ expect(result).toEqual({
302
+ stats: [],
303
+ reservations: [],
304
+ newlyReservedCount: 3,
305
+ terminateRunnerInstanceIds: [],
306
+ });
307
+ expect(pollDemandAndReserveTx).toHaveBeenCalledOnce();
308
+ expect(listActiveRunnerInstanceCountsByTemplateTx).toHaveBeenCalledOnce();
309
+ } finally {
310
+ vi.doUnmock('#db/db.js');
311
+ vi.doUnmock('#db/reservations.js');
312
+ vi.doUnmock('#db/runner-instances.js');
313
+ vi.resetModules();
314
+ }
315
+ });
316
+
132
317
  it('does not calculate divergence for a non-returned long-poll retry', async () => {
133
318
  vi.resetModules();
134
319
  const abortController = new AbortController();
135
- const pollDemandAndReserveTx = vi.fn().mockResolvedValue({stats: [], reservations: []});
320
+ const pollDemandAndReserveTx = vi
321
+ .fn()
322
+ .mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
136
323
  const listProvisionerTerminateIntentRowsTx = vi.fn().mockImplementation(() => {
137
324
  abortController.abort();
138
325
  return [];
@@ -180,7 +367,9 @@ describe('pollDemand', () => {
180
367
  vi.resetModules();
181
368
  vi.stubEnv('PROVISIONED_RUNNER_COUNT_DIVERGENCE_TEMPLATE_KEY_LABEL_ENABLED', 'true');
182
369
  const divergenceAdd = vi.fn();
183
- const pollDemandAndReserveTx = vi.fn().mockResolvedValue({stats: [], reservations: []});
370
+ const pollDemandAndReserveTx = vi
371
+ .fn()
372
+ .mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
184
373
  const listProvisionerTerminateIntentRowsTx = vi.fn().mockResolvedValue([]);
185
374
  const listActiveRunnerInstanceCountsByTemplateTx = vi
186
375
  .fn()
@@ -18,6 +18,7 @@ import {
18
18
  import {
19
19
  providerRunnerCountDivergenceCount,
20
20
  providerRunnerTerminateIntentIssuedCount,
21
+ recordProviderRunnerActivationOutcome,
21
22
  } from '#metrics/instance.js';
22
23
 
23
24
  export interface PollDemandParams {
@@ -35,6 +36,8 @@ export interface PollDemandResult {
35
36
  stats: DemandStat[];
36
37
  reservations: ReservationGrant[];
37
38
  terminateRunnerInstanceIds: string[];
39
+ /** Units reserved this poll. A bound-only allocation carries no launch grant, so this is what ends the long poll. */
40
+ newlyReservedCount?: number;
38
41
  }
39
42
 
40
43
  export interface RunnerInstanceCountDivergence {
@@ -78,6 +81,7 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
78
81
  provisionerId: params.provisionerId,
79
82
  maxReservations: params.maxReservations,
80
83
  ttlSeconds: params.ttlSeconds,
84
+ activationGraceSeconds: config.RESERVATION_TTL_SECONDS,
81
85
  templates: params.templates,
82
86
  });
83
87
  const terminateIntents = await listProvisionerTerminateIntentRowsTx(tx, {
@@ -85,12 +89,26 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
85
89
  provisionerId: params.provisionerId,
86
90
  limit: params.terminateIntentLimit,
87
91
  });
92
+ const newlyReservedCount = demand.newlyReservedUnits.reduce(
93
+ (total, reservation) => total + reservation.count,
94
+ 0,
95
+ );
88
96
  const result: PollDemandResult = {
89
- ...demand,
97
+ stats: demand.stats,
98
+ reservations: demand.reservations,
99
+ ...(newlyReservedCount > 0 ? {newlyReservedCount} : {}),
90
100
  terminateRunnerInstanceIds: terminateIntents.map((intent) => intent.providerRunnerId),
91
101
  };
92
102
 
93
- if (!shouldReturn(result, params.maxReservations, totalCapacity, deadlinePassed)) {
103
+ if (
104
+ !shouldReturn(
105
+ result,
106
+ params.maxReservations,
107
+ totalCapacity,
108
+ deadlinePassed,
109
+ terminateIntents,
110
+ )
111
+ ) {
94
112
  return {result, terminateIntents, divergences: []};
95
113
  }
96
114
 
@@ -113,7 +131,15 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
113
131
 
114
132
  lastSnapshot = snapshot;
115
133
 
116
- if (shouldReturn(lastSnapshot.result, params.maxReservations, totalCapacity, deadlinePassed)) {
134
+ if (
135
+ shouldReturn(
136
+ lastSnapshot.result,
137
+ params.maxReservations,
138
+ totalCapacity,
139
+ deadlinePassed,
140
+ lastSnapshot.terminateIntents,
141
+ )
142
+ ) {
117
143
  recordPollDemandMetrics(params, lastSnapshot);
118
144
  return lastSnapshot.result;
119
145
  }
@@ -132,6 +158,9 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
132
158
  }
133
159
 
134
160
  export async function releaseReservationGrants(reservations: ReservationGrant[]): Promise<void> {
161
+ // Fully adopted reservations are intentionally absent from this list: deleting
162
+ // one on disconnect would orphan the runner bound to that reservation. They
163
+ // remain protected until terminal cleanup or expiry.
135
164
  await deleteReservationsByIds(reservations.map((reservation) => reservation.reservationId));
136
165
  }
137
166
 
@@ -140,12 +169,19 @@ export function shouldReturn(
140
169
  maxReservations: number,
141
170
  totalCapacity: number,
142
171
  deadlinePassed: boolean,
172
+ terminateIntents?: readonly RunnerInstanceTerminateIntent[],
143
173
  ): boolean {
174
+ const hasImmediateTerminateIntent =
175
+ terminateIntents?.some(
176
+ (intent) => intent.reason !== 'activation-timeout' || !intent.activationTimeoutRetry,
177
+ ) ?? result.terminateRunnerInstanceIds.length > 0;
178
+
144
179
  return (
145
180
  maxReservations === 0 ||
146
181
  totalCapacity === 0 ||
147
182
  result.reservations.length > 0 ||
148
- result.terminateRunnerInstanceIds.length > 0 ||
183
+ (result.newlyReservedCount ?? 0) > 0 ||
184
+ hasImmediateTerminateIntent ||
149
185
  deadlinePassed
150
186
  );
151
187
  }
@@ -219,6 +255,10 @@ function recordPollDemandMetrics(params: PollDemandParams, snapshot: PollDemandS
219
255
  surface: 'poll-demand',
220
256
  reason: intent.reason,
221
257
  });
258
+ // Retries redeliver an intent already counted on its first emission.
259
+ if (intent.reason === 'activation-timeout' && !intent.activationTimeoutRetry) {
260
+ recordProviderRunnerActivationOutcome({outcome: 'reaped'});
261
+ }
222
262
  }
223
263
  }
224
264
 
@@ -4,6 +4,7 @@ export interface Reservation {
4
4
  provisionerId: string;
5
5
  requiredLabels: string[];
6
6
  count: number;
7
+ kind: 'bound' | 'launch';
7
8
  createdAt: Date;
8
9
  expiresAt: Date;
9
10
  }
@@ -6,12 +6,16 @@ export type RunnerInstanceState =
6
6
  | 'failed'
7
7
  | 'terminated';
8
8
 
9
+ export type RunnerInstanceLaunchKind = 'demand' | 'warm' | 'manual';
10
+
9
11
  export interface RunnerInstance {
10
12
  id: string;
11
13
  workspaceId: string | null;
12
14
  provisionerId: string;
13
15
  providerRunnerId: string;
16
+ intendedReservationId: string | null;
14
17
  reservationId: string | null;
18
+ launchKind: RunnerInstanceLaunchKind;
15
19
  assignedAt?: Date | null;
16
20
  templateKey: string | null;
17
21
  labels: string[];
@@ -1,14 +1,22 @@
1
+ import {logger} from '@shipfox/node-opentelemetry';
1
2
  import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
2
3
  import {and, eq, isNull, sql} from 'drizzle-orm';
3
4
  import type {Tx} from '#db/db.js';
4
5
  import {db} from '#db/db.js';
5
6
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
7
+ import type {RunnerInstanceDb} from '#db/schema/runner-instances.js';
6
8
  import {providerRunners} from '#db/schema/runner-instances.js';
9
+ import {
10
+ type RunnerActivationTokenNotIssuedReason,
11
+ type RunnerActivationTokenNotIssuedSurface,
12
+ recordRunnerActivationTokenNotIssued,
13
+ } from '#metrics/instance.js';
7
14
 
8
15
  export async function issueRunnerActivationToken(params: {
9
16
  runnerInstanceId: string;
10
17
  provisionerId: string;
11
18
  ttlSeconds: number;
19
+ surface: RunnerActivationTokenNotIssuedSurface;
12
20
  }): Promise<string | null> {
13
21
  return await db().transaction(async (tx) => issueRunnerActivationTokenTx(tx, params));
14
22
  }
@@ -19,6 +27,7 @@ export async function issueRunnerActivationTokenTx(
19
27
  runnerInstanceId: string;
20
28
  provisionerId: string;
21
29
  ttlSeconds: number;
30
+ surface: RunnerActivationTokenNotIssuedSurface;
22
31
  },
23
32
  ): Promise<string | null> {
24
33
  await tx.execute(
@@ -39,7 +48,11 @@ export async function issueRunnerActivationTokenTx(
39
48
  )
40
49
  .limit(1)
41
50
  .for('update');
42
- if (!runner?.workspaceId || runner.runnerSessionId || runner.state !== 'running') return null;
51
+ const notIssuedReason = getRunnerActivationTokenNotIssuedReason(runner);
52
+ if (notIssuedReason) {
53
+ recordRunnerActivationTokenNotIssuedWithLog(notIssuedReason, params);
54
+ return null;
55
+ }
43
56
 
44
57
  await tx
45
58
  .update(runnerActivationTokens)
@@ -69,6 +82,7 @@ export async function getRunnerAssignment(params: {
69
82
  .select({
70
83
  workspaceId: providerRunners.workspaceId,
71
84
  runnerSessionId: providerRunners.runnerSessionId,
85
+ state: providerRunners.state,
72
86
  })
73
87
  .from(providerRunners)
74
88
  .where(
@@ -78,5 +92,37 @@ export async function getRunnerAssignment(params: {
78
92
  ),
79
93
  )
80
94
  .limit(1);
81
- return runner?.workspaceId && !runner.runnerSessionId ? runner : null;
95
+ return runner?.workspaceId && !runner.runnerSessionId && runner.state === 'running'
96
+ ? {workspaceId: runner.workspaceId, runnerSessionId: runner.runnerSessionId}
97
+ : null;
98
+ }
99
+
100
+ function getRunnerActivationTokenNotIssuedReason(
101
+ runner: Pick<RunnerInstanceDb, 'workspaceId' | 'runnerSessionId' | 'state'> | undefined,
102
+ ): RunnerActivationTokenNotIssuedReason | null {
103
+ if (!runner) return 'runner-not-found';
104
+ if (!runner.workspaceId) return 'missing-workspace';
105
+ if (runner.runnerSessionId) return 'existing-session';
106
+ if (runner.state !== 'running') return 'not-running';
107
+ return null;
108
+ }
109
+
110
+ function recordRunnerActivationTokenNotIssuedWithLog(
111
+ reason: RunnerActivationTokenNotIssuedReason,
112
+ params: {
113
+ runnerInstanceId: string;
114
+ provisionerId: string;
115
+ surface: RunnerActivationTokenNotIssuedSurface;
116
+ },
117
+ ): void {
118
+ recordRunnerActivationTokenNotIssued({reason, surface: params.surface});
119
+ logger().debug(
120
+ {
121
+ runnerInstanceId: params.runnerInstanceId,
122
+ provisionerId: params.provisionerId,
123
+ reason,
124
+ surface: params.surface,
125
+ },
126
+ 'Runner activation token was not issued',
127
+ );
82
128
  }