@shipfox/api-runners 12.7.0 → 13.1.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 (55) 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 -24
  8. package/dist/db/job-executions.d.ts.map +1 -1
  9. package/dist/db/job-executions.js +106 -83
  10. package/dist/db/job-executions.js.map +1 -1
  11. package/dist/db/reservations.d.ts.map +1 -1
  12. package/dist/db/reservations.js +6 -4
  13. package/dist/db/reservations.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/presentation/index.d.ts +2 -1
  19. package/dist/presentation/index.d.ts.map +1 -1
  20. package/dist/presentation/index.js +2 -1
  21. package/dist/presentation/index.js.map +1 -1
  22. package/dist/presentation/inter-module.d.ts +0 -1
  23. package/dist/presentation/inter-module.d.ts.map +1 -1
  24. package/dist/presentation/inter-module.js +2 -25
  25. package/dist/presentation/inter-module.js.map +1 -1
  26. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts +3 -0
  27. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts.map +1 -0
  28. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js +21 -0
  29. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js.map +1 -0
  30. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts +3 -0
  31. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts.map +1 -0
  32. package/dist/presentation/subscribers/{on-workflows-job-execution-timed-out.js → on-workflows-job-execution-terminated.js} +5 -4
  33. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.js.map +1 -0
  34. package/dist/tsconfig.test.tsbuildinfo +1 -1
  35. package/package.json +3 -3
  36. package/src/db/index.ts +0 -2
  37. package/src/db/job-executions.test.ts +197 -335
  38. package/src/db/job-executions.ts +145 -100
  39. package/src/db/reservations.ts +6 -3
  40. package/src/db/runner-instances.test.ts +100 -0
  41. package/src/index.test.ts +1 -0
  42. package/src/index.ts +8 -7
  43. package/src/presentation/index.ts +2 -1
  44. package/src/presentation/inter-module.ts +2 -40
  45. package/src/presentation/subscribers/on-workflows-job-execution-queued.test.ts +57 -0
  46. package/src/presentation/subscribers/on-workflows-job-execution-queued.ts +26 -0
  47. package/src/presentation/subscribers/on-workflows-job-execution-terminated.test.ts +142 -0
  48. package/src/presentation/subscribers/{on-workflows-job-execution-timed-out.ts → on-workflows-job-execution-terminated.ts} +5 -4
  49. package/test/factories/pending-job.ts +3 -0
  50. package/tsconfig.build.tsbuildinfo +1 -1
  51. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts +0 -3
  52. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts.map +0 -1
  53. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +0 -1
  54. package/src/presentation/inter-module.test.ts +0 -15
  55. 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.7.0",
4
+ "version": "13.1.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.7.0",
27
+ "@shipfox/api-runners-dto": "13.0.0",
28
+ "@shipfox/api-workflows-dto": "13.1.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,7 +21,6 @@ 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';
@@ -70,6 +65,7 @@ describe('enqueueJobExecution', () => {
70
65
  const workflowRunAttemptId = crypto.randomUUID();
71
66
  const workspaceId = crypto.randomUUID();
72
67
  const projectId = crypto.randomUUID();
68
+ const queuedAt = new Date('2026-08-11T08:00:00.000Z');
73
69
 
74
70
  await enqueueJobExecution({
75
71
  workspaceId,
@@ -79,6 +75,7 @@ describe('enqueueJobExecution', () => {
79
75
  workflowRunAttemptId,
80
76
  projectId,
81
77
  requiredLabels: ['linux'],
78
+ queuedAt,
82
79
  });
83
80
 
84
81
  const rows = await db()
@@ -93,6 +90,7 @@ describe('enqueueJobExecution', () => {
93
90
  expect(rows[0]?.projectId).toBe(projectId);
94
91
  expect(rows[0]?.workspaceId).toBe(workspaceId);
95
92
  expect(rows[0]?.requiredLabels).toEqual(['linux']);
93
+ expect(rows[0]?.createdAt).toEqual(queuedAt);
96
94
  expect(rows[0]).not.toHaveProperty('payload');
97
95
  });
98
96
 
@@ -108,6 +106,7 @@ describe('enqueueJobExecution', () => {
108
106
  workflowRunAttemptId: crypto.randomUUID(),
109
107
  projectId: crypto.randomUUID(),
110
108
  requiredLabels: ['Ubuntu22', ' ubuntu22 ', 'LINUX'],
109
+ queuedAt: new Date(),
111
110
  });
112
111
 
113
112
  const rows = await db()
@@ -127,6 +126,7 @@ describe('enqueueJobExecution', () => {
127
126
  workflowRunAttemptId: crypto.randomUUID(),
128
127
  projectId: crypto.randomUUID(),
129
128
  requiredLabels: [],
129
+ queuedAt: new Date(),
130
130
  }),
131
131
  ).rejects.toBeInstanceOf(EmptyRequiredLabelsError);
132
132
  });
@@ -141,6 +141,7 @@ describe('enqueueJobExecution', () => {
141
141
  jobId,
142
142
  jobExecutionId: crypto.randomUUID(),
143
143
  requiredLabels: ['linux'],
144
+ queuedAt: new Date(),
144
145
  };
145
146
 
146
147
  await enqueueJobExecution(params);
@@ -152,63 +153,6 @@ describe('enqueueJobExecution', () => {
152
153
  .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
153
154
  expect(rows).toHaveLength(1);
154
155
  });
155
-
156
- it('emits runners.job.queued carrying the pending row created_at', async () => {
157
- const jobId = crypto.randomUUID();
158
- const workflowRunId = crypto.randomUUID();
159
- const workflowRunAttemptId = crypto.randomUUID();
160
-
161
- await enqueueJobExecution({
162
- workspaceId: crypto.randomUUID(),
163
- workflowRunId,
164
- jobId,
165
- jobExecutionId: crypto.randomUUID(),
166
- workflowRunAttemptId,
167
- projectId: crypto.randomUUID(),
168
- requiredLabels: ['linux'],
169
- });
170
-
171
- const [pending] = await db()
172
- .select()
173
- .from(pendingJobExecutions)
174
- .where(eq(pendingJobExecutions.jobId, jobId));
175
- const outbox = await outboxEventsForJob(RUNNER_JOB_QUEUED, jobId);
176
- expect(outbox).toHaveLength(1);
177
- expect(outbox[0]?.eventType).toBe(RUNNER_JOB_QUEUED);
178
- const payload = outbox[0]?.payload as {
179
- jobId: string;
180
- workflowRunId: string;
181
- workflowRunAttemptId: string;
182
- queuedAt: string;
183
- };
184
- expect(payload.jobId).toBe(jobId);
185
- expect(payload.workflowRunId).toBe(workflowRunId);
186
- expect(payload.workflowRunAttemptId).toBe(workflowRunAttemptId);
187
- expect(new Date(payload.queuedAt).getTime()).toBe(pending?.createdAt.getTime());
188
- });
189
-
190
- it('does not double-emit queued when the same jobId is re-enqueued (idempotency regression)', async () => {
191
- const params = {
192
- workspaceId: crypto.randomUUID(),
193
- workflowRunId: crypto.randomUUID(),
194
- workflowRunAttemptId: crypto.randomUUID(),
195
- projectId: crypto.randomUUID(),
196
- jobId: crypto.randomUUID(),
197
- jobExecutionId: crypto.randomUUID(),
198
- requiredLabels: ['linux'],
199
- };
200
-
201
- await enqueueJobExecution(params);
202
- await enqueueJobExecution(params);
203
-
204
- expect(
205
- await db()
206
- .select()
207
- .from(pendingJobExecutions)
208
- .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId)),
209
- ).toHaveLength(1);
210
- expect(await outboxEventsForJob(RUNNER_JOB_QUEUED, params.jobId)).toHaveLength(1);
211
- });
212
156
  });
213
157
 
