@semiont/jobs 0.6.6 → 0.6.8

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.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { JobId, UserId, UnitCursor, ResourceId, EntityType, JobReferenceAnnotationResult, GenerationJobParams, JobHighlightAnnotationResult, JobAssessmentAnnotationResult, JobCommentAnnotationResult, TagSchema, JobTagAnnotationResult, Logger, EventBus, components, Annotation, SupportedMediaType, GatheredContext, EventMap } from '@semiont/core';
2
- import { SemiontState } from '@semiont/core/node';
1
+ import { JobId, components, UserId, UnitCursor, ResourceId, EntityType, JobReferenceAnnotationResult, GenerationJobParams, JobHighlightAnnotationResult, JobAssessmentAnnotationResult, JobCommentAnnotationResult, TagSchema, JobTagAnnotationResult, Annotation, Logger, SupportedMediaType, GatheredContext, BusRequestErrorCode, BusRequestPrimitive, EventMap } from '@semiont/core';
3
2
  import { InferenceClient } from '@semiont/inference';
3
+ import { Observable } from 'rxjs';
4
4
 
5
5
  /**
6
6
  * Job Queue Type Definitions - Discriminated Union Design
@@ -15,8 +15,8 @@ import { InferenceClient } from '@semiont/inference';
15
15
  * - State machine is explicit and type-safe
16
16
  */
17
17
 
18
- type JobType = 'reference-annotation' | 'generation' | 'highlight-annotation' | 'assessment-annotation' | 'comment-annotation' | 'tag-annotation';
19
- type JobStatus = 'pending' | 'running' | 'complete' | 'failed' | 'cancelled';
18
+ type JobType = components['schemas']['JobType'];
19
+ type JobStatus = components['schemas']['Job']['status'];
20
20
  /**
21
21
  * Job metadata - common to all states
22
22
  */
