diff --git a/.changeset/tiny-carrots-rest.md b/.changeset/tiny-carrots-rest.md
new file mode 100644
index 0000000000..5c1c7b8942
--- /dev/null
+++ b/.changeset/tiny-carrots-rest.md
@@ -0,0 +1,41 @@
+---
+"@trigger.dev/sdk": minor
+---
+
+Prevent uncaught errors in the `onSuccess`, `onComplete`, and `onFailure` lifecycle hooks from failing attempts/runs.
+
+Deprecated the `onStart` lifecycle hook (which only fires before the `run` function on the first attempt). Replaced with `onStartAttempt` that fires before the run function on every attempt:
+
+```ts
+export const taskWithOnStartAttempt = task({
+ id: "task-with-on-start-attempt",
+ onStartAttempt: async ({ payload, ctx }) => {
+ //...
+ },
+ run: async (payload: any, { ctx }) => {
+ //...
+ },
+});
+
+// Default a global lifecycle hook using tasks
+tasks.onStartAttempt(({ ctx, payload, task }) => {
+ console.log(
+ `Run ${ctx.run.id} started on task ${task} attempt ${ctx.run.attempt.number}`,
+ ctx.run
+ );
+});
+```
+
+If you want to execute code before just the first attempt, you can use the `onStartAttempt` function and check `ctx.run.attempt.number === 1`:
+
+```ts /trigger/on-start-attempt.ts
+export const taskWithOnStartAttempt = task({
+ id: "task-with-on-start-attempt",
+ onStartAttempt: async ({ payload, ctx }) => {
+ if (ctx.run.attempt.number === 1) {
+ console.log("Run started on attempt 1", ctx.run);
+ }
+ },
+});
+```
+
diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx
index fd277997af..b14aa5f576 100644
--- a/apps/webapp/app/components/runs/v3/RunIcon.tsx
+++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx
@@ -97,6 +97,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return ;
case "task-hook-init":
case "task-hook-onStart":
+ case "task-hook-onStartAttempt":
case "task-hook-onSuccess":
case "task-hook-onWait":
case "task-hook-onResume":
diff --git a/docs/images/lifecycle-functions.png b/docs/images/lifecycle-functions.png
index d27936600d..7aafd3faec 100644
Binary files a/docs/images/lifecycle-functions.png and b/docs/images/lifecycle-functions.png differ
diff --git a/docs/tasks/overview.mdx b/docs/tasks/overview.mdx
index 2b3452c87a..fe2e7ce5c7 100644
--- a/docs/tasks/overview.mdx
+++ b/docs/tasks/overview.mdx
@@ -174,63 +174,14 @@ tasks.onStart(({ ctx, payload, task }) => {

-### `init` function
-
-This function is called before a run attempt:
-
-```ts /trigger/init.ts
-export const taskWithInit = task({
- id: "task-with-init",
- init: async ({ payload, ctx }) => {
- //...
- },
- run: async (payload: any, { ctx }) => {
- //...
- },
-});
-```
-
-You can also return data from the `init` function that will be available in the params of the `run`, `cleanup`, `onSuccess`, and `onFailure` functions.
-
-```ts /trigger/init-return.ts
-export const taskWithInitReturn = task({
- id: "task-with-init-return",
- init: async ({ payload, ctx }) => {
- return { someData: "someValue" };
- },
- run: async (payload: any, { ctx, init }) => {
- console.log(init.someData); // "someValue"
- },
-});
-```
-
-Errors thrown in the `init` function are ignored.
-
-### `cleanup` function
-
-This function is called after the `run` function is executed, regardless of whether the run was successful or not. It's useful for cleaning up resources, logging, or other side effects.
-
-```ts /trigger/cleanup.ts
-export const taskWithCleanup = task({
- id: "task-with-cleanup",
- cleanup: async ({ payload, ctx }) => {
- //...
- },
- run: async (payload: any, { ctx }) => {
- //...
- },
-});
-```
-
-Errors thrown in the `cleanup` function will fail the attempt.
-
### `middleware` and `locals` functions
Our task middleware system runs at the top level, executing before and after all lifecycle hooks. This allows you to wrap the entire task execution lifecycle with custom logic.
An error thrown in `middleware` is just like an uncaught error in the run function: it will
- propagate through to `catchError()` function and then will fail the attempt (causing a retry).
+ propagate through to `catchError()` function and then will fail the attempt (either causing a
+ retry or failing the run).
The `locals` API allows you to share data between middleware and hooks.
@@ -296,14 +247,16 @@ export const myTask = task({
});
```
-### `onStart` function
+### `onStartAttempt` function
+
+The `onStartAttempt` function was introduced in v4.1.0
-When a task run starts, the `onStart` function is called. It's useful for sending notifications, logging, and other side effects. This function will only be called one per run (not per retry). If you want to run code before each retry, use the `init` function.
+Before a task run attempt starts, the `onStartAttempt` function is called. It's useful for sending notifications, logging, and other side effects.
```ts /trigger/on-start.ts
-export const taskWithOnStart = task({
- id: "task-with-on-start",
- onStart: async ({ payload, ctx }) => {
+export const taskWithOnStartAttempt = task({
+ id: "task-with-on-start-attempt",
+ onStartAttempt: async ({ payload, ctx }) => {
//...
},
run: async (payload: any, { ctx }) => {
@@ -312,20 +265,33 @@ export const taskWithOnStart = task({
});
```
-You can also define an `onStart` function in your `trigger.config.ts` file to get notified when any task starts.
+You can also define a global `onStartAttempt` function using `tasks.onStartAttempt()`.
-```ts trigger.config.ts
-import { defineConfig } from "@trigger.dev/sdk";
+```ts init.ts
+import { tasks } from "@trigger.dev/sdk";
-export default defineConfig({
- project: "proj_1234",
- onStart: async ({ payload, ctx }) => {
- console.log("Task started", ctx.task.id);
- },
+tasks.onStartAttempt(({ ctx, payload, task }) => {
+ console.log(
+ `Run ${ctx.run.id} started on task ${task} attempt ${ctx.run.attempt.number}`,
+ ctx.run
+ );
});
```
-Errors thrown in the `onStart` function are ignored.
+Errors thrown in the `onStartAttempt` function will cause the attempt to fail.
+
+If you want to execute code before just the first attempt, you can use the `onStartAttempt` function and check `ctx.run.attempt.number === 1`:
+
+```ts /trigger/on-start-attempt.ts
+export const taskWithOnStartAttempt = task({
+ id: "task-with-on-start-attempt",
+ onStartAttempt: async ({ payload, ctx }) => {
+ if (ctx.run.attempt.number === 1) {
+ console.log("Run started on attempt 1", ctx.run);
+ }
+ },
+});
+```
### `onWait` and `onResume` functions
@@ -350,6 +316,20 @@ export const myTask = task({
});
```
+You can also define global `onWait` and `onResume` functions using `tasks.onWait()` and `tasks.onResume()`:
+
+```ts init.ts
+import { tasks } from "@trigger.dev/sdk";
+
+tasks.onWait(({ ctx, payload, wait, task }) => {
+ console.log("Run paused", ctx.run, wait);
+});
+
+tasks.onResume(({ ctx, payload, wait, task }) => {
+ console.log("Run resumed", ctx.run, wait);
+});
+```
+
### `onSuccess` function
When a task run succeeds, the `onSuccess` function is called. It's useful for sending notifications, logging, syncing state to your database, or other side effects.
@@ -366,20 +346,20 @@ export const taskWithOnSuccess = task({
});
```
-You can also define an `onSuccess` function in your `trigger.config.ts` file to get notified when any task succeeds.
+You can also define a global `onSuccess` function using `tasks.onSuccess()`.
-```ts trigger.config.ts
-import { defineConfig } from "@trigger.dev/sdk";
+```ts init.ts
+import { tasks } from "@trigger.dev/sdk";
-export default defineConfig({
- project: "proj_1234",
- onSuccess: async ({ payload, output, ctx }) => {
- console.log("Task succeeded", ctx.task.id);
- },
+tasks.onSuccess(({ ctx, payload, output }) => {
+ console.log("Task succeeded", ctx.task.id);
});
```
-Errors thrown in the `onSuccess` function are ignored.
+
+ Errors thrown in the `onSuccess` function will be ignored, but you will still be able to see them
+ in the dashboard.
+
### `onComplete` function
@@ -397,6 +377,21 @@ export const taskWithOnComplete = task({
});
```
+You can also define a global `onComplete` function using `tasks.onComplete()`.
+
+```ts init.ts
+import { tasks } from "@trigger.dev/sdk";
+
+tasks.onComplete(({ ctx, payload, output }) => {
+ console.log("Task completed", ctx.task.id);
+});
+```
+
+
+ Errors thrown in the `onComplete` function will be ignored, but you will still be able to see them
+ in the dashboard.
+
+
### `onFailure` function
When a task run fails, the `onFailure` function is called. It's useful for sending notifications, logging, or other side effects. It will only be executed once the task run has exhausted all its retries.
@@ -413,20 +408,20 @@ export const taskWithOnFailure = task({
});
```
-You can also define an `onFailure` function in your `trigger.config.ts` file to get notified when any task fails.
+You can also define a global `onFailure` function using `tasks.onFailure()`.
-```ts trigger.config.ts
-import { defineConfig } from "@trigger.dev/sdk";
+```ts init.ts
+import { tasks } from "@trigger.dev/sdk";
-export default defineConfig({
- project: "proj_1234",
- onFailure: async ({ payload, error, ctx }) => {
- console.log("Task failed", ctx.task.id);
- },
+tasks.onFailure(({ ctx, payload, error }) => {
+ console.log("Task failed", ctx.task.id);
});
```
-Errors thrown in the `onFailure` function are ignored.
+
+ Errors thrown in the `onFailure` function will be ignored, but you will still be able to see them
+ in the dashboard.
+
`onFailure` doesn’t fire for some of the run statuses like `Crashed`, `System failures`, and
@@ -441,7 +436,7 @@ Read more about `catchError` in our [Errors and Retrying guide](/errors-retrying
Uncaught errors will throw a special internal error of the type `HANDLE_ERROR_ERROR`.
-### onCancel
+### `onCancel` function
You can define an `onCancel` hook that is called when a run is cancelled. This is useful if you want to clean up any resources that were allocated for the run.
@@ -540,6 +535,101 @@ export const cancelExampleTask = task({
point the process will be killed.
+### `onStart` function (deprecated)
+
+The `onStart` function was deprecated in v4.1.0. Use `onStartAttempt` instead.
+
+When a task run starts, the `onStart` function is called. It's useful for sending notifications, logging, and other side effects.
+
+
+ This function will only be called once per run (not per attempt). If you want to run code before
+ each attempt, use a middleware function or the `onStartAttempt` function.
+
+
+```ts /trigger/on-start.ts
+export const taskWithOnStart = task({
+ id: "task-with-on-start",
+ onStart: async ({ payload, ctx }) => {
+ //...
+ },
+ run: async (payload: any, { ctx }) => {
+ //...
+ },
+});
+```
+
+You can also define a global `onStart` function using `tasks.onStart()`.
+
+```ts init.ts
+import { tasks } from "@trigger.dev/sdk";
+
+tasks.onStart(({ ctx, payload, task }) => {
+ console.log(`Run ${ctx.run.id} started on task ${task}`, ctx.run);
+});
+```
+
+Errors thrown in the `onStart` function will cause the attempt to fail.
+
+### `init` function (deprecated)
+
+
+ The `init` hook is deprecated and will be removed in the future. Use
+ [middleware](/tasks/overview#middleware-and-locals-functions) instead.
+
+
+This function is called before a run attempt:
+
+```ts /trigger/init.ts
+export const taskWithInit = task({
+ id: "task-with-init",
+ init: async ({ payload, ctx }) => {
+ //...
+ },
+ run: async (payload: any, { ctx }) => {
+ //...
+ },
+});
+```
+
+You can also return data from the `init` function that will be available in the params of the `run`, `cleanup`, `onSuccess`, and `onFailure` functions.
+
+```ts /trigger/init-return.ts
+export const taskWithInitReturn = task({
+ id: "task-with-init-return",
+ init: async ({ payload, ctx }) => {
+ return { someData: "someValue" };
+ },
+ run: async (payload: any, { ctx, init }) => {
+ console.log(init.someData); // "someValue"
+ },
+});
+```
+
+Errors thrown in the `init` function will cause the attempt to fail.
+
+### `cleanup` function (deprecated)
+
+
+ The `cleanup` hook is deprecated and will be removed in the future. Use
+ [middleware](/tasks/overview#middleware-and-locals-functions) instead.
+
+
+This function is called after the `run` function is executed, regardless of whether the run was successful or not. It's useful for cleaning up resources, logging, or other side effects.
+
+```ts /trigger/cleanup.ts
+export const taskWithCleanup = task({
+ id: "task-with-cleanup",
+ cleanup: async ({ payload, ctx }) => {
+ //...
+ },
+ run: async (payload: any, { ctx }) => {
+ //...
+ },
+});
+```
+
+Errors thrown in the `cleanup` function will cause the attempt to fail.
+
## Next steps
diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json
index b978f0c85a..da68cb1377 100644
--- a/packages/cli-v3/package.json
+++ b/packages/cli-v3/package.json
@@ -155,4 +155,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/packages/core/src/v3/lifecycle-hooks-api.ts b/packages/core/src/v3/lifecycle-hooks-api.ts
index 3c74f719cb..e08bd66671 100644
--- a/packages/core/src/v3/lifecycle-hooks-api.ts
+++ b/packages/core/src/v3/lifecycle-hooks-api.ts
@@ -35,4 +35,5 @@ export type {
TaskCancelHookParams,
OnCancelHookFunction,
AnyOnCancelHookFunction,
+ AnyOnStartAttemptHookFunction,
} from "./lifecycleHooks/types.js";
diff --git a/packages/core/src/v3/lifecycleHooks/index.ts b/packages/core/src/v3/lifecycleHooks/index.ts
index 99ed47ae60..0011bd5d5a 100644
--- a/packages/core/src/v3/lifecycleHooks/index.ts
+++ b/packages/core/src/v3/lifecycleHooks/index.ts
@@ -18,6 +18,7 @@ import {
RegisterHookFunctionParams,
TaskWait,
type LifecycleHooksManager,
+ AnyOnStartAttemptHookFunction,
} from "./types.js";
const NOOP_LIFECYCLE_HOOKS_MANAGER = new NoopLifecycleHooksManager();
@@ -81,6 +82,27 @@ export class LifecycleHooksAPI {
return this.#getManager().getGlobalStartHooks();
}
+ public registerTaskStartAttemptHook(
+ taskId: string,
+ hook: RegisterHookFunctionParams
+ ): void {
+ this.#getManager().registerTaskStartAttemptHook(taskId, hook);
+ }
+
+ public registerGlobalStartAttemptHook(
+ hook: RegisterHookFunctionParams
+ ): void {
+ this.#getManager().registerGlobalStartAttemptHook(hook);
+ }
+
+ public getTaskStartAttemptHook(taskId: string): AnyOnStartAttemptHookFunction | undefined {
+ return this.#getManager().getTaskStartAttemptHook(taskId);
+ }
+
+ public getGlobalStartAttemptHooks(): RegisteredHookFunction[] {
+ return this.#getManager().getGlobalStartAttemptHooks();
+ }
+
public registerGlobalFailureHook(
hook: RegisterHookFunctionParams
): void {
diff --git a/packages/core/src/v3/lifecycleHooks/manager.ts b/packages/core/src/v3/lifecycleHooks/manager.ts
index 282d2fe16c..e755e66d3f 100644
--- a/packages/core/src/v3/lifecycleHooks/manager.ts
+++ b/packages/core/src/v3/lifecycleHooks/manager.ts
@@ -14,6 +14,7 @@ import {
AnyOnCleanupHookFunction,
TaskWait,
AnyOnCancelHookFunction,
+ AnyOnStartAttemptHookFunction,
} from "./types.js";
export class StandardLifecycleHooksManager implements LifecycleHooksManager {
@@ -23,6 +24,15 @@ export class StandardLifecycleHooksManager implements LifecycleHooksManager {
private globalStartHooks: Map> = new Map();
private taskStartHooks: Map> = new Map();
+ private globalStartAttemptHooks: Map<
+ string,
+ RegisteredHookFunction
+ > = new Map();
+ private taskStartAttemptHooks: Map<
+ string,
+ RegisteredHookFunction
+ > = new Map();
+
private globalFailureHooks: Map> =
new Map();
private taskFailureHooks: Map> =
@@ -129,6 +139,37 @@ export class StandardLifecycleHooksManager implements LifecycleHooksManager {
return Array.from(this.globalStartHooks.values());
}
+ registerGlobalStartAttemptHook(
+ hook: RegisterHookFunctionParams
+ ): void {
+ const id = generateHookId(hook);
+ this.globalStartAttemptHooks.set(id, {
+ id,
+ name: hook.id,
+ fn: hook.fn,
+ });
+ }
+
+ registerTaskStartAttemptHook(
+ taskId: string,
+ hook: RegisterHookFunctionParams
+ ): void {
+ const id = generateHookId(hook);
+ this.taskStartAttemptHooks.set(taskId, {
+ id,
+ name: hook.id,
+ fn: hook.fn,
+ });
+ }
+
+ getTaskStartAttemptHook(taskId: string): AnyOnStartAttemptHookFunction | undefined {
+ return this.taskStartAttemptHooks.get(taskId)?.fn;
+ }
+
+ getGlobalStartAttemptHooks(): RegisteredHookFunction[] {
+ return Array.from(this.globalStartAttemptHooks.values());
+ }
+
registerGlobalInitHook(hook: RegisterHookFunctionParams): void {
// if there is no id, lets generate one based on the contents of the function
const id = generateHookId(hook);
@@ -527,6 +568,22 @@ export class NoopLifecycleHooksManager implements LifecycleHooksManager {
return [];
}
+ registerGlobalStartAttemptHook(): void {
+ // Noop
+ }
+
+ registerTaskStartAttemptHook(): void {
+ // Noop
+ }
+
+ getTaskStartAttemptHook(): undefined {
+ return undefined;
+ }
+
+ getGlobalStartAttemptHooks(): RegisteredHookFunction[] {
+ return [];
+ }
+
registerGlobalFailureHook(hook: RegisterHookFunctionParams): void {
// Noop
}
diff --git a/packages/core/src/v3/lifecycleHooks/types.ts b/packages/core/src/v3/lifecycleHooks/types.ts
index 9501216546..51518a165a 100644
--- a/packages/core/src/v3/lifecycleHooks/types.ts
+++ b/packages/core/src/v3/lifecycleHooks/types.ts
@@ -33,6 +33,19 @@ export type OnStartHookFunction;
+export type TaskStartAttemptHookParams = {
+ ctx: TaskRunContext;
+ payload: TPayload;
+ task: string;
+ signal: AbortSignal;
+};
+
+export type OnStartAttemptHookFunction = (
+ params: TaskStartAttemptHookParams
+) => undefined | void | Promise;
+
+export type AnyOnStartAttemptHookFunction = OnStartAttemptHookFunction;
+
export type TaskWait =
| {
type: "duration";
@@ -268,6 +281,17 @@ export interface LifecycleHooksManager {
): void;
getTaskStartHook(taskId: string): AnyOnStartHookFunction | undefined;
getGlobalStartHooks(): RegisteredHookFunction[];
+
+ registerGlobalStartAttemptHook(
+ hook: RegisterHookFunctionParams
+ ): void;
+ registerTaskStartAttemptHook(
+ taskId: string,
+ hook: RegisterHookFunctionParams
+ ): void;
+ getTaskStartAttemptHook(taskId: string): AnyOnStartAttemptHookFunction | undefined;
+ getGlobalStartAttemptHooks(): RegisteredHookFunction[];
+
registerGlobalFailureHook(hook: RegisterHookFunctionParams): void;
registerTaskFailureHook(
taskId: string,
diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts
index 67c80d40b4..ace6c462ba 100644
--- a/packages/core/src/v3/types/tasks.ts
+++ b/packages/core/src/v3/types/tasks.ts
@@ -13,6 +13,7 @@ import {
OnSuccessHookFunction,
OnWaitHookFunction,
OnCancelHookFunction,
+ OnStartAttemptHookFunction,
} from "../lifecycleHooks/types.js";
import { RunTags } from "../schemas/api.js";
import {
@@ -114,6 +115,13 @@ export type StartFnParams = Prettify<{
signal: AbortSignal;
}>;
+export type StartAttemptFnParams = Prettify<{
+ ctx: Context;
+ init?: InitOutput;
+ /** Abort signal that is aborted when a task run exceeds it's maxDuration or if the task run is cancelled. Can be used to automatically cancel downstream requests */
+ signal: AbortSignal;
+}>;
+
export type CancelFnParams = Prettify<{
ctx: Context;
/** Abort signal that is aborted when a task run exceeds it's maxDuration or if the task run is cancelled. Can be used to automatically cancel downstream requests */
@@ -328,9 +336,18 @@ type CommonTaskOptions<
/**
* onStart is called the first time a task is executed in a run (not before every retry)
+ *
+ * @deprecated Use onStartAttempt instead
*/
onStart?: OnStartHookFunction;
+ /**
+ * onStartAttempt is called before each attempt of a task is executed.
+ *
+ * You can detect the first attempt by checking `ctx.attempt.number === 1`.
+ */
+ onStartAttempt?: OnStartAttemptHookFunction;
+
/**
* onSuccess is called after the run function has successfully completed.
*/
@@ -912,6 +929,7 @@ export type TaskMetadataWithFunctions = TaskMetadata & {
onSuccess?: (payload: any, output: any, params: SuccessFnParams) => Promise;
onFailure?: (payload: any, error: unknown, params: FailureFnParams) => Promise;
onStart?: (payload: any, params: StartFnParams) => Promise;
+ onStartAttempt?: (payload: any, params: StartAttemptFnParams) => Promise;
parsePayload?: AnySchemaParseFn;
};
schema?: TaskSchema;
diff --git a/packages/core/src/v3/workers/taskExecutor.ts b/packages/core/src/v3/workers/taskExecutor.ts
index 38e7d7c19a..2f6f08592a 100644
--- a/packages/core/src/v3/workers/taskExecutor.ts
+++ b/packages/core/src/v3/workers/taskExecutor.ts
@@ -183,6 +183,8 @@ export class TaskExecutor {
await this.#callOnStartFunctions(payload, ctx, initOutput, signal);
}
+ await this.#callOnStartAttemptFunctions(payload, ctx, signal);
+
try {
return await this.#callRun(payload, ctx, initOutput, signal);
} catch (error) {
@@ -795,7 +797,7 @@ export class TaskExecutor {
return await runTimelineMetrics.measureMetric("trigger.dev/execution", "success", async () => {
for (const hook of globalSuccessHooks) {
- const [hookError] = await tryCatch(
+ await tryCatch(
this._tracer.startActiveSpan(
"onSuccess()",
async (span) => {
@@ -817,14 +819,10 @@ export class TaskExecutor {
}
)
);
-
- if (hookError) {
- throw hookError;
- }
}
if (taskSuccessHook) {
- const [hookError] = await tryCatch(
+ await tryCatch(
this._tracer.startActiveSpan(
"onSuccess()",
async (span) => {
@@ -846,10 +844,6 @@ export class TaskExecutor {
}
)
);
-
- if (hookError) {
- throw hookError;
- }
}
});
}
@@ -870,7 +864,7 @@ export class TaskExecutor {
return await runTimelineMetrics.measureMetric("trigger.dev/execution", "failure", async () => {
for (const hook of globalFailureHooks) {
- const [hookError] = await tryCatch(
+ await tryCatch(
this._tracer.startActiveSpan(
"onFailure()",
async (span) => {
@@ -892,14 +886,10 @@ export class TaskExecutor {
}
)
);
-
- if (hookError) {
- throw hookError;
- }
}
if (taskFailureHook) {
- const [hookError] = await tryCatch(
+ await tryCatch(
this._tracer.startActiveSpan(
"onFailure()",
async (span) => {
@@ -921,10 +911,6 @@ export class TaskExecutor {
}
)
);
-
- if (hookError) {
- throw hookError;
- }
}
});
}
@@ -1007,6 +993,70 @@ export class TaskExecutor {
});
}
+ async #callOnStartAttemptFunctions(payload: unknown, ctx: TaskRunContext, signal: AbortSignal) {
+ const globalStartHooks = lifecycleHooks.getGlobalStartAttemptHooks();
+ const taskStartHook = lifecycleHooks.getTaskStartAttemptHook(this.task.id);
+
+ if (globalStartHooks.length === 0 && !taskStartHook) {
+ return;
+ }
+
+ return await runTimelineMetrics.measureMetric(
+ "trigger.dev/execution",
+ "startAttempt",
+ async () => {
+ for (const hook of globalStartHooks) {
+ const [hookError] = await tryCatch(
+ this._tracer.startActiveSpan(
+ "onStartAttempt()",
+ async (span) => {
+ await hook.fn({ payload, ctx, signal, task: this.task.id });
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStartAttempt",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ ...this.#lifecycleHookAccessoryAttributes(hook.name),
+ },
+ }
+ )
+ );
+
+ if (hookError) {
+ throw hookError;
+ }
+ }
+
+ if (taskStartHook) {
+ const [hookError] = await tryCatch(
+ this._tracer.startActiveSpan(
+ "onStartAttempt()",
+ async (span) => {
+ await taskStartHook({
+ payload,
+ ctx,
+ signal,
+ task: this.task.id,
+ });
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStartAttempt",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ ...this.#lifecycleHookAccessoryAttributes("task"),
+ },
+ }
+ )
+ );
+
+ if (hookError) {
+ throw hookError;
+ }
+ }
+ }
+ );
+ }
+
async #cleanupAndWaitUntil(
payload: unknown,
ctx: TaskRunContext,
@@ -1315,7 +1365,7 @@ export class TaskExecutor {
return await runTimelineMetrics.measureMetric("trigger.dev/execution", "complete", async () => {
for (const hook of globalCompleteHooks) {
- const [hookError] = await tryCatch(
+ await tryCatch(
this._tracer.startActiveSpan(
"onComplete()",
async (span) => {
@@ -1337,14 +1387,10 @@ export class TaskExecutor {
}
)
);
-
- if (hookError) {
- throw hookError;
- }
}
if (taskCompleteHook) {
- const [hookError] = await tryCatch(
+ await tryCatch(
this._tracer.startActiveSpan(
"onComplete()",
async (span) => {
@@ -1366,10 +1412,6 @@ export class TaskExecutor {
}
)
);
-
- if (hookError) {
- throw hookError;
- }
}
});
}
diff --git a/packages/core/test/taskExecutor.test.ts b/packages/core/test/taskExecutor.test.ts
index 229a952fff..34b36076fe 100644
--- a/packages/core/test/taskExecutor.test.ts
+++ b/packages/core/test/taskExecutor.test.ts
@@ -269,6 +269,91 @@ describe("TaskExecutor", () => {
});
});
+ test("should call onStartAttempt hooks in correct order with proper data", async () => {
+ const globalStartOrder: string[] = [];
+ const startPayloads: any[] = [];
+
+ // Register global init hook to provide init data
+ lifecycleHooks.registerGlobalInitHook({
+ id: "test-init",
+ fn: async () => {
+ return {
+ foo: "bar",
+ };
+ },
+ });
+
+ // Register two global start hooks
+ lifecycleHooks.registerGlobalStartAttemptHook({
+ id: "global-start-1",
+ fn: async ({ payload, ctx }) => {
+ console.log("Executing global start hook 1");
+ globalStartOrder.push("global-1");
+ startPayloads.push(payload);
+ },
+ });
+
+ lifecycleHooks.registerGlobalStartAttemptHook({
+ id: "global-start-2",
+ fn: async ({ payload, ctx }) => {
+ console.log("Executing global start hook 2");
+ globalStartOrder.push("global-2");
+ startPayloads.push(payload);
+ },
+ });
+
+ // Register task-specific start hook
+ lifecycleHooks.registerTaskStartAttemptHook("test-task", {
+ id: "task-start",
+ fn: async ({ payload, ctx }) => {
+ console.log("Executing task start hook");
+ globalStartOrder.push("task");
+ startPayloads.push(payload);
+ },
+ });
+
+ // Verify hooks are registered
+ const globalHooks = lifecycleHooks.getGlobalStartAttemptHooks();
+ console.log(
+ "Registered global hooks:",
+ globalHooks.map((h) => h.id)
+ );
+ const taskHook = lifecycleHooks.getTaskStartAttemptHook("test-task");
+ console.log("Registered task hook:", taskHook ? "yes" : "no");
+
+ const task = {
+ id: "test-task",
+ fns: {
+ run: async (payload: any, params: RunFnParams) => {
+ return {
+ output: "test-output",
+ init: params.init,
+ };
+ },
+ },
+ };
+
+ const result = await executeTask(task, { test: "data" }, undefined);
+
+ // Verify hooks were called in correct order
+ expect(globalStartOrder).toEqual(["global-1", "global-2", "task"]);
+
+ // Verify each hook received the correct payload
+ startPayloads.forEach((payload) => {
+ expect(payload).toEqual({ test: "data" });
+ });
+
+ // Verify the final result
+ expect(result).toEqual({
+ result: {
+ ok: true,
+ id: "test-run-id",
+ output: '{"json":{"output":"test-output","init":{"foo":"bar"}}}',
+ outputType: "application/super+json",
+ },
+ });
+ });
+
test("should call onFailure hooks with error when task fails", async () => {
const globalFailureOrder: string[] = [];
const failurePayloads: any[] = [];
@@ -1246,6 +1331,48 @@ describe("TaskExecutor", () => {
});
});
+ test("should NOT propagate errors from onSuccess hooks", async () => {
+ const executionOrder: string[] = [];
+ const expectedError = new Error("On success hook error");
+
+ // Register global on success hook that throws an error
+ lifecycleHooks.registerGlobalSuccessHook({
+ id: "global-success",
+ fn: async () => {
+ executionOrder.push("global-success");
+ throw expectedError;
+ },
+ });
+
+ const task = {
+ id: "test-task",
+ fns: {
+ run: async (payload: any, params: RunFnParams) => {
+ executionOrder.push("run");
+ return {
+ output: "test-output",
+ };
+ },
+ },
+ };
+
+ // Expect that this does not throw an error
+ const result = await executeTask(task, { test: "data" }, undefined);
+
+ // Verify that run was called and on success hook was called
+ expect(executionOrder).toEqual(["run", "global-success"]);
+
+ // Verify the error result
+ expect(result).toEqual({
+ result: {
+ ok: true,
+ id: "test-run-id",
+ output: '{"json":{"output":"test-output"}}',
+ outputType: "application/super+json",
+ },
+ });
+ });
+
test("should call cleanup hooks in correct order after other hooks but before middleware completion", async () => {
const executionOrder: string[] = [];
diff --git a/packages/trigger-sdk/src/v3/hooks.ts b/packages/trigger-sdk/src/v3/hooks.ts
index b4e9cd0988..c6811ca6e9 100644
--- a/packages/trigger-sdk/src/v3/hooks.ts
+++ b/packages/trigger-sdk/src/v3/hooks.ts
@@ -12,6 +12,7 @@ import {
type AnyOnCatchErrorHookFunction,
type AnyOnMiddlewareHookFunction,
type AnyOnCancelHookFunction,
+ type AnyOnStartAttemptHookFunction,
} from "@trigger.dev/core/v3";
export type {
@@ -41,6 +42,18 @@ export function onStart(
});
}
+export function onStartAttempt(name: string, fn: AnyOnStartAttemptHookFunction): void;
+export function onStartAttempt(fn: AnyOnStartAttemptHookFunction): void;
+export function onStartAttempt(
+ fnOrName: string | AnyOnStartAttemptHookFunction,
+ fn?: AnyOnStartAttemptHookFunction
+): void {
+ lifecycleHooks.registerGlobalStartAttemptHook({
+ id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
+ fn: typeof fnOrName === "function" ? fnOrName : fn!,
+ });
+}
+
export function onFailure(name: string, fn: AnyOnFailureHookFunction): void;
export function onFailure(fn: AnyOnFailureHookFunction): void;
export function onFailure(
diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts
index 11b92c2f43..16db25867e 100644
--- a/packages/trigger-sdk/src/v3/shared.ts
+++ b/packages/trigger-sdk/src/v3/shared.ts
@@ -90,6 +90,7 @@ import type {
TriggerAndWaitOptions,
TriggerApiRequestOptions,
TriggerOptions,
+ AnyOnStartAttemptHookFunction,
} from "@trigger.dev/core/v3";
export type {
@@ -1587,6 +1588,12 @@ function registerTaskLifecycleHooks<
});
}
+ if (params.onStartAttempt) {
+ lifecycleHooks.registerTaskStartAttemptHook(taskId, {
+ fn: params.onStartAttempt as AnyOnStartAttemptHookFunction,
+ });
+ }
+
if (params.onFailure) {
lifecycleHooks.registerTaskFailureHook(taskId, {
fn: params.onFailure as AnyOnFailureHookFunction,
diff --git a/packages/trigger-sdk/src/v3/tasks.ts b/packages/trigger-sdk/src/v3/tasks.ts
index 078666dc68..c8b3fbd4fe 100644
--- a/packages/trigger-sdk/src/v3/tasks.ts
+++ b/packages/trigger-sdk/src/v3/tasks.ts
@@ -1,5 +1,6 @@
import {
onStart,
+ onStartAttempt,
onFailure,
onSuccess,
onComplete,
@@ -88,7 +89,9 @@ export const tasks = {
batchTrigger,
triggerAndWait,
batchTriggerAndWait,
+ /** @deprecated Use onStartAttempt instead */
onStart,
+ onStartAttempt,
onFailure,
onSuccess,
onComplete,
diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts
index 14fdd0faad..ac8917798a 100644
--- a/references/hello-world/src/trigger/example.ts
+++ b/references/hello-world/src/trigger/example.ts
@@ -438,3 +438,93 @@ export const lotsOfLogsTask = task({
}
},
});
+
+export const throwErrorInOnSuccessHookTask = task({
+ id: "throw-error-in-on-success-hook",
+ run: async (payload: { message: string }, { ctx }) => {
+ logger.info("Hello, world from the throw error in on success hook task", {
+ message: payload.message,
+ });
+ },
+ onSuccess: async ({ payload, output, ctx }) => {
+ logger.info("Hello, world from the on success hook", { payload, output });
+ throw new Error("Forced error to cause a retry");
+ },
+});
+
+export const throwErrorInOnStartHookTask = task({
+ id: "throw-error-in-on-start-hook",
+ run: async (payload: { message: string }, { ctx }) => {
+ logger.info("Hello, world from the throw error in on start hook task", {
+ message: payload.message,
+ });
+ },
+ onStart: async ({ payload, ctx }) => {
+ logger.info("Hello, world from the on start hook", { payload });
+ throw new Error("Forced error to cause a retry");
+ },
+});
+
+export const throwErrorInOnCompleteHookTask = task({
+ id: "throw-error-in-on-complete-hook",
+ run: async (payload: { message: string }, { ctx }) => {
+ logger.info("Hello, world from the throw error in on complete hook task", {
+ message: payload.message,
+ });
+ },
+ onComplete: async ({ payload, result, ctx }) => {
+ logger.info("Hello, world from the on complete hook", { payload, result });
+ throw new Error("Forced error to cause a retry");
+ },
+});
+
+export const throwErrorInOnFailureHookTask = task({
+ id: "throw-error-in-on-failure-hook",
+ retry: {
+ maxAttempts: 1,
+ },
+ run: async (payload: { message: string }, { ctx }) => {
+ logger.info("Hello, world from the throw error in on failure hook task", {
+ message: payload.message,
+ });
+ throw new Error("Forced error to cause a retry");
+ },
+ onFailure: async ({ payload, error, ctx }) => {
+ logger.info("Hello, world from the on failure hook", { payload, error });
+ throw new Error("Forced error to cause a retry in on failure hook");
+ },
+});
+
+export const throwErrorInInitHookTask = task({
+ id: "throw-error-in-init-hook",
+ run: async (payload: { message: string }, { ctx }) => {
+ logger.info("Hello, world from the throw error in init hook task", {
+ message: payload.message,
+ });
+ },
+ init: async ({ payload, ctx }) => {
+ logger.info("Hello, world from the init hook", { payload });
+ throw new Error("Forced error to cause a retry");
+ },
+});
+
+export const testStartAttemptHookTask = task({
+ id: "test-start-attempt-hook",
+ retry: {
+ maxAttempts: 3,
+ },
+ run: async (payload: { message: string }, { ctx }) => {
+ logger.info("Hello, world from the test start attempt hook task", { message: payload.message });
+
+ if (ctx.attempt.number === 1) {
+ throw new Error("Forced error to cause a retry so we can test the onStartAttempt hook");
+ }
+ },
+ onStartAttempt: async ({ payload, ctx }) => {
+ console.log(`onStartAttempt hook called ${ctx.attempt.number}`);
+ },
+});
+
+tasks.onStartAttempt(({ payload, ctx }) => {
+ console.log(`global onStartAttempt hook called ${ctx.attempt.number}`);
+});