@semiont/jobs 0.5.36 → 0.5.38

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/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { promises, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
2
2
  import * as path from 'path';
3
3
  import { join } from 'path';
4
4
  import { replyChannelsFor, jobId, deriveViews, GENERATABLE_MEDIA_TYPES, estimateTokens, isObject, isString, reconcileSelector, getLocaleEnglishName, cutChunk, chunkText } from '@semiont/core';
5
+ import { connect, RetentionPolicy, DeliverPolicy, AckPolicy, nanos } from 'nats';
5
6
  import { withSpan, recordAnchorOutcome, recordDetectionCall } from '@semiont/observability';
6
7
  import { StructuredReadError } from '@semiont/inference';
7
8
  import { execFileSync } from 'child_process';
@@ -11,17 +12,15 @@ import '@semiont/event-sourcing';
11
12
  import '@semiont/sdk';
12
13
  import '@semiont/http-transport';
13
14
 
14
- // src/fs-job-queue.ts
15
+ // src/job-queue-interface.ts
16
+ var JOB_QUEUE_EMITS = ["job:queued"];
15
17
 
16
18
  // src/will-retry.ts
17
19
  function willRetryAfter(metadata, failureClass) {
18
20
  return failureClass !== "deterministic" && metadata.retryCount < metadata.maxRetries;
19
21
  }
20
22
 
21
- // src/fs-job-queue.ts
22
- var REANNOUNCE_INTERVAL_MS = 3e4;
23
- var STALE_RUNNING_MS = 30 * 6e4;
24
- var PROGRESS_WRITE_MIN_INTERVAL_MS = 5e3;
23
+ // src/checkpoint-merge.ts
25
24
  function mergeUnitCursors(existing, incoming, completed) {
26
25
  const done = new Set(completed);
27
26
  const merged = {};
@@ -35,6 +34,11 @@ function mergeUnitCursors(existing, incoming, completed) {
35
34
  }
36
35
  return merged;
37
36
  }
37
+
38
+ // src/fs-job-queue.ts
39
+ var REANNOUNCE_INTERVAL_MS = 3e4;
40
+ var STALE_RUNNING_MS = 30 * 6e4;
41
+ var PROGRESS_WRITE_MIN_INTERVAL_MS = 5e3;
38
42
  var RETENTION_HOURS = 24;
39
43
  var CLEANUP_INTERVAL_MS = 36e5;
40
44
  var FsJobQueue = class {
@@ -165,10 +169,43 @@ var FsJobQueue = class {
165
169
  }
166
170
  return null;
167
171
  }
172
+ /**
173
+ * Serializes claims. This driver's atomicity is single-process by design,
174
+ * but `claimJob`'s contract must hold even against interleaved awaits in
175
+ * that one process — a read-check-write with await points in it can admit
176
+ * two winners without this chain (the TOCTOU the operation exists to kill).
177
+ */
178
+ claimChain = Promise.resolve();
179
+ async claimNextJob(types) {
180
+ const claim = this.claimChain.then(() => this.doClaimNext(types));
181
+ this.claimChain = claim.catch(() => void 0);
182
+ return claim;
183
+ }
184
+ async doClaimNext(types) {
185
+ const pending = await this.listJobs({ status: "pending", limit: Number.MAX_SAFE_INTEGER });
186
+ const match = pending.find((j) => types.length === 0 || types.includes(j.metadata.type));
187
+ if (!match) return { declined: "none-available" };
188
+ const running = {
189
+ status: "running",
190
+ metadata: match.metadata,
191
+ params: match.params,
192
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
193
+ progress: {}
194
+ };
195
+ await this.transition(running, "pending");
196
+ return { job: running };
197
+ }
168
198
  /**
169
199
  * Update a job (atomic: delete old, write new)
170
200
  */
171
- async updateJob(job, oldStatus) {
201
+ /**
202
+ * Cross-status move (or in-place rewrite): delete old slot, write new.
203
+ * Private — the interface exposes named transitions only (JOB-QUEUE-DRIVER
204
+ * P0); a generic public patch is trivial on files and impossible on an
205
+ * in-flight message, so no driver may offer one. Re-entering `pending`
206
+ * announces, which is how failJob's retry reaches a listening worker.
207
+ */
208
+ async transition(job, oldStatus) {
172
209
  if (oldStatus && oldStatus !== job.status) {
173
210
  const oldPath = this.getJobPath(job.metadata.id, oldStatus);
174
211
  try {
@@ -206,7 +243,7 @@ var FsJobQueue = class {
206
243
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
207
244
  result
208
245
  };
209
- await this.updateJob(completed, "running");
246
+ await this.transition(completed, "running");
210
247
  return true;
211
248
  }
212
249
  /**
@@ -235,7 +272,7 @@ var FsJobQueue = class {
235
272
  metadata: { ...metadata, retryCount: job.metadata.retryCount + 1 },
236
273
  params: job.params
237
274
  };
238
- await this.updateJob(retried, "running");
275
+ await this.transition(retried, "running");
239
276
  return "retried";
240
277
  }
241
278
  if (failureClass === "deterministic" && job.metadata.retryCount < job.metadata.maxRetries) {
@@ -249,7 +286,7 @@ var FsJobQueue = class {
249
286
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
250
287
  error
251
288
  };
252
- await this.updateJob(failed, "running");
289
+ await this.transition(failed, "running");
253
290
  return "failed";
254
291
  }
255
292
  /**
@@ -352,7 +389,7 @@ var FsJobQueue = class {
352
389
  startedAt: job.status === "running" ? job.startedAt : void 0,
353
390
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
354
391
  };
355
- await this.updateJob(cancelledJob, oldStatus);
392
+ await this.transition(cancelledJob, oldStatus);
356
393
  return true;
357
394
  }
358
395
  /**
@@ -474,6 +511,471 @@ var FsJobQueue = class {
474
511
  return stats;
475
512
  }
476
513
  };
514
+ var STREAM = "JOBS";
515
+ var CONSUMER = "gateway-claims";
516
+ var BUCKET = "jobs";
517
+ var PROGRESS_WRITE_MIN_INTERVAL_MS2 = 5e3;
518
+ var MAX_CAS_ATTEMPTS = 20;
519
+ var enc = new TextEncoder();
520
+ var dec = new TextDecoder();
521
+ var JOBS_STREAM_SUBJECTS = ["jobs.>"];
522
+ function categoryOf(type) {
523
+ return type === "generation" ? "generation" : "annotation";
524
+ }
525
+ function subjectFor(job) {
526
+ return `jobs.${categoryOf(job.metadata.type)}.${job.metadata.type}`;
527
+ }
528
+ var JetStreamJobQueue = class {
529
+ constructor(options, logger, eventBus) {
530
+ this.options = options;
531
+ this.logger = logger;
532
+ this.eventBus = eventBus;
533
+ this.staleRunningMs = options.staleRunningMs ?? 30 * 6e4;
534
+ this.ackWaitMs = options.ackWaitMs ?? 3e4;
535
+ this.tickMs = options.tickMs ?? 3e4;
536
+ }
537
+ options;
538
+ logger;
539
+ eventBus;
540
+ nc;
541
+ js;
542
+ jsm;
543
+ kv;
544
+ iter = null;
545
+ /** Delivered messages this process holds — the lease, keyed by jobId,
546
+ * with the job's type so a by-type claim can walk them without a read. */
547
+ held = /* @__PURE__ */ new Map();
548
+ heartbeat = null;
549
+ reconciling = false;
550
+ tick = null;
551
+ ticking = false;
552
+ lastProgressWrite = /* @__PURE__ */ new Map();
553
+ staleRunningMs;
554
+ ackWaitMs;
555
+ tickMs;
556
+ async initialize() {
557
+ this.nc = await connect({
558
+ servers: this.options.servers,
559
+ reconnect: this.options.reconnect ?? true,
560
+ timeout: 1e4
561
+ });
562
+ this.js = this.nc.jetstream();
563
+ this.jsm = await this.nc.jetstreamManager();
564
+ this.kv = await this.js.views.kv(BUCKET);
565
+ try {
566
+ await this.jsm.streams.info(STREAM);
567
+ } catch {
568
+ await this.jsm.streams.add({
569
+ name: STREAM,
570
+ subjects: [...JOBS_STREAM_SUBJECTS],
571
+ retention: RetentionPolicy.Workqueue
572
+ });
573
+ }
574
+ try {
575
+ await this.jsm.consumers.info(STREAM, CONSUMER);
576
+ } catch {
577
+ await this.jsm.consumers.add(STREAM, {
578
+ durable_name: CONSUMER,
579
+ ack_policy: AckPolicy.Explicit,
580
+ deliver_policy: DeliverPolicy.All,
581
+ ack_wait: nanos(this.ackWaitMs),
582
+ // ONE retry authority: will-retry.ts decides; the stream never
583
+ // gives up a message on its own count.
584
+ max_deliver: -1
585
+ });
586
+ }
587
+ const consumer = await this.js.consumers.get(STREAM, CONSUMER);
588
+ this.iter = await consumer.consume({
589
+ callback: (m) => {
590
+ void this.onDelivery(m).catch((error) => {
591
+ this.logger.warn("Job delivery handling failed", {
592
+ error: error instanceof Error ? error.message : String(error)
593
+ });
594
+ });
595
+ }
596
+ });
597
+ this.heartbeat = setInterval(() => {
598
+ if (this.reconciling) return;
599
+ this.reconciling = true;
600
+ void this.reconcileHeld().catch((error) => {
601
+ this.logger.warn("Held-lease reconciliation failed", {
602
+ error: error instanceof Error ? error.message : String(error)
603
+ });
604
+ }).finally(() => {
605
+ this.reconciling = false;
606
+ });
607
+ }, Math.max(250, Math.floor(this.ackWaitMs / 4)));
608
+ this.heartbeat.unref?.();
609
+ this.tick = setInterval(() => {
610
+ if (this.ticking) return;
611
+ this.ticking = true;
612
+ void (async () => {
613
+ for (const [id, held] of [...this.held]) {
614
+ const envelope = await this.read(jobId(id));
615
+ if (envelope?.job.status === "pending") this.announce(envelope.job);
616
+ }
617
+ await this.recoverStaleRunningJobs();
618
+ })().catch((error) => {
619
+ this.logger.warn("Job-queue tick failed", {
620
+ error: error instanceof Error ? error.message : String(error)
621
+ });
622
+ }).finally(() => {
623
+ this.ticking = false;
624
+ });
625
+ }, this.tickMs);
626
+ this.tick.unref?.();
627
+ }
628
+ destroy() {
629
+ this.iter?.stop();
630
+ this.iter = null;
631
+ if (this.heartbeat) clearInterval(this.heartbeat);
632
+ this.heartbeat = null;
633
+ if (this.tick) clearInterval(this.tick);
634
+ this.tick = null;
635
+ this.held.clear();
636
+ void this.nc?.close();
637
+ }
638
+ /**
639
+ * A delivery is a job arriving at this gateway. What it means depends on
640
+ * the job's authoritative (KV) state:
641
+ * - pending → hold the lease, announce for a worker to claim;
642
+ * - running → hold silently (a claim raced ahead of the delivery, or a
643
+ * redelivery reached a fresh instance after a gateway death);
644
+ * - terminal → the work already concluded elsewhere; consume the message;
645
+ * - unknown → not ours to run; terminate it.
646
+ */
647
+ async onDelivery(m) {
648
+ let id;
649
+ try {
650
+ id = JSON.parse(dec.decode(m.data)).jobId;
651
+ } catch {
652
+ m.term();
653
+ return;
654
+ }
655
+ const envelope = await this.read(jobId(id));
656
+ if (!envelope) {
657
+ m.term();
658
+ return;
659
+ }
660
+ switch (envelope.job.status) {
661
+ case "pending":
662
+ this.held.set(id, { m, type: envelope.job.metadata.type });
663
+ this.announce(envelope.job);
664
+ break;
665
+ case "running":
666
+ this.held.set(id, { m, type: envelope.job.metadata.type });
667
+ break;
668
+ default:
669
+ m.ack();
670
+ this.held.delete(id);
671
+ }
672
+ }
673
+ /** Same wire shape as the fs driver: only jobs with a resourceId announce. */
674
+ announce(job) {
675
+ if (this.eventBus && "params" in job && "resourceId" in job.params) {
676
+ this.eventBus.get("job:queued").next({
677
+ jobId: job.metadata.id,
678
+ jobType: job.metadata.type,
679
+ resourceId: job.params.resourceId,
680
+ userId: job.metadata.userId
681
+ });
682
+ }
683
+ }
684
+ async read(jobIdArg) {
685
+ const entry = await this.kv.get(jobIdArg);
686
+ if (!entry || entry.operation !== "PUT") return null;
687
+ const envelope = JSON.parse(dec.decode(entry.value));
688
+ return { ...envelope, revision: entry.revision };
689
+ }
690
+ async write(jobIdArg, envelope, revision) {
691
+ const payload = enc.encode(JSON.stringify(envelope));
692
+ if (revision === void 0) return this.kv.put(jobIdArg, payload);
693
+ return this.kv.update(jobIdArg, payload, revision);
694
+ }
695
+ /**
696
+ * Read-transform-CAS with bounded retries: the transform sees the current
697
+ * job and returns the replacement (or null to stop). Every state
698
+ * transition goes through here, which is what makes each one atomic.
699
+ */
700
+ async cas(jobIdArg, transform, onMissing) {
701
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
702
+ const current = await this.read(jobIdArg);
703
+ if (!current) return onMissing;
704
+ const out = transform(current.job, current.lastProgressAt);
705
+ if (out === null) return onMissing;
706
+ if (!("envelope" in out)) return out.result;
707
+ try {
708
+ await this.write(jobIdArg, out.envelope, current.revision);
709
+ return out.result;
710
+ } catch {
711
+ }
712
+ }
713
+ throw new Error(`Job ${jobIdArg} transition failed after ${MAX_CAS_ATTEMPTS} CAS attempts`);
714
+ }
715
+ async createJob(job) {
716
+ const now = (/* @__PURE__ */ new Date()).toISOString();
717
+ await this.kv.create(job.metadata.id, enc.encode(JSON.stringify({ job, lastProgressAt: now })));
718
+ if (job.status === "pending") {
719
+ await this.js.publish(subjectFor(job), enc.encode(JSON.stringify({ jobId: job.metadata.id })));
720
+ }
721
+ }
722
+ async getJob(jobIdArg) {
723
+ const envelope = await this.read(jobIdArg);
724
+ return envelope?.job ?? null;
725
+ }
726
+ async claimNextJob(types) {
727
+ const matches = (t) => types.length === 0 || types.includes(t);
728
+ for (const [id, held] of [...this.held]) {
729
+ if (!matches(held.type)) continue;
730
+ const won = await this.tryClaim(jobId(id));
731
+ if (won) return won;
732
+ }
733
+ for (const key of await this.allKeys()) {
734
+ const envelope = await this.read(jobId(key));
735
+ if (!envelope || envelope.job.status !== "pending" || !matches(envelope.job.metadata.type)) continue;
736
+ const won = await this.tryClaim(jobId(key));
737
+ if (won) return won;
738
+ }
739
+ return { declined: "none-available" };
740
+ }
741
+ /** CAS one pending job to running; null when someone else won it. */
742
+ async tryClaim(jobIdArg) {
743
+ return this.cas(
744
+ jobIdArg,
745
+ (job) => {
746
+ if (job.status !== "pending") return { result: null };
747
+ const running = {
748
+ status: "running",
749
+ metadata: job.metadata,
750
+ params: job.params,
751
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
752
+ progress: {}
753
+ };
754
+ return {
755
+ envelope: { job: running, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() },
756
+ result: { job: running }
757
+ };
758
+ },
759
+ null
760
+ );
761
+ }
762
+ async completeJob(jobIdArg, result) {
763
+ const moved = await this.cas(
764
+ jobIdArg,
765
+ (job) => {
766
+ if (job.status !== "running") return { result: false };
767
+ const completed = {
768
+ status: "complete",
769
+ metadata: job.metadata,
770
+ params: job.params,
771
+ startedAt: job.startedAt,
772
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
773
+ result
774
+ };
775
+ return { envelope: { job: completed, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: true };
776
+ },
777
+ false
778
+ );
779
+ if (moved) this.settleLease(jobIdArg, "ack");
780
+ return moved;
781
+ }
782
+ async failJob(jobIdArg, error, completedUnits, failureClass, unitCursors) {
783
+ const outcome = await this.cas(
784
+ jobIdArg,
785
+ (job) => {
786
+ if (job.status !== "running") return { result: null };
787
+ const units = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits ?? []])];
788
+ const cursors = mergeUnitCursors(job.metadata.unitCursors, unitCursors, units);
789
+ const metadata = {
790
+ ...job.metadata,
791
+ ...units.length > 0 ? { completedUnits: units } : {},
792
+ ...Object.keys(cursors).length > 0 ? { unitCursors: cursors } : {}
793
+ };
794
+ if ("unitCursors" in metadata && Object.keys(cursors).length === 0) delete metadata.unitCursors;
795
+ if (willRetryAfter(job.metadata, failureClass)) {
796
+ const retried = {
797
+ status: "pending",
798
+ metadata: { ...metadata, retryCount: job.metadata.retryCount + 1 },
799
+ params: job.params
800
+ };
801
+ return { envelope: { job: retried, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: "retried" };
802
+ }
803
+ const failed = {
804
+ status: "failed",
805
+ metadata,
806
+ params: job.params,
807
+ startedAt: job.startedAt,
808
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
809
+ error
810
+ };
811
+ return { envelope: { job: failed, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: "failed" };
812
+ },
813
+ null
814
+ );
815
+ if (outcome === "retried") {
816
+ const held = this.held.get(jobIdArg);
817
+ if (held) {
818
+ this.held.delete(jobIdArg);
819
+ held.m.nak();
820
+ } else {
821
+ const envelope = await this.read(jobIdArg);
822
+ if (envelope) await this.js.publish(subjectFor(envelope.job), enc.encode(JSON.stringify({ jobId: jobIdArg })));
823
+ }
824
+ } else if (outcome === "failed") {
825
+ this.settleLease(jobIdArg, "term");
826
+ }
827
+ return outcome;
828
+ }
829
+ async checkpointUnits(jobIdArg, completedUnits, unitCursors) {
830
+ await this.cas(
831
+ jobIdArg,
832
+ (job) => {
833
+ if (job.status !== "running") return { result: void 0 };
834
+ const units = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits])];
835
+ const cursors = mergeUnitCursors(job.metadata.unitCursors, unitCursors, units);
836
+ const metadata = { ...job.metadata, completedUnits: units };
837
+ if (Object.keys(cursors).length > 0) metadata.unitCursors = cursors;
838
+ else delete metadata.unitCursors;
839
+ const updated = { ...job, metadata };
840
+ return { envelope: { job: updated, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: void 0 };
841
+ },
842
+ void 0
843
+ );
844
+ }
845
+ async recordProgress(jobIdArg, progress) {
846
+ const last = this.lastProgressWrite.get(jobIdArg) ?? 0;
847
+ if (Date.now() - last < PROGRESS_WRITE_MIN_INTERVAL_MS2) return;
848
+ this.lastProgressWrite.set(jobIdArg, Date.now());
849
+ this.held.get(jobIdArg)?.m.working();
850
+ await this.cas(
851
+ jobIdArg,
852
+ (job) => {
853
+ if (job.status !== "running") return { result: void 0 };
854
+ const updated = {
855
+ status: "running",
856
+ metadata: job.metadata,
857
+ params: job.params,
858
+ startedAt: job.startedAt,
859
+ progress
860
+ };
861
+ return { envelope: { job: updated, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: void 0 };
862
+ },
863
+ void 0
864
+ );
865
+ }
866
+ async cancelPendingJobs(category) {
867
+ const matches = category === "generation" ? (type) => type === "generation" : (type) => type.endsWith("-annotation");
868
+ let cancelled = 0;
869
+ for (const key of await this.allKeys()) {
870
+ const envelope = await this.read(jobId(key));
871
+ if (!envelope || envelope.job.status !== "pending" || !matches(envelope.job.metadata.type)) continue;
872
+ const done = await this.cas(
873
+ jobId(key),
874
+ (job) => {
875
+ if (job.status !== "pending" || !matches(job.metadata.type)) return { result: false };
876
+ const record = {
877
+ status: "cancelled",
878
+ metadata: job.metadata,
879
+ params: job.params,
880
+ startedAt: void 0,
881
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
882
+ };
883
+ return { envelope: { job: record, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: true };
884
+ },
885
+ false
886
+ );
887
+ if (done) {
888
+ cancelled++;
889
+ this.settleLease(jobId(key), "term");
890
+ }
891
+ }
892
+ await this.jsm.streams.purge(STREAM, { filter: `jobs.${category}.>` });
893
+ return cancelled;
894
+ }
895
+ async cancelJob(jobIdArg) {
896
+ const done = await this.cas(
897
+ jobIdArg,
898
+ (job) => {
899
+ if (job.status !== "pending" && job.status !== "running") return { result: false };
900
+ const record = {
901
+ status: "cancelled",
902
+ metadata: job.metadata,
903
+ params: job.params,
904
+ startedAt: job.status === "running" ? job.startedAt : void 0,
905
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
906
+ };
907
+ return { envelope: { job: record, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: true };
908
+ },
909
+ false
910
+ );
911
+ if (done) this.settleLease(jobIdArg, "term");
912
+ return done;
913
+ }
914
+ async getStats() {
915
+ const stats = { pending: 0, running: 0, complete: 0, failed: 0, cancelled: 0 };
916
+ for (const key of await this.allKeys()) {
917
+ const envelope = await this.read(jobId(key));
918
+ if (envelope) stats[envelope.job.status]++;
919
+ }
920
+ return stats;
921
+ }
922
+ /**
923
+ * Worker-death recovery: the same contract the fs driver's mtime janitor
924
+ * implemented — a running job with no progress inside the stale window is
925
+ * retried or failed, checkpoint intact. Gateway-death recovery never comes
926
+ * through here; that is AckWait redelivery.
927
+ */
928
+ async recoverStaleRunningJobs() {
929
+ const now = Date.now();
930
+ let recovered = 0;
931
+ for (const key of await this.allKeys()) {
932
+ const envelope = await this.read(jobId(key));
933
+ if (!envelope || envelope.job.status !== "running") continue;
934
+ if (now - Date.parse(envelope.lastProgressAt) < this.staleRunningMs) continue;
935
+ const outcome = await this.failJob(
936
+ jobId(key),
937
+ `worker presumed dead \u2014 no progress within ${this.staleRunningMs / 6e4} minutes`
938
+ );
939
+ if (outcome) {
940
+ this.logger.warn("Recovered stale running job", { jobId: key, outcome });
941
+ recovered++;
942
+ }
943
+ }
944
+ return recovered;
945
+ }
946
+ /** The heartbeat body — extend live leases, settle concluded ones. */
947
+ async reconcileHeld() {
948
+ for (const [id, held] of [...this.held]) {
949
+ const envelope = await this.read(jobId(id));
950
+ const status = envelope?.job.status;
951
+ if (!envelope || status === "complete" || status === "failed" || status === "cancelled") {
952
+ this.held.delete(id);
953
+ held.m.ack();
954
+ } else {
955
+ held.m.working();
956
+ }
957
+ }
958
+ }
959
+ /**
960
+ * Materialize the KV key list BEFORE touching any entry: interleaving
961
+ * `get`/`update` with an open `keys()` iterator on the same connection
962
+ * makes the iterator drop entries (measured: the second pending job
963
+ * vanished from every sweep until this was split into two passes).
964
+ */
965
+ async allKeys() {
966
+ const keys = [];
967
+ for await (const key of await this.kv.keys()) keys.push(key);
968
+ return keys;
969
+ }
970
+ /** Conclude this process's lease on a job, if it holds one. */
971
+ settleLease(jobIdArg, how) {
972
+ const held = this.held.get(jobIdArg);
973
+ if (!held) return;
974
+ this.held.delete(jobIdArg);
975
+ if (how === "ack") held.m.ack();
976
+ else held.m.term();
977
+ }
978
+ };
477
979
 
478
980
  // src/types.ts
479
981
  function isPendingJob(job) {
@@ -2435,6 +2937,6 @@ var WORKER_AWAITED_OPERATIONS = [
2435
2937
  ];
2436
2938
  replyChannelsFor(WORKER_AWAITED_OPERATIONS);
2437
2939
 
2438
- export { AnnotationDetection, FsJobQueue, STALL_THRESHOLD_MS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
2940
+ export { AnnotationDetection, FsJobQueue, JOBS_STREAM_SUBJECTS, JOB_QUEUE_EMITS, JetStreamJobQueue, STALL_THRESHOLD_MS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
2439
2941
  //# sourceMappingURL=index.js.map
2440
2942
  //# sourceMappingURL=index.js.map