@@ -3,6 +3,7 @@ import hashjs from "hash.js";
33import ms , { type StringValue } from "ms" ;
44import { z } from "zod/v3" ;
55
6+ import { StaleDispatchError } from "../../api/api.ts" ;
67import {
78 defaultMaxRetries ,
89 ExecutionVersion ,
@@ -91,6 +92,12 @@ import {
9192
9293const { sha1 } = hashjs ;
9394
95+ // Defends against `instanceof` failing across bundle boundaries.
96+ const isStaleDispatchError = ( err : unknown ) : boolean =>
97+ err instanceof StaleDispatchError ||
98+ // biome-ignore lint/suspicious/noExplicitAny: name check across boundaries
99+ ( err as any ) ?. name === "StaleDispatchError" ;
100+
94101/**
95102 * Retry configuration for checkpoint operations.
96103 *
@@ -466,9 +473,13 @@ class InngestExecutionEngine
466473 runId : this . fnArg . runId ,
467474 fnId : internalFnId ,
468475 queueItemId,
476+ generationId : this . options . generationId ,
469477 steps,
470478 } ) ,
471- CHECKPOINT_RETRY_OPTIONS ,
479+ {
480+ ...CHECKPOINT_RETRY_OPTIONS ,
481+ shouldRetry : ( err ) => ! isStaleDispatchError ( err ) ,
482+ } ,
472483 ) ;
473484 } else {
474485 throw new Error (
@@ -864,6 +875,21 @@ class InngestExecutionEngine
864875 this . state . checkpointingStepBuffer ,
865876 ) ) ;
866877 } catch ( err ) {
878+ // Stale dispatch: the executor has already requeued. Returning
879+ // buffered ops would let it memoize them as canonical and chain the
880+ // next dispatch off this dead invocation, producing duplicate step
881+ // executions.
882+ if ( isStaleDispatchError ( err ) ) {
883+ this . devDebug ( "stale dispatch detected; halting execution" ) ;
884+ return {
885+ type : "function-rejected" as const ,
886+ ctx : this . fnArg ,
887+ ops : { } ,
888+ error : serializeError ( err ) ,
889+ retriable : false ,
890+ } ;
891+ }
892+
867893 // If checkpointing fails for any reason, fall back to returning
868894 // ALL buffered steps to the executor via the normal async flow.
869895 // The executor persists completed steps and rediscovers any
0 commit comments