@shipfox/api-runners 12.7.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 (55) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +12 -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 +62 -81
  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 +105 -335
  38. package/src/db/job-executions.ts +70 -91
  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.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.7.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,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', () => {
@@ -827,7 +771,7 @@ describe('claimPendingJobExecution', () => {
827
771
  expect(running[0]?.jobId).toBe(created.jobId);
828
772
  });
829
773
 
830
- 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 () => {
831
775
  const created = await pendingJobFactory.create({workspaceId});
832
776
  const first = await claimPendingJobExecution({workspaceId, runnerSessionId});
833
777
  if (!first) throw new Error('Expected pending job to be claimed');
@@ -847,15 +791,15 @@ describe('claimPendingJobExecution', () => {
847
791
  runnerSessionId,
848
792
  sessionLabels: ['macos'],
849
793
  });
850
- await releaseJobExecution({jobExecutionId: created.jobExecutionId});
794
+ await reconcileTerminalJobExecution({jobExecutionId: created.jobExecutionId});
851
795
 
852
796
  expect(second).toBeNull();
853
- expect(
854
- await db()
855
- .select()
856
- .from(runningJobExecutions)
857
- .where(eq(runningJobExecutions.workspaceId, workspaceId)),
858
- ).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();
859
803
  expect(
860
804
  await db()
861
805
  .select()
@@ -936,206 +880,6 @@ describe('claimJobExecution', () => {
936
880
  });
937
881
  });
938
882
 
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
883
  describe('recordHeartbeat', () => {
1140
884
  let workspaceId: string;
1141
885
  let runnerSessionId: string;
@@ -1293,6 +1037,49 @@ describe('reconcileTerminalJobExecution', () => {
1293
1037
  expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
1294
1038
  });
1295
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
+
1296
1083
  it('is idempotent: second call preserves the first timestamp', async () => {
1297
1084
  const pending = await pendingJobFactory.create({workspaceId});
1298
1085
  const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
@@ -1454,68 +1241,6 @@ async function waitForLockWait(params: {queryLike: string}) {
1454
1241
  throw new Error(`Timed out waiting for lock waiter matching ${params.queryLike}`);
1455
1242
  }
1456
1243
 
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
1244
  describe('detectAndExpireStuckJobs', () => {
1520
1245
  let workspaceId: string;
1521
1246
  let runnerSessionId: string;
@@ -1613,6 +1338,50 @@ describe('detectAndExpireStuckJobs', () => {
1613
1338
  expect(payload.steps).toBeUndefined();
1614
1339
  });
1615
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
+
1616
1385
  it('does not requeue an execution after its lease has expired', async () => {
1617
1386
  const stale = await makeStaleJob(600);
1618
1387
 
@@ -1629,6 +1398,7 @@ describe('detectAndExpireStuckJobs', () => {
1629
1398
  jobExecutionId: stale.jobExecutionId,
1630
1399
  projectId: stale.projectId,
1631
1400
  requiredLabels: ['linux'],
1401
+ queuedAt: new Date(),
1632
1402
  });
1633
1403
 
1634
1404
  expect(
@@ -1780,7 +1550,7 @@ describe('detectAndExpireStuckJobs', () => {
1780
1550
  expect(await outboxForJobs([jobId])).toHaveLength(1);
1781
1551
  });
1782
1552
 
1783
- 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 () => {
1784
1554
  const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
1785
1555
  await makeStaleJob(600);
1786
1556
  // A post-claim enqueue retry left a pending row whose job is already running;