@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 +20 -4
- package/dist/index.d.ts +73 -8
- package/dist/index.js +348 -116
- package/dist/index.js.map +1 -1
- package/dist/worker-main.js +499 -176
- package/dist/worker-main.js.map +1 -1
- package/package.json +11 -11
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
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
|
696
|
-
* dead-worker janitor (30 min) re-queues the job
|
|
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
|
|