From 1887eb2044ae4eb46cc8b6340a8c6e1c36447431 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:39:50 +0000 Subject: [PATCH 1/5] feat: add isReplay to run context Add isReplay boolean to the run context, following the same pattern as isTest. The value is derived from the existing replayedFromTaskRunFriendlyId database field - no schema migration needed. Changes: - Add isReplay to TaskRun and V3TaskRun schemas (default false) - Add RUN_IS_REPLAY semantic attribute - Propagate isReplay through dequeue and attempt systems - Add isReplay to DequeuedMessage and LazyAttemptPayload schemas Co-Authored-By: nick <55853254+nicktrn@users.noreply.github.com> --- .../src/engine/systems/dequeueSystem.ts | 1 + .../src/engine/systems/runAttemptSystem.ts | 88 ++++++++++--------- packages/core/src/v3/schemas/common.ts | 14 +-- packages/core/src/v3/schemas/runEngine.ts | 1 + packages/core/src/v3/schemas/schemas.ts | 1 + .../core/src/v3/semanticInternalAttributes.ts | 1 + packages/core/src/v3/taskContext/index.ts | 1 + 7 files changed, 59 insertions(+), 48 deletions(-) diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 15d79e76baa..3fe1ef072cf 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -607,6 +607,7 @@ export class DequeueSystem { id: lockedTaskRun.id, friendlyId: lockedTaskRun.friendlyId, isTest: lockedTaskRun.isTest, + isReplay: !!lockedTaskRun.replayedFromTaskRunFriendlyId, machine: machinePreset, attemptNumber: nextAttemptNumber, // Keeping this for backwards compatibility, but really this should be called workerQueue diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 8e95519241c..27ddedde006 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -196,6 +196,7 @@ export class RunAttemptSystem { machinePreset: true, runTags: true, isTest: true, + replayedFromTaskRunFriendlyId: true, idempotencyKey: true, idempotencyKeyOptions: true, startedAt: true, @@ -232,9 +233,9 @@ export class RunAttemptSystem { run.lockedById ? this.#resolveTaskRunExecutionTask(run.lockedById) : Promise.resolve({ - id: run.taskIdentifier, - filePath: "unknown", - }), + id: run.taskIdentifier, + filePath: "unknown", + }), this.#resolveTaskRunExecutionQueue({ lockedQueueId: run.lockedQueueId ?? undefined, queueName: run.queue, @@ -245,13 +246,13 @@ export class RunAttemptSystem { run.lockedById ? this.#resolveTaskRunExecutionMachinePreset(run.lockedById, run.machinePreset) : Promise.resolve( - getMachinePreset({ - defaultMachine: this.options.machines.defaultMachine, - machines: this.options.machines.machines, - config: undefined, - run, - }) - ), + getMachinePreset({ + defaultMachine: this.options.machines.defaultMachine, + machines: this.options.machines.machines, + config: undefined, + run, + }) + ), run.lockedById ? this.#resolveTaskRunExecutionDeployment(run.lockedById) : Promise.resolve(undefined), @@ -262,6 +263,7 @@ export class RunAttemptSystem { id: run.friendlyId, tags: run.runTags, isTest: run.isTest, + isReplay: !!run.replayedFromTaskRunFriendlyId, createdAt: run.createdAt, startedAt: run.startedAt ?? run.createdAt, idempotencyKey: getUserProvidedIdempotencyKey(run) ?? undefined, @@ -426,6 +428,7 @@ export class RunAttemptSystem { payloadType: true, runTags: true, isTest: true, + replayedFromTaskRunFriendlyId: true, idempotencyKey: true, idempotencyKeyOptions: true, startedAt: true, @@ -459,8 +462,9 @@ export class RunAttemptSystem { run, snapshot: { executionStatus: "EXECUTING", - description: `Attempt created, starting execution${isWarmStart ? " (warm start)" : "" - }`, + description: `Attempt created, starting execution${ + isWarmStart ? " (warm start)" : "" + }`, }, previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, @@ -574,6 +578,7 @@ export class RunAttemptSystem { createdAt: updatedRun.createdAt, tags: updatedRun.runTags, isTest: updatedRun.isTest, + isReplay: !!updatedRun.replayedFromTaskRunFriendlyId, idempotencyKey: getUserProvidedIdempotencyKey(updatedRun) ?? undefined, idempotencyKeyScope: extractIdempotencyKeyScope(updatedRun), startedAt: updatedRun.startedAt ?? updatedRun.createdAt, @@ -618,8 +623,8 @@ export class RunAttemptSystem { deployment, batch: updatedRun.batchId ? { - id: BatchId.toFriendlyId(updatedRun.batchId), - } + id: BatchId.toFriendlyId(updatedRun.batchId), + } : undefined, }; @@ -1387,8 +1392,8 @@ export class RunAttemptSystem { error, bulkActionGroupIds: bulkActionId ? { - push: bulkActionId, - } + push: bulkActionId, + } : undefined, ...(usageUpdate && { usageDurationMs: usageUpdate.usageDurationMs, @@ -1876,26 +1881,26 @@ export class RunAttemptSystem { const result = await this.cache.queues.swr(cacheKey, async () => { const queue = params.lockedQueueId ? await this.$.readOnlyPrisma.taskQueue.findFirst({ - where: { - id: params.lockedQueueId, - }, - select: { - id: true, - friendlyId: true, - name: true, - }, - }) + where: { + id: params.lockedQueueId, + }, + select: { + id: true, + friendlyId: true, + name: true, + }, + }) : await this.$.readOnlyPrisma.taskQueue.findFirst({ - where: { - runtimeEnvironmentId: params.runtimeEnvironmentId, - name: params.queueName, - }, - select: { - id: true, - friendlyId: true, - name: true, - }, - }); + where: { + runtimeEnvironmentId: params.runtimeEnvironmentId, + name: params.queueName, + }, + select: { + id: true, + friendlyId: true, + name: true, + }, + }); if (!queue) { // Return synthetic queue so run/span view still loads (e.g. createFailedTaskRun with fallback queue) @@ -2068,13 +2073,13 @@ export class RunAttemptSystem { if (environmentType !== "DEVELOPMENT") { const machinePreset = machinePresetName ? machinePresetFromName( - this.options.machines.machines, - machinePresetName as MachinePresetName - ) + this.options.machines.machines, + machinePresetName as MachinePresetName + ) : machinePresetFromName( - this.options.machines.machines, - this.options.machines.defaultMachine - ); + this.options.machines.machines, + this.options.machines.defaultMachine + ); costInCents = currentCostInCents + attemptDurationMs * machinePreset.centsPerMs; } @@ -2084,7 +2089,6 @@ export class RunAttemptSystem { costInCents, }; } - } export function safeParseGitMeta(git: unknown): GitMeta | undefined { diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index f3757208335..8bd22dd4bbb 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -215,6 +215,7 @@ export const TaskRun = z.object({ payloadType: z.string(), tags: z.array(z.string()), isTest: z.boolean().default(false), + isReplay: z.boolean().default(false), createdAt: z.coerce.date(), startedAt: z.coerce.date().default(() => new Date()), /** The user-provided idempotency key (not the hash) */ @@ -378,6 +379,7 @@ export const V3TaskRun = z.object({ payloadType: z.string(), tags: z.array(z.string()), isTest: z.boolean().default(false), + isReplay: z.boolean().default(false), createdAt: z.coerce.date(), startedAt: z.coerce.date().default(() => new Date()), /** The user-provided idempotency key (not the hash) */ @@ -538,13 +540,13 @@ export type WaitpointTokenResult = z.infer; export type WaitpointTokenTypedResult = | { - ok: true; - output: T; - } + ok: true; + output: T; + } | { - ok: false; - error: Error; - }; + ok: false; + error: Error; + }; export const SerializedError = z.object({ message: z.string(), diff --git a/packages/core/src/v3/schemas/runEngine.ts b/packages/core/src/v3/schemas/runEngine.ts index 9378b290270..b9e41c9a8d7 100644 --- a/packages/core/src/v3/schemas/runEngine.ts +++ b/packages/core/src/v3/schemas/runEngine.ts @@ -277,6 +277,7 @@ export const DequeuedMessage = z.object({ id: z.string(), friendlyId: z.string(), isTest: z.boolean(), + isReplay: z.boolean().default(false), machine: MachinePreset, attemptNumber: z.number(), masterQueue: z.string(), diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 4ec559ebf41..5fb85f80ae8 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -292,6 +292,7 @@ export const TaskRunExecutionLazyAttemptPayload = z.object({ attemptCount: z.number().optional(), messageId: z.string(), isTest: z.boolean(), + isReplay: z.boolean().default(false), traceContext: z.record(z.unknown()), environment: z.record(z.string()).optional(), metrics: TaskRunExecutionMetrics.optional(), diff --git a/packages/core/src/v3/semanticInternalAttributes.ts b/packages/core/src/v3/semanticInternalAttributes.ts index 3fb20a06499..2c715a03ea1 100644 --- a/packages/core/src/v3/semanticInternalAttributes.ts +++ b/packages/core/src/v3/semanticInternalAttributes.ts @@ -12,6 +12,7 @@ export const SemanticInternalAttributes = { ATTEMPT_NUMBER: "ctx.attempt.number", RUN_ID: "ctx.run.id", RUN_IS_TEST: "ctx.run.isTest", + RUN_IS_REPLAY: "ctx.run.isReplay", ORIGINAL_RUN_ID: "$original_run_id", BATCH_ID: "ctx.batch.id", TASK_SLUG: "ctx.task.id", diff --git a/packages/core/src/v3/taskContext/index.ts b/packages/core/src/v3/taskContext/index.ts index f76671160a6..92e0194cde9 100644 --- a/packages/core/src/v3/taskContext/index.ts +++ b/packages/core/src/v3/taskContext/index.ts @@ -94,6 +94,7 @@ export class TaskContextAPI { [SemanticInternalAttributes.QUEUE_ID]: this.ctx.queue.id, [SemanticInternalAttributes.RUN_ID]: this.ctx.run.id, [SemanticInternalAttributes.RUN_IS_TEST]: this.ctx.run.isTest, + [SemanticInternalAttributes.RUN_IS_REPLAY]: this.ctx.run.isReplay, [SemanticInternalAttributes.BATCH_ID]: this.ctx.batch?.id, [SemanticInternalAttributes.IDEMPOTENCY_KEY]: this.ctx.run.idempotencyKey, }; From 157ad462e3a9624593167086b22f329c9030e531 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:44:30 +0000 Subject: [PATCH 2/5] fix: populate isReplay in V1 legacy lazy attempt payload paths Co-Authored-By: nick <55853254+nicktrn@users.noreply.github.com> --- apps/webapp/app/v3/marqs/devQueueConsumer.server.ts | 1 + apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts index 2bd80d465b4..3143e40f0de 100644 --- a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts @@ -519,6 +519,7 @@ export class DevQueueConsumer { runId: lockedTaskRun.friendlyId, messageId: lockedTaskRun.id, isTest: lockedTaskRun.isTest, + isReplay: !!lockedTaskRun.replayedFromTaskRunFriendlyId, metrics: [ { name: "start", diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 20ee9daf7da..cbcc023b40d 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -2045,6 +2045,7 @@ class SharedQueueTasks { traceContext: true, friendlyId: true, isTest: true, + replayedFromTaskRunFriendlyId: true, lockedBy: { select: { machineConfig: true, @@ -2090,6 +2091,7 @@ class SharedQueueTasks { runId: run.friendlyId, messageId: run.id, isTest: run.isTest, + isReplay: !!run.replayedFromTaskRunFriendlyId, attemptCount, metrics: [], } satisfies TaskRunExecutionLazyAttemptPayload; From 3f5a2354bcc472dcf418bc39ae0ad35f5f70a4b4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:48:50 +0000 Subject: [PATCH 3/5] chore: add changeset for isReplay Co-Authored-By: nick <55853254+nicktrn@users.noreply.github.com> --- .changeset/add-is-replay-context.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-is-replay-context.md diff --git a/.changeset/add-is-replay-context.md b/.changeset/add-is-replay-context.md new file mode 100644 index 00000000000..28f6a01380d --- /dev/null +++ b/.changeset/add-is-replay-context.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add `isReplay` boolean to the run context (`ctx.run.isReplay`), derived from the existing `replayedFromTaskRunFriendlyId` database field. Defaults to `false` for backwards compatibility. From 38fc9e0121352cd76a783a9a1dfc1d80d1e9ef97 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:07:10 +0000 Subject: [PATCH 4/5] fix: add isReplay to remaining webapp execution context constructions Co-Authored-By: nick <55853254+nicktrn@users.noreply.github.com> --- .../app/presenters/v3/SpanPresenter.server.ts | 15 ++++----------- .../app/v3/marqs/sharedQueueConsumer.server.ts | 2 ++ .../v3/services/createTaskRunAttempt.server.ts | 1 + 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 0ea9b37ab7f..de41aee4411 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -42,9 +42,7 @@ export type PromptSpanData = { config?: string; }; -function extractPromptSpanData( - properties: Record -): PromptSpanData | undefined { +function extractPromptSpanData(properties: Record): PromptSpanData | undefined { // Properties come as an unflattened nested object from ClickHouse, // e.g. { prompt: { slug: "...", version: 3, ... } } const prompt = properties.prompt; @@ -592,10 +590,7 @@ export class SpanPresenter extends BasePresenter { triggeredRuns, aiData: span.properties && typeof span.properties === "object" - ? extractAISpanData( - span.properties as Record, - span.duration / 1_000_000 - ) + ? extractAISpanData(span.properties as Record, span.duration / 1_000_000) : undefined, }; @@ -739,10 +734,7 @@ export class SpanPresenter extends BasePresenter { "ai.streamObject", ]; - if ( - typeof span.message === "string" && - AI_SUMMARY_MESSAGES.includes(span.message) - ) { + if (typeof span.message === "string" && AI_SUMMARY_MESSAGES.includes(span.message)) { const aiSummaryData = extractAISummarySpanData( span.properties as Record, span.duration / 1_000_000 @@ -899,6 +891,7 @@ export class SpanPresenter extends BasePresenter { createdAt: run.createdAt, tags: run.runTags, isTest: run.isTest, + isReplay: !!run.replayedFromTaskRunFriendlyId, idempotencyKey: getUserProvidedIdempotencyKey(run) ?? undefined, startedAt: run.startedAt ?? run.createdAt, durationMs: run.usageDurationMs, diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index cbcc023b40d..518b64666d4 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -1640,6 +1640,7 @@ export const AttemptForExecutionGetPayload = { createdAt: true, startedAt: true, isTest: true, + replayedFromTaskRunFriendlyId: true, metadata: true, metadataType: true, idempotencyKey: true, @@ -1726,6 +1727,7 @@ class SharedQueueTasks { startedAt: taskRun.startedAt ?? taskRun.createdAt, tags: taskRun.runTags ?? [], isTest: taskRun.isTest, + isReplay: !!taskRun.replayedFromTaskRunFriendlyId, idempotencyKey: taskRun.idempotencyKey ?? undefined, durationMs: taskRun.usageDurationMs, costInCents: taskRun.costInCents, diff --git a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts index 7e0b40dd826..8be2b9557cc 100644 --- a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts +++ b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts @@ -210,6 +210,7 @@ export class CreateTaskRunAttemptService extends BaseService { createdAt: taskRun.createdAt, tags: taskRun.runTags ?? [], isTest: taskRun.isTest, + isReplay: !!taskRun.replayedFromTaskRunFriendlyId, idempotencyKey: taskRun.idempotencyKey ?? undefined, startedAt: taskRun.startedAt ?? taskRun.createdAt, durationMs: taskRun.usageDurationMs, From c64b9d663e646bd066d7deb902d795e94eacb253 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:52:38 +0000 Subject: [PATCH 5/5] docs: add isReplay to context docs and replaying page Co-Authored-By: nick <55853254+nicktrn@users.noreply.github.com> --- docs/context.mdx | 3 +++ docs/replaying.mdx | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/context.mdx b/docs/context.mdx index 4e4b8f7bac6..f522fd8ccb5 100644 --- a/docs/context.mdx +++ b/docs/context.mdx @@ -81,6 +81,9 @@ export const parentTask = task({ Whether this is a [test run](/run-tests). + + Whether this run is a [replay](/replaying) of a previous run. + The creation time of the task run. diff --git a/docs/replaying.mdx b/docs/replaying.mdx index 0e348dcd58e..34e5aac7980 100644 --- a/docs/replaying.mdx +++ b/docs/replaying.mdx @@ -30,6 +30,21 @@ description: "A replay is a copy of a run with the same payload but against the +### Detecting replays in your task + +You can check if a run is a replay using the [context](/context) object: + +```ts +export const myTask = task({ + id: "my-task", + run: async (payload, { ctx }) => { + if (ctx.run.isReplay) { + // This run is a replay of a previous run + } + }, +}); +``` + ### Replaying using the SDK You can replay a run using the SDK: