@shipfox/api-runners 12.2.0 → 12.3.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 (43) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +12 -0
  3. package/dist/config.js +3 -3
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  6. package/dist/core/runner-control-sessions.js +14 -2
  7. package/dist/core/runner-control-sessions.js.map +1 -1
  8. package/dist/db/index.d.ts +1 -1
  9. package/dist/db/index.d.ts.map +1 -1
  10. package/dist/db/index.js +1 -1
  11. package/dist/db/index.js.map +1 -1
  12. package/dist/db/reservations.d.ts.map +1 -1
  13. package/dist/db/reservations.js +22 -6
  14. package/dist/db/reservations.js.map +1 -1
  15. package/dist/db/runner-instances.d.ts +3 -0
  16. package/dist/db/runner-instances.d.ts.map +1 -1
  17. package/dist/db/runner-instances.js +10 -1
  18. package/dist/db/runner-instances.js.map +1 -1
  19. package/dist/db/runner-sessions.d.ts.map +1 -1
  20. package/dist/db/runner-sessions.js +22 -15
  21. package/dist/db/runner-sessions.js.map +1 -1
  22. package/dist/metrics/service.d.ts.map +1 -1
  23. package/dist/metrics/service.js +20 -4
  24. package/dist/metrics/service.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +3 -3
  27. package/src/config.test.ts +2 -2
  28. package/src/config.ts +3 -3
  29. package/src/core/runner-control-sessions.test.ts +169 -4
  30. package/src/core/runner-control-sessions.ts +22 -1
  31. package/src/db/index.ts +1 -0
  32. package/src/db/reservations.test.ts +369 -1
  33. package/src/db/reservations.ts +74 -9
  34. package/src/db/runner-instances.test.ts +231 -0
  35. package/src/db/runner-instances.ts +32 -1
  36. package/src/db/runner-sessions.ts +26 -15
  37. package/src/metrics/service.test.ts +99 -0
  38. package/src/metrics/service.ts +24 -4
  39. package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
  40. package/src/presentation/routes/poll-demand.test.ts +23 -6
  41. package/test/fixtures/late-runner-enrollment.ts +103 -0
  42. package/test/index.ts +5 -0
  43. 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';
@@ -231,6 +233,139 @@ describe('reportRunnerInstances', () => {
231
233
  expect(rows[0]?.reason).toBe('fresh');
232
234
  });
233
235
 
