@semiont/jobs 0.5.28 → 0.5.30

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
@@ -10,7 +10,7 @@ Job queue, worker infrastructure, and annotation workers for [Semiont](https://g
10
10
 
11
11
  ## Architecture Context
12
12
 
13
- Workers run in a separate process and connect to the Knowledge System (KS) over HTTP/SSE using a `SemiontSession` (from `@semiont/sdk`) driven by a `JobClaimAdapter`. Workers receive job assignments via an SSE `job:queued` subscription, claim jobs atomically, and emit domain events back to the KS via `session.client.transport.emit(...)`. The KS ingests these events onto its EventBus for SSE delivery to the frontend.
13
+ Workers run in a separate process and connect to the Knowledge System (KS) over HTTP/SSE using a `SemiontSession` (from `@semiont/sdk`) driven by a `JobClaimAdapter`. Workers receive job assignments via an SSE `job:queued` subscription, claim jobs atomically, and emit domain events back to the KS via `session.client.transport.emit(...)`. The KS ingests these events onto its EventBus for SSE delivery to the Browser.
14
14
 
15
15
  ## Installation
16
16
 
@@ -36,7 +36,9 @@ import { SemiontProject } from '@semiont/core/node';
36
36
 
37
37
  // Initialize — jobs are stored under project.jobsDir
38
38
  const eventBus = new EventBus();
39
- const project = new SemiontProject('/path/to/project');
39
+ const project = new SemiontProject('/path/to/project', {
40
+ anchoredTextDir: process.env.SEMIONT_ANCHORED_TEXT_DIR!,
41
+ });
40
42
  const jobQueue = new FsJobQueue(project, logger, eventBus);
41
43
  await jobQueue.initialize();
42
44
 
@@ -96,10 +98,12 @@ interface JobMetadata {
96
98
  created: string;
97
99
  retryCount: number;
98
100
  maxRetries: number;
101
+ completedUnits?: string[]; // Checkpoint: work units (entity types) already
102
+ // persisted by a failed attempt — the retry skips them
99
103
  }
100
104
  ```
101
105
 
102
- The `userName`, `userEmail`, and `userDomain` fields are an audit-only snapshot of the requesting user, persisted in the on-disk job file. Workers derive annotation `creator` attribution from `userId` via `didToAgent()`.
106
+ The `userName`, `userEmail`, and `userDomain` fields are an audit-only snapshot of the requesting user, persisted in the on-disk job file. Workers derive annotation `creator` attribution from `userId` via `didToAgent()`. `completedUnits` is written only by `failJob`, unioned across attempts, and carried on the `job:fail` event — see Failure discipline below.
103
107
 
104
108
  ## Annotation Workers
105
109
 
@@ -116,7 +120,19 @@ The worker process (`worker-main.ts` → `startWorkerProcess` in `worker-process
116
120
 
117
121
  Detection logic lives in the `AnnotationDetection` class (`src/workers/annotation-detection.ts`); generation synthesis in `generateResourceFromTopic()` (`src/workers/generation/resource-generation.ts`). Processors never fetch content themselves — the worker process fetches it via `session.client.browse.resourceContent(resourceId)` and passes it in.
118
122
 
119
- Workers emit bus events via `session.client.transport.emit('mark:create' | 'job:start' | 'job:report-progress' | 'job:complete' | 'job:fail', payload)` — the Stower actor in @semiont/make-meaning handles persistence to the event log, and the job command handlers mirror the same events into the queue files (completion, retry-on-failure with `maxRetries`, progress-as-heartbeat).
123
+ Workers emit lifecycle events via `session.client.transport.emit('job:start' | 'job:report-progress' | 'job:checkpoint' | 'job:complete' | 'job:fail', payload)` and persist annotations through the **awaited `mark:commit` operation** a batch per unit of work that resolves only once the Stower actor in @semiont/make-meaning has appended every annotation to the event log. Unit completion and `job:complete` gate on that acknowledgement, never on emission, so a down persistence sink is a retryable failure instead of silent loss. The job command handlers mirror the lifecycle events into the queue files (completion, retry-on-failure with `maxRetries`, progress-as-heartbeat). `job:fail` carries the fields the worker computes: `completedUnits` (the checkpoint), `failureClass`, and `willRetry` — see Failure discipline.
124
+
125
+ ## Failure discipline
126
+
127
+ Long inference work fails in bounded, classified, resumable ways — every piece below was built against a measured production failure, not a hypothetical:
128
+
129
+ - **Every inference call is bounded and truly cancelled.** A call gets 10 minutes (`INFERENCE_TIMEOUT_MS`); at the bound the worker aborts it at the transport (`AbortSignal` through the provider SDK to the socket — milliseconds to rejection, no zombie billing on) and fails the job with a typed `InferenceTimeoutError`. An in-flight heartbeat reports elapsed-time liveness every 15 s during long calls.
130
+ - **Budgets are derived, never tuned** (`workers/detection/detection-chunking.ts`). Input:output allocation is 1:2 per entity type asked for (`input ≤ outputBudget / (2 × typesPerCall)`), and **every** provider gets a duration cap: per-call output is bounded at what the provider's worst-case rate finishes in HALF the bound — the published rate when there is one, a conservative assumed floor (`ASSUMED_OUTPUT_TOKENS_PER_HOUR`, 30 tok/s) for rate-silent providers like Ollama, where an unbounded budget turned model repetition loops into hour-long transient burns.
131
+ - **Size-shaped failures subdivide in place** (`callChunkSubdividing`). Four failure families descend, each with its own floor: a **truncation** descends by size and gets one same-size re-roll at the floor; a **timeout** descends two levels then propagates; an **`'unknown'`-stop unreadable response** (garbage output with no stop reason — measured size-correlated on real documents) descends by size and propagates at the floor; and a **flagged under-report** (below) descends by size, and at the floor its salvage — everything it did find, every span write-time-verified — is **accepted loudly** rather than discarded. A piece that cannot actually shrink is at its floor regardless of arithmetic: at temperature 0, an identical re-run returns the identical failure. Sub-piece overlap duplicates fall to the existing span-keyed dedupe.
132
+ - **Successful-looking extractions are verified** (`assertYieldNotCollapsed`). A local model can return a clean, schema-conforming response carrying a fraction of the entities present — deterministic and otherwise invisible. When the provider declares `verifyDetectionYield` (all real providers do), each chunk's item count is checked against a cheap parallel count call; an extraction under half the count is flagged and subdivided. Every anchoring outcome and every call — including flagged and failed ones — is recorded to `semiont.detection.*` metrics (`@semiont/observability`).
133
+ - **Entity types run concurrently up to the provider's declared capacity** (`client.maxConcurrency`): a hosted API with rate headroom runs several types at once; a local single-model server runs them sequentially, because concurrent requests only split one GPU. Jobs never switches on provider identity — both behaviors are capabilities declared on the `InferenceClient`.
134
+ - **Failures are classified at the worker, where errors are still typed** (`failure-class.ts`). Only KNOWN-deterministic failures — truncation at the subdivision floor, unsupported media, non-throttle 4xx — skip the retry budget; everything unrecognized stays retryable. The class rides `job:fail` as `failureClass`.
135
+ - **Retries resume from the checkpoint.** Reference-annotation persists each entity type's annotations as that unit completes; completed units ride `job:fail` into `metadata.completedUnits`, and the retry processes only what's left.
120
136
 
121
137
  ## Adding a Job Type
122
138
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { JobId, UserId, ResourceId, EntityType, GenerationJobParams, TagSchema, Logger, EventBus, components, Annotation, SupportedMediaType, GatheredContext } from '@semiont/core';
2
- import { SemiontProject } from '@semiont/core/node';
2
+ import { SemiontState } from '@semiont/core/node';
3
3
  import { InferenceClient } from '@semiont/inference';
4
4
 
5
5
  /**
@@ -37,6 +37,16 @@ interface JobMetadata {
37
37
  created: string;
38
38
  retryCount: number;
39
39
  maxRetries: number;
40
+ /**
41
+ * Checkpointed resume (ABANDONED-INFERENCE P2, HD1): the entity-type
42
+ * units whose annotations were fully emitted by earlier failed
43
+ * attempts. Written only by `failJob`, unioned across attempts — and
44
+ * because `failJob` rebuilds the retried record by spreading metadata,
45
+ * the checkpoint survives every subsequent rebuild for free. A retried
46
+ * claim skips these units, so completed work is neither redone nor
47
+ * duplicated.
48
+ */
49
+ completedUnits?: string[];
40
50
  }