@@ -80,7 +80,7 @@ interface JobMetadata {
80
80
  /**
81
81
  * Detection job parameters
82
82
  */
83
- interface DetectionParams {
83
+ type DetectionParams = {
84
84
  resourceId: ResourceId;
85
85
  entityTypes: EntityType[];
86
86
  includeDescriptiveReferences?: boolean;
@@ -88,21 +88,21 @@ interface DetectionParams {
88
88
  language?: string;
89
89
  /** Source-resource locale — see locale conventions above. */
90
90
  sourceLanguage?: string;
91
- }
91
+ };
92
92
  /**
93
93
  * Highlight detection job parameters
94
94
  */
95
- interface HighlightDetectionParams {
95
+ type HighlightDetectionParams = {
96
96
  resourceId: ResourceId;
97
97
  instructions?: string;
98
98
  density?: number;
99
99
  /** Source-resource locale — see locale conventions above. */
100
100
  sourceLanguage?: string;
101
- }
101
+ };
102
102
  /**
103
103
  * Assessment detection job parameters
104
104
  */
105
- interface AssessmentDetectionParams {
105
+ type AssessmentDetectionParams = {
106
106
  resourceId: ResourceId;
107
107
  instructions?: string;
108
108
  tone?: 'analytical' | 'critical' | 'balanced' | 'constructive';
@@ -111,11 +111,11 @@ interface AssessmentDetectionParams {
111
111
  language?: string;
112
112
  /** Source-resource locale — see locale conventions above. */
113
113
  sourceLanguage?: string;
114
- }
114
+ };
115
115
  /**
116
116
  * Comment detection job parameters
117
117
  */
118
- interface CommentDetectionParams {
118
+ type CommentDetectionParams = {
119
119
  resourceId: ResourceId;
120
120
  instructions?: string;
121
121
  tone?: 'scholarly' | 'explanatory' | 'conversational' | 'technical';
@@ -124,7 +124,7 @@ interface CommentDetectionParams {
124
124
  language?: string;
125
125
  /** Source-resource locale — see locale conventions above. */
126
126
  sourceLanguage?: string;
127
- }
127
+ };
128
128
  /**
129
129
  * Tag detection job parameters.
130
130
  *
@@ -133,7 +133,7 @@ interface CommentDetectionParams {
133
133
  * at job-creation time and embeds the resolved schema here, keeping the
134
134
  * worker independent of the registry.
135
135
  */
136
- interface TagDetectionParams {
136
+ type TagDetectionParams = {
137
137
  resourceId: ResourceId;
138
138
  schema: TagSchema;
139
139
  categories: string[];
@@ -141,25 +141,7 @@ interface TagDetectionParams {
141
141
  language?: string;
142
142
  /** Source-resource locale — see locale conventions above. */
143
143
  sourceLanguage?: string;
144
- }
145
- /**
146
- * Detection job progress
147
- */
148
- interface DetectionProgress {
149
- totalEntityTypes: number;
150
- processedEntityTypes: number;
151
- entitiesFound: number;
152
- entitiesEmitted: number;
153
- }
154
- /**
155
- * Generation job progress
156
- */
157
- interface YieldProgress {
158
- /** The two real generation transitions — LLM call running, then persisting. */
159
- stage: 'generating' | 'creating';
160
- percentage: number;
161
- message?: string;
162
- }
144
+ };
163
145
  /**
164
146
  * Generation job result
165
147
  */
@@ -171,40 +153,11 @@ interface GenerationResult {
171
153
  truncated: boolean;
172
154
  }
173
155
  /**
174
- * Highlight detection job progress
156
+ * The progress a running job carries: the last its worker reported with
157
+ * `job:report-progress`, or an empty object before the first report. One shape
158
+ * for every job type, as the spec states it (JobRunning).
175
159
  */
176
- interface HighlightDetectionProgress {
177
- stage: 'analyzing' | 'creating';
178
- percentage: number;
179
- message?: string;
180
- }
181
- /**
182
- * Assessment detection job progress
183
- */
184
- interface AssessmentDetectionProgress {
185
- stage: 'analyzing' | 'creating';
186
- percentage: number;
187
- message?: string;
188
- }
189
- /**
190
- * Comment detection job progress
191
- */
192
- interface CommentDetectionProgress {
193
- stage: 'analyzing' | 'creating';
194
- percentage: number;
195
- message?: string;
196
- }
197
- /**
198
- * Tag detection job progress
199
- */
200
- interface TagDetectionProgress {
201
- stage: 'analyzing' | 'creating';
202
- percentage: number;
203
- currentCategory?: string;
204
- processedCategories: number;
205
- totalCategories: number;
206
- message?: string;
207
- }
160
+ type StoredProgress = components['schemas']['JobStoredProgress'];
208
161
  /**
209
162
  * Pending job - just created, waiting to be picked up
210
163
  */
@@ -216,12 +169,12 @@ interface PendingJob<P> {
216
169
  /**
217
170
  * Running job - actively being processed
218
171
  */
219
- interface RunningJob<P, PG> {
172
+ interface RunningJob<P> {
220
173
  status: 'running';
221
174
  metadata: JobMetadata;
222
175
  params: P;
223
176
  startedAt: string;
224
- progress: PG;
177
+ progress: StoredProgress;
225
178
  }
226
179
  /**
227
180
  * Complete job - successfully finished
@@ -258,464 +211,29 @@ interface CancelledJob<P> {
258
211
  /**
259
212
  * Generic job - discriminated union of all states
260
213
  */
261
- type Job<P, PG, R> = PendingJob<P> | RunningJob<P, PG> | CompleteJob<P, R> | FailedJob<P> | CancelledJob<P>;
262
- type DetectionJob = Job<DetectionParams, DetectionProgress, JobReferenceAnnotationResult>;
263
- type GenerationJob = Job<GenerationJobParams, YieldProgress, GenerationResult>;
264
- type HighlightDetectionJob = Job<HighlightDetectionParams, HighlightDetectionProgress, JobHighlightAnnotationResult>;
265
- type AssessmentDetectionJob = Job<AssessmentDetectionParams, AssessmentDetectionProgress, JobAssessmentAnnotationResult>;
266
- type CommentDetectionJob = Job<CommentDetectionParams, CommentDetectionProgress, JobCommentAnnotationResult>;
267
- type TagDetectionJob = Job<TagDetectionParams, TagDetectionProgress, JobTagAnnotationResult>;
214
+ type Job<P, R> = PendingJob<P> | RunningJob<P> | CompleteJob<P, R> | FailedJob<P> | CancelledJob<P>;
215
+ type DetectionJob = Job<DetectionParams, JobReferenceAnnotationResult>;
216
+ /** A generation job's params carry the resource the dispatcher derived from the context's focus. */
217
+ type GenerationJob = Job<GenerationJobParams & {
218
+ resourceId: ResourceId;
219
+ }, GenerationResult>;
220
+ type HighlightDetectionJob = Job<HighlightDetectionParams, JobHighlightAnnotationResult>;
221
+ type AssessmentDetectionJob = Job<AssessmentDetectionParams, JobAssessmentAnnotationResult>;
222
+ type CommentDetectionJob = Job<CommentDetectionParams, JobCommentAnnotationResult>;
223
+ type TagDetectionJob = Job<TagDetectionParams, JobTagAnnotationResult>;
268
224
  /**
269
225
  * Discriminated union of all job types
270
226
  */
271
227
  type AnyJob = DetectionJob | GenerationJob | HighlightDetectionJob | AssessmentDetectionJob | CommentDetectionJob | TagDetectionJob;
228
+ /** A job of any type, running: what a claim returns. */
229
+ type RunningAnyJob = Extract<AnyJob, {
230
+ status: 'running';
231
+ }>;
272
232
  declare function isPendingJob(job: AnyJob): job is PendingJob<any>;
273
- declare function isRunningJob(job: AnyJob): job is RunningJob<any, any>;
233
+ declare function isRunningJob(job: AnyJob): job is RunningJob<any>;
274
234
  declare function isCompleteJob(job: AnyJob): job is CompleteJob<any, any>;
275
235
  declare function isFailedJob(job: AnyJob): job is FailedJob<any>;
276
236
  declare function isCancelledJob(job: AnyJob): job is CancelledJob<any>;
277
- interface JobQueryFilters {
278
- status?: JobStatus;
279
- type?: JobType;
280
- userId?: UserId;
281
- limit?: number;
282
- offset?: number;
283
- }
284
-
285
- interface JobQueue {
286
- initialize(): Promise<void>;
287
- destroy(): void;
288
- createJob(job: AnyJob): Promise<void>;
289
- getJob(jobId: JobId): Promise<AnyJob | null>;
290
- /**
291
- * Atomically claim the NEXT pending job matching one of `types` — the ONE
292
- * transition that makes this a queue rather than a state store
293
- * (JOB-QUEUE-DRIVER P0; reshaped claim-by-TYPE in P2, while every worker
294
- * is still ours). pending → running, `startedAt` stamped, progress empty.
295
- * An announcement is a WAKE-UP, not a reservation: the claimed job may
296
- * differ from any announced one, no ordering among matching pending jobs
297
- * is promised, and an empty `types` accepts any type. Simultaneous claims
298
- * admit one winner PER pending job; a claim that finds nothing is
299
- * DECLINED (`none-available`), never an error.
300
- */
301
- claimNextJob(types: string[]): Promise<{
302
- job: AnyJob;
303
- } | {
304
- declined: 'none-available';
305
- }>;
306
- /** Move a running job to `complete`. Returns false if the job isn't running. */
307
- completeJob(jobId: JobId, result: Record<string, unknown>): Promise<boolean>;
308
- /**
309
- * Move a running job back to `pending` (retry, re-announced) while
310
- * `retryCount < maxRetries`, else to `failed`. Returns what happened,
311
- * or null if the job isn't running. `completedUnits` — the units the
312
- * failing attempt fully emitted — are unioned into the record's
313
- * checkpoint (ABANDONED-INFERENCE P2) so a retry skips them. A
314
- * `failureClass` of 'deterministic' goes straight to `failed` with any
315
- * budget remaining — a second identical attempt cannot succeed (P3).
316
- */
317
- failJob(jobId: JobId, error: string, completedUnits?: string[], failureClass?: 'transient' | 'deterministic', unitCursors?: Record<string, UnitCursor>): Promise<'retried' | 'failed' | null>;
318
- /**
319
- * Persist a running job's completed-unit checkpoint AT unit completion —
320
- * not only when a job fails (JOB-RESTART-SAFETY P2). `failJob` carries the
321
- * checkpoint on a clean failure, but a worker that DIES (crash/OOM/kill)
322
- * never emits `job:fail`, so its finished units would be lost and the
323
- * janitor's recovery would redo them. This writes them into the running
324
- * file's metadata as each unit lands, unioned with any existing
325
- * checkpoint, so recovery resumes rather than restarts. Unthrottled (a
326
- * unit completion must never be dropped); a no-op for non-running jobs.
327
- *
328
- * `unitCursors` is the finer grain `completedUnits` cannot express
329
- * (CHUNK-GRAIN-RESUME P2): how far an UNFINISHED unit got, so a job that
330
- * dies mid-unit resumes there instead of at the top. It is written per
331
- * committed chunk, not per unit.
332
- *
333
- * **The two merge differently, and the difference is the contract.**
334
- * `completedUnits` is a set, so a union converges under concurrent snapshots
335
- * — a set only grows. A cursor converges only if the merge is **monotone per
336
- * unit**: a stale snapshot must never move one backward. `next` and `size`
337
- * move TOGETHER as one observation; taking `size` from one snapshot and
338
- * `next` from another would describe a chunk that never existed. A unit that
339
- * reaches `completedUnits` drops its cursor, so "in progress with a cursor"
340
- * and "complete" stay structurally exclusive rather than by convention.
341
- *
342
- * The rule belongs here rather than to any one driver because it is what
343
- * makes a cursor safe to merge at all.
344
- */
345
- checkpointUnits(jobId: JobId, completedUnits: string[], unitCursors?: Record<string, UnitCursor>): Promise<void>;
346
- /** Write progress into a running job's file (throttled, best-effort). */
347
- recordProgress(jobId: JobId, progress: Record<string, unknown>): Promise<void>;
348
- /**
349
- * Cancel all pending jobs in a category — 'generation' is the
350
- * `generation` type; 'annotation' is every `*-annotation` type.
351
- * Running jobs are left to finish. Returns the number cancelled.
352
- */
353
- cancelPendingJobs(category: 'annotation' | 'generation'): Promise<number>;
354
- cancelJob(jobId: JobId): Promise<boolean>;
355
- getStats(): Promise<{
356
- pending: number;
357
- running: number;
358
- complete: number;
359
- failed: number;
360
- cancelled: number;
361
- }>;
362
- }
363
- /**
364
- * Every bus channel a queue DRIVER emits, maintained beside the interface
365
- * both drivers implement and censused against their sources
366
- * (queue-emits-census.test.ts). The gateway's signal bridge forwards these
367
- * from its local bus onto the plane — a queue announcement that stays on
368
- * the raw bus reaches no worker under a remote driver (the job:queued
369
- * starvation, 2026-09-15).
370
- */
371
- declare const JOB_QUEUE_EMITS: readonly ["job:queued"];
372
- /**
373
- * How long a job's record survives after it reaches a terminal state
374
- * (complete/failed/cancelled), and how often a driver enforces that.
375
- *
376
- * ONE pair for every driver. The window is a CONTRACT fact — a caller that
377
- * reads a finished job's result gets the same day whichever backing store the
378
- * stack runs — and the cadence is what turns the window from an aspiration
379
- * into a promise. Restated per driver the two numbers drift, and the drift is
380
- * invisible until someone compares two deployments.
381
- *
382
- * The METHOD that enforces retention is not on the interface and cannot be:
383
- * it is an unlink in one driver and a stream purge in another. The NUMBER is
384
- * here because it is the same number.
385
- */
386
- declare const TERMINAL_JOB_RETENTION_MS: number;
387
- /** How often a driver sweeps for records past `TERMINAL_JOB_RETENTION_MS`. */
388
- declare const TERMINAL_JOB_SWEEP_INTERVAL_MS: number;
389
-
390
- /**
391
- * Job Queue Manager
392
- *
393
- * Filesystem-based job queue with atomic operations.
394
- * Jobs are stored in directories by status; status transitions are
395
- * atomic delete + write across directories.
396
- */
397
-
398
- declare class FsJobQueue implements JobQueue {
399
- private eventBus?;
400
- private jobsDir;
401
- private logger;
402
- private reannounceTimer;
403
- private cleanupTimer;
404
- /** Per-job timestamp of the last progress write, for throttling. */
405
- private lastProgressWrite;
406
- constructor(
407
- /** `SemiontState` and not `SemiontProject`: the queue reads ONE path,
408
- * and the gateway that owns it mounts no KB tree (SINGLE-KB-MOUNT P5). */
409
- state: SemiontState, logger: Logger, eventBus?: EventBus | undefined);
410
- /**
411
- * Initialize job queue directories, announce any pending backlog,
412
- * and start the re-announce interval. Idempotent.
413
- */
414
- initialize(): Promise<void>;
415
- /**
416
- * Stop the re-announce and retention intervals
417
- */
418
- destroy(): void;
419
- /**
420
- * Emit `job:queued` for a pending job, if an EventBus is wired and
421
- * the job carries a `resourceId` (every current job type does).
422
- */
423
- private announce;
424
- /**
425
- * Announce every job currently in `pending/`. Files that vanish or
426
- * fail to parse mid-scan (claimed, cancelled, partially written)
427
- * are skipped — they're either gone for a good reason or picked up
428
- * on the next tick.
429
- */
430
- private announcePendingJobs;
431
- /**
432
- * Create a new job
433
- */
434
- createJob(job: AnyJob): Promise<void>;
435
- /**
436
- * Get a job by ID (searches all status directories)
437
- */
438
- getJob(jobId: JobId): Promise<AnyJob | null>;
439
- /**
440
- * Serializes claims. This driver's atomicity is single-process by design,
441
- * but `claimJob`'s contract must hold even against interleaved awaits in
442
- * that one process — a read-check-write with await points in it can admit
443
- * two winners without this chain (the TOCTOU the operation exists to kill).
444
- */
445
- private claimChain;
446
- claimNextJob(types: string[]): Promise<{
447
- job: AnyJob;
448
- } | {
449
- declined: 'none-available';
450
- }>;
451
- private doClaimNext;
452
- /**
453
- * Update a job (atomic: delete old, write new)
454
- */
455
- /**
456
- * Cross-status move (or in-place rewrite): delete old slot, write new.
457
- * Private — the interface exposes named transitions only (JOB-QUEUE-DRIVER
458
- * P0); a generic public patch is trivial on files and impossible on an
459
- * in-flight message, so no driver may offer one. Re-entering `pending`
460
- * announces, which is how failJob's retry reaches a listening worker.
461
- */
462
- private transition;
463
- /**
464
- * Move a running job to `complete`. Returns false (and changes
465
- * nothing) if the job is missing or not running — which also makes
466
- * duplicate `job:complete` events harmless.
467
- */
468
- completeJob(jobId: JobId, result: Record<string, unknown>): Promise<boolean>;
469
- /**
470
- * Retry-or-fail a running job. While `retryCount < maxRetries` the
471
- * job goes back to `pending` with the count bumped (and is
472
- * re-announced); after that it lands in `failed` with the error.
473
- * Returns null (and changes nothing) if the job isn't running.
474
- */
475
- failJob(jobId: JobId, error: string, completedUnits?: string[], failureClass?: 'transient' | 'deterministic', unitCursors?: Record<string, UnitCursor>): Promise<'retried' | 'failed' | null>;
476
- /**
477
- * Persist a running job's completed-unit checkpoint at unit completion
478
- * (JOB-RESTART-SAFETY P2). `failJob` writes this checkpoint on a clean
479
- * failure, but a worker that dies without emitting `job:fail` would lose
480
- * its finished units — so the janitor's recovery (which re-queues with
481
- * whatever `metadata.completedUnits` the running file holds) would redo
482
- * them. Writing it HERE, as each unit lands, makes a crash lose at most
483
- * the in-flight unit. Unioned with any existing checkpoint, unthrottled
484
- * (a unit completion is rare and must never be dropped), a no-op for
485
- * non-running jobs. Written directly, like `recordProgress`, so it also
486
- * refreshes the mtime heartbeat.
487
- */
488
- checkpointUnits(jobId: JobId, completedUnits: string[], unitCursors?: Record<string, UnitCursor>): Promise<void>;
489
- /**
490
- * Write progress into a running job's file. Throttled per job, and
491
- * a no-op for jobs that aren't running. Beyond surfacing live
492
- * progress to `job:status-requested`, each write refreshes the
493
- * file's mtime — the heartbeat `recoverStaleRunningJobs` watches.
494
- */
495
- recordProgress(jobId: JobId, progress: Record<string, unknown>): Promise<void>;
496
- /**
497
- * List jobs with filters
498
- */
499
- listJobs(filters?: JobQueryFilters): Promise<AnyJob[]>;
500
- /**
501
- * Cancel a job
502
- */
503
- cancelJob(jobId: JobId): Promise<boolean>;
504
- /**
505
- * Cancel all pending jobs in a category — the granularity of the
506
- * `job:cancel-requested` UI signal. Running jobs are left to finish:
507
- * interrupting a worker mid-inference would need a worker-side kill
508
- * channel that doesn't exist.
509
- */
510
- cancelPendingJobs(category: 'annotation' | 'generation'): Promise<number>;
511
- /**
512
- * Recover running jobs orphaned by a dead worker: any `running/`
513
- * file whose mtime is older than the stale window is fed through
514
- * the same retry-or-fail path as `job:fail`. Progress writes
515
- * refresh the mtime, so a live worker is never recovered out from
516
- * under itself as long as it reports within the window.
517
- */
518
- recoverStaleRunningJobs(): Promise<number>;
519
- /**
520
- * Delete terminal jobs whose `completedAt` is older than `retentionMs`.
521
- * The window is a parameter with no default: the ONE value the queue runs
522
- * on is `TERMINAL_JOB_RETENTION_MS`, and a default here would be a second
523
- * copy of it that nobody notices going stale.
524
- */
525
- cleanupOldJobs(retentionMs: number): Promise<number>;
526
- /**
527
- * Get job file path
528
- */
529
- private getJobPath;
530
- /**
531
- * Get statistics about the queue
532
- */
533
- getStats(): Promise<{
534
- pending: number;
535
- running: number;
536
- complete: number;
537
- failed: number;
538
- cancelled: number;
539
- }>;
540
- }
541
-
542
- /**
543
- * JetStreamJobQueue — the JetStream driver behind the `JobQueue` interface
544
- * (JOB-QUEUE-DRIVER P1, topology ruling M: broker-mediated — the queue's own
545
- * process holds the broker connection and the leases; workers never touch the
546
- * broker. That process was the gateway; it is the dispatcher now.)
547
- *
548
- * Two primitives, one authority each:
549
- *
550
- * - **KV bucket `jobs`** is the AUTHORITATIVE operational state: one entry
551
- * per job (`{ job, lastProgressAt }`), every transition a revision-CAS —
552
- * which is what makes `claimJob` atomic: simultaneous claims race on one
553
- * revision and exactly one update wins. It holds LIVE jobs plus a day of
554
- * concluded ones, not a history: `pruneTerminalJobs` is the retention the
555
- * fs driver's janitor holds, and without it every scan over the bucket
556
- * — `getStats` is one, on the metrics interval — grows forever.
557
- * - **Stream `JOBS`** (subjects `jobs.<category>.<type>`, work-queue
558
- * retention) is the DELIVERY vehicle and redelivery timer. A delivered
559
- * message is the lease this process holds for a job; `working()`
560
- * heartbeats extend it, and a dispatcher that dies stops heartbeating, so
561
- * `AckWait` redelivers the job to a live instance — dispatcher-death
562
- * recovery, protocol-native.
563
- *
564
- * Worker-death recovery is NOT AckWait's job under the mediated topology
565
- * (the dispatcher holding the lease is alive; the worker vanished): it is the
566
- * `lastProgressAt` sweep (`recoverStaleRunningJobs`), the same contract the
567
- * fs driver's mtime janitor implemented. The redelivery handler checks KV
568
- * state, so the two recovery paths can never double-apply.
569
- *
570
- * Battery ownership (JOB-QUEUE-DRIVER P0): the retry decision belongs to
571
- * `will-retry.ts` — `max_deliver` is unlimited and a deterministic failure
572
- * is `term()`ed, so the backend's retry engine never becomes a second
573
- * authority. The cursor merge belongs to `checkpoint-merge.ts`, shared with
574
- * every driver.
575
- *
576
- * The boundary is the point: no NATS type, subject string, or delivery
577
- * handle escapes this file.
578
- */
579
-
580
- interface JetStreamJobQueueOptions {
581
- /** NATS server address(es), e.g. "192.168.64.42:4222". */
582
- servers: string | string[];
583
- /**
584
- * Broker credentials (INTER-COMPONENT-ACCESS P3). The same broker the signal
585
- * plane connects to, so the same pair. Absent means an unauthenticated
586
- * broker — which is what every deployment had before this, and means anyone
587
- * who can reach NATS can read and write the job stream.
588
- */
589
- user?: string;
590
- pass?: string;
591
- /** Worker presumed dead after this long without progress (default 30 min). */
592
- staleRunningMs?: number;
593
- /** Lease redelivery window when THIS process stops heartbeating (default 30 s). */
594
- ackWaitMs?: number;
595
- /** Re-announce + worker-death-sweep cadence (default 30 s; tests shrink it). */
596
- tickMs?: number;
597
- /** Reconnect on connection loss (default true; tests turn it off). */
598
- reconnect?: boolean;
599
- }
600
- declare class JetStreamJobQueue implements JobQueue {
601
- private readonly options;
602
- private readonly logger;
603
- private readonly eventBus?;
604
- private nc;
605
- private js;
606
- private jsm;
607
- private kv;
608
- private iter;
609
- /** Delivered messages this process holds — the lease, keyed by jobId,
610
- * with the job's type so a by-type claim can walk them without a read. */
611
- private readonly held;
612
- private heartbeat;
613
- private reconciling;
614
- private tick;
615
- private ticking;
616
- private cleanupTimer;
617
- private readonly lastProgressWrite;
618
- private readonly staleRunningMs;
619
- private readonly ackWaitMs;
620
- private readonly tickMs;
621
- constructor(options: JetStreamJobQueueOptions, logger: Logger, eventBus?: EventBus | undefined);
622
- initialize(): Promise<void>;
623
- /** An outage is never silent: one line down, one line back, one line if the client gives up. */
624
- private watchConnection;
625
- destroy(): void;
626
- /**
627
- * A delivery is a job arriving at this dispatcher. What it means depends on
628
- * the job's authoritative (KV) state:
629
- * - pending → hold the lease, announce for a worker to claim;
630
- * - running → hold silently (a claim raced ahead of the delivery, or a
631
- * redelivery reached a fresh instance after a dispatcher death);
632
- * - terminal → the work already concluded elsewhere; consume the message;
633
- * - unknown → not ours to run; terminate it.
634
- */
635
- private onDelivery;
636
- /** Same wire shape as the fs driver: only jobs with a resourceId announce. */
637
- private announce;
638
- private read;
639
- private write;
640
- /**
641
- * Read-transform-CAS with bounded retries: the transform sees the current
642
- * job and returns the replacement (or null to stop). Every state
643
- * transition goes through here, which is what makes each one atomic.
644
- */
645
- private cas;
646
- createJob(job: AnyJob): Promise<void>;
647
- getJob(jobIdArg: JobId): Promise<AnyJob | null>;
648
- claimNextJob(types: string[]): Promise<{
649
- job: AnyJob;
650
- } | {
651
- declined: 'none-available';
652
- }>;
653
- /** CAS one pending job to running; null when someone else won it. */
654
- private tryClaim;
655
- completeJob(jobIdArg: JobId, result: Record<string, unknown>): Promise<boolean>;
656
- failJob(jobIdArg: JobId, error: string, completedUnits?: string[], failureClass?: 'transient' | 'deterministic', unitCursors?: Record<string, UnitCursor>): Promise<'retried' | 'failed' | null>;
657
- checkpointUnits(jobIdArg: JobId, completedUnits: string[], unitCursors?: Record<string, UnitCursor>): Promise<void>;
658
- recordProgress(jobIdArg: JobId, progress: Record<string, unknown>): Promise<void>;
659
- cancelPendingJobs(category: 'annotation' | 'generation'): Promise<number>;
660
- cancelJob(jobIdArg: JobId): Promise<boolean>;
661
- /**
662
- * Counts by status. The three terminal counts are the RETENTION WINDOW, not
663
- * lifetime totals — `pruneTerminalJobs` drops a record a day after it
664
- * concluded — so they answer "recently finished", never "finished ever".
665
- */
666
- getStats(): Promise<{
667
- pending: number;
668
- running: number;
669
- complete: number;
670
- failed: number;
671
- cancelled: number;
672
- }>;
673
- /**
674
- * Worker-death recovery: the same contract the fs driver's mtime janitor
675
- * implemented — a running job with no progress inside the stale window is
676
- * retried or failed, checkpoint intact. Gateway-death recovery never comes
677
- * through here; that is AckWait redelivery.
678
- */
679
- recoverStaleRunningJobs(): Promise<number>;
680
- /**
681
- * Terminal-job retention: the fs driver's hourly janitor, in this driver's
682
- * terms. Without it the bucket keeps every job it has ever seen, and
683
- * `getStats` — an OTel observable gauge, so a full bucket scan every metric
684
- * interval — pays for all of them forever. Measured on a live stack
685
- * 2026-09-24: 139 concluded records re-read every 30 seconds by a knowledge
686
- * base that had done very little work.
687
- *
688
- * A SWEEP AND NOT A BUCKET TTL, deliberately. `ttl` on `views.kv()` is a
689
- * BUCKET limit — the backing stream's `max_age` — so it expires every key
690
- * by age, not the terminal ones by status: a pending job nobody claimed for
691
- * a day, a running job whose worker went quiet, both evaporate alongside
692
- * the finished ones. That is not a slower version of this bug, it is a
693
- * worse one. This bucket is the AUTHORITATIVE record: with the entry gone
694
- * `read` returns null, every CAS falls through to its `onMissing`, and the
695
- * next delivery for that job is `term()`ed as unknown — the job disappears
696
- * with no failure and no trace. The rule retention needs to express is
697
- * about STATUS, and status is exactly what a bucket-wide clock cannot see.
698
- * (A per-message TTL set at the terminal write would express it, but that
699
- * is nats-server 2.11 plus a client that exposes it; nats.js 2.x does not.)
700
- *
701
- * Terminal is absorbing — no transition leaves `complete`/`failed`/
702
- * `cancelled` — so a record this sweep reads as expired cannot come back to
703
- * life under it, and the lease was settled at the transition that made it
704
- * terminal.
705
- */
706
- pruneTerminalJobs(retentionMs: number): Promise<number>;
707
- /** The heartbeat body — extend live leases, settle concluded ones. */
708
- private reconcileHeld;
709
- /**
710
- * Materialize the KV key list BEFORE touching any entry: interleaving
711
- * `get`/`update` with an open `keys()` iterator on the same connection
712
- * makes the iterator drop entries (measured: the second pending job
713
- * vanished from every sweep until this was split into two passes).
714
- */
715
- private allKeys;
716
- /** Conclude this process's lease on a job, if it holds one. */
717
- private settleLease;
718
- }
719
237
 
720
238
  /**
721
239
  * Citation-token resolver (INLINE-CITATIONS P1).
@@ -1059,6 +577,181 @@ declare function generateResourceFromTopic(topic: string, entityTypes: string[],
1059
577
  truncated: boolean;
1060
578
  }>;
1061
579
 
580
+ /**
581
+ * Job Claim Adapter — worker-side job lifecycle glue on top of a
582
+ * shared bus.
583
+ *
584
+ * Replaces the old `WorkerStateUnit`, which owned its own actor and
585
+ * duplicated the SSE connection that `SemiontClient` already held.
586
+ * Workers construct a `SemiontSession` normally (one actor, one
587
+ * SSE connection) and use this adapter to attach job-claim behaviour
588
+ * on top of the session's bus. It does **not** own the bus, has no HTTP
589
+ * concerns, and has no modal state.
590
+ *
591
+ * THE MODEL — a worker asks the queue at every moment it becomes idle,
592
+ * and never otherwise. `job:claim` carries this worker's types; the
593
+ * dispatcher answers with the next pending job of those types, atomically
594
+ * claimed, or declines. Queue state is the truth; no message carries
595
+ * correctness. The idle moments:
596
+ *
597
+ * - start, once the transport is open;
598
+ * - settle — `completeJob` / `failJob` — immediately, with no timer;
599
+ * - a matching `job:queued` while parked;
600
+ * - reconnect — every `state$` edge into `open` after the first.
601
+ *
602
+ * `job:queued` is a WAKE-UP with no memory. While parked it triggers a
603
+ * pull; while a claim is in flight it sets a dirty bit that earns exactly
604
+ * one more pull before parking, so a wake-up cannot be lost in that window;
605
+ * while a job is held it is ignored, because the settle pulls. One claim
606
+ * in flight at a time — under one-job-at-a-time processing "drain" is one
607
+ * claim per idle moment and one at settle, never a loop of claims while a
608
+ * job is held. The type filter on the wake-up is a PRE-FILTER on
609
+ * information the announcement already carries, so a worker that cannot
610
+ * run the announced type does not spend a round trip to be declined; the
611
+ * claim's `types` argument is the rule.
612
+ *
613
+ * What the dispatcher's decline codes mean here (`BusRequestError.code`,
614
+ * promoted from the reply's `CommandError.code`): `bus.none-pending` is
615
+ * the quiet park — nothing to do until a wake-up, nothing emitted. Every
616
+ * other refusal goes out on `refused$` for the runtime to judge;
617
+ * `bus.unauthorized` is the one it exits on, because a credential that
618
+ * can never claim has nothing to do here and the launcher's preflight
619
+ * names the repair.
620
+ *
621
+ * The queue drivers' 30 s re-announce tick survives as INSURANCE against a
622
+ * lost wake-up on an idle worker — never as dispatch. A healthy stack never
623
+ * sees it act.
624
+ *
625
+ * The `bus` parameter is typed against the small `BusRequestPrimitive`
626
+ * interface (from `@semiont/core`) so the adapter is transport-neutral.
627
+ * HTTP workers pass `(session.client.transport as HttpTransport).actor`;
628
+ * an in-process worker could pass a shim wrapping `client.bus`.
629
+ */
630
+
631
+ interface ActiveJob {
632
+ jobId: string;
633
+ type: string;
634
+ resourceId: string;
635
+ userId: string;
636
+ params: Record<string, unknown>;
637
+ /**
638
+ * Entity-type units earlier failed attempts fully emitted
639
+ * (ABANDONED-INFERENCE P2 checkpointed resume) — carried on the claimed
640
+ * record's metadata by `failJob`. The worker skips them, so a retry
641
+ * neither redoes nor duplicates completed work. Empty on first attempts.
642
+ */
643
+ completedUnits: string[];
644
+ /**
645
+ * How far each UNFINISHED unit got on an earlier attempt
646
+ * (CHUNK-GRAIN-RESUME P2) — the grain `completedUnits` cannot express, and
647
+ * the only checkpoint a one-unit job can produce before it finishes. Empty
648
+ * on first attempts, and never overlapping `completedUnits`: a unit is
649
+ * either finished or partway, never both.
650
+ */
651
+ unitCursors: Record<string, UnitCursor>;
652
+ /**
653
+ * The claimed record's retry budget, carried so the worker can report
654
+ * `willRetry` on `job:fail` (JOB-RESTART-SAFETY P5). It is the same budget
655
+ * the queue re-reads at `failJob`, and only `failJob` changes it, so the
656
+ * two evaluations of `willRetryAfter` agree.
657
+ */
658
+ retryCount: number;
659
+ maxRetries: number;
660
+ }
661
+ interface JobClaimAdapterOptions {
662
+ /** Shared bus (typically the session's HTTP actor or an in-process bus shim). */
663
+ bus: BusRequestPrimitive;
664
+ /**
665
+ * Job types this worker can process — the claim's `types` argument, and
666
+ * the pre-filter on `job:queued` wake-ups. Empty array = accept any.
667
+ */
668
+ jobTypes: string[];
669
+ }
670
+ /**
671
+ * A claim the dispatcher refused for a reason other than "nothing pending".
672
+ *
673
+ * `code` is the `BusRequestError` code the reply was promoted to, or `null`
674
+ * when the failure was local — an unusable record, a thrown non-bus error —
675
+ * never a manufactured bus code. `bus.none-pending` never appears here: it is
676
+ * the quiet park, and emitting it would make an empty queue look like a fault.
677
+ */
678
+ interface ClaimRefusal {
679
+ code: BusRequestErrorCode | null;
680
+ message: string;
681
+ }
682
+ /**
683
+ * Point-in-time liveness snapshot (WORKER-LIVENESS.md P1). The adapter
684
+ * is the only component that sees every wake-up, claim, and finish,
685
+ * so its snapshot is what `/health` reports and the stall watchdog reads.
686
+ *
687
+ * `lastQueuedEventAt` is any `job:queued` received, matching or not. It
688
+ * proves the transport delivered a broadcast, but on an idle stack with an
689
+ * empty queue it stands still by design — no announcement is owed — so a
690
+ * still stamp alone is not a transport fault; transport liveness proper is
691
+ * the actor's SSE heartbeat. `lastActivityAt` (claim, progress emission, or
692
+ * finish) is processing liveness; a job stuck mid-inference stops advancing
693
+ * it, and that is what the watchdog reads.
694
+ */
695
+ interface WorkerVitals {
696
+ lastQueuedEventAt: string | null;
697
+ lastClaimAt: string | null;
698
+ /** Last completion or failure — a failing-but-moving worker is alive. */
699
+ lastFinishedAt: string | null;
700
+ lastActivityAt: string | null;
701
+ activeJob: {
702
+ jobId: string;
703
+ type: string;
704
+ since: string;
705
+ } | null;
706
+ jobsCompleted: number;
707
+ }
708
+ interface JobClaimAdapter {
709
+ /** Currently-claimed job, or null when idle. */
710
+ readonly activeJob$: Observable<ActiveJob | null>;
711
+ /** True while a claim is in flight or a job is being processed. */
712
+ readonly isProcessing$: Observable<boolean>;
713
+ /** Monotonically-incrementing count of successfully-completed jobs. */
714
+ readonly jobsCompleted$: Observable<number>;
715
+ /** Stream of job failures reported through `failJob`. */
716
+ readonly errors$: Observable<{
717
+ jobId: string;
718
+ error: string;
719
+ }>;
720
+ /**
721
+ * Claims the dispatcher refused for a reason the runtime must judge.
722
+ * `bus.unauthorized` means this credential can never claim; the runtime
723
+ * exits on it. Anything else is logged and the worker stays parked until
724
+ * the next wake-up. See `ClaimRefusal`.
725
+ */
726
+ readonly refused$: Observable<ClaimRefusal>;
727
+ /**
728
+ * Begin pulling: one claim now (once the transport is open), then on every
729
+ * settle, matching wake-up, and reconnect. Idempotent — calling `start()`
730
+ * twice is a no-op.
731
+ */
732
+ start(): void;
733
+ /** Stop pulling. Does not cancel an in-flight job. */
734
+ stop(): void;
735
+ /** Signal successful completion of `activeJob$`. Pulls the next job. */
736
+ completeJob(): void;
737
+ /** Signal failure of `activeJob$`. Emits on `errors$`, then pulls the next job. */
738
+ failJob(jobId: string, error: string): void;
739
+ /** Liveness snapshot for `/health` and the stall watchdog. */
740
+ vitals(): WorkerVitals;
741
+ /**
742
+ * Record processing activity. The worker process calls this on every
743
+ * progress emission so a long multi-call job keeps proving liveness
744
+ * between inference calls.
745
+ */
746
+ touchActivity(): void;
747
+ /** Release observables. Does not dispose the shared bus. */
748
+ dispose(): void;
749
+ }
750
+ /**
751
+ * Attach job-claim behaviour to a shared bus.
752
+ */
753
+ declare function createJobClaimAdapter(options: JobClaimAdapterOptions): JobClaimAdapter;
754
+
1062
755
  /**
1063
756
  * Worker Runtime — the importable half of the worker host.
1064
757
  *
@@ -1099,9 +792,9 @@ declare function generateResourceFromTopic(topic: string, entityTypes: string[],
1099
792
  * wedged). The unbounded emit that caused that specific hang is now bounded
1100
793
  * at the transport (`EMIT_TIMEOUT_MS`), so the loop errors instead of
1101
794
  * blocking; but for any future blocked-loop bug the ONLY backstop is the
1102
- * out-of-process one — the gateway's janitor sweeping this worker's job
1103
- * files by mtime (`fs-job-queue.ts` `recoverStaleRunningJobs`). A liveness
1104
- * guarantee a blocked loop defeats is not one; the janitor is the guarantee.
795
+ * out-of-process one — the dispatcher's sweep, which fails a running job
796
+ * whose worker has gone silent (docs/protocol/JOBS.md). A liveness guarantee a
797
+ * blocked loop defeats is not one; the sweep is the guarantee.
1105
798
  */
1106
799
  declare const STALL_THRESHOLD_MS: number;
1107
800
  /**
@@ -1125,5 +818,5 @@ declare const WORKER_CONSUMED_BROADCASTS: readonly ["job:queued", "job:cancel-re
1125
818
  */
1126
819
  declare const WORKER_CHANNELS: readonly (keyof EventMap)[];
1127
820
 
1128
- export { AnnotationDetection, FsJobQueue, JOB_QUEUE_EMITS, JetStreamJobQueue, STALL_THRESHOLD_MS, TERMINAL_JOB_RETENTION_MS, TERMINAL_JOB_SWEEP_INTERVAL_MS, WORKER_CHANNELS, WORKER_CONSUMED_BROADCASTS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
1129
- export type { AnyJob, AssessmentDetectionJob, AssessmentDetectionParams, AssessmentDetectionProgress, CancelledJob, CommentDetectionJob, CommentDetectionParams, CommentDetectionProgress, CompleteJob, DetectionJob, DetectionParams, DetectionProgress, FailedJob, GenerationJob, GenerationResult, HighlightDetectionJob, HighlightDetectionParams, HighlightDetectionProgress, JetStreamJobQueueOptions, JobMetadata, JobQueryFilters, JobQueue, JobStatus, JobType, OnProgress, PendingJob, ProcessorResult, RunningJob, TagDetectionJob, TagDetectionParams, TagDetectionProgress, YieldProgress };
821
+ export { AnnotationDetection, STALL_THRESHOLD_MS, WORKER_CHANNELS, WORKER_CONSUMED_BROADCASTS, createJobClaimAdapter, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
822
+ export type { ActiveJob, AnyJob, AssessmentDetectionJob, AssessmentDetectionParams, CancelledJob, ClaimRefusal, CommentDetectionJob, CommentDetectionParams, CompleteJob, DetectionJob, DetectionParams, FailedJob, GenerationJob, GenerationResult, HighlightDetectionJob, HighlightDetectionParams, JobClaimAdapter, JobClaimAdapterOptions, JobMetadata, JobStatus, JobType, OnProgress, PendingJob, ProcessorResult, RunningAnyJob, RunningJob, StoredProgress, TagDetectionJob, TagDetectionParams, WorkerVitals };