trigger.dev 4.0.0-v4-beta.11 → 4.0.0-v4-beta.13

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 (67) hide show
  1. package/dist/esm/build/bundle.d.ts +4 -0
  2. package/dist/esm/build/bundle.js +8 -1
  3. package/dist/esm/build/bundle.js.map +1 -1
  4. package/dist/esm/cli/common.d.ts +1 -1
  5. package/dist/esm/cli/common.js +4 -0
  6. package/dist/esm/cli/common.js.map +1 -1
  7. package/dist/esm/commands/deploy.d.ts +1 -1
  8. package/dist/esm/commands/deploy.js +58 -11
  9. package/dist/esm/commands/deploy.js.map +1 -1
  10. package/dist/esm/commands/dev.js +1 -1
  11. package/dist/esm/commands/dev.js.map +1 -1
  12. package/dist/esm/commands/init.d.ts +1 -1
  13. package/dist/esm/commands/list-profiles.d.ts +1 -1
  14. package/dist/esm/commands/login.d.ts +1 -1
  15. package/dist/esm/commands/logout.d.ts +1 -1
  16. package/dist/esm/commands/promote.d.ts +1 -1
  17. package/dist/esm/commands/switch.d.ts +1 -1
  18. package/dist/esm/commands/trigger.d.ts +1 -1
  19. package/dist/esm/commands/whoami.d.ts +1 -1
  20. package/dist/esm/dev/devOutput.js +30 -1
  21. package/dist/esm/dev/devOutput.js.map +1 -1
  22. package/dist/esm/dev/devSession.js +27 -16
  23. package/dist/esm/dev/devSession.js.map +1 -1
  24. package/dist/esm/entryPoints/dev-run-worker.js +32 -21
  25. package/dist/esm/entryPoints/dev-run-worker.js.map +1 -1
  26. package/dist/esm/entryPoints/managed/controller.d.ts +5 -2
  27. package/dist/esm/entryPoints/managed/controller.js +43 -74
  28. package/dist/esm/entryPoints/managed/controller.js.map +1 -1
  29. package/dist/esm/entryPoints/managed/env.d.ts +10 -15
  30. package/dist/esm/entryPoints/managed/env.js +14 -12
  31. package/dist/esm/entryPoints/managed/env.js.map +1 -1
  32. package/dist/esm/entryPoints/managed/execution.d.ts +52 -16
  33. package/dist/esm/entryPoints/managed/execution.js +348 -223
  34. package/dist/esm/entryPoints/managed/execution.js.map +1 -1
  35. package/dist/esm/entryPoints/managed/logger.d.ts +15 -5
  36. package/dist/esm/entryPoints/managed/logger.js +19 -6
  37. package/dist/esm/entryPoints/managed/logger.js.map +1 -1
  38. package/dist/esm/entryPoints/managed/notifier.d.ts +30 -0
  39. package/dist/esm/entryPoints/managed/notifier.js +63 -0
  40. package/dist/esm/entryPoints/managed/notifier.js.map +1 -0
  41. package/dist/esm/entryPoints/managed/overrides.d.ts +3 -1
  42. package/dist/esm/entryPoints/managed/overrides.js +5 -3
  43. package/dist/esm/entryPoints/managed/overrides.js.map +1 -1
  44. package/dist/esm/entryPoints/managed/poller.d.ts +12 -8
  45. package/dist/esm/entryPoints/managed/poller.js +34 -29
  46. package/dist/esm/entryPoints/managed/poller.js.map +1 -1
  47. package/dist/esm/entryPoints/managed/snapshot.d.ts +54 -0
  48. package/dist/esm/entryPoints/managed/snapshot.js +293 -0
  49. package/dist/esm/entryPoints/managed/snapshot.js.map +1 -0
  50. package/dist/esm/entryPoints/managed-run-worker.js +31 -20
  51. package/dist/esm/entryPoints/managed-run-worker.js.map +1 -1
  52. package/dist/esm/executions/taskRunProcess.d.ts +9 -13
  53. package/dist/esm/executions/taskRunProcess.js +47 -68
  54. package/dist/esm/executions/taskRunProcess.js.map +1 -1
  55. package/dist/esm/utilities/cliOutput.d.ts +6 -1
  56. package/dist/esm/utilities/cliOutput.js +10 -1
  57. package/dist/esm/utilities/cliOutput.js.map +1 -1
  58. package/dist/esm/utilities/eventBus.d.ts +1 -0
  59. package/dist/esm/utilities/eventBus.js.map +1 -1
  60. package/dist/esm/utilities/supportsHyperlinks.d.ts +15 -0
  61. package/dist/esm/utilities/supportsHyperlinks.js +122 -0
  62. package/dist/esm/utilities/supportsHyperlinks.js.map +1 -0
  63. package/dist/esm/utilities/terminalLink.d.ts +56 -0
  64. package/dist/esm/utilities/terminalLink.js +76 -0
  65. package/dist/esm/utilities/terminalLink.js.map +1 -0
  66. package/dist/esm/version.js +1 -1
  67. package/package.json +10 -4
@@ -5,6 +5,8 @@ import { RunExecutionSnapshotPoller } from "./poller.js";
5
5
  import { assertExhaustive, tryCatch } from "@trigger.dev/core/utils";
6
6
  import { MetadataClient } from "./overrides.js";
7
7
  import { randomBytes } from "node:crypto";