41
51
  /**
42
52
  * Locale conventions for detection/generation params.
@@ -316,9 +326,24 @@ interface JobQueue {
316
326
  /**
317
327
  * Move a running job back to `pending` (retry, re-announced) while
318
328
  * `retryCount < maxRetries`, else to `failed`. Returns what happened,
319
- * or null if the job isn't running.
329
+ * or null if the job isn't running. `completedUnits` — the units the
330
+ * failing attempt fully emitted — are unioned into the record's
331
+ * checkpoint (ABANDONED-INFERENCE P2) so a retry skips them. A
332
+ * `failureClass` of 'deterministic' goes straight to `failed` with any
333
+ * budget remaining — a second identical attempt cannot succeed (P3).
320
334
  */
321
- failJob(jobId: JobId, error: string): Promise<'retried' | 'failed' | null>;
335
+ failJob(jobId: JobId, error: string, completedUnits?: string[], failureClass?: 'transient' | 'deterministic'): Promise<'retried' | 'failed' | null>;
336
+ /**
337
+ * Persist a running job's completed-unit checkpoint AT unit completion —
338
+ * not only when a job fails (JOB-RESTART-SAFETY P2). `failJob` carries the
339
+ * checkpoint on a clean failure, but a worker that DIES (crash/OOM/kill)
340
+ * never emits `job:fail`, so its finished units would be lost and the
341
+ * janitor's recovery would redo them. This writes them into the running
342
+ * file's metadata as each unit lands, unioned with any existing
343
+ * checkpoint, so recovery resumes rather than restarts. Unthrottled (a
344
+ * unit completion must never be dropped); a no-op for non-running jobs.
345
+ */
346
+ checkpointUnits(jobId: JobId, completedUnits: string[]): Promise<void>;
322
347
  /** Write progress into a running job's file (throttled, best-effort). */