214
158
  describe('claimPendingJobExecution', () => {
@@ -538,6 +482,79 @@ describe('claimPendingJobExecution', () => {
538
482
  expect(runner?.firstClaimedAt).toBeInstanceOf(Date);
539
483
  });
540
484
 
485
+ it('releases a provisioned runner reservation on its first claim only', async () => {
486
+ const provisionerId = crypto.randomUUID();
487
+ const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
488
+ const [reservation] = await db()
489
+ .insert(reservations)
490
+ .values({
491
+ workspaceId,
492
+ provisionerId,
493
+ requiredLabels: sessionLabels,
494
+ count: 2,
495
+ expiresAt: new Date(Date.now() + 60_000),
496
+ })
497
+ .returning({id: reservations.id});
498
+ if (!reservation) throw new Error('Expected reservation');
499
+
500
+ const runner = await providerRunnerFactory.create({
501
+ workspaceId,
502
+ provisionerId,
503
+ providerRunnerId,
504
+ reservationId: reservation.id,
505
+ runnerSessionId,
506
+ state: 'running',
507
+ labels: sessionLabels,
508
+ });
509
+ await db()
510
+ .update(runnerSessions)
511
+ .set({
512
+ registrationTokenKind: 'ephemeral',
513
+ maxClaims: 2,
514
+ provisionerId,
515
+ providerRunnerId,
516
+ })
517
+ .where(eq(runnerSessions.id, runnerSessionId));
518
+
519
+ const firstPending = await pendingJobFactory.create({workspaceId});
520
+ const firstClaim = await claimPendingJobExecution({
521
+ workspaceId,
522
+ runnerSessionId,
523
+ maxClaims: 2,
524
+ });
525
+ const [afterFirstClaim] = await db()
526
+ .select({count: reservations.count})
527
+ .from(reservations)
528
+ .where(eq(reservations.id, reservation.id));
529
+ const [runnerAfterFirstClaim] = await db()
530
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
531
+ .from(providerRunners)
532
+ .where(eq(providerRunners.id, runner.id));
533
+ const firstReleaseAt = runnerAfterFirstClaim?.reservationReleasedAt;
534
+
535
+ const secondPending = await pendingJobFactory.create({workspaceId});
536
+ const secondClaim = await claimPendingJobExecution({
537
+ workspaceId,
538
+ runnerSessionId,
539
+ maxClaims: 2,
540
+ });
541
+ const [afterSecondClaim] = await db()
542
+ .select({count: reservations.count})
543
+ .from(reservations)
544
+ .where(eq(reservations.id, reservation.id));
545
+ const [runnerAfterSecondClaim] = await db()
546
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
547
+ .from(providerRunners)
548
+ .where(eq(providerRunners.id, runner.id));
549
+
550
+ expect(firstClaim?.jobExecutionId).toBe(firstPending.jobExecutionId);
551
+ expect(afterFirstClaim?.count).toBe(1);
552
+ expect(firstReleaseAt).toBeInstanceOf(Date);
553
+ expect(secondClaim?.jobExecutionId).toBe(secondPending.jobExecutionId);
554
+ expect(afterSecondClaim?.count).toBe(1);
555
+ expect(runnerAfterSecondClaim?.reservationReleasedAt).toEqual(firstReleaseAt);
556
+ });
557
+
541
558
  it('allows a manual session to claim repeatedly', async () => {
542
559
  const first = await pendingJobFactory.create({workspaceId});
543
560
  const second = await pendingJobFactory.create({workspaceId});
@@ -827,7 +844,7 @@ describe('claimPendingJobExecution', () => {
827
844
  expect(running[0]?.jobId).toBe(created.jobId);
828
845
  });
829
846
 
830
- it('leaves a non-matching orphan unclaimed until release sweeps it', async () => {
847
+ it('leaves a non-matching orphan unclaimed until terminal reconciliation sweeps it', async () => {
831
848
  const created = await pendingJobFactory.create({workspaceId});
832
849
  const first = await claimPendingJobExecution({workspaceId, runnerSessionId});
833
850
  if (!first) throw new Error('Expected pending job to be claimed');
@@ -847,15 +864,15 @@ describe('claimPendingJobExecution', () => {
847
864
  runnerSessionId,
848
865
  sessionLabels: ['macos'],
849
866
  });
850
- await releaseJobExecution({jobExecutionId: created.jobExecutionId});
867
+ await reconcileTerminalJobExecution({jobExecutionId: created.jobExecutionId});
851
868
 
852
869
  expect(second).toBeNull();
853
- expect(
854
- await db()
855
- .select()
856
- .from(runningJobExecutions)
857
- .where(eq(runningJobExecutions.workspaceId, workspaceId)),
858
- ).toHaveLength(0);
870
+ const [running] = await db()
871
+ .select()
872
+ .from(runningJobExecutions)
873
+ .where(eq(runningJobExecutions.workspaceId, workspaceId));
874
+ expect(running?.jobExecutionId).toBe(created.jobExecutionId);
875
+ expect(running?.cancellationRequestedAt).not.toBeNull();
859
876
  expect(
860
877
  await db()
861
878
  .select()
@@ -936,206 +953,6 @@ describe('claimJobExecution', () => {
936
953
  });
937
954
  });
938
955
 
939
- describe('releaseJobExecution', () => {
940
- let workspaceId: string;
941
- let runnerSessionId: string;
942
-
943
- beforeEach(async () => {
944
- workspaceId = crypto.randomUUID();
945
- const runnerSession = await runnerSessionFactory.create({workspaceId});
946
- runnerSessionId = runnerSession.id;
947
- });
948
-
949
- it('deletes the running row and writes no outbox event', async () => {
950
- await pendingJobFactory.create({workspaceId});
951
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
952
- const before = await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string);
953
-
954
- await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
955
-
956
- expect(
957
- await db()
958
- .select()
959
- .from(runningJobExecutions)
960
- .where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
961
- ).toHaveLength(0);
962
- expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string)).toHaveLength(
963
- before.length,
964
- );
965
- });
966
-
967
- it('is a no-op when the job is absent (idempotent)', async () => {
968
- await expect(
969
- releaseJobExecution({jobExecutionId: crypto.randomUUID()}),
970
- ).resolves.toBeUndefined();
971
- });
972
-
973
- it('releases a terminal runner reservation after deleting its final lease', async () => {
974
- const provisionerId = crypto.randomUUID();
975
- const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
976
- const [reservation] = await db()
977
- .insert(reservations)
978
- .values({
979
- workspaceId,
980
- provisionerId,
981
- requiredLabels: sessionLabels,
982
- count: 1,
983
- expiresAt: new Date(Date.now() + 60_000),
984
- })
985
- .returning({id: reservations.id});
986
- if (!reservation) throw new Error('Expected reservation');
987
-
988
- await db()
989
- .update(runnerSessions)
990
- .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
991
- .where(eq(runnerSessions.id, runnerSessionId));
992
- await db().insert(providerRunners).values({
993
- workspaceId,
994
- provisionerId,
995
- providerRunnerId,
996
- reservationId: reservation.id,
997
- runnerSessionId,
998
- state: 'terminated',
999
- reportedAt: new Date(),
1000
- terminatedAt: new Date(),
1001
- labels: sessionLabels,
1002
- });
1003
-
1004
- const created = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
1005
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
1006
- expect(claimed?.jobExecutionId).toBe(created.jobExecutionId);
1007
-
1008
- await releaseJobExecution({jobExecutionId: created.jobExecutionId});
1009
-
1010
- expect(
1011
- await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1012
- ).toHaveLength(0);
1013
- const [runner] = await db()
1014
- .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
1015
- .from(providerRunners)
1016
- .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1017
- expect(runner?.reservationReleasedAt).toEqual(expect.any(Date));
1018
- });
1019
-
1020
- it('rechecks terminal state after a concurrent terminal projection commits', async () => {
1021
- const provisionerId = crypto.randomUUID();
1022
- const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
1023
- const [reservation] = await db()
1024
- .insert(reservations)
1025
- .values({
1026
- workspaceId,
1027
- provisionerId,
1028
- requiredLabels: sessionLabels,
1029
- count: 1,
1030
- expiresAt: new Date(Date.now() + 60_000),
1031
- })
1032
- .returning({id: reservations.id});
1033
- if (!reservation) throw new Error('Expected reservation');
1034
-
1035
- await db()
1036
- .update(runnerSessions)
1037
- .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
1038
- .where(eq(runnerSessions.id, runnerSessionId));
1039
- await db().insert(providerRunners).values({
1040
- workspaceId,
1041
- provisionerId,
1042
- providerRunnerId,
1043
- reservationId: reservation.id,
1044
- runnerSessionId,
1045
- state: 'running',
1046
- reportedAt: new Date(),
1047
- labels: sessionLabels,
1048
- });
1049
-
1050
- const created = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
1051
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
1052
- expect(claimed?.jobExecutionId).toBe(created.jobExecutionId);
1053
-
1054
- const terminalProjectionReady = deferred<void>();
1055
- const releaseTerminalProjection = deferred<void>();
1056
- const terminalProjection = db().transaction(async (tx) => {
1057
- await tx
1058
- .update(providerRunners)
1059
- .set({state: 'failed', failedAt: new Date(), updatedAt: new Date()})
1060
- .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1061
- terminalProjectionReady.resolve();
1062
- await releaseTerminalProjection.promise;
1063
- });
1064
-
1065
- await terminalProjectionReady.promise;
1066
- const cleanup = releaseJobExecution({jobExecutionId: created.jobExecutionId});
1067
- try {
1068
- await waitForLockWait({queryLike: '%runner_instances%'});
1069
-
1070
- expect(
1071
- await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1072
- ).toHaveLength(1);
1073
- } finally {
1074
- releaseTerminalProjection.resolve();
1075
- await Promise.all([terminalProjection, cleanup]);
1076
- }
1077
-
1078
- expect(
1079
- await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1080
- ).toHaveLength(0);
1081
- const [runner] = await db()
1082
- .select({
1083
- reservationReleasedAt: providerRunners.reservationReleasedAt,
1084
- state: providerRunners.state,
1085
- })
1086
- .from(providerRunners)
1087
- .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1088
- expect(runner).toMatchObject({state: 'failed', reservationReleasedAt: expect.any(Date)});
1089
- });
1090
-
1091
- it('releases regardless of which session holds the lease', async () => {
1092
- await pendingJobFactory.create({workspaceId});
1093
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1094
-
1095
- // No token is passed: the workflow is authoritative over the lease.
1096
- await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
1097
-
1098
- expect(
1099
- await db()
1100
- .select()
1101
- .from(runningJobExecutions)
1102
- .where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
1103
- ).toHaveLength(0);
1104
- });
1105
-
1106
- it('also sweeps a lingering pending row for the same job', async () => {
1107
- await pendingJobFactory.create({workspaceId});
1108
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1109
- // An orphan pending row left by a post-claim enqueue retry.
1110
- await db()
1111
- .insert(pendingJobExecutions)
1112
- .values({
1113
- workspaceId,
1114
- workflowRunId: claimed?.workflowRunId as string,
1115
- jobId: claimed?.jobId as string,
1116
- jobExecutionId: claimed?.jobExecutionId as string,
1117
- workflowRunAttemptId: claimed?.workflowRunAttemptId as string,
1118
- projectId: claimed?.projectId as string,
1119
- requiredLabels: ['linux'],
1120
- });
1121
-
1122
- await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
1123
-
1124
- expect(
1125
- await db()
1126
- .select()
1127
- .from(runningJobExecutions)
1128
- .where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
1129
- ).toHaveLength(0);
1130
- expect(
1131
- await db()
1132
- .select()
1133
- .from(pendingJobExecutions)
1134
- .where(eq(pendingJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
1135
- ).toHaveLength(0);
1136
- });
1137
- });
1138
-
1139
956
  describe('recordHeartbeat', () => {
1140
957
  let workspaceId: string;
1141
958
  let runnerSessionId: string;
@@ -1293,6 +1110,68 @@ describe('reconcileTerminalJobExecution', () => {
1293
1110
  expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
1294
1111
  });
1295
1112
 
1113
+ it('does not double-release a reservation when a claimed runner becomes terminal', async () => {
1114
+ const provisionerId = crypto.randomUUID();
1115
+ const providerRunnerId = crypto.randomUUID();
1116
+ const [reservation] = await db()
1117
+ .insert(reservations)
1118
+ .values({
1119
+ workspaceId,
1120
+ provisionerId,
1121
+ requiredLabels: sessionLabels,
1122
+ count: 2,
1123
+ expiresAt: new Date(Date.now() + 60_000),
1124
+ })
1125
+ .returning({id: reservations.id});
1126
+ if (!reservation) throw new Error('Expected reservation');
1127
+ await providerRunnerFactory.create({
1128
+ workspaceId,
1129
+ provisionerId,
1130
+ providerRunnerId,
1131
+ reservationId: reservation.id,
1132
+ runnerSessionId,
1133
+ state: 'running',
1134
+ });
1135
+ await db()
1136
+ .update(runnerSessions)
1137
+ .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
1138
+ .where(eq(runnerSessions.id, runnerSessionId));
1139
+ const pending = await pendingJobFactory.create({workspaceId});
1140
+ const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
1141
+ expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
1142
+ const [afterClaim] = await db()
1143
+ .select({count: reservations.count})
1144
+ .from(reservations)
1145
+ .where(eq(reservations.id, reservation.id));
1146
+ const [runnerAfterClaim] = await db()
1147
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
1148
+ .from(providerRunners)
1149
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1150
+ expect(afterClaim?.count).toBe(1);
1151
+ expect(runnerAfterClaim?.reservationReleasedAt).toBeInstanceOf(Date);
1152
+ await db()
1153
+ .update(providerRunners)
1154
+ .set({state: 'terminated', terminatedAt: new Date()})
1155
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1156
+ await db()
1157
+ .update(runningJobExecutions)
1158
+ .set({provisionerId, providerRunnerId})
1159
+ .where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
1160
+
1161
+ await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1162
+
1163
+ const [afterTerminal] = await db()
1164
+ .select({count: reservations.count})
1165
+ .from(reservations)
1166
+ .where(eq(reservations.id, reservation.id));
1167
+ expect(afterTerminal?.count).toBe(1);
1168
+ const [runner] = await db()
1169
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
1170
+ .from(providerRunners)
1171
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1172
+ expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
1173
+ });
1174
+
1296
1175
  it('is idempotent: second call preserves the first timestamp', async () => {
1297
1176
  const pending = await pendingJobFactory.create({workspaceId});
1298
1177
  const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
@@ -1454,68 +1333,6 @@ async function waitForLockWait(params: {queryLike: string}) {
1454
1333
  throw new Error(`Timed out waiting for lock waiter matching ${params.queryLike}`);
1455
1334
  }
1456
1335
 
1457
- describe('cancelRunnerJobs', () => {
1458
- let workspaceId: string;
1459
- let runnerSessionId: string;
1460
-
1461
- beforeEach(async () => {
1462
- workspaceId = crypto.randomUUID();
1463
- const runnerSession = await runnerSessionFactory.create({workspaceId});
1464
- runnerSessionId = runnerSession.id;
1465
- });
1466
-
1467
- it('deletes queued jobs and requests cancellation for running jobs', async () => {
1468
- const running = await pendingJobFactory.create({workspaceId});
1469
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1470
- const queued = await pendingJobFactory.create({workspaceId});
1471
-
1472
- await cancelRunnerJobs({jobIds: [queued.jobId, claimed?.jobId as string]});
1473
-
1474
- expect(
1475
- await db()
1476
- .select()
1477
- .from(pendingJobExecutions)
1478
- .where(eq(pendingJobExecutions.workspaceId, workspaceId)),
1479
- ).toHaveLength(0);
1480
- const rows = await db()
1481
- .select()
1482
- .from(runningJobExecutions)
1483
- .where(eq(runningJobExecutions.workspaceId, workspaceId));
1484
- expect(rows).toHaveLength(1);
1485
- expect(rows[0]?.jobId).toBe(running.jobId);
1486
- expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
1487
- });
1488
-
1489
- it('is idempotent and no-ops for absent jobs', async () => {
1490
- const queued = await pendingJobFactory.create({workspaceId});
1491
-
1492
- await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
1493
- await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
1494
-
1495
- expect(
1496
- await db()
1497
- .select()
1498
- .from(pendingJobExecutions)
1499
- .where(eq(pendingJobExecutions.workspaceId, workspaceId)),
1500
- ).toHaveLength(0);
1501
- expect(
1502
- await db()
1503
- .select()
1504
- .from(runningJobExecutions)
1505
- .where(eq(runningJobExecutions.workspaceId, workspaceId)),
1506
- ).toHaveLength(0);
1507
- });
1508
-
1509
- it('prevents a cancelled queued job from being claimed', async () => {
1510
- const queued = await pendingJobFactory.create({workspaceId});
1511
-
1512
- await cancelRunnerJobs({jobIds: [queued.jobId]});
1513
-
1514
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1515
- expect(claimed).toBeNull();
1516
- });
1517
- });
1518
-
1519
1336
  describe('detectAndExpireStuckJobs', () => {
1520
1337
  let workspaceId: string;
1521
1338
  let runnerSessionId: string;
@@ -1613,6 +1430,50 @@ describe('detectAndExpireStuckJobs', () => {
1613
1430
  expect(payload.steps).toBeUndefined();
1614
1431
  });
1615
1432
 
1433
+ it('releases a terminal runner reservation when its stuck lease is reaped', async () => {
1434
+ const stale = await makeStaleJob(600);
1435
+ const provisionerId = crypto.randomUUID();
1436
+ const providerRunnerId = crypto.randomUUID();
1437
+ const [reservation] = await db()
1438
+ .insert(reservations)
1439
+ .values({
1440
+ workspaceId,
1441
+ provisionerId,
1442
+ requiredLabels: ['linux'],
1443
+ count: 1,
1444
+ expiresAt: new Date(Date.now() + 60_000),
1445
+ })
1446
+ .returning();
1447
+ if (!reservation) throw new Error('Expected reservation');
1448
+ await providerRunnerFactory.create({
1449
+ workspaceId,
1450
+ provisionerId,
1451
+ providerRunnerId,
1452
+ reservationId: reservation.id,
1453
+ runnerSessionId,
1454
+ state: 'terminated',
1455
+ terminatedAt: new Date(),
1456
+ });
1457
+ await db()
1458
+ .update(runningJobExecutions)
1459
+ .set({provisionerId, providerRunnerId})
1460
+ .where(eq(runningJobExecutions.jobExecutionId, stale.jobExecutionId));
1461
+
1462
+ await expireStuckJobExecutions({
1463
+ noFirstHeartbeatGraceSeconds: 60,
1464
+ thresholdSeconds: 180,
1465
+ });
1466
+
1467
+ expect(
1468
+ await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1469
+ ).toHaveLength(0);
1470
+ const [runner] = await db()
1471
+ .select()
1472
+ .from(providerRunners)
1473
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1474
+ expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
1475
+ });
1476
+
1616
1477
  it('does not requeue an execution after its lease has expired', async () => {
1617
1478
  const stale = await makeStaleJob(600);
1618
1479
 
@@ -1629,6 +1490,7 @@ describe('detectAndExpireStuckJobs', () => {
1629
1490
  jobExecutionId: stale.jobExecutionId,
1630
1491
  projectId: stale.projectId,
1631
1492
  requiredLabels: ['linux'],
1493
+ queuedAt: new Date(),
1632
1494
  });
1633
1495
 
1634
1496
  expect(
@@ -1780,7 +1642,7 @@ describe('detectAndExpireStuckJobs', () => {
1780
1642
  expect(await outboxForJobs([jobId])).toHaveLength(1);
1781
1643
  });
1782
1644
 
1783
- it('sweeps an orphan pending row for the job it reaps (best-effort release may have failed)', async () => {
1645
+ it('sweeps an orphan pending row for the job it reaps', async () => {
1784
1646
  const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
1785
1647
  await makeStaleJob(600);
1786
1648
  // A post-claim enqueue retry left a pending row whose job is already running;