@semiont/jobs 0.5.29 → 0.5.31

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
@@ -98,10 +98,12 @@ interface JobMetadata {
98
98
  created: string;
99
99
  retryCount: number;
100
100
  maxRetries: number;
101
+ completedUnits?: string[]; // Checkpoint: work units (entity types) already
102
+ // persisted by a failed attempt — the retry skips them
101
103
  }
102
104
  ```
103
105
 
104
- 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.
105
107
 
106
108
  ## Annotation Workers
107
109
 
@@ -118,7 +120,19 @@ The worker process (`worker-main.ts` → `startWorkerProcess` in `worker-process
118
120
 
119
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.
120
122
 
121
- 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.
122
136
 
123
137
  ## Adding a Job Type
124
138
 
package/dist/index.d.ts CHANGED
@@ -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
  /**
@@ -402,7 +427,20 @@ declare class FsJobQueue implements JobQueue {
402
427
  * re-announced); after that it lands in `failed` with the error.
403
428
  * Returns null (and changes nothing) if the job isn't running.
404
429
  */
405
- 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>;
406
444
  /**
407
445
  * Write progress into a running job's file. Throttled per job, and
408
446
  * a no-op for jobs that aren't running. Beyond surfacing live
@@ -524,7 +562,18 @@ interface ProcessorResult<R> {
524
562
  declare function processHighlightJob(content: string, inferenceClient: InferenceClient, params: HighlightDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<HighlightDetectionResult>>;
525
563
  declare function processCommentJob(content: string, inferenceClient: InferenceClient, params: CommentDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<CommentDetectionResult>>;
526
564
  declare function processAssessmentJob(content: string, inferenceClient: InferenceClient, params: AssessmentDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<AssessmentDetectionResult>>;
527
- 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
+ }>;
528
577
  declare function processTagJob(content: string, inferenceClient: InferenceClient, params: TagDetectionParams, buildAnnotation: BuildAnnotation, onProgress: OnProgress): Promise<ProcessorResult<TagDetectionResult>>;
529
578
  declare function processGenerationJob(inferenceClient: InferenceClient, params: GenerationJobParams, onProgress: OnProgress, logger: Logger): Promise<{
530
579
  content: Uint8Array;
@@ -695,8 +744,21 @@ declare function generateResourceFromTopic(topic: string, entityTypes: string[],
695
744
  *
696
745
  * Thresholds are fixed by design (no env knobs) and deliberately
697
746
  * layered: inference timeout (10 min, P2) fires first; this watchdog
698
- * (15 min) catches the failure modes nobody predicted; the gateway's
699
- * 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.
700
762
  */
701
763
  declare const STALL_THRESHOLD_MS: number;
702
764