236
+ it('preserves a rebound reservation through stale reports and releases it on termination', async () => {
237
+ const staleReservationId = await createReservation(1);
238
+ await db()
239
+ .update(reservations)
240
+ .set({expiresAt: new Date(Date.now() - 60_000)})
241
+ .where(eq(reservations.id, staleReservationId));
242
+ const reboundReservationId = await createReservation(2);
243
+ const initialReportedAt = new Date(Date.now() - 120_000);
244
+ const runningReportedAt = new Date(Date.now() - 60_000);
245
+ await providerRunnerFactory.create({
246
+ workspaceId,
247
+ provisionerId,
248
+ providerRunnerId: 'rebound-runner',
249
+ reservationId: reboundReservationId,
250
+ state: 'running',
251
+ reportedAt: initialReportedAt,
252
+ });
253
+
254
+ const runningReport = await reportRunnerInstances({
255
+ scope: 'workspace',
256
+ workspaceId,
257
+ provisionerId,
258
+ events: [
259
+ event({
260
+ providerRunnerId: 'rebound-runner',
261
+ reservationId: staleReservationId,
262
+ reportedAt: runningReportedAt,
263
+ }),
264
+ ],
265
+ });
266
+ const terminalReport = await reportRunnerInstances({
267
+ scope: 'workspace',
268
+ workspaceId,
269
+ provisionerId,
270
+ events: [
271
+ event({
272
+ providerRunnerId: 'rebound-runner',
273
+ reservationId: staleReservationId,
274
+ state: 'failed',
275
+ reportedAt: new Date(runningReportedAt.getTime() + 1_000),
276
+ }),
277
+ ],
278
+ });
279
+
280
+ const [runner] = await db()
281
+ .select()
282
+ .from(providerRunners)
283
+ .where(eq(providerRunners.providerRunnerId, 'rebound-runner'));
284
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
285
+ expect(runningReport).toEqual({
286
+ accepted: 1,
287
+ reservationsReleased: 0,
288
+ terminateIntentsHonored: [],
289
+ });
290
+ expect(terminalReport).toEqual({
291
+ accepted: 1,
292
+ reservationsReleased: 1,
293
+ terminateIntentsHonored: [],
294
+ });
295
+ expect(runner).toMatchObject({
296
+ reservationId: reboundReservationId,
297
+ reservationReleasedAt: expect.any(Date),
298
+ });
299
+ expect(
300
+ reservationRows.find((reservation) => reservation.id === reboundReservationId)?.count,
301
+ ).toBe(1);
302
+ expect(
303
+ reservationRows.find((reservation) => reservation.id === staleReservationId)?.count,
304
+ ).toBe(1);
305
+ });
306
+
307
+ it('keeps the stored reservation when a report carries a stale reservation id', async () => {
308
+ const staleReservationId = await createReservation(1);
309
+ const boundReservationId = await createReservation(1);
310
+ await db()
311
+ .insert(providerRunners)
312
+ .values({
313
+ workspaceId,
314
+ provisionerId,
315
+ reservationId: boundReservationId,
316
+ providerRunnerId: 'provisioned-runner-1',
317
+ state: 'running',
318
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
319
+ });
320
+
321
+ await reportRunnerInstances({
322
+ scope: 'workspace',
323
+ workspaceId,
324
+ provisionerId,
325
+ events: [
326
+ event({
327
+ providerRunnerId: 'provisioned-runner-1',
328
+ reservationId: staleReservationId,
329
+ state: 'running',
330
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
331
+ }),
332
+ ],
333
+ });
334
+
335
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
336
+ expect(rows[0]?.reservationId).toBe(boundReservationId);
337
+ });
338
+
339
+ it('adopts a reported reservation id when the runner carries none', async () => {
340
+ const reservationId = await createReservation(1);
341
+ await db()
342
+ .insert(providerRunners)
343
+ .values({
344
+ workspaceId,
345
+ provisionerId,
346
+ providerRunnerId: 'provisioned-runner-1',
347
+ state: 'starting',
348
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
349
+ });
350
+
351
+ await reportRunnerInstances({
352
+ scope: 'workspace',
353
+ workspaceId,
354
+ provisionerId,
355
+ events: [
356
+ event({
357
+ providerRunnerId: 'provisioned-runner-1',
358
+ reservationId,
359
+ state: 'running',
360
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
361
+ }),
362
+ ],
363
+ });
364
+
365
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
366
+ expect(rows[0]?.reservationId).toBe(reservationId);
367
+ });
368
+
234
369
  it('does not let equal-timestamp lower-priority reports flip terminal state', async () => {
235
370
  const reservationId = await createReservation(1);
236
371
  const reportedAt = new Date('2025-01-01T00:00:00.000Z');
@@ -2046,6 +2181,102 @@ describe('reconcileRunnerInstances', () => {
2046
2181
  }
2047
2182
  });
2048
2183
 
2184
+ describe('countStaleEnrolledRunnerInstances', () => {
2185
+ let provisionerId: string;
2186
+
2187
+ beforeEach(() => {
2188
+ provisionerId = crypto.randomUUID();
2189
+ });
2190
+
2191
+ function staleAt(): Date {
2192
+ return new Date(Date.now() - 120_000);
2193
+ }
2194
+
2195
+ it('counts only stale reported runners with a live control session and no assignment', async () => {
2196
+ const baseline = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
2197
+ const stale = await createRunner({updatedAt: staleAt()});
2198
+ await createControlSession(stale);
2199
+
2200
+ const fresh = await createRunner({updatedAt: new Date()});
2201
+ await createControlSession(fresh);
2202
+
2203
+ const recentlyMutated = await createRunner({reportedAt: staleAt(), updatedAt: new Date()});
2204
+ await createControlSession(recentlyMutated);
2205
+
2206
+ const freshReport = await createRunner({reportedAt: new Date(), updatedAt: staleAt()});
2207
+ await createControlSession(freshReport);
2208
+
2209
+ const assigned = await createRunner({workspaceId: crypto.randomUUID(), updatedAt: staleAt()});
2210
+ await createControlSession(assigned);
2211
+
2212
+ const activated = await createRunner({
2213
+ runnerSessionId: crypto.randomUUID(),
2214
+ updatedAt: staleAt(),
2215
+ });
2216
+ await createControlSession(activated);
2217
+
2218
+ await createRunner({updatedAt: staleAt()});
2219
+ const expiredControlSession = await createRunner({updatedAt: staleAt()});
2220
+ await createControlSession(expiredControlSession, {
2221
+ expiresAt: new Date(Date.now() - 1_000),
2222
+ });
2223
+ const closedControlSession = await createRunner({updatedAt: staleAt()});
2224
+ await createControlSession(closedControlSession, {
2225
+ closedAt: new Date(),
2226
+ });
2227
+ const stopped = await createRunner({state: 'stopped', updatedAt: staleAt()});
2228
+ await createControlSession(stopped);
2229
+
2230
+ const count = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
2231
+
2232
+ expect(count - baseline).toBe(2);
2233
+ });
2234
+
2235
+ async function createRunner(params: {
2236
+ state?: 'running' | 'stopped';
2237
+ workspaceId?: string | null;
2238
+ runnerSessionId?: string | null;
2239
+ reportedAt?: Date;
2240
+ updatedAt?: Date;
2241
+ }) {
2242
+ const reportedAt = params.reportedAt ?? params.updatedAt ?? new Date();
2243
+ const [runner] = await db()
2244
+ .insert(providerRunners)
2245
+ .values({
2246
+ workspaceId: params.workspaceId ?? null,
2247
+ provisionerId,
2248
+ providerRunnerId: crypto.randomUUID(),
2249
+ templateKey: 'linux',
2250
+ labels: ['linux'],
2251
+ state: params.state ?? 'running',
2252
+ runnerSessionId: params.runnerSessionId ?? null,
2253
+ providerKind: 'docker',
2254
+ reportedAt,
2255
+ updatedAt: params.updatedAt ?? reportedAt,
2256
+ })
2257
+ .returning({id: providerRunners.id});
2258
+ if (!runner) throw new Error('Expected runner instance');
2259
+ return runner.id;
2260
+ }
2261
+
2262
+ async function createControlSession(
2263
+ runnerInstanceId: string,
2264
+ params: {expiresAt?: Date; closedAt?: Date} = {},
2265
+ ) {
2266
+ await db()
2267
+ .insert(runnerControlSessions)
2268
+ .values({
2269
+ runnerInstanceId,
2270
+ provisionerId,
2271
+ hashedToken: crypto.randomUUID(),
2272
+ prefix: 'test',
2273
+ expiresAt: params.expiresAt ?? new Date(Date.now() + 60_000),
2274
+ closedAt: params.closedAt,
2275
+ closeReason: params.closedAt ? 'test' : null,
2276
+ });
2277
+ }
2278
+ });
2279
+
2049
2280
  describe('runner instance provider attachment', () => {
2050
2281
  it('updates the pre-created runner instance before its first lifecycle report', async () => {
2051
2282
  const provisionerId = crypto.randomUUID();
@@ -29,6 +29,7 @@ import {releaseReservationUnits} from './reservations.js';
29
29
  import {ephemeralRegistrationTokens} from './schema/ephemeral-registration-tokens.js';
30
30
  import {provisionerTokens} from './schema/provisioner-tokens.js';
31
31
  import {reservations} from './schema/reservations.js';
32
+ import {runnerControlSessions} from './schema/runner-control-sessions.js';
32
33
  import {providerRunners, toRunnerInstance} from './schema/runner-instances.js';
33
34
  import {runnerSessions} from './schema/runner-sessions.js';
34
35
  import {runningJobExecutions} from './schema/running-job-executions.js';
@@ -190,7 +191,7 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
190
191
  target: [providerRunners.provisionerId, providerRunners.providerRunnerId],
191
192
  targetWhere: isNotNull(providerRunners.providerRunnerId),
192
193
  set: {
193
- reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(excluded.reservation_id, ${providerRunners.reservationId}) ELSE ${providerRunners.reservationId} END`,
194
+ reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(${providerRunners.reservationId}, excluded.reservation_id) ELSE ${providerRunners.reservationId} END`,
194
195
  templateKey: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(excluded.template_key, ${providerRunners.templateKey}) ELSE ${providerRunners.templateKey} END`,
195
196
  labels: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.labels ELSE ${providerRunners.labels} END`,
196
197
  state: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.state ELSE ${providerRunners.state} END`,
@@ -247,6 +248,36 @@ export async function listActiveRunnerInstanceCountsByTemplateTx(
247
248
  );
248
249
  }
249
250
 
251
+ export async function countStaleEnrolledRunnerInstances(params: {
252
+ graceSeconds: number;
253
+ }): Promise<number> {
254
+ const [row] = await db()
255
+ .select({count: sql<number>`count(*)::int`})
256
+ .from(providerRunners)
257
+ .where(
258
+ and(
259
+ eq(providerRunners.state, 'running'),
260
+ isNull(providerRunners.workspaceId),
261
+ isNull(providerRunners.runnerSessionId),
262
+ lt(providerRunners.reportedAt, staleRunnerInstanceCutoff(params.graceSeconds)),
263
+ exists(
264
+ db()
265
+ .select({id: runnerControlSessions.id})
266
+ .from(runnerControlSessions)
267
+ .where(
268
+ and(
269
+ eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
270
+ isNull(runnerControlSessions.closedAt),
271
+ gt(runnerControlSessions.expiresAt, sql`now()`),
272
+ ),
273
+ ),
274
+ ),
275
+ ),
276
+ );
277
+
278
+ return row?.count ?? 0;
279
+ }
280
+
250
281
  export async function listActiveRunnerInstances(params: {
251
282
  workspaceId: string;
252
283
  windowSeconds: number;
@@ -59,33 +59,44 @@ export async function createRunnerSessionConsumingActivationToken(params: {
59
59
  toolCapabilities?: RunnerToolCapabilitiesDto | null;
60
60
  }) {
61
61
  return await db().transaction(async (tx) => {
62
- const [token] = await tx
62
+ const [runner] = await tx
63
63
  .select({
64
- id: runnerActivationTokens.id,
65
- runnerInstanceId: runnerActivationTokens.runnerInstanceId,
64
+ activationTokenId: runnerActivationTokens.id,
65
+ runnerInstanceId: providerRunners.id,
66
66
  workspaceId: providerRunners.workspaceId,
67
67
  provisionerId: providerRunners.provisionerId,
68
68
  providerRunnerId: providerRunners.providerRunnerId,
69
+ runnerSessionId: providerRunners.runnerSessionId,
69
70
  })
70
71
  .from(runnerActivationTokens)
71
72
  .innerJoin(providerRunners, eq(providerRunners.id, runnerActivationTokens.runnerInstanceId))
73
+ .where(eq(runnerActivationTokens.id, params.activationTokenId))
74
+ .limit(1)
75
+ .for('update', {of: providerRunners});
76
+ if (!runner?.workspaceId || !runner.providerRunnerId || runner.runnerSessionId)
77
+ throw new Error('Runner activation token is invalid, expired, or has already been used');
78
+
79
+ const [activationToken] = await tx
80
+ .select({id: runnerActivationTokens.id})
81
+ .from(runnerActivationTokens)
72
82
  .where(
73
83
  and(
74
- eq(runnerActivationTokens.id, params.activationTokenId),
84
+ eq(runnerActivationTokens.id, runner.activationTokenId),
85
+ eq(runnerActivationTokens.runnerInstanceId, runner.runnerInstanceId),
75
86
  isNull(runnerActivationTokens.consumedAt),
76
87
  isNull(runnerActivationTokens.revokedAt),
77
88
  gt(runnerActivationTokens.expiresAt, sql`now()`),
78
- isNull(providerRunners.runnerSessionId),
79
89
  ),
80
90
  )
81
91
  .limit(1)
82
92
  .for('update');
83
- if (!token?.workspaceId || !token.providerRunnerId)
93
+ if (!activationToken)
84
94
  throw new Error('Runner activation token is invalid, expired, or has already been used');
95
+
85
96
  const [provisioner] = await tx
86
97
  .select({scope: provisionerTokens.scope})
87
98
  .from(provisionerTokens)
88
- .where(eq(provisionerTokens.id, token.provisionerId))
99
+ .where(eq(provisionerTokens.id, runner.provisionerId))
89
100
  .limit(1);
90
101
  const labels = sanitizeRunnerLabelsOrThrow(params.labels, {
91
102
  scope: provisioner?.scope ?? 'workspace',
@@ -95,13 +106,13 @@ export async function createRunnerSessionConsumingActivationToken(params: {
95
106
  const [session] = await tx
96
107
  .insert(runnerSessions)
97
108
  .values({
98
- workspaceId: token.workspaceId,
109
+ workspaceId: runner.workspaceId,
99
110
  scope: 'workspace',
100
- registrationTokenId: token.id,
111
+ registrationTokenId: activationToken.id,
101
112
  registrationTokenKind: 'activation',
102
- runnerInstanceId: token.runnerInstanceId,
103
- provisionerId: token.provisionerId,
104
- providerRunnerId: token.providerRunnerId,
113
+ runnerInstanceId: runner.runnerInstanceId,
114
+ provisionerId: runner.provisionerId,
115
+ providerRunnerId: runner.providerRunnerId,
105
116
  labels,
106
117
  toolCapabilities: params.toolCapabilities ?? null,
107
118
  toolCapabilitiesReportedAt: params.toolCapabilities ? sql`now()` : null,
@@ -113,13 +124,13 @@ export async function createRunnerSessionConsumingActivationToken(params: {
113
124
  await tx
114
125
  .update(runnerActivationTokens)
115
126
  .set({consumedAt: sql`now()`, consumedSessionId: session.id})
116
- .where(eq(runnerActivationTokens.id, token.id));
127
+ .where(eq(runnerActivationTokens.id, activationToken.id));
117
128
  await tx
118
129
  .update(providerRunners)
119
130
  .set({runnerSessionId: session.id, updatedAt: sql`now()`})
120
131
  .where(
121
132
  and(
122
- eq(providerRunners.id, token.runnerInstanceId),
133
+ eq(providerRunners.id, runner.runnerInstanceId),
123
134
  isNull(providerRunners.runnerSessionId),
124
135
  ),
125
136
  );
@@ -128,7 +139,7 @@ export async function createRunnerSessionConsumingActivationToken(params: {
128
139
  .set({closedAt: sql`now()`, closeReason: 'activated'})
129
140
  .where(
130
141
  and(
131
- eq(runnerControlSessions.runnerInstanceId, token.runnerInstanceId),
142
+ eq(runnerControlSessions.runnerInstanceId, runner.runnerInstanceId),
132
143
  isNull(runnerControlSessions.closedAt),
133
144
  ),
134
145
  );
@@ -0,0 +1,99 @@
1
+ const mocks = vi.hoisted(() => {
2
+ const gauges = {
3
+ enrolledRunnersWithoutRecentReport: {},
4
+ pendingJobExecutions: {},
5
+ runningJobExecutions: {},
6
+ };
7
+ const gaugeByName = {
8
+ runners_enrolled_without_recent_report: gauges.enrolledRunnersWithoutRecentReport,
9
+ runners_pending_job_executions: gauges.pendingJobExecutions,
10
+ runners_running_job_executions: gauges.runningJobExecutions,
11
+ };
12
+ return {
13
+ addBatchObservableCallback: vi.fn(),
14
+ countStaleEnrolledRunnerInstances: vi.fn(),
15
+ createObservableGauge: vi.fn((name: string) => gaugeByName[name as keyof typeof gaugeByName]),
16
+ gauges,
17
+ getMeter: vi.fn(),
18
+ getJobExecutionQueueDepth: vi.fn(),
19
+ getServiceMetricsProvider: vi.fn(),
20
+ };
21
+ });
22
+
23
+ vi.mock('@shipfox/node-opentelemetry', () => ({
24
+ getServiceMetricsProvider: mocks.getServiceMetricsProvider,
25
+ }));
26
+ vi.mock('#config.js', () => ({
27
+ config: {RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS: 300},
28
+ }));
29
+ vi.mock('#db/job-executions.js', () => ({
30
+ getJobExecutionQueueDepth: mocks.getJobExecutionQueueDepth,
31
+ }));
32
+ vi.mock('#db/runner-instances.js', () => ({
33
+ countStaleEnrolledRunnerInstances: mocks.countStaleEnrolledRunnerInstances,
34
+ }));
35
+
36
+ import {registerRunnersServiceMetrics} from './service.js';
37
+
38
+ describe('registerRunnersServiceMetrics', () => {
39
+ beforeEach(() => {
40
+ mocks.addBatchObservableCallback.mockReset();
41
+ mocks.countStaleEnrolledRunnerInstances.mockReset();
42
+ mocks.createObservableGauge.mockClear();
43
+ mocks.getJobExecutionQueueDepth.mockReset();
44
+ mocks.getMeter.mockReset();
45
+ mocks.getServiceMetricsProvider.mockReset();
46
+ mocks.getJobExecutionQueueDepth.mockResolvedValue({
47
+ pendingJobExecutions: 0,
48
+ runningJobExecutions: 0,
49
+ });
50
+ mocks.getMeter.mockReturnValue({
51
+ createObservableGauge: mocks.createObservableGauge,
52
+ addBatchObservableCallback: mocks.addBatchObservableCallback,
53
+ });
54
+ mocks.getServiceMetricsProvider.mockReturnValue({getMeter: mocks.getMeter});
55
+ });
56
+
57
+ it('observes enrolled runners without recent reports after the grace window', async () => {
58
+ mocks.countStaleEnrolledRunnerInstances.mockResolvedValue(2);
59
+
60
+ registerRunnersServiceMetrics();
61
+ const callback = mocks.addBatchObservableCallback.mock.calls[0]?.[0];
62
+ if (typeof callback !== 'function') throw new Error('Expected metrics callback');
63
+ const observer = {observe: vi.fn()};
64
+
65
+ await callback(observer);
66
+
67
+ expect(mocks.createObservableGauge).toHaveBeenCalledWith(
68
+ 'runners_enrolled_without_recent_report',
69
+ {
70
+ description:
71
+ 'Running enrolled runners with a live control session, no workspace or runner session, and no recent provisioner report after the stale-runner grace window',
72
+ },
73
+ );
74
+ expect(mocks.countStaleEnrolledRunnerInstances).toHaveBeenCalledWith({graceSeconds: 300});
75
+ expect(observer.observe).toHaveBeenCalledWith(
76
+ mocks.gauges.enrolledRunnersWithoutRecentReport,
77
+ 2,
78
+ );
79
+ });
80
+
81
+ it('keeps queue gauges observable when the enrolled-runner query fails', async () => {
82
+ mocks.getJobExecutionQueueDepth.mockResolvedValue({
83
+ pendingJobExecutions: 3,
84
+ runningJobExecutions: 4,
85
+ });
86
+ mocks.countStaleEnrolledRunnerInstances.mockRejectedValue(new Error('database unavailable'));
87
+
88
+ registerRunnersServiceMetrics();
89
+ const callback = mocks.addBatchObservableCallback.mock.calls[0]?.[0];
90
+ if (typeof callback !== 'function') throw new Error('Expected metrics callback');
91
+ const observer = {observe: vi.fn()};
92
+
93
+ await callback(observer);
94
+
95
+ expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.pendingJobExecutions, 3);
96
+ expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.runningJobExecutions, 4);
97
+ expect(observer.observe).toHaveBeenCalledTimes(2);
98
+ });
99
+ });
@@ -1,5 +1,7 @@
1
1
  import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
2
+ import {config} from '#config.js';
2
3
  import {getJobExecutionQueueDepth} from '#db/job-executions.js';
4
+ import {countStaleEnrolledRunnerInstances} from '#db/runner-instances.js';
3
5
 
4
6
  export function registerRunnersServiceMetrics(): void {
5
7
  const meter = getServiceMetricsProvider().getMeter('runners');
@@ -10,13 +12,31 @@ export function registerRunnersServiceMetrics(): void {
10
12
  const runningJobExecutions = meter.createObservableGauge('runners_running_job_executions', {
11
13
  description: 'Job executions currently claimed by a runner and in progress',
12
14
  });
15
+ const enrolledRunnersWithoutRecentReport = meter.createObservableGauge(
16
+ 'runners_enrolled_without_recent_report',
17
+ {
18
+ description:
19
+ 'Running enrolled runners with a live control session, no workspace or runner session, and no recent provisioner report after the stale-runner grace window',
20
+ },
21
+ );
13
22
 
14
23
  meter.addBatchObservableCallback(
15
24
  async (observer) => {
16
- const depth = await getJobExecutionQueueDepth();
17
- observer.observe(pendingJobExecutions, depth.pendingJobExecutions);
18
- observer.observe(runningJobExecutions, depth.runningJobExecutions);
25
+ const [depthResult, staleEnrolledRunnerCountResult] = await Promise.allSettled([
26
+ getJobExecutionQueueDepth(),
27
+ countStaleEnrolledRunnerInstances({
28
+ graceSeconds: config.RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS,
29
+ }),
30
+ ]);
31
+
32
+ if (depthResult.status === 'fulfilled') {
33
+ observer.observe(pendingJobExecutions, depthResult.value.pendingJobExecutions);
34
+ observer.observe(runningJobExecutions, depthResult.value.runningJobExecutions);
35
+ }
36
+ if (staleEnrolledRunnerCountResult.status === 'fulfilled') {
37
+ observer.observe(enrolledRunnersWithoutRecentReport, staleEnrolledRunnerCountResult.value);
38
+ }
19
39
  },
20
- [pendingJobExecutions, runningJobExecutions],
40
+ [pendingJobExecutions, runningJobExecutions, enrolledRunnersWithoutRecentReport],
21
41
  );
22
42
  }