@spooled/sdk 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -590,12 +590,13 @@ import type {
590
590
 
591
591
  ## Development and releases
592
592
 
593
- Before opening a pull request or tagging a release, run the same checks used by the package and publish workflow:
593
+ CI (`.github/workflows/ci.yml`) runs these checks on Node 22 and 24 for every push to `main` and every pull request; the publish workflow runs them again on Node 22 before publishing. Run them locally before opening a pull request or tagging a release:
594
594
 
595
595
  ```bash
596
596
  npm ci
597
597
  npm run lint
598
598
  npm run typecheck
599
+ npm run typecheck:examples
599
600
  npm run build
600
601
  npm test
601
602
  ```
@@ -224,11 +224,11 @@ declare const DEFAULT_CONFIG: {
224
224
  successThreshold: number;
225
225
  timeout: number;
226
226
  };
227
- readonly userAgent: "@spooled/sdk-nodejs/1.4.0";
227
+ readonly userAgent: "@spooled/sdk-nodejs/1.5.0";
228
228
  readonly autoRefreshToken: true;
229
229
  };
230
230
  /** SDK version */
231
- declare const SDK_VERSION = "1.4.0";
231
+ declare const SDK_VERSION = "1.5.0";
232
232
  /** API version prefix */
233
233
  declare const API_VERSION = "v1";
234
234
  /**
@@ -750,7 +750,17 @@ declare class AuthResource {
750
750
  */
751
751
  /** JSON primitive types */
752
752
  type JsonPrimitive = string | number | boolean | null;
753
- /** JSON value type */
753
+ /**
754
+ * JSON value type.
755
+ *
756
+ * Inside user-owned JSON (`payload`, `result`, `tags`, and the other
757
+ * free-form fields) an integer whose magnitude is greater than 2^53 is
758
+ * returned as a decimal string, so the digits survive decoding. A float64
759
+ * cannot hold those integers, and a string is already a `JsonValue`. Every
760
+ * smaller number, and every number outside those fields, stays a `number`.
761
+ * gRPC payloads cannot do this: `google.protobuf.Struct` has already stored
762
+ * the value as a float64.
763
+ */
754
764
  type JsonValue = JsonPrimitive | JsonObject | JsonArray;
755
765
  /** JSON object type */