8
+ import { SnapshotManager } from "./snapshot.js";
9
+ import { RunNotifier } from "./notifier.js";
8
10
  class ExecutionAbortError extends Error {
9
11
  constructor(message) {
10
12
  super(message);
@@ -15,9 +17,9 @@ export class RunExecution {
15
17
  id;
16
18
  executionAbortController;
17
19
  _runFriendlyId;
18
- currentSnapshotId;
19
20
  currentAttemptNumber;
20
21
  currentTaskRunEnv;
22
+ snapshotManager;
21
23
  dequeuedAt;
22
24
  podScheduledAt;
23
25
  workerManifest;
@@ -29,20 +31,50 @@ export class RunExecution {
29
31
  snapshotPoller;
30
32
  lastHeartbeat;
31
33
  isShuttingDown = false;
34
+ shutdownReason;
35
+ supervisorSocket;
36
+ notifier;
37
+ metadataClient;
32
38
  constructor(opts) {
33
39
  this.id = randomBytes(4).toString("hex");
34
40
  this.workerManifest = opts.workerManifest;
35
41
  this.env = opts.env;
36
42
  this.httpClient = opts.httpClient;
37
43
  this.logger = opts.logger;
44
+ this.supervisorSocket = opts.supervisorSocket;
38
45
  this.restoreCount = 0;
39
46
  this.executionAbortController = new AbortController();
47
+ if (this.env.TRIGGER_METADATA_URL) {
48
+ this.metadataClient = new MetadataClient(this.env.TRIGGER_METADATA_URL);
49
+ }
50
+ }
51
+ /**
52
+ * Cancels the current execution.
53
+ */
54
+ async cancel() {
55
+ if (this.isShuttingDown) {
56
+ throw new Error("cancel called after execution shut down");
57
+ }
58
+ this.sendDebugLog("cancelling attempt", { runId: this.runFriendlyId });
59
+ await this.taskRunProcess?.cancel();
60
+ }
61
+ /**
62
+ * Kills the current execution.
63
+ */
64
+ async kill({ exitExecution = true } = {}) {
65
+ await this.taskRunProcess?.kill("SIGKILL");
66
+ if (exitExecution) {
67
+ this.shutdown("kill");
68
+ }
40
69
  }
41
70
  /**
42
71
  * Prepares the execution with task run environment variables.
43
72
  * This should be called before executing, typically after a successful run to prepare for the next one.
44
73
  */
45
74
  prepareForExecution(opts) {
75
+ if (this.isShuttingDown) {
76
+ throw new Error("prepareForExecution called after execution shut down");
77
+ }
46
78
  if (this.taskRunProcess) {
47
79
  throw new Error("prepareForExecution called after process was already created");
48
80
  }
@@ -89,6 +121,12 @@ export class RunExecution {
89
121
  this.sendDebugLog("onTaskRunHeartbeat: failed", { error: error.message });
90
122
  }
91
123
  });
124
+ taskRunProcess.onSendDebugLog.attach(async (debugLog) => {
125
+ this.sendRuntimeDebugLog(debugLog.message, debugLog.properties);
126
+ });
127
+ taskRunProcess.onSetSuspendable.attach(async ({ suspendable }) => {
128
+ this.suspendable = suspendable;
129
+ });
92
130
  return taskRunProcess;
93
131
  }
94
132
  /**
@@ -103,81 +141,50 @@ export class RunExecution {
103
141
  }
104
142
  /**
105
143
  * Called by the RunController when it receives a websocket notification
106
- * or when the snapshot poller detects a change
144
+ * or when the snapshot poller detects a change.
145
+ *
146
+ * This is the main entry point for snapshot changes, but processing is deferred to the snapshot manager.
107
147
  */
108
- async handleSnapshotChange(runData) {
148
+ async enqueueSnapshotChangesAndWait(snapshots) {
109
149
  if (this.isShuttingDown) {
110
- this.sendDebugLog("handleSnapshotChange: shutting down, skipping");
111
- return;
112
- }
113
- const { run, snapshot, completedWaitpoints } = runData;
114
- const snapshotMetadata = {
115
- incomingRunId: run.friendlyId,
116
- incomingSnapshotId: snapshot.friendlyId,
117
- completedWaitpoints: completedWaitpoints.length,
118
- };
119
- // Ensure we have run details
120
- if (!this.runFriendlyId || !this.currentSnapshotId) {
121
- this.sendDebugLog("handleSnapshotChange: missing run or snapshot ID", snapshotMetadata, run.friendlyId);
150
+ this.sendDebugLog("enqueueSnapshotChangeAndWait: shutting down, skipping");
122
151
  return;
123
152
  }
124
- // Ensure the run ID matches
125
- if (run.friendlyId !== this.runFriendlyId) {
126
- // Send debug log to both runs
127
- this.sendDebugLog("handleSnapshotChange: mismatched run IDs", snapshotMetadata);
128
- this.sendDebugLog("handleSnapshotChange: mismatched run IDs", snapshotMetadata, run.friendlyId);
153
+ if (!this.snapshotManager) {
154
+ this.sendDebugLog("enqueueSnapshotChangeAndWait: missing snapshot manager");
129
155
  return;
130
156
  }
131
- this.snapshotChangeQueue.push(runData);
132
- await this.processSnapshotChangeQueue();
157
+ await this.snapshotManager.handleSnapshotChanges(snapshots);
133
158
  }
134
- snapshotChangeQueue = [];
135
- snapshotChangeQueueLock = false;
136
- async processSnapshotChangeQueue() {
137
- if (this.snapshotChangeQueueLock) {
138
- return;
139
- }
140
- this.snapshotChangeQueueLock = true;
141
- while (this.snapshotChangeQueue.length > 0) {
142
- const runData = this.snapshotChangeQueue.shift();
143
- if (!runData) {
144
- continue;
145
- }
146
- const [error] = await tryCatch(this.processSnapshotChange(runData));
147
- if (error) {
148
- this.sendDebugLog("Failed to process snapshot change", { error: error.message });
149
- }
150
- }
151
- this.snapshotChangeQueueLock = false;
152
- }
153
- async processSnapshotChange(runData) {
159
+ async processSnapshotChange(runData, deprecated) {
154
160
  const { run, snapshot, completedWaitpoints } = runData;
155
161
  const snapshotMetadata = {
156
162
  incomingSnapshotId: snapshot.friendlyId,
157
163
  completedWaitpoints: completedWaitpoints.length,
158
164
  };
159
- // Check if the incoming snapshot is newer than the current one
160
- if (!this.currentSnapshotId || snapshot.friendlyId < this.currentSnapshotId) {
161
- this.sendDebugLog("handleSnapshotChange: received older snapshot, skipping", snapshotMetadata);
162
- return;
163
- }
164
- if (snapshot.friendlyId === this.currentSnapshotId) {
165
+ if (!this.snapshotManager) {
166
+ this.sendDebugLog("handleSnapshotChange: missing snapshot manager", snapshotMetadata);
165
167
  return;
166
168
  }
167
169
  if (this.currentAttemptNumber && this.currentAttemptNumber !== run.attemptNumber) {
168
- this.sendDebugLog("ERROR: attempt number mismatch", snapshotMetadata);
169
- await this.taskRunProcess?.suspend();
170
+ this.sendDebugLog("error: attempt number mismatch", snapshotMetadata);
171
+ // This is a rogue execution, a new one will already have been created elsewhere
172
+ await this.exitTaskRunProcessWithoutFailingRun({ flush: false });
170
173
  return;
171
174
  }
172
- this.sendDebugLog(`snapshot has changed to: ${snapshot.executionStatus}`, snapshotMetadata);
175
+ // DO NOT REMOVE (very noisy, but helpful for debugging)
176
+ // this.sendDebugLog(`processing snapshot change: ${snapshot.executionStatus}`, snapshotMetadata);
173
177
  // Reset the snapshot poll interval so we don't do unnecessary work
174
- this.snapshotPoller?.resetCurrentInterval();
175
- // Update internal state
176
- this.currentSnapshotId = snapshot.friendlyId;
177
- // Update services
178
178
  this.snapshotPoller?.updateSnapshotId(snapshot.friendlyId);
179
+ this.snapshotPoller?.resetCurrentInterval();
180
+ if (deprecated) {
181
+ this.sendDebugLog("run execution is deprecated", { incomingSnapshot: snapshot });
182
+ await this.exitTaskRunProcessWithoutFailingRun({ flush: false });
183
+ return;
184
+ }
179
185
  switch (snapshot.executionStatus) {
180
186
  case "PENDING_CANCEL": {
187
+ this.sendDebugLog("run was cancelled", snapshotMetadata);
181
188
  const [error] = await tryCatch(this.cancel());
182
189
  if (error) {
183
190
  this.sendDebugLog("snapshot change: failed to cancel attempt", {
@@ -189,83 +196,37 @@ export class RunExecution {
189
196
  return;
190
197
  }
191
198
  case "QUEUED": {
192
- this.sendDebugLog("Run was re-queued", snapshotMetadata);
193
- // Pretend we've just suspended the run. This will kill the process without failing the run.
194
- await this.taskRunProcess?.suspend();
199
+ this.sendDebugLog("run was re-queued", snapshotMetadata);
200
+ await this.exitTaskRunProcessWithoutFailingRun({ flush: true });
195
201
  return;
196
202
  }
197
203
  case "FINISHED": {
198
- this.sendDebugLog("Run is finished", snapshotMetadata);
199
- // Pretend we've just suspended the run. This will kill the process without failing the run.
200
- await this.taskRunProcess?.suspend();
204
+ this.sendDebugLog("run is finished", snapshotMetadata);
205
+ await this.exitTaskRunProcessWithoutFailingRun({ flush: true });
201
206
  return;
202
207
  }
203
208
  case "QUEUED_EXECUTING":
204
209
  case "EXECUTING_WITH_WAITPOINTS": {
205
- this.sendDebugLog("Run is executing with waitpoints", snapshotMetadata);
206
- const [error] = await tryCatch(this.taskRunProcess?.cleanup(false));
207
- if (error) {
208
- this.sendDebugLog("Failed to cleanup task run process, carrying on", {
209
- ...snapshotMetadata,
210
- error: error.message,
211
- });
212
- }
213
- if (snapshot.friendlyId !== this.currentSnapshotId) {
214
- this.sendDebugLog("Snapshot changed after cleanup, abort", snapshotMetadata);
215
- this.abortExecution();
216
- return;
217
- }
218
- await sleep(this.env.TRIGGER_PRE_SUSPEND_WAIT_MS);
219
- if (snapshot.friendlyId !== this.currentSnapshotId) {
220
- this.sendDebugLog("Snapshot changed after suspend threshold, abort", snapshotMetadata);
221
- this.abortExecution();
222
- return;
223
- }
224
- if (!this.runFriendlyId || !this.currentSnapshotId) {
225
- this.sendDebugLog("handleSnapshotChange: Missing run ID or snapshot ID after suspension, abort", snapshotMetadata);
226
- this.abortExecution();
227
- return;
228
- }
229
- const suspendResult = await this.httpClient.suspendRun(this.runFriendlyId, this.currentSnapshotId);
230
- if (!suspendResult.success) {
231
- this.sendDebugLog("Failed to suspend run, staying alive 🎶", {
232
- ...snapshotMetadata,
233
- error: suspendResult.error,
234
- });
235
- this.sendDebugLog("checkpoint: suspend request failed", {
236
- ...snapshotMetadata,
237
- error: suspendResult.error,
238
- });
239
- // This is fine, we'll wait for the next status change
240
- return;
241
- }
242
- if (!suspendResult.data.ok) {
243
- this.sendDebugLog("checkpoint: failed to suspend run", {
244
- snapshotId: this.currentSnapshotId,
245
- error: suspendResult.data.error,
246
- });
247
- // This is fine, we'll wait for the next status change
248
- return;
249
- }
250
- this.sendDebugLog("Suspending, any day now 🚬", snapshotMetadata);
251
- // Wait for next status change
210
+ this.sendDebugLog("run is executing with waitpoints", snapshotMetadata);
211
+ // Wait for next status change - suspension is handled by the snapshot manager
252
212
  return;
253
213
  }
254
214
  case "SUSPENDED": {
255
- this.sendDebugLog("Run was suspended, kill the process", snapshotMetadata);
215
+ this.sendDebugLog("run was suspended", snapshotMetadata);
256
216
  // This will kill the process and fail the execution with a SuspendedProcessError
257
- await this.taskRunProcess?.suspend();
217
+ // We don't flush because we already did before suspending
218
+ await this.exitTaskRunProcessWithoutFailingRun({ flush: false });
258
219
  return;
259
220
  }
260
221
  case "PENDING_EXECUTING": {
261
- this.sendDebugLog("Run is pending execution", snapshotMetadata);
222
+ this.sendDebugLog("run is pending execution", snapshotMetadata);
262
223
  if (completedWaitpoints.length === 0) {
263
- this.sendDebugLog("No waitpoints to complete, nothing to do", snapshotMetadata);
224
+ this.sendDebugLog("no waitpoints to complete, nothing to do", snapshotMetadata);
264
225
  return;
265
226
  }
266
227
  const [error] = await tryCatch(this.restore());
267
228
  if (error) {
268
- this.sendDebugLog("Failed to restore execution", {
229
+ this.sendDebugLog("failed to restore execution", {
269
230
  ...snapshotMetadata,
270
231
  error: error.message,
271
232
  });
@@ -275,13 +236,13 @@ export class RunExecution {
275
236
  return;
276
237
  }
277
238
  case "EXECUTING": {
278
- this.sendDebugLog("Run is now executing", snapshotMetadata);
279
239
  if (completedWaitpoints.length === 0) {
240
+ this.sendDebugLog("run is executing without completed waitpoints", snapshotMetadata);
280
241
  return;
281
242
  }
282
- this.sendDebugLog("Processing completed waitpoints", snapshotMetadata);
243
+ this.sendDebugLog("run is executing with completed waitpoints", snapshotMetadata);
283
244
  if (!this.taskRunProcess) {
284
- this.sendDebugLog("No task run process, ignoring completed waitpoints", snapshotMetadata);
245
+ this.sendDebugLog("no task run process, ignoring completed waitpoints", snapshotMetadata);
285
246
  this.abortExecution();
286
247
  return;
287
248
  }
@@ -291,7 +252,7 @@ export class RunExecution {
291
252
  return;
292
253
  }
293
254
  case "RUN_CREATED": {
294
- this.sendDebugLog("Invalid status change", snapshotMetadata);
255
+ this.sendDebugLog("aborting execution: invalid status change: RUN_CREATED", snapshotMetadata);
295
256
  this.abortExecution();
296
257
  return;
297
258
  }
@@ -301,16 +262,16 @@ export class RunExecution {
301
262
  }
302
263
  }
303
264
  async startAttempt({ isWarmStart, }) {
304
- if (!this.runFriendlyId || !this.currentSnapshotId) {
305
- throw new Error("Cannot start attempt: missing run or snapshot ID");
265
+ if (!this.runFriendlyId || !this.snapshotManager) {
266
+ throw new Error("Cannot start attempt: missing run or snapshot manager");
306
267
  }
307
- this.sendDebugLog("Starting attempt");
268
+ this.sendDebugLog("starting attempt");
308
269
  const attemptStartedAt = Date.now();
309
270
  // Check for abort before each major async operation
310
271
  if (this.executionAbortController.signal.aborted) {
311
272
  throw new ExecutionAbortError("Execution aborted before start");
312
273
  }
313
- const start = await this.httpClient.startRunAttempt(this.runFriendlyId, this.currentSnapshotId, { isWarmStart });
274
+ const start = await this.httpClient.startRunAttempt(this.runFriendlyId, this.snapshotManager.snapshotId, { isWarmStart });
314
275
  if (this.executionAbortController.signal.aborted) {
315
276
  throw new ExecutionAbortError("Execution aborted after start");
316
277
  }
@@ -318,14 +279,14 @@ export class RunExecution {
318
279
  throw new Error(`Start API call failed: ${start.error}`);
319
280
  }
320
281
  // A snapshot was just created, so update the snapshot ID
321
- this.currentSnapshotId = start.data.snapshot.friendlyId;
282
+ this.snapshotManager.updateSnapshot(start.data.snapshot.friendlyId, start.data.snapshot.executionStatus);
322
283
  // Also set or update the attempt number - we do this to detect illegal attempt number changes, e.g. from stalled runners coming back online
323
284
  const attemptNumber = start.data.run.attemptNumber;
324
285
  if (attemptNumber && attemptNumber > 0) {
325
286
  this.currentAttemptNumber = attemptNumber;
326
287
  }
327
288
  else {
328
- this.sendDebugLog("ERROR: invalid attempt number returned from start attempt", {
289
+ this.sendDebugLog("error: invalid attempt number returned from start attempt", {
329
290
  attemptNumber: String(attemptNumber),
330
291
  });
331
292
  }
@@ -334,7 +295,7 @@ export class RunExecution {
334
295
  dequeuedAt: this.dequeuedAt?.getTime(),
335
296
  podScheduledAt: this.podScheduledAt?.getTime(),
336
297
  });
337
- this.sendDebugLog("Started attempt");
298
+ this.sendDebugLog("started attempt");
338
299
  return { ...start.data, metrics };
339
300
  }
340
301
  /**
@@ -342,34 +303,53 @@ export class RunExecution {
342
303
  * When this returns, the child process will have been cleaned up.
343
304
  */
344
305
  async execute(runOpts) {
306
+ if (this.isShuttingDown) {
307
+ throw new Error("execute called after execution shut down");
308
+ }
345
309
  // Setup initial state
346
310
  this.runFriendlyId = runOpts.runFriendlyId;
347
- this.currentSnapshotId = runOpts.snapshotFriendlyId;
311
+ // Create snapshot manager
312
+ this.snapshotManager = new SnapshotManager({
313
+ runFriendlyId: runOpts.runFriendlyId,
314
+ runnerId: this.env.TRIGGER_RUNNER_ID,
315
+ initialSnapshotId: runOpts.snapshotFriendlyId,
316
+ // We're just guessing here, but "PENDING_EXECUTING" is probably fine
317
+ initialStatus: "PENDING_EXECUTING",
318
+ logger: this.logger,
319
+ metadataClient: this.metadataClient,
320
+ onSnapshotChange: this.processSnapshotChange.bind(this),
321
+ onSuspendable: this.handleSuspendable.bind(this),
322
+ });
348
323
  this.dequeuedAt = runOpts.dequeuedAt;
349
324
  this.podScheduledAt = runOpts.podScheduledAt;
350
325
  // Create and start services
351
326
  this.snapshotPoller = new RunExecutionSnapshotPoller({
352
327
  runFriendlyId: this.runFriendlyId,
353
- snapshotFriendlyId: this.currentSnapshotId,
354
- httpClient: this.httpClient,
328
+ snapshotFriendlyId: this.snapshotManager.snapshotId,
355
329
  logger: this.logger,
356
330
  snapshotPollIntervalSeconds: this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS,
357
- handleSnapshotChange: this.handleSnapshotChange.bind(this),
358
- });
359
- this.snapshotPoller.start();
331
+ onPoll: this.fetchAndProcessSnapshotChanges.bind(this),
332
+ }).start();
333
+ this.notifier = new RunNotifier({
334
+ runFriendlyId: this.runFriendlyId,
335
+ supervisorSocket: this.supervisorSocket,
336
+ onNotify: this.fetchAndProcessSnapshotChanges.bind(this),
337
+ logger: this.logger,
338
+ }).start();
360
339
  const [startError, start] = await tryCatch(this.startAttempt({ isWarmStart: runOpts.isWarmStart }));
361
340
  if (startError) {
362
- this.sendDebugLog("Failed to start attempt", { error: startError.message });
363
- this.stopServices();
341
+ this.sendDebugLog("failed to start attempt", { error: startError.message });
342
+ this.shutdown("failed to start attempt");
364
343
  return;
365
344
  }
366
345
  const [executeError] = await tryCatch(this.executeRunWrapper(start));
367
346
  if (executeError) {
368
- this.sendDebugLog("Failed to execute run", { error: executeError.message });
369
- this.stopServices();
347
+ this.sendDebugLog("failed to execute run", { error: executeError.message });
348
+ this.shutdown("failed to execute run");
370
349
  return;
371
350
  }
372
- this.stopServices();
351
+ // This is here for safety, but it
352
+ this.shutdown("execute call finished");
373
353
  }
374
354
  async executeRunWrapper({ run, snapshot, envVars, execution, metrics, isWarmStart, }) {
375
355
  this.currentTaskRunEnv = envVars;
@@ -381,13 +361,11 @@ export class RunExecution {
381
361
  metrics,
382
362
  isWarmStart,
383
363
  }));
384
- this.sendDebugLog("Run execution completed", { error: executeError?.message });
385
364
  if (!executeError) {
386
- this.stopServices();
387
365
  return;
388
366
  }
389
367
  if (executeError instanceof SuspendedProcessError) {
390
- this.sendDebugLog("Run was suspended", {
368
+ this.sendDebugLog("execution was suspended", {
391
369
  run: run.friendlyId,
392
370
  snapshot: snapshot.friendlyId,
393
371
  error: executeError.message,
@@ -395,14 +373,14 @@ export class RunExecution {
395
373
  return;
396
374
  }
397
375
  if (executeError instanceof ExecutionAbortError) {
398
- this.sendDebugLog("Run was interrupted", {
376
+ this.sendDebugLog("execution was aborted", {
399
377
  run: run.friendlyId,
400
378
  snapshot: snapshot.friendlyId,
401
379
  error: executeError.message,
402
380
  });
403
381
  return;
404
382
  }
405
- this.sendDebugLog("Error while executing attempt", {
383
+ this.sendDebugLog("error while executing attempt", {
406
384
  error: executeError.message,
407
385
  runId: run.friendlyId,
408
386
  snapshotId: snapshot.friendlyId,
@@ -415,9 +393,8 @@ export class RunExecution {
415
393
  };
416
394
  const [completeError] = await tryCatch(this.complete({ completion }));
417
395
  if (completeError) {
418
- this.sendDebugLog("Failed to complete run", { error: completeError.message });
396
+ this.sendDebugLog("failed to complete run", { error: completeError.message });
419
397
  }
420
- this.stopServices();
421
398
  }
422
399
  async executeRun({ run, snapshot, envVars, execution, metrics, isWarmStart, }) {
423
400
  // For immediate retries, we need to ensure the task run process is prepared for the next attempt
@@ -425,7 +402,7 @@ export class RunExecution {
425
402
  this.taskRunProcess &&
426
403
  !this.taskRunProcess.isPreparedForNextAttempt) {
427
404
  this.sendDebugLog("killing existing task run process before executing next attempt");
428
- await this.kill().catch(() => { });
405
+ await this.kill({ exitExecution: false }).catch(() => { });
429
406
  }
430
407
  // To skip this step and eagerly create the task run process, run prepareForExecution first
431
408
  if (!this.taskRunProcess || !this.taskRunProcess.isPreparedForNextRun) {
@@ -434,7 +411,7 @@ export class RunExecution {
434
411
  this.sendDebugLog("executing task run process", { runId: execution.run.id });
435
412
  // Set up an abort handler that will cleanup the task run process
436
413
  this.executionAbortController.signal.addEventListener("abort", async () => {
437
- this.sendDebugLog("Execution aborted during task run, cleaning up process", {
414
+ this.sendDebugLog("execution aborted during task run, cleaning up process", {
438
415
  runId: execution.run.id,
439
416
  });
440
417
  await this.taskRunProcess?.cleanup(true);
@@ -449,39 +426,24 @@ export class RunExecution {
449
426
  env: envVars,
450
427
  }, isWarmStart);
451
428
  // If we get here, the task completed normally
452
- this.sendDebugLog("Completed run attempt", { attemptSuccess: completion.ok });
429
+ this.sendDebugLog("completed run attempt", { attemptSuccess: completion.ok });
453
430
  // The execution has finished, so we can cleanup the task run process. Killing it should be safe.
454
431
  const [error] = await tryCatch(this.taskRunProcess.cleanup(true));
455
432
  if (error) {
456
- this.sendDebugLog("Failed to cleanup task run process, submitting completion anyway", {
433
+ this.sendDebugLog("failed to cleanup task run process, submitting completion anyway", {
457
434
  error: error.message,
458
435
  });
459
436
  }
460
437
  const [completionError] = await tryCatch(this.complete({ completion }));
461
438
  if (completionError) {
462
- this.sendDebugLog("Failed to complete run", { error: completionError.message });
463
- }
464
- }
465
- /**
466
- * Cancels the current execution.
467
- */
468
- async cancel() {
469
- this.sendDebugLog("cancelling attempt", { runId: this.runFriendlyId });
470
- await this.taskRunProcess?.cancel();
471
- }
472
- exit() {
473
- if (this.taskRunProcess?.isPreparedForNextRun) {
474
- this.taskRunProcess?.forceExit();
439
+ this.sendDebugLog("failed to complete run", { error: completionError.message });
475
440
  }
476
441
  }
477
- async kill() {
478
- await this.taskRunProcess?.kill("SIGKILL");
479
- }
480
442
  async complete({ completion }) {
481
- if (!this.runFriendlyId || !this.currentSnapshotId) {
482
- throw new Error("Cannot complete run: missing run or snapshot ID");
443
+ if (!this.runFriendlyId || !this.snapshotManager) {
444
+ throw new Error("cannot complete run: missing run or snapshot manager");
483
445
  }
484
- const completionResult = await this.httpClient.completeRunAttempt(this.runFriendlyId, this.currentSnapshotId, { completion });
446
+ const completionResult = await this.httpClient.completeRunAttempt(this.runFriendlyId, this.snapshotManager.snapshotId, { completion });
485
447
  if (!completionResult.success) {
486
448
  throw new Error(`failed to submit completion: ${completionResult.error}`);
487
449
  }
@@ -491,39 +453,57 @@ export class RunExecution {
491
453
  });
492
454
  }
493
455
  async handleCompletionResult({ completion, result, }) {
494
- this.sendDebugLog("Handling completion result", {
456
+ this.sendDebugLog(`completion result: ${result.attemptStatus}`, {
495
457
  attemptSuccess: completion.ok,
496
458
  attemptStatus: result.attemptStatus,
497
459
  snapshotId: result.snapshot.friendlyId,
498
460
  runId: result.run.friendlyId,
499
461
  });
500
- // Update our snapshot ID to match the completion result
501
- // This ensures any subsequent API calls use the correct snapshot
502
- this.currentSnapshotId = result.snapshot.friendlyId;
462
+ const snapshotStatus = this.convertAttemptStatusToSnapshotStatus(result.attemptStatus);
463
+ // Update our snapshot ID to match the completion result to ensure any subsequent API calls use the correct snapshot
464
+ this.updateSnapshotAfterCompletion(result.snapshot.friendlyId, snapshotStatus);
503
465
  const { attemptStatus } = result;
504
- if (attemptStatus === "RUN_FINISHED") {
505
- this.sendDebugLog("Run finished");
506
- return;
507
- }
508
- if (attemptStatus === "RUN_PENDING_CANCEL") {
509
- this.sendDebugLog("Run pending cancel");
510
- return;
511
- }
512
- if (attemptStatus === "RETRY_QUEUED") {
513
- this.sendDebugLog("Retry queued");
514
- return;
515
- }
516
- if (attemptStatus === "RETRY_IMMEDIATELY") {
517
- if (completion.ok) {
518
- throw new Error("Should retry but completion OK.");
466
+ switch (attemptStatus) {
467
+ case "RUN_FINISHED":
468
+ case "RUN_PENDING_CANCEL":
469
+ case "RETRY_QUEUED": {
470
+ return;
519
471
  }
520
- if (!completion.retry) {
521
- throw new Error("Should retry but missing retry params.");
472
+ case "RETRY_IMMEDIATELY": {
473
+ if (attemptStatus !== "RETRY_IMMEDIATELY") {
474
+ return;
475
+ }
476
+ if (completion.ok) {
477
+ throw new Error("Should retry but completion OK.");
478
+ }
479
+ if (!completion.retry) {
480
+ throw new Error("Should retry but missing retry params.");
481
+ }
482
+ await this.retryImmediately({ retryOpts: completion.retry });
483
+ return;
484
+ }
485
+ default: {
486
+ assertExhaustive(attemptStatus);
522
487
  }
523
- await this.retryImmediately({ retryOpts: completion.retry });
524
- return;
525
488
  }
526
- assertExhaustive(attemptStatus);
489
+ }
490
+ updateSnapshotAfterCompletion(snapshotId, status) {
491
+ this.snapshotManager?.updateSnapshot(snapshotId, status);
492
+ this.snapshotPoller?.updateSnapshotId(snapshotId);
493
+ }
494
+ convertAttemptStatusToSnapshotStatus(attemptStatus) {
495
+ switch (attemptStatus) {
496
+ case "RUN_FINISHED":
497
+ return "FINISHED";
498
+ case "RUN_PENDING_CANCEL":
499
+ return "PENDING_CANCEL";
500
+ case "RETRY_QUEUED":
501
+ return "QUEUED";
502
+ case "RETRY_IMMEDIATELY":
503
+ return "EXECUTING";
504
+ default:
505
+ assertExhaustive(attemptStatus);
506
+ }
527
507
  }
528
508
  measureExecutionMetrics({ attemptCreatedAt, dequeuedAt, podScheduledAt, }) {
529
509
  const metrics = [
@@ -553,7 +533,7 @@ export class RunExecution {
553
533
  return metrics;
554
534
  }
555
535
  async retryImmediately({ retryOpts }) {
556
- this.sendDebugLog("Retrying run immediately", {
536
+ this.sendDebugLog("retrying run immediately", {
557
537
  timestamp: retryOpts.timestamp,
558
538
  delay: retryOpts.delay,
559
539
  });
@@ -565,52 +545,68 @@ export class RunExecution {
565
545
  // Start and execute next attempt
566
546
  const [startError, start] = await tryCatch(this.startAttempt({ isWarmStart: true }));
567
547
  if (startError) {
568
- this.sendDebugLog("Failed to start attempt for retry", { error: startError.message });
569
- this.stopServices();
548
+ this.sendDebugLog("failed to start attempt for retry", { error: startError.message });
549
+ this.shutdown("retryImmediately: failed to start attempt");
570
550
  return;
571
551
  }
572
552
  const [executeError] = await tryCatch(this.executeRunWrapper({ ...start, isWarmStart: true }));
573
553
  if (executeError) {
574
- this.sendDebugLog("Failed to execute run for retry", { error: executeError.message });
575
- this.stopServices();
554
+ this.sendDebugLog("failed to execute run for retry", { error: executeError.message });
555
+ this.shutdown("retryImmediately: failed to execute run");
576
556
  return;
577
557
  }
578
- this.stopServices();
579
558
  }
580
559
  /**
581
560
  * Restores a suspended execution from PENDING_EXECUTING
582
561
  */
583
562
  async restore() {
584
- this.sendDebugLog("Restoring execution");
585
- if (!this.runFriendlyId || !this.currentSnapshotId) {
586
- throw new Error("Cannot restore: missing run or snapshot ID");
563
+ this.sendDebugLog("restoring execution");
564
+ if (!this.runFriendlyId || !this.snapshotManager) {
565
+ throw new Error("Cannot restore: missing run or snapshot manager");
587
566
  }
588
567
  // Short delay to give websocket time to reconnect
589
568
  await sleep(100);
590
569
  // Process any env overrides
591
- await this.processEnvOverrides();
592
- const continuationResult = await this.httpClient.continueRunExecution(this.runFriendlyId, this.currentSnapshotId);
570
+ await this.processEnvOverrides("restore");
571
+ const continuationResult = await this.httpClient.continueRunExecution(this.runFriendlyId, this.snapshotManager.snapshotId);
593
572
  if (!continuationResult.success) {
594
573
  throw new Error(continuationResult.error);
595
574
  }
596
575
  // Track restore count
597
576
  this.restoreCount++;
598
577
  }
578
+ async exitTaskRunProcessWithoutFailingRun({ flush }) {
579
+ await this.taskRunProcess?.suspend({ flush });
580
+ // No services should be left running after this line - let's make sure of it
581
+ this.shutdown("exitTaskRunProcessWithoutFailingRun");
582
+ }
599
583
  /**
600
584
  * Processes env overrides from the metadata service. Generally called when we're resuming from a suspended state.
601
585
  */
602
- async processEnvOverrides() {
603
- if (!this.env.TRIGGER_METADATA_URL) {
604
- this.sendDebugLog("No metadata URL, skipping env overrides");
605
- return;
586
+ async processEnvOverrides(reason) {
587
+ if (!this.metadataClient) {
588
+ return null;
606
589
  }
607
- const metadataClient = new MetadataClient(this.env.TRIGGER_METADATA_URL);
608
- const overrides = await metadataClient.getEnvOverrides();
609
- if (!overrides) {
610
- this.sendDebugLog("No env overrides, skipping");
611
- return;
590
+ const [error, overrides] = await this.metadataClient.getEnvOverrides();
591
+ if (error) {
592
+ this.sendDebugLog("[override] failed to fetch", {
593
+ reason,
594
+ error: error.message,
595
+ });
596
+ return null;
612
597
  }
613
- this.sendDebugLog("Processing env overrides", overrides);
598
+ if (overrides.TRIGGER_RUN_ID && overrides.TRIGGER_RUN_ID !== this.runFriendlyId) {
599
+ this.sendDebugLog("[override] run ID mismatch, ignoring overrides", {
600
+ reason,
601
+ currentRunId: this.runFriendlyId,
602
+ incomingRunId: overrides.TRIGGER_RUN_ID,
603
+ });
604
+ return null;
605
+ }
606
+ this.sendDebugLog(`[override] processing: ${reason}`, {
607
+ overrides,
608
+ currentEnv: this.env.raw,
609
+ });
614
610
  // Override the env with the new values
615
611
  this.env.override(overrides);
616
612
  // Update services with new values
@@ -625,20 +621,23 @@ export class RunExecution {
625
621
  if (overrides.TRIGGER_RUNNER_ID) {
626
622
  this.httpClient.updateRunnerId(this.env.TRIGGER_RUNNER_ID);
627
623
  }
624
+ return {
625
+ overrides,
626
+ };
628
627
  }
629
628
  async onHeartbeat() {
630
629
  if (!this.runFriendlyId) {
631
- this.sendDebugLog("Heartbeat: missing run ID");
630
+ this.sendDebugLog("heartbeat: missing run ID");
632
631
  return;
633
632
  }
634
- if (!this.currentSnapshotId) {
635
- this.sendDebugLog("Heartbeat: missing snapshot ID");
633
+ if (!this.snapshotManager) {
634
+ this.sendDebugLog("heartbeat: missing snapshot manager");
636
635
  return;
637
636
  }
638
- this.sendDebugLog("Heartbeat: started");
639
- const response = await this.httpClient.heartbeatRun(this.runFriendlyId, this.currentSnapshotId);
637
+ this.sendDebugLog("heartbeat");
638
+ const response = await this.httpClient.heartbeatRun(this.runFriendlyId, this.snapshotManager.snapshotId);
640
639
  if (!response.success) {
641
- this.sendDebugLog("Heartbeat: failed", { error: response.error });
640
+ this.sendDebugLog("heartbeat: failed", { error: response.error });
642
641
  }
643
642
  this.lastHeartbeat = new Date();
644
643
  }
@@ -649,13 +648,33 @@ export class RunExecution {
649
648
  properties: {
650
649
  ...properties,
651
650
  runId: this.runFriendlyId,
652
- snapshotId: this.currentSnapshotId,
651
+ snapshotId: this.currentSnapshotFriendlyId,
652
+ executionId: this.id,
653
+ executionRestoreCount: this.restoreCount,
654
+ lastHeartbeat: this.lastHeartbeat?.toISOString(),
655
+ },
656
+ });
657
+ }
658
+ sendRuntimeDebugLog(message, properties, runIdOverride) {
659
+ this.logger.sendDebugLog({
660
+ runId: runIdOverride ?? this.runFriendlyId,
661
+ message: `[runtime] ${message}`,
662
+ print: false,
663
+ properties: {
664
+ ...properties,
665
+ runId: this.runFriendlyId,
666
+ snapshotId: this.currentSnapshotFriendlyId,
653
667
  executionId: this.id,
654
668
  executionRestoreCount: this.restoreCount,
655
669
  lastHeartbeat: this.lastHeartbeat?.toISOString(),
656
670
  },
657
671
  });
658
672
  }
673
+ set suspendable(suspendable) {
674
+ this.snapshotManager?.setSuspendable(suspendable).catch((error) => {
675
+ this.sendDebugLog("failed to set suspendable", { error: error.message });
676
+ });
677
+ }
659
678
  // Ensure we can only set this once
660
679
  set runFriendlyId(id) {
661
680
  if (this._runFriendlyId) {
@@ -667,14 +686,19 @@ export class RunExecution {
667
686
  return this._runFriendlyId;
668
687
  }
669
688
  get currentSnapshotFriendlyId() {
670
- return this.currentSnapshotId;
689
+ return this.snapshotManager?.snapshotId;
671
690
  }
672
691
  get taskRunEnv() {
673
692
  return this.currentTaskRunEnv;
674
693
  }
675
694
  get metrics() {
676
695
  return {
677
- restoreCount: this.restoreCount,
696
+ execution: {
697
+ restoreCount: this.restoreCount,
698
+ lastHeartbeat: this.lastHeartbeat,
699
+ },
700
+ poller: this.snapshotPoller?.metrics,
701
+ notifier: this.notifier?.metrics,
678
702
  };
679
703
  }
680
704
  get isAborted() {
@@ -682,19 +706,120 @@ export class RunExecution {
682
706
  }
683
707
  abortExecution() {
684
708
  if (this.isAborted) {
685
- this.sendDebugLog("Execution already aborted");
709
+ this.sendDebugLog("execution already aborted");
686
710
  return;
687
711
  }
688
712
  this.executionAbortController.abort();
689
- this.stopServices();
713
+ this.shutdown("abortExecution");
690
714
  }
691
- stopServices() {
715
+ shutdown(reason) {
692
716
  if (this.isShuttingDown) {
717
+ this.sendDebugLog(`[shutdown] ${reason} (already shutting down)`, {
718
+ firstShutdownReason: this.shutdownReason,
719
+ });
693
720
  return;
694
721
  }
722
+ this.sendDebugLog(`[shutdown] ${reason}`);
695
723
  this.isShuttingDown = true;
724
+ this.shutdownReason = reason;
696
725
  this.snapshotPoller?.stop();
697
- this.taskRunProcess?.onTaskRunHeartbeat.detach();
726
+ this.snapshotManager?.stop();
727
+ this.notifier?.stop();
728
+ this.taskRunProcess?.unsafeDetachEvtHandlers();
729
+ }
730
+ async handleSuspendable(suspendableSnapshot) {
731
+ this.sendDebugLog("handleSuspendable", { suspendableSnapshot });
732
+ if (!this.snapshotManager) {
733
+ this.sendDebugLog("handleSuspendable: missing snapshot manager", { suspendableSnapshot });
734
+ return;
735
+ }
736
+ // Ensure this is the current snapshot
737
+ if (suspendableSnapshot.id !== this.currentSnapshotFriendlyId) {
738
+ this.sendDebugLog("snapshot changed before cleanup, abort", {
739
+ suspendableSnapshot,
740
+ currentSnapshotId: this.currentSnapshotFriendlyId,
741
+ });
742
+ this.abortExecution();
743
+ return;
744
+ }
745
+ // First cleanup the task run process
746
+ const [error] = await tryCatch(this.taskRunProcess?.cleanup(false));
747
+ if (error) {
748
+ this.sendDebugLog("failed to cleanup task run process, carrying on", {
749
+ suspendableSnapshot,
750
+ error: error.message,
751
+ });
752
+ }
753
+ // Double check snapshot hasn't changed after cleanup
754
+ if (suspendableSnapshot.id !== this.currentSnapshotFriendlyId) {
755
+ this.sendDebugLog("snapshot changed after cleanup, abort", {
756
+ suspendableSnapshot,
757
+ currentSnapshotId: this.currentSnapshotFriendlyId,
758
+ });
759
+ this.abortExecution();
760
+ return;
761
+ }
762
+ if (!this.runFriendlyId) {
763
+ this.sendDebugLog("missing run ID for suspension, abort", { suspendableSnapshot });
764
+ this.abortExecution();
765
+ return;
766
+ }
767
+ // Call the suspend API with the current snapshot ID
768
+ const suspendResult = await this.httpClient.suspendRun(this.runFriendlyId, suspendableSnapshot.id);
769
+ if (!suspendResult.success) {
770
+ this.sendDebugLog("suspension request failed, staying alive 🎶", {
771
+ suspendableSnapshot,
772
+ error: suspendResult.error,
773
+ });
774
+ // This is fine, we'll wait for the next status change
775
+ return;
776
+ }
777
+ if (!suspendResult.data.ok) {
778
+ this.sendDebugLog("suspension request returned error, staying alive 🎶", {
779
+ suspendableSnapshot,
780
+ error: suspendResult.data.error,
781
+ });
782
+ // This is fine, we'll wait for the next status change
783
+ return;
784
+ }
785
+ this.sendDebugLog("suspending, any day now 🚬", { suspendableSnapshot });
786
+ }
787
+ /**
788
+ * Fetches the latest execution data and enqueues snapshot changes. Used by both poller and notification handlers.
789
+ * @param source string - where this call originated (e.g. 'poller', 'notification')
790
+ */
791
+ async fetchAndProcessSnapshotChanges(source) {
792
+ if (!this.runFriendlyId) {
793
+ this.sendDebugLog(`fetchAndProcessSnapshotChanges: missing runFriendlyId`, { source });
794
+ return;
795
+ }
796
+ // Use the last processed snapshot as the since parameter
797
+ const sinceSnapshotId = this.currentSnapshotFriendlyId;
798
+ if (!sinceSnapshotId) {
799
+ this.sendDebugLog(`fetchAndProcessSnapshotChanges: missing sinceSnapshotId`, { source });
800
+ return;
801
+ }
802
+ const response = await this.httpClient.getSnapshotsSince(this.runFriendlyId, sinceSnapshotId);
803
+ if (!response.success) {
804
+ this.sendDebugLog(`fetchAndProcessSnapshotChanges: failed to get snapshots since`, {
805
+ source,
806
+ error: response.error,
807
+ });
808
+ await this.processEnvOverrides("snapshots since error");
809
+ return;
810
+ }
811
+ const { snapshots } = response.data;
812
+ if (!snapshots.length) {
813
+ return;
814
+ }
815
+ const [error] = await tryCatch(this.enqueueSnapshotChangesAndWait(snapshots));
816
+ if (error) {
817
+ this.sendDebugLog(`fetchAndProcessSnapshotChanges: failed to enqueue and process snapshot change`, {
818
+ source,
819
+ error: error.message,
820
+ });
821
+ return;
822
+ }
698
823
  }
699
824
  }
700
825
  //# sourceMappingURL=execution.js.map