323
348
  recordProgress(jobId: JobId, progress: Record<string, unknown>): Promise<void>;
324
349
  /**
@@ -353,7 +378,10 @@ declare class FsJobQueue implements JobQueue {
353
378
  private cleanupTimer;
354
379
  /** Per-job timestamp of the last progress write, for throttling. */
355
380
  private lastProgressWrite;
356
- constructor(project: SemiontProject, logger: Logger, eventBus?: EventBus | undefined);
381
+ constructor(
382
+ /** `SemiontState` and not `SemiontProject`: the queue reads ONE path,
383
+ * and the gateway that owns it mounts no KB tree (SINGLE-KB-MOUNT P5). */
384
+ state: SemiontState, logger: Logger, eventBus?: EventBus | undefined);
357
385
  /**
358
386
  * Initialize job queue directories, announce any pending backlog,
359
387
  * and start the re-announce interval. Idempotent.
@@ -399,7 +427,20 @@ declare class FsJobQueue implements JobQueue {
399
427
  * re-announced); after that it lands in `failed` with the error.
400
428
  * Returns null (and changes nothing) if the job isn't running.
401
429
  */
402
- failJob(jobId: JobId, error: string): Promise<'retried' | 'failed' | null>;
430
+ failJob(jobId: JobId, error: string, completedUnits?: string[], failureClass?: 'transient' | 'deterministic'): Promise<'retried' | 'failed' | null>;
431
+ /**
432
+ * Persist a running job's completed-unit checkpoint at unit completion
433
+ * (JOB-RESTART-SAFETY P2). `failJob` writes this checkpoint on a clean
434
+ * failure, but a worker that dies without emitting `job:fail` would lose
435
+ * its finished units — so the janitor's recovery (which re-queues with
436
+ * whatever `metadata.completedUnits` the running file holds) would redo
437
+ * them. Writing it HERE, as each unit lands, makes a crash lose at most
438
+ * the in-flight unit. Unioned with any existing checkpoint, unthrottled
439
+ * (a unit completion is rare and must never be dropped), a no-op for
440
+ * non-running jobs. Written directly, like `recordProgress`, so it also
441
+ * refreshes the mtime heartbeat.
442
+ */
443
+ checkpointUnits(jobId: JobId, completedUnits: string[]): Promise<void>;
403
444
  /**
404
445
  * Write progress into a running job's file. Throttled per job, and
405
446
  * a no-op for jobs that aren't running. Beyond surfacing live
@@ -521,7 +562,18 @@ interface ProcessorResult<R> {
521
562
  declare function processHighlightJob(content: string, inferenceClient: InferenceClient, params: HighlightDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<HighlightDetectionResult>>;
522
563
  declare function processCommentJob(content: string, inferenceClient: InferenceClient, params: CommentDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<CommentDetectionResult>>;
523
564
  declare function processAssessmentJob(content: string, inferenceClient: InferenceClient, params: AssessmentDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<AssessmentDetectionResult>>;
524
- declare function processReferenceJob(content: string, inferenceClient: InferenceClient, params: DetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress, logger: Logger): Promise<ProcessorResult<DetectionResult>>;
565
+ /**
566
+ * Reference detection commits per UNIT — one entity type — through
567
+ * `onUnitComplete` (ABANDONED-INFERENCE P2, checkpointed resume): the
568
+ * callback receives the unit's deduped annotations, and only after it
569
+ * resolves does the unit count as complete. Emission belongs to the
570
+ * callback alone; the processor returns only the result — returning the
571
+ * annotations as well would recreate the post-run batch that N2 showed
572
+ * discards completed work wholesale.
573
+ */
574
+ declare function processReferenceJob(content: string, inferenceClient: InferenceClient, params: DetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress, logger: Logger, onUnitComplete: (entityType: string, annotations: Annotation[]) => Promise<void>, signal?: AbortSignal): Promise<{
575
+ result: DetectionResult;
576
+ }>;
525
577
  declare function processTagJob(content: string, inferenceClient: InferenceClient, params: TagDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<TagDetectionResult>>;
526
578
  declare function processGenerationJob(inferenceClient: InferenceClient, params: GenerationJobParams, onProgress: OnProgress, logger: Logger): Promise<{
527
579
  content: Uint8Array;
@@ -692,8 +744,21 @@ declare function generateResourceFromTopic(topic: string, entityTypes: string[],
692
744
  *
693
745
  * Thresholds are fixed by design (no env knobs) and deliberately
694
746
  * layered: inference timeout (10 min, P2) fires first; this watchdog
695
- * (15 min) catches the failure modes nobody predicted; the backend's
696
- * dead-worker janitor (30 min) re-queues the job regardless.
747
+ * (15 min) catches wedges where the loop still turns but activity has
748
+ * stopped; the gateway's dead-worker janitor (30 min) re-queues the job
749
+ * regardless.
750
+ *
751
+ * The layering matters because this watchdog has a hard limit: it is an
752
+ * IN-PROCESS timer, so it cannot fire while the event loop itself is
753
+ * blocked — the exact condition a blocked loop creates
754
+ * (JOB-RESTART-SAFETY P7, the 2026-09-03 finalization hang: 18 min silent,
755
+ * this watchdog never fired, an empty /health confirming the loop was
756
+ * wedged). The unbounded emit that caused that specific hang is now bounded
757
+ * at the transport (`EMIT_TIMEOUT_MS`), so the loop errors instead of
758
+ * blocking; but for any future blocked-loop bug the ONLY backstop is the
759
+ * out-of-process one — the gateway's janitor sweeping this worker's job
760
+ * files by mtime (`fs-job-queue.ts` `recoverStaleRunningJobs`). A liveness
761
+ * guarantee a blocked loop defeats is not one; the janitor is the guarantee.
697
762
  */
698
763
  declare const STALL_THRESHOLD_MS: number;
699
764