@shipfox/api-runners 12.6.0 → 13.0.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 (78) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +20 -0
  3. package/dist/db/index.d.ts +1 -1
  4. package/dist/db/index.d.ts.map +1 -1
  5. package/dist/db/index.js +1 -1
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/db/job-executions.d.ts +8 -21
  8. package/dist/db/job-executions.d.ts.map +1 -1
  9. package/dist/db/job-executions.js +63 -60
  10. package/dist/db/job-executions.js.map +1 -1
  11. package/dist/db/provisioner-tokens.d.ts.map +1 -1
  12. package/dist/db/provisioner-tokens.js +14 -2
  13. package/dist/db/provisioner-tokens.js.map +1 -1
  14. package/dist/db/reservations.d.ts +10 -0
  15. package/dist/db/reservations.d.ts.map +1 -1
  16. package/dist/db/reservations.js +139 -17
  17. package/dist/db/reservations.js.map +1 -1
  18. package/dist/db/runner-assignments.d.ts.map +1 -1
  19. package/dist/db/runner-assignments.js +8 -5
  20. package/dist/db/runner-assignments.js.map +1 -1
  21. package/dist/db/runner-instances.d.ts +0 -1
  22. package/dist/db/runner-instances.d.ts.map +1 -1
  23. package/dist/db/runner-instances.js +85 -100
  24. package/dist/db/runner-instances.js.map +1 -1
  25. package/dist/db/runner-states.d.ts +3 -1
  26. package/dist/db/runner-states.d.ts.map +1 -1
  27. package/dist/db/runner-states.js +6 -4
  28. package/dist/db/runner-states.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -4
  32. package/dist/index.js.map +1 -1
  33. package/dist/presentation/index.d.ts +2 -1
  34. package/dist/presentation/index.d.ts.map +1 -1
  35. package/dist/presentation/index.js +2 -1
  36. package/dist/presentation/index.js.map +1 -1
  37. package/dist/presentation/inter-module.d.ts +0 -1
  38. package/dist/presentation/inter-module.d.ts.map +1 -1
  39. package/dist/presentation/inter-module.js +2 -25
  40. package/dist/presentation/inter-module.js.map +1 -1
  41. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts +3 -0
  42. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts.map +1 -0
  43. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js +21 -0
  44. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js.map +1 -0
  45. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts +3 -0
  46. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts.map +1 -0
  47. package/dist/presentation/subscribers/{on-workflows-job-execution-timed-out.js → on-workflows-job-execution-terminated.js} +5 -4
  48. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.js.map +1 -0
  49. package/dist/tsconfig.test.tsbuildinfo +1 -1
  50. package/package.json +3 -3
  51. package/src/db/index.ts +0 -2
  52. package/src/db/job-executions.test.ts +106 -217
  53. package/src/db/job-executions.ts +71 -70
  54. package/src/db/provisioner-tokens.test.ts +19 -0
  55. package/src/db/provisioner-tokens.ts +13 -2
  56. package/src/db/reservations.test.ts +138 -0
  57. package/src/db/reservations.ts +314 -35
  58. package/src/db/runner-assignments.test.ts +20 -0
  59. package/src/db/runner-assignments.ts +6 -7
  60. package/src/db/runner-instances.test.ts +350 -15
  61. package/src/db/runner-instances.ts +136 -198
  62. package/src/db/runner-states.test.ts +16 -0
  63. package/src/db/runner-states.ts +9 -4
  64. package/src/index.test.ts +1 -0
  65. package/src/index.ts +8 -7
  66. package/src/presentation/index.ts +2 -1
  67. package/src/presentation/inter-module.ts +2 -40
  68. package/src/presentation/subscribers/on-workflows-job-execution-queued.test.ts +57 -0
  69. package/src/presentation/subscribers/on-workflows-job-execution-queued.ts +26 -0
  70. package/src/presentation/subscribers/on-workflows-job-execution-terminated.test.ts +142 -0
  71. package/src/presentation/subscribers/{on-workflows-job-execution-timed-out.ts → on-workflows-job-execution-terminated.ts} +5 -4
  72. package/test/factories/pending-job.ts +3 -0
  73. package/tsconfig.build.tsbuildinfo +1 -1
  74. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts +0 -3
  75. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts.map +0 -1
  76. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +0 -1
  77. package/src/presentation/inter-module.test.ts +0 -15
  78. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +0 -163
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-runners",
3
3
  "license": "MIT",
4
- "version": "12.6.0",
4
+ "version": "13.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -24,8 +24,8 @@
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.4.0",
28
- "@shipfox/api-workflows-dto": "12.6.0",
27
+ "@shipfox/api-runners-dto": "13.0.0",
28
+ "@shipfox/api-workflows-dto": "13.0.0",
29
29
  "@shipfox/config": "1.2.4",
30
30
  "@shipfox/inter-module": "0.2.3",
31
31
  "@shipfox/node-drizzle": "0.3.5",
package/src/db/index.ts CHANGED
@@ -25,7 +25,6 @@ export type {
25
25
  RunnerInstanceBoundJobExecution,
26
26
  } from './job-executions.js';
27
27
  export {
28
- cancelRunnerJobs,
29
28
  claimPendingJobExecution,
30
29
  enqueueJobExecution,
31
30
  expireStuckJobExecutions,
@@ -34,7 +33,6 @@ export {
34
33
  listActiveRunningJobExecutions,
35
34
  listRunningJobExecutionsByRunnerInstanceTx,
36
35
  recordHeartbeat,
37
- releaseJobExecution,
38
36
  } from './job-executions.js';
39
37
  export type {CreateManualRegistrationTokenParams} from './manual-registration-tokens.js';
40
38
  export {
@@ -1,8 +1,4 @@
1
- import {
2
- RUNNER_JOB_CLAIMED,
3
- RUNNER_JOB_LEASE_EXPIRED,
4
- RUNNER_JOB_QUEUED,
5
- } from '@shipfox/api-runners-dto';
1
+ import {RUNNER_JOB_CLAIMED, RUNNER_JOB_LEASE_EXPIRED} from '@shipfox/api-runners-dto';
6
2
  import {pgClient} from '@shipfox/node-postgres';
7
3
  import {eq, sql} from 'drizzle-orm';
8
4
  import {EmptyRequiredLabelsError, RunnerSessionExhaustedError} from '#core/errors.js';
@@ -12,12 +8,12 @@ import * as runnerMetrics from '#metrics/instance.js';
12
8
  import {
13
9
  getLeaseTokenClaims,
14
10
  pendingJobFactory,
11
+ providerRunnerFactory,
15
12
  runnerSessionFactory,
16
13
  runnersTestAuthClient,
17
14
  } from '#test/index.js';
18
15
  import {db} from './db.js';
19
16
  import {
20
- cancelRunnerJobs,
21
17
  claimPendingJobExecution as claimPendingJobExecutionDb,
22
18
  enqueueJobExecution,
23
19
  expireStuckJobExecutions,
@@ -25,10 +21,10 @@ import {
25
21
  isJobLeaseActive,
26
22
  reconcileTerminalJobExecution,
27
23
  recordHeartbeat,
28
- releaseJobExecution,
29
24
  } from './job-executions.js';
30
25
  import {runnersOutbox} from './schema/outbox.js';
31
26
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
27
+ import {reservations} from './schema/reservations.js';
32
28
  import {providerRunners} from './schema/runner-instances.js';
33
29
  import {runnerSessions} from './schema/runner-sessions.js';
34
30
  import {runningJobExecutions} from './schema/running-job-executions.js';
@@ -69,6 +65,7 @@ describe('enqueueJobExecution', () => {
69
65
  const workflowRunAttemptId = crypto.randomUUID();
70
66
  const workspaceId = crypto.randomUUID();
71
67
  const projectId = crypto.randomUUID();
68
+ const queuedAt = new Date('2026-08-11T08:00:00.000Z');
72
69
 
73
70
  await enqueueJobExecution({
74
71
  workspaceId,
@@ -78,6 +75,7 @@ describe('enqueueJobExecution', () => {
78
75
  workflowRunAttemptId,
79
76
  projectId,
80
77
  requiredLabels: ['linux'],
78
+ queuedAt,
81
79
  });
82
80
 
83
81
  const rows = await db()
@@ -92,6 +90,7 @@ describe('enqueueJobExecution', () => {
92
90
  expect(rows[0]?.projectId).toBe(projectId);
93
91
  expect(rows[0]?.workspaceId).toBe(workspaceId);
94
92
  expect(rows[0]?.requiredLabels).toEqual(['linux']);
93
+ expect(rows[0]?.createdAt).toEqual(queuedAt);
95
94
  expect(rows[0]).not.toHaveProperty('payload');
96
95
  });
97
96
 
@@ -107,6 +106,7 @@ describe('enqueueJobExecution', () => {
107
106
  workflowRunAttemptId: crypto.randomUUID(),
108
107
  projectId: crypto.randomUUID(),
109
108
  requiredLabels: ['Ubuntu22', ' ubuntu22 ', 'LINUX'],
109
+ queuedAt: new Date(),
110
110
  });
111
111
 
112
112
  const rows = await db()
@@ -126,6 +126,7 @@ describe('enqueueJobExecution', () => {
126
126
  workflowRunAttemptId: crypto.randomUUID(),
127
127
  projectId: crypto.randomUUID(),
128
128
  requiredLabels: [],
129
+ queuedAt: new Date(),
129
130
  }),
130
131
  ).rejects.toBeInstanceOf(EmptyRequiredLabelsError);
131
132
  });
@@ -140,6 +141,7 @@ describe('enqueueJobExecution', () => {
140
141
  jobId,
141
142
  jobExecutionId: crypto.randomUUID(),
142
143
  requiredLabels: ['linux'],
144
+ queuedAt: new Date(),
143
145
  };
144
146
 
145
147
  await enqueueJobExecution(params);
@@ -151,63 +153,6 @@ describe('enqueueJobExecution', () => {
151
153
  .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
152
154
  expect(rows).toHaveLength(1);
153
155
  });
154
-
155
- it('emits runners.job.queued carrying the pending row created_at', async () => {
156
- const jobId = crypto.randomUUID();
157
- const workflowRunId = crypto.randomUUID();
158
- const workflowRunAttemptId = crypto.randomUUID();
159
-
160
- await enqueueJobExecution({
161
- workspaceId: crypto.randomUUID(),
162
- workflowRunId,
163
- jobId,
164
- jobExecutionId: crypto.randomUUID(),
165
- workflowRunAttemptId,
166
- projectId: crypto.randomUUID(),
167
- requiredLabels: ['linux'],
168
- });
169
-
170
- const [pending] = await db()
171
- .select()
172
- .from(pendingJobExecutions)
173
- .where(eq(pendingJobExecutions.jobId, jobId));
174
- const outbox = await outboxEventsForJob(RUNNER_JOB_QUEUED, jobId);
175
- expect(outbox).toHaveLength(1);
176
- expect(outbox[0]?.eventType).toBe(RUNNER_JOB_QUEUED);
177
- const payload = outbox[0]?.payload as {
178
- jobId: string;
179
- workflowRunId: string;
180
- workflowRunAttemptId: string;
181
- queuedAt: string;
182
- };
183
- expect(payload.jobId).toBe(jobId);
184
- expect(payload.workflowRunId).toBe(workflowRunId);
185
- expect(payload.workflowRunAttemptId).toBe(workflowRunAttemptId);
186
- expect(new Date(payload.queuedAt).getTime()).toBe(pending?.createdAt.getTime());
187
- });
188
-
189
- it('does not double-emit queued when the same jobId is re-enqueued (idempotency regression)', async () => {
190
- const params = {
191
- workspaceId: crypto.randomUUID(),
192
- workflowRunId: crypto.randomUUID(),
193
- workflowRunAttemptId: crypto.randomUUID(),
194
- projectId: crypto.randomUUID(),
195
- jobId: crypto.randomUUID(),
196
- jobExecutionId: crypto.randomUUID(),
197
- requiredLabels: ['linux'],
198
- };
199
-
200
- await enqueueJobExecution(params);
201
- await enqueueJobExecution(params);
202
-
203
- expect(
204
- await db()
205
- .select()
206
- .from(pendingJobExecutions)
207
- .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId)),
208
- ).toHaveLength(1);
209
- expect(await outboxEventsForJob(RUNNER_JOB_QUEUED, params.jobId)).toHaveLength(1);
210
- });
211
156
  });
212
157
 
213
158
  describe('claimPendingJobExecution', () => {
@@ -826,7 +771,7 @@ describe('claimPendingJobExecution', () => {
826
771
  expect(running[0]?.jobId).toBe(created.jobId);
827
772
  });
828
773
 
829
- it('leaves a non-matching orphan unclaimed until release sweeps it', async () => {
774
+ it('leaves a non-matching orphan unclaimed until terminal reconciliation sweeps it', async () => {
830
775
  const created = await pendingJobFactory.create({workspaceId});
831
776
  const first = await claimPendingJobExecution({workspaceId, runnerSessionId});
832
777
  if (!first) throw new Error('Expected pending job to be claimed');
@@ -846,15 +791,15 @@ describe('claimPendingJobExecution', () => {
846
791
  runnerSessionId,
847
792
  sessionLabels: ['macos'],
848
793
  });
849
- await releaseJobExecution({jobExecutionId: created.jobExecutionId});
794
+ await reconcileTerminalJobExecution({jobExecutionId: created.jobExecutionId});
850
795
 
851
796
  expect(second).toBeNull();
852
- expect(
853
- await db()
854
- .select()
855
- .from(runningJobExecutions)
856
- .where(eq(runningJobExecutions.workspaceId, workspaceId)),
857
- ).toHaveLength(0);
797
+ const [running] = await db()
798
+ .select()
799
+ .from(runningJobExecutions)
800
+ .where(eq(runningJobExecutions.workspaceId, workspaceId));
801
+ expect(running?.jobExecutionId).toBe(created.jobExecutionId);
802
+ expect(running?.cancellationRequestedAt).not.toBeNull();
858
803
  expect(
859
804
  await db()
860
805
  .select()
@@ -935,88 +880,6 @@ describe('claimJobExecution', () => {
935
880
  });
936
881
  });
937
882
 
938
- describe('releaseJobExecution', () => {
939
- let workspaceId: string;
940
- let runnerSessionId: string;
941
-
942
- beforeEach(async () => {
943
- workspaceId = crypto.randomUUID();
944
- const runnerSession = await runnerSessionFactory.create({workspaceId});
945
- runnerSessionId = runnerSession.id;
946
- });
947
-
948
- it('deletes the running row and writes no outbox event', async () => {
949
- await pendingJobFactory.create({workspaceId});
950
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
951
- const before = await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string);
952
-
953
- await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
954
-
955
- expect(
956
- await db()
957
- .select()
958
- .from(runningJobExecutions)
959
- .where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
960
- ).toHaveLength(0);
961
- expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string)).toHaveLength(
962
- before.length,
963
- );
964
- });
965
-
966
- it('is a no-op when the job is absent (idempotent)', async () => {
967
- await expect(
968
- releaseJobExecution({jobExecutionId: crypto.randomUUID()}),
969
- ).resolves.toBeUndefined();
970
- });
971
-
972
- it('releases regardless of which session holds the lease', async () => {
973
- await pendingJobFactory.create({workspaceId});
974
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
975
-
976
- // No token is passed: the workflow is authoritative over the lease.
977
- await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
978
-
979
- expect(
980
- await db()
981
- .select()
982
- .from(runningJobExecutions)
983
- .where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
984
- ).toHaveLength(0);
985
- });
986
-
987
- it('also sweeps a lingering pending row for the same job', async () => {
988
- await pendingJobFactory.create({workspaceId});
989
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
990
- // An orphan pending row left by a post-claim enqueue retry.
991
- await db()
992
- .insert(pendingJobExecutions)
993
- .values({
994
- workspaceId,
995
- workflowRunId: claimed?.workflowRunId as string,
996
- jobId: claimed?.jobId as string,
997
- jobExecutionId: claimed?.jobExecutionId as string,
998
- workflowRunAttemptId: claimed?.workflowRunAttemptId as string,
999
- projectId: claimed?.projectId as string,
1000
- requiredLabels: ['linux'],
1001
- });
1002
-
1003
- await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
1004
-
1005
- expect(
1006
- await db()
1007
- .select()
1008
- .from(runningJobExecutions)
1009
- .where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
1010
- ).toHaveLength(0);
1011
- expect(
1012
- await db()
1013
- .select()
1014
- .from(pendingJobExecutions)
1015
- .where(eq(pendingJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
1016
- ).toHaveLength(0);
1017
- });
1018
- });
1019
-
1020
883
  describe('recordHeartbeat', () => {
1021
884
  let workspaceId: string;
1022
885
  let runnerSessionId: string;
@@ -1174,6 +1037,49 @@ describe('reconcileTerminalJobExecution', () => {
1174
1037
  expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
1175
1038
  });
1176
1039
 
1040
+ it('releases an already-terminal runner reservation after cancelling its last lease', async () => {
1041
+ const provisionerId = crypto.randomUUID();
1042
+ const providerRunnerId = crypto.randomUUID();
1043
+ const [reservation] = await db()
1044
+ .insert(reservations)
1045
+ .values({
1046
+ workspaceId,
1047
+ provisionerId,
1048
+ requiredLabels: sessionLabels,
1049
+ count: 1,
1050
+ expiresAt: new Date(Date.now() + 60_000),
1051
+ })
1052
+ .returning({id: reservations.id});
1053
+ if (!reservation) throw new Error('Expected reservation');
1054
+ await providerRunnerFactory.create({
1055
+ workspaceId,
1056
+ provisionerId,
1057
+ providerRunnerId,
1058
+ reservationId: reservation.id,
1059
+ runnerSessionId,
1060
+ state: 'terminated',
1061
+ terminatedAt: new Date(),
1062
+ });
1063
+ const pending = await pendingJobFactory.create({workspaceId});
1064
+ const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1065
+ expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
1066
+ await db()
1067
+ .update(runningJobExecutions)
1068
+ .set({provisionerId, providerRunnerId})
1069
+ .where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
1070
+
1071
+ await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1072
+
1073
+ expect(
1074
+ await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1075
+ ).toHaveLength(0);
1076
+ const [runner] = await db()
1077
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
1078
+ .from(providerRunners)
1079
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1080
+ expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
1081
+ });
1082
+
1177
1083
  it('is idempotent: second call preserves the first timestamp', async () => {
1178
1084
  const pending = await pendingJobFactory.create({workspaceId});
1179
1085
  const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
@@ -1335,68 +1241,6 @@ async function waitForLockWait(params: {queryLike: string}) {
1335
1241
  throw new Error(`Timed out waiting for lock waiter matching ${params.queryLike}`);
1336
1242
  }
1337
1243
 
1338
- describe('cancelRunnerJobs', () => {
1339
- let workspaceId: string;
1340
- let runnerSessionId: string;
1341
-
1342
- beforeEach(async () => {
1343
- workspaceId = crypto.randomUUID();
1344
- const runnerSession = await runnerSessionFactory.create({workspaceId});
1345
- runnerSessionId = runnerSession.id;
1346
- });
1347
-
1348
- it('deletes queued jobs and requests cancellation for running jobs', async () => {
1349
- const running = await pendingJobFactory.create({workspaceId});
1350
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1351
- const queued = await pendingJobFactory.create({workspaceId});
1352
-
1353
- await cancelRunnerJobs({jobIds: [queued.jobId, claimed?.jobId as string]});
1354
-
1355
- expect(
1356
- await db()
1357
- .select()
1358
- .from(pendingJobExecutions)
1359
- .where(eq(pendingJobExecutions.workspaceId, workspaceId)),
1360
- ).toHaveLength(0);
1361
- const rows = await db()
1362
- .select()
1363
- .from(runningJobExecutions)
1364
- .where(eq(runningJobExecutions.workspaceId, workspaceId));
1365
- expect(rows).toHaveLength(1);
1366
- expect(rows[0]?.jobId).toBe(running.jobId);
1367
- expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
1368
- });
1369
-
1370
- it('is idempotent and no-ops for absent jobs', async () => {
1371
- const queued = await pendingJobFactory.create({workspaceId});
1372
-
1373
- await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
1374
- await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
1375
-
1376
- expect(
1377
- await db()
1378
- .select()
1379
- .from(pendingJobExecutions)
1380
- .where(eq(pendingJobExecutions.workspaceId, workspaceId)),
1381
- ).toHaveLength(0);
1382
- expect(
1383
- await db()
1384
- .select()
1385
- .from(runningJobExecutions)
1386
- .where(eq(runningJobExecutions.workspaceId, workspaceId)),
1387
- ).toHaveLength(0);
1388
- });
1389
-
1390
- it('prevents a cancelled queued job from being claimed', async () => {
1391
- const queued = await pendingJobFactory.create({workspaceId});
1392
-
1393
- await cancelRunnerJobs({jobIds: [queued.jobId]});
1394
-
1395
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1396
- expect(claimed).toBeNull();
1397
- });
1398
- });
1399
-
1400
1244
  describe('detectAndExpireStuckJobs', () => {
1401
1245
  let workspaceId: string;
1402
1246
  let runnerSessionId: string;
@@ -1494,6 +1338,50 @@ describe('detectAndExpireStuckJobs', () => {
1494
1338
  expect(payload.steps).toBeUndefined();
1495
1339
  });
1496
1340
 
1341
+ it('releases a terminal runner reservation when its stuck lease is reaped', async () => {
1342
+ const stale = await makeStaleJob(600);
1343
+ const provisionerId = crypto.randomUUID();
1344
+ const providerRunnerId = crypto.randomUUID();
1345
+ const [reservation] = await db()
1346
+ .insert(reservations)
1347
+ .values({
1348
+ workspaceId,
1349
+ provisionerId,
1350
+ requiredLabels: ['linux'],
1351
+ count: 1,
1352
+ expiresAt: new Date(Date.now() + 60_000),
1353
+ })
1354
+ .returning();
1355
+ if (!reservation) throw new Error('Expected reservation');
1356
+ await providerRunnerFactory.create({
1357
+ workspaceId,
1358
+ provisionerId,
1359
+ providerRunnerId,
1360
+ reservationId: reservation.id,
1361
+ runnerSessionId,
1362
+ state: 'terminated',
1363
+ terminatedAt: new Date(),
1364
+ });
1365
+ await db()
1366
+ .update(runningJobExecutions)
1367
+ .set({provisionerId, providerRunnerId})
1368
+ .where(eq(runningJobExecutions.jobExecutionId, stale.jobExecutionId));
1369
+
1370
+ await expireStuckJobExecutions({
1371
+ noFirstHeartbeatGraceSeconds: 60,
1372
+ thresholdSeconds: 180,
1373
+ });
1374
+
1375
+ expect(
1376
+ await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1377
+ ).toHaveLength(0);
1378
+ const [runner] = await db()
1379
+ .select()
1380
+ .from(providerRunners)
1381
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1382
+ expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
1383
+ });
1384
+
1497
1385
  it('does not requeue an execution after its lease has expired', async () => {
1498
1386
  const stale = await makeStaleJob(600);
1499
1387
 
@@ -1510,6 +1398,7 @@ describe('detectAndExpireStuckJobs', () => {
1510
1398
  jobExecutionId: stale.jobExecutionId,
1511
1399
  projectId: stale.projectId,
1512
1400
  requiredLabels: ['linux'],
1401
+ queuedAt: new Date(),
1513
1402
  });
1514
1403
 
1515
1404
  expect(
@@ -1661,7 +1550,7 @@ describe('detectAndExpireStuckJobs', () => {
1661
1550
  expect(await outboxForJobs([jobId])).toHaveLength(1);
1662
1551
  });
1663
1552
 
1664
- it('sweeps an orphan pending row for the job it reaps (best-effort release may have failed)', async () => {
1553
+ it('sweeps an orphan pending row for the job it reaps', async () => {
1665
1554
  const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
1666
1555
  await makeStaleJob(600);
1667
1556
  // A post-claim enqueue retry left a pending row whose job is already running;