756
766
  interface JsonObject {
@@ -3224,9 +3234,18 @@ interface GrpcJob {
3224
3234
  organizationId: string;
3225
3235
  queueName: string;
3226
3236
  status: GrpcJobStatus | string;
3227
- /** Payload as google.protobuf.Struct (object) */
3237
+ /**
3238
+ * Payload as google.protobuf.Struct (object).
3239
+ *
3240
+ * Struct stores every number as a float64, so an integer above 2^53 is
3241
+ * already rounded before this SDK sees it. REST keeps those integers as
3242
+ * decimal strings; send them as strings on this path.
3243
+ */
3228
3244
  payload?: Record<string, unknown> | null;
3229
- /** Result as google.protobuf.Struct (object) */
3245
+ /**
3246
+ * Result as google.protobuf.Struct (object). Same float64 limit as
3247
+ * `payload`: an integer above 2^53 cannot round-trip.
3248
+ */
3230
3249
  result?: Record<string, unknown> | null;
3231
3250
  retryCount: number;
3232
3251
  maxRetries: number;
@@ -4004,10 +4023,13 @@ interface JobContext {
4004
4023
  /** Maximum retries */
4005
4024
  maxRetries: number;
4006
4025
  /**
4007
- * Abort signal. Aborted when the job exceeds its `timeoutSeconds` or when a
4008
- * stop outlives `shutdownTimeout`; NOT aborted merely because a stop was
4009
- * requested (the handler gets the grace period to finish and be acked).
4010
- * `signal.reason` carries the cause.
4026
+ * Abort signal. Aborted when the job exceeds its `timeoutSeconds`, when a
4027
+ * stop outlives `shutdownTimeout`, or when this worker loses the lease
4028
+ * (a heartbeat of 409 `LEASE_EXPIRED`, which is also how a lease mismatch
4029
+ * is reported). NOT aborted merely because a stop was requested (the
4030
+ * handler gets the grace period to finish and be acked). `signal.reason`
4031
+ * carries the cause. A lost lease is not acked: the handler's later result
4032
+ * is discarded, because the scheduler has already given the job away.
4011
4033
  */
4012
4034
  signal: AbortSignal;
4013
4035
  /** Log local job progress (0-100); backend progress persistence is not yet available. */
@@ -4076,6 +4098,12 @@ interface ActiveJob {
4076
4098
  * else may ack it. Set when the handler settles or the shutdown fails it.
4077
4099
  */
4078
4100
  settled?: boolean;
4101
+ /**
4102
+ * Heartbeat returned 409 `LEASE_EXPIRED` (the lease timed out or the
4103
+ * `lease_id` no longer matches). The scheduler has reclaimed the job, so
4104
+ * this execution must not complete or fail it.
4105
+ */
4106
+ leaseLost?: boolean;
4079
4107
  /** The complete/fail request for this execution, once started. */
4080
4108
  ack?: Promise<void>;
4081
4109
  }
@@ -4190,6 +4218,11 @@ declare class SpooledWorker {
4190
4218
  private clearTimeoutTimer;
4191
4219
  private cleanupJob;
4192
4220
  private sendJobHeartbeat;
4221
+ /**
4222
+ * The lease is gone. Abort the handler if it listens, stop renewing, and
4223
+ * refuse a later complete/fail with the stale lease.
4224
+ */
4225
+ private noteLeaseLost;
4193
4226
  private sendWorkerHeartbeat;
4194
4227
  /**
4195
4228
  * Resolve once no job is active, or after `timeoutMs`. The timer is always
@@ -224,11 +224,11 @@ declare const DEFAULT_CONFIG: {
224
224
  successThreshold: number;
225
225
  timeout: number;
226
226
  };
227
- readonly userAgent: "@spooled/sdk-nodejs/1.4.0";
227
+ readonly userAgent: "@spooled/sdk-nodejs/1.5.0";
228
228
  readonly autoRefreshToken: true;
229
229
  };
230
230
  /** SDK version */
231
- declare const SDK_VERSION = "1.4.0";
231
+ declare const SDK_VERSION = "1.5.0";
232
232
  /** API version prefix */
233
233
  declare const API_VERSION = "v1";
234
234
  /**
@@ -750,7 +750,17 @@ declare class AuthResource {
750
750
  */
751
751
  /** JSON primitive types */
752
752
  type JsonPrimitive = string | number | boolean | null;
753
- /** JSON value type */
753
+ /**
754
+ * JSON value type.
755
+ *
756
+ * Inside user-owned JSON (`payload`, `result`, `tags`, and the other
757
+ * free-form fields) an integer whose magnitude is greater than 2^53 is
758
+ * returned as a decimal string, so the digits survive decoding. A float64
759
+ * cannot hold those integers, and a string is already a `JsonValue`. Every
760
+ * smaller number, and every number outside those fields, stays a `number`.
761
+ * gRPC payloads cannot do this: `google.protobuf.Struct` has already stored
762
+ * the value as a float64.
763
+ */
754
764
  type JsonValue = JsonPrimitive | JsonObject | JsonArray;
755
765
  /** JSON object type */
756
766
  interface JsonObject {
@@ -3224,9 +3234,18 @@ interface GrpcJob {
3224
3234
  organizationId: string;
3225
3235
  queueName: string;
3226
3236
  status: GrpcJobStatus | string;
3227
- /** Payload as google.protobuf.Struct (object) */
3237
+ /**
3238
+ * Payload as google.protobuf.Struct (object).
3239
+ *
3240
+ * Struct stores every number as a float64, so an integer above 2^53 is
3241
+ * already rounded before this SDK sees it. REST keeps those integers as
3242
+ * decimal strings; send them as strings on this path.
3243
+ */
3228
3244
  payload?: Record<string, unknown> | null;
3229
- /** Result as google.protobuf.Struct (object) */
3245
+ /**
3246
+ * Result as google.protobuf.Struct (object). Same float64 limit as
3247
+ * `payload`: an integer above 2^53 cannot round-trip.
3248
+ */
3230
3249
  result?: Record<string, unknown> | null;
3231
3250
  retryCount: number;
3232
3251
  maxRetries: number;
@@ -4004,10 +4023,13 @@ interface JobContext {
4004
4023
  /** Maximum retries */
4005
4024
  maxRetries: number;
4006
4025
  /**
4007
- * Abort signal. Aborted when the job exceeds its `timeoutSeconds` or when a
4008
- * stop outlives `shutdownTimeout`; NOT aborted merely because a stop was
4009
- * requested (the handler gets the grace period to finish and be acked).
4010
- * `signal.reason` carries the cause.
4026
+ * Abort signal. Aborted when the job exceeds its `timeoutSeconds`, when a
4027
+ * stop outlives `shutdownTimeout`, or when this worker loses the lease
4028
+ * (a heartbeat of 409 `LEASE_EXPIRED`, which is also how a lease mismatch
4029
+ * is reported). NOT aborted merely because a stop was requested (the
4030
+ * handler gets the grace period to finish and be acked). `signal.reason`
4031
+ * carries the cause. A lost lease is not acked: the handler's later result
4032
+ * is discarded, because the scheduler has already given the job away.
4011
4033
  */
4012
4034
  signal: AbortSignal;
4013
4035
  /** Log local job progress (0-100); backend progress persistence is not yet available. */
@@ -4076,6 +4098,12 @@ interface ActiveJob {
4076
4098
  * else may ack it. Set when the handler settles or the shutdown fails it.
4077
4099
  */
4078
4100
  settled?: boolean;
4101
+ /**
4102
+ * Heartbeat returned 409 `LEASE_EXPIRED` (the lease timed out or the
4103
+ * `lease_id` no longer matches). The scheduler has reclaimed the job, so
4104
+ * this execution must not complete or fail it.
4105
+ */
4106
+ leaseLost?: boolean;
4079
4107
  /** The complete/fail request for this execution, once started. */
4080
4108
  ack?: Promise<void>;
4081
4109
  }
@@ -4190,6 +4218,11 @@ declare class SpooledWorker {
4190
4218
  private clearTimeoutTimer;
4191
4219
  private cleanupJob;
4192
4220
  private sendJobHeartbeat;
4221
+ /**
4222
+ * The lease is gone. Abort the handler if it listens, stop renewing, and
4223
+ * refuse a later complete/fail with the stale lease.
4224
+ */
4225
+ private noteLeaseLost;
4193
4226
  private sendWorkerHeartbeat;
4194
4227
  /**
4195
4228
  * Resolve once no job is active, or after `timeoutMs`. The timer is always
package/dist/index.cjs CHANGED
@@ -48,10 +48,10 @@ var DEFAULT_CONFIG = {
48
48
  successThreshold: 3,
49
49
  timeout: 3e4
50
50
  },
51
- userAgent: "@spooled/sdk-nodejs/1.4.0",
51
+ userAgent: "@spooled/sdk-nodejs/1.5.0",
52
52
  autoRefreshToken: true
53
53
  };
54
- var SDK_VERSION = "1.4.0";
54
+ var SDK_VERSION = "1.5.0";
55
55
  var API_VERSION = "v1";
56
56
  var API_BASE_PATH = `/api/${API_VERSION}`;
57
57
  function normalizeCredential(value) {
@@ -631,6 +631,175 @@ function createRetryWrapper(config) {
631
631
  };
632
632
  }
633
633
 
634
+ // src/utils/json.ts
635
+ var TWO_POW_53 = "9007199254740992";
636
+ function parseApiJson(text) {
637
+ return JSON.parse(preserveExactIntegers(text));
638
+ }
639
+ function magnitudeExceedsTwoPow53(digits) {
640
+ let start = 0;
641
+ while (start < digits.length - 1 && digits.charCodeAt(start) === 48) {
642
+ start++;
643
+ }
644
+ const len = digits.length - start;
645
+ if (len !== TWO_POW_53.length) {
646
+ return len > TWO_POW_53.length;
647
+ }
648
+ for (let i = 0; i < len; i++) {
649
+ const digit = digits.charCodeAt(start + i);
650
+ const limit = TWO_POW_53.charCodeAt(i);
651
+ if (digit !== limit) {
652
+ return digit > limit;
653
+ }
654
+ }
655
+ return false;
656
+ }
657
+ function inExactValue(frames) {
658
+ const top = frames[frames.length - 1];
659
+ if (!top || top.expect !== "value") {
660
+ return false;
661
+ }
662
+ return top.exact || top.keyIsExact;
663
+ }
664
+ function preserveExactIntegers(text) {
665
+ const out = [];
666
+ const frames = [];
667
+ const n = text.length;
668
+ let i = 0;
669
+ const valueEnded = () => {
670
+ const top = frames[frames.length - 1];
671
+ if (top) {
672
+ top.expect = "after";
673
+ }
674
+ };
675
+ while (i < n) {
676
+ const c = text[i];
677
+ if (c === " " || c === "\n" || c === "\r" || c === " ") {
678
+ out.push(c);
679
+ i++;
680
+ continue;
681
+ }
682
+ if (c === "{") {
683
+ frames.push({
684
+ kind: "object",
685
+ exact: inExactValue(frames),
686
+ expect: "key",
687
+ keyIsExact: false
688
+ });
689
+ out.push(c);
690
+ i++;
691
+ continue;
692
+ }
693
+ if (c === "[") {
694
+ frames.push({
695
+ kind: "array",
696
+ exact: inExactValue(frames),
697
+ expect: "value",
698
+ keyIsExact: false
699
+ });
700
+ out.push(c);
701
+ i++;
702
+ continue;
703
+ }
704
+ if (c === "}" || c === "]") {
705
+ frames.pop();
706
+ out.push(c);
707
+ i++;
708
+ valueEnded();
709
+ continue;
710
+ }
711
+ if (c === ":") {
712
+ const top = frames[frames.length - 1];
713
+ if (top && top.kind === "object") {
714
+ top.expect = "value";
715
+ }
716
+ out.push(c);
717
+ i++;
718
+ continue;
719
+ }
720
+ if (c === ",") {
721
+ const top = frames[frames.length - 1];
722
+ if (top) {
723
+ top.expect = top.kind === "object" ? "key" : "value";
724
+ top.keyIsExact = false;
725
+ }
726
+ out.push(c);
727
+ i++;
728
+ continue;
729
+ }
730
+ if (c === '"') {
731
+ const start = i;
732
+ i++;
733
+ while (i < n) {
734
+ if (text[i] === "\\") {
735
+ i++;
736
+ if (i < n) i++;
737
+ continue;
738
+ }
739
+ if (text[i] === '"') {
740
+ i++;
741
+ break;
742
+ }
743
+ i++;
744
+ }
745
+ const raw = text.slice(start, i);
746
+ const top = frames[frames.length - 1];
747
+ if (top && top.kind === "object" && top.expect === "key") {
748
+ let key = "";
749
+ try {
750
+ key = JSON.parse(raw);
751
+ } catch {
752
+ key = "";
753
+ }
754
+ top.keyIsExact = typeof key === "string" && SKIP_CONVERSION_KEYS.has(key);
755
+ top.expect = "colon";
756
+ } else {
757
+ valueEnded();
758
+ }
759
+ out.push(raw);
760
+ continue;
761
+ }
762
+ if (c === "-" || c >= "0" && c <= "9") {
763
+ const start = i;
764
+ if (text[i] === "-") i++;
765
+ const digitsAt = i;
766
+ while (i < n && text[i] >= "0" && text[i] <= "9") i++;
767
+ const digitsEnd = i;
768
+ let isInteger = true;
769
+ if (i < n && text[i] === ".") {
770
+ isInteger = false;
771
+ i++;
772
+ while (i < n && text[i] >= "0" && text[i] <= "9") i++;
773
+ }
774
+ if (i < n && (text[i] === "e" || text[i] === "E")) {
775
+ isInteger = false;
776
+ i++;
777
+ if (i < n && (text[i] === "+" || text[i] === "-")) i++;
778
+ while (i < n && text[i] >= "0" && text[i] <= "9") i++;
779
+ }
780
+ const raw = text.slice(start, i);
781
+ const digits = text.slice(digitsAt, digitsEnd);
782
+ if (isInteger && digits.length > 0 && inExactValue(frames) && magnitudeExceedsTwoPow53(digits)) {
783
+ out.push(`"${raw}"`);
784
+ } else {
785
+ out.push(raw);
786
+ }
787
+ valueEnded();
788
+ continue;
789
+ }
790
+ if (c >= "a" && c <= "z") {
791
+ const start = i;
792
+ while (i < n && text[i] >= "a" && text[i] <= "z") i++;
793
+ out.push(text.slice(start, i));
794
+ valueEnded();
795
+ continue;
796
+ }
797
+ out.push(c);
798
+ i++;
799
+ }
800
+ return out.join("");
801
+ }
802
+
634
803
  // src/utils/http.ts
635
804
  var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE"]);
636
805
  var HttpClient = class {
@@ -831,7 +1000,7 @@ var HttpClient = class {
831
1000
  if (response.status === 204) {
832
1001
  data = void 0;
833
1002
  } else if (contentType.includes("application/json")) {
834
- const rawData = await response.json();
1003
+ const rawData = parseApiJson(await response.text());
835
1004
  data = options.skipResponseConversion ? rawData : convertResponse(rawData);
836
1005
  } else {
837
1006
  data = await response.text();
@@ -2589,7 +2758,7 @@ var WebSocketRealtimeClient = class {
2589
2758
  }
2590
2759
  handleMessage(data) {
2591
2760
  try {
2592
- const message = convertResponse(JSON.parse(data));
2761
+ const message = convertResponse(parseApiJson(data));
2593
2762
  const eventType = mapEventType(String(message.type));
2594
2763
  message.type = eventType;
2595
2764
  const event = message;
@@ -2774,8 +2943,9 @@ var SseRealtimeClient = class {
2774
2943
  this.setState("connecting");
2775
2944
  const sseUrl = this.buildSseUrl(filter);
2776
2945
  this.options.debug(`Connecting to SSE: ${sseUrl}`);
2946
+ const usingPlatformEventSource = typeof EventSource !== "undefined";
2777
2947
  let EventSourceImpl;
2778
- if (typeof EventSource !== "undefined") {
2948
+ if (usingPlatformEventSource) {
2779
2949
  EventSourceImpl = EventSource;
2780
2950
  } else {
2781
2951
  try {
@@ -2799,16 +2969,20 @@ var SseRealtimeClient = class {
2799
2969
  }
2800
2970
  return new Promise((resolve, reject) => {
2801
2971
  try {
2802
- this.eventSource = new EventSourceImpl(sseUrl, {
2803
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2804
- fetch: (input, init) => fetch(input, {
2805
- ...init,
2806
- headers: {
2807
- ...init?.headers,
2808
- Authorization: `Bearer ${token}`
2809
- }
2810
- })
2811
- });
2972
+ const connectUrl = usingPlatformEventSource ? appendSseToken(sseUrl, token) : sseUrl;
2973
+ this.eventSource = new EventSourceImpl(
2974
+ connectUrl,
2975
+ usingPlatformEventSource ? void 0 : {
2976
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2977
+ fetch: (input, init) => fetch(input, {
2978
+ ...init,
2979
+ headers: {
2980
+ ...init?.headers,
2981
+ Authorization: `Bearer ${token}`
2982
+ }
2983
+ })
2984
+ }
2985
+ );
2812
2986
  this.eventSource.onopen = () => {
2813
2987
  this.options.debug("SSE connected");
2814
2988
  this.setState("connected");
@@ -2920,7 +3094,7 @@ var SseRealtimeClient = class {
2920
3094
  }
2921
3095
  handleMessage(data) {
2922
3096
  try {
2923
- const parsed = convertResponse(JSON.parse(data));
3097
+ const parsed = convertResponse(parseApiJson(data));
2924
3098
  const eventType = mapEventType(String(parsed.type));
2925
3099
  parsed.type = eventType;
2926
3100
  const event = parsed;
@@ -2995,6 +3169,10 @@ var SseRealtimeClient = class {
2995
3169
  }, delay);
2996
3170
  }
2997
3171
  };
3172
+ function appendSseToken(url, token) {
3173
+ const separator = url.includes("?") ? "&" : "?";
3174
+ return `${url}${separator}token=${encodeURIComponent(token)}`;
3175
+ }
2998
3176
  function isStreamEndingEvent(eventType, data) {
2999
3177
  if (eventType === "error" || eventType === "job.completed") {
3000
3178
  return true;
@@ -4460,6 +4638,10 @@ var SpooledWorker = class {
4460
4638
  };
4461
4639
  try {
4462
4640
  const outcome = await this.runHandler(activeJob, context);
4641
+ if (activeJob.leaseLost) {
4642
+ this.debug(`Job ${job.id} lost its lease; dropping late result`);
4643
+ return;
4644
+ }
4463
4645
  if (activeJob.settled) {
4464
4646
  this.debug(`Job ${job.id} was already settled; dropping late result`);
4465
4647
  return;
@@ -4467,11 +4649,14 @@ var SpooledWorker = class {
4467
4649
  activeJob.settled = true;
4468
4650
  switch (outcome.kind) {
4469
4651
  case "returned":
4470
- activeJob.ack = this.completeJob(job, outcome.value ?? void 0);
4652
+ activeJob.ack = this.completeJob(
4653
+ activeJob,
4654
+ outcome.value ?? void 0
4655
+ );
4471
4656
  break;
4472
4657
  case "threw":
4473
4658
  activeJob.ack = this.failJob(
4474
- job,
4659
+ activeJob,
4475
4660
  outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
4476
4661
  );
4477
4662
  break;
@@ -4481,7 +4666,7 @@ var SpooledWorker = class {
4481
4666
  jobId: job.id,
4482
4667
  queueName: job.queueName
4483
4668
  });
4484
- activeJob.ack = this.failJob(job, JOB_TIMEOUT_MESSAGE);
4669
+ activeJob.ack = this.failJob(activeJob, JOB_TIMEOUT_MESSAGE);
4485
4670
  break;
4486
4671
  }
4487
4672
  await activeJob.ack;
@@ -4521,12 +4706,13 @@ var SpooledWorker = class {
4521
4706
  this.debug(`Force-failing job ${activeJob.job.id} due to shutdown timeout`);
4522
4707
  activeJob.settled = true;
4523
4708
  activeJob.abortController.abort(new Error(SHUTDOWN_TIMEOUT_MESSAGE));
4524
- activeJob.ack = this.failJob(activeJob.job, SHUTDOWN_TIMEOUT_MESSAGE);
4709
+ activeJob.ack = this.failJob(activeJob, SHUTDOWN_TIMEOUT_MESSAGE);
4525
4710
  await activeJob.ack;
4526
4711
  this.cleanupJob(activeJob);
4527
4712
  }
4528
- async completeJob(job, result) {
4529
- if (!this.workerId) return;
4713
+ async completeJob(activeJob, result) {
4714
+ if (!this.workerId || activeJob.leaseLost) return;
4715
+ const { job } = activeJob;
4530
4716
  try {
4531
4717
  await this.client.jobs.complete(job.id, {
4532
4718
  workerId: this.workerId,
@@ -4539,12 +4725,17 @@ var SpooledWorker = class {
4539
4725
  result
4540
4726
  });
4541
4727
  } catch (error) {
4728
+ if (isLeaseExpired(error)) {
4729
+ this.noteLeaseLost(activeJob, error);
4730
+ return;
4731
+ }
4542
4732
  this.debug(`Failed to complete job ${job.id}`, error);
4543
4733
  this.emit("error", { error });
4544
4734
  }
4545
4735
  }
4546
- async failJob(job, errorMessage) {
4547
- if (!this.workerId) return;
4736
+ async failJob(activeJob, errorMessage) {
4737
+ if (!this.workerId || activeJob.leaseLost) return;
4738
+ const { job } = activeJob;
4548
4739
  const willRetry = job.retryCount < job.maxRetries;
4549
4740
  const message = coerceFailureMessage(errorMessage);
4550
4741
  try {
@@ -4560,6 +4751,10 @@ var SpooledWorker = class {
4560
4751
  willRetry
4561
4752
  });
4562
4753
  } catch (error) {
4754
+ if (isLeaseExpired(error)) {
4755
+ this.noteLeaseLost(activeJob, error);
4756
+ return;
4757
+ }
4563
4758
  this.debug(`Failed to fail job ${job.id}`, error);
4564
4759
  this.emit("error", { error });
4565
4760
  }
@@ -4584,7 +4779,7 @@ var SpooledWorker = class {
4584
4779
  }
4585
4780
  }
4586
4781
  async sendJobHeartbeat(activeJob) {
4587
- if (!this.workerId) return;
4782
+ if (!this.workerId || activeJob.leaseLost) return;
4588
4783
  const { job } = activeJob;
4589
4784
  try {
4590
4785
  await this.client.jobs.heartbeat(job.id, {
@@ -4593,9 +4788,34 @@ var SpooledWorker = class {
4593
4788
  ...job.leaseId != null && { leaseId: job.leaseId }
4594
4789
  });
4595
4790
  } catch (error) {
4791
+ if (isLeaseExpired(error)) {
4792
+ this.noteLeaseLost(activeJob, error);
4793
+ return;
4794
+ }
4596
4795
  this.debug(`Job heartbeat failed for ${job.id}`, error);
4597
4796
  }
4598
4797
  }
4798
+ /**
4799
+ * The lease is gone. Abort the handler if it listens, stop renewing, and
4800
+ * refuse a later complete/fail with the stale lease.
4801
+ */
4802
+ noteLeaseLost(activeJob, error) {
4803
+ if (activeJob.leaseLost) return;
4804
+ activeJob.leaseLost = true;
4805
+ if (activeJob.heartbeatTimer) {
4806
+ clearInterval(activeJob.heartbeatTimer);
4807
+ activeJob.heartbeatTimer = void 0;
4808
+ }
4809
+ this.clearTimeoutTimer(activeJob);
4810
+ const reason = error instanceof Error ? error : new Error("Job lease expired");
4811
+ activeJob.abortController.abort(reason);
4812
+ this.debug(
4813
+ `Job lease lost for ${activeJob.job.id}; stopping handler`,
4814
+ error
4815
+ );
4816
+ activeJob.settled = true;
4817
+ this.cleanupJob(activeJob);
4818
+ }
4599
4819
  async sendWorkerHeartbeat() {
4600
4820
  if (!this.workerId || this.state !== "running" && this.state !== "stopping") {
4601
4821
  return;
@@ -4630,6 +4850,9 @@ var SpooledWorker = class {
4630
4850
  });
4631
4851
  }
4632
4852
  };
4853
+ function isLeaseExpired(error) {
4854
+ return error instanceof SpooledError && error.statusCode === 409 && error.code.toUpperCase() === "LEASE_EXPIRED";
4855
+ }
4633
4856
 
4634
4857
  exports.API_VERSION = API_VERSION;
4635
4858
  exports.AdminResource = AdminResource;