@semiont/jobs 0.5.11 → 0.5.13

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.
@@ -1,4 +1,4 @@
1
- import { createTomlConfigLoader, didToAgent, baseUrl, busRequest, getPrimaryMediaType, textExtractionOf, assembleAnnotation, reconcileSelector, getLocaleEnglishName, isArray, isObject, isString, deriveViews } from '@semiont/core';
1
+ import { createTomlConfigLoader, didToAgent, baseUrl, STARTUP_FETCH_RETRY, retryWithBackoff, isTransientFetchError, busRequest, getPrimaryMediaType, textExtractionOf, assembleAnnotation, reconcileSelector, getLocaleEnglishName, isArray, isObject, isString, deriveViews } from '@semiont/core';
2
2
  import { deriveStorageUri } from '@semiont/content';
3
3
  import { withSpan, SpanKind, recordJobOutcome } from '@semiont/observability';
4
4
  import { generateAnnotationId } from '@semiont/event-sourcing';
@@ -9226,6 +9226,12 @@ function createJobClaimAdapter(options) {
9226
9226
  const errors$ = new import_rxjs.Subject();
9227
9227
  let jobSubscription = null;
9228
9228
  let started = false;
9229
+ let lastQueuedEventAt = null;
9230
+ let lastClaimAt = null;
9231
+ let lastFinishedAt = null;
9232
+ let lastActivityAt = null;
9233
+ let activeSince = null;
9234
+ const iso = (t) => t === null ? null : new Date(t).toISOString();
9229
9235
  const claimJob = async (assignment) => {
9230
9236
  try {
9231
9237
  const job = await busRequest(requestBus, "job:claim", { jobId: assignment.jobId }, 1e4);
@@ -9250,12 +9256,17 @@ function createJobClaimAdapter(options) {
9250
9256
  started = true;
9251
9257
  bus.addChannels?.(["job:queued"]);
9252
9258
  jobSubscription = bus.on$("job:queued").subscribe((event) => {
9259
+ lastQueuedEventAt = Date.now();
9253
9260
  const jobType = event.jobType;
9254
9261
  if (jobTypes.length > 0 && !jobTypes.includes(jobType)) return;
9255
9262
  if (isProcessing$.getValue()) return;
9256
9263
  isProcessing$.next(true);
9257
9264
  claimJob({ jobId: event.jobId, type: jobType, resourceId: event.resourceId }).then((job) => {
9258
9265
  if (job) {
9266
+ const now = Date.now();
9267
+ lastClaimAt = now;
9268
+ lastActivityAt = now;
9269
+ activeSince = now;
9259
9270
  activeJob$.next(job);
9260
9271
  } else {
9261
9272
  isProcessing$.next(false);
@@ -9271,15 +9282,37 @@ function createJobClaimAdapter(options) {
9271
9282
  started = false;
9272
9283
  },
9273
9284
  completeJob: () => {
9285
+ const now = Date.now();
9286
+ lastFinishedAt = now;
9287
+ lastActivityAt = now;
9288
+ activeSince = null;
9274
9289
  activeJob$.next(null);
9275
9290
  isProcessing$.next(false);
9276
9291
  jobsCompleted$.next(jobsCompleted$.getValue() + 1);
9277
9292
  },
9278
9293
  failJob: (jid, error) => {
9294
+ const now = Date.now();
9295
+ lastFinishedAt = now;
9296
+ lastActivityAt = now;
9297
+ activeSince = null;
9279
9298
  activeJob$.next(null);
9280
9299
  isProcessing$.next(false);
9281
9300
  errors$.next({ jobId: jid, error });
9282
9301
  },
9302
+ vitals: () => {
9303
+ const active = activeJob$.getValue();
9304
+ return {
9305
+ lastQueuedEventAt: iso(lastQueuedEventAt),
9306
+ lastClaimAt: iso(lastClaimAt),
9307
+ lastFinishedAt: iso(lastFinishedAt),
9308
+ lastActivityAt: iso(lastActivityAt),
9309
+ activeJob: active && activeSince !== null ? { jobId: active.jobId, type: active.type, since: iso(activeSince) } : null,
9310
+ jobsCompleted: jobsCompleted$.getValue()
9311
+ };
9312
+ },
9313
+ touchActivity: () => {
9314
+ lastActivityAt = Date.now();
9315
+ },
9283
9316
  dispose: () => {
9284
9317
  jobSubscription?.unsubscribe();
9285
9318
  jobSubscription = null;
@@ -9291,6 +9324,41 @@ function createJobClaimAdapter(options) {
9291
9324
  }
9292
9325
  };
9293
9326
  }
9327
+
9328
+ // src/workers/inference-call.ts
9329
+ var INFERENCE_TIMEOUT_MS = 10 * 6e4;
9330
+ async function withTimeout(work, label) {
9331
+ let timer;
9332
+ const timedOut = new Promise((_, reject) => {
9333
+ timer = setTimeout(() => {
9334
+ reject(new Error(
9335
+ `Inference call timed out after ${INFERENCE_TIMEOUT_MS / 6e4} minutes (${label}) \u2014 failing the job to keep the claim loop live`
9336
+ ));
9337
+ }, INFERENCE_TIMEOUT_MS);
9338
+ timer.unref?.();
9339
+ });
9340
+ try {
9341
+ return await Promise.race([work, timedOut]);
9342
+ } catch (err) {
9343
+ work.catch(() => {
9344
+ });
9345
+ throw err;
9346
+ } finally {
9347
+ clearTimeout(timer);
9348
+ }
9349
+ }
9350
+ function boundedGenerate(client, prompt, maxTokens, temperature, options) {
9351
+ return withTimeout(
9352
+ client.generateText(prompt, maxTokens, temperature, options),
9353
+ `${client.type}:${client.modelId}`
9354
+ );
9355
+ }
9356
+ function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, options) {
9357
+ return withTimeout(
9358
+ client.generateTextWithMetadata(prompt, maxTokens, temperature, options),
9359
+ `${client.type}:${client.modelId}`
9360
+ );
9361
+ }
9294
9362
  function languageName(tag) {
9295
9363
  return getLocaleEnglishName(tag) || tag;
9296
9364
  }
@@ -9772,7 +9840,7 @@ var AnnotationDetection = class {
9772
9840
  */
9773
9841
  static async detectComments(content, client, instructions, tone, density, language, sourceLanguage) {
9774
9842
  const prompt = MotivationPrompts.buildCommentPrompt(content, instructions, tone, density, language, sourceLanguage);
9775
- const response = await client.generateTextWithMetadata(prompt, 3e3, 0.4, { format: "json" });
9843
+ const response = await boundedGenerateWithMetadata(client, prompt, 3e3, 0.4, { format: "json" });
9776
9844
  assertNotTruncated(response, "comment");
9777
9845
  return MotivationParsers.parseComments(response.text, content);
9778
9846
  }
@@ -9785,7 +9853,7 @@ var AnnotationDetection = class {
9785
9853
  */
9786
9854
  static async detectHighlights(content, client, instructions, density, sourceLanguage) {
9787
9855
  const prompt = MotivationPrompts.buildHighlightPrompt(content, instructions, density, sourceLanguage);
9788
- const response = await client.generateTextWithMetadata(prompt, 2e3, 0.3, { format: "json" });
9856
+ const response = await boundedGenerateWithMetadata(client, prompt, 2e3, 0.3, { format: "json" });
9789
9857
  assertNotTruncated(response, "highlight");
9790
9858
  return MotivationParsers.parseHighlights(response.text, content);
9791
9859
  }
@@ -9798,7 +9866,7 @@ var AnnotationDetection = class {
9798
9866
  */
9799
9867
  static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage) {
9800
9868
  const prompt = MotivationPrompts.buildAssessmentPrompt(content, instructions, tone, density, language, sourceLanguage);
9801
- const response = await client.generateTextWithMetadata(prompt, 3e3, 0.3, { format: "json" });
9869
+ const response = await boundedGenerateWithMetadata(client, prompt, 3e3, 0.3, { format: "json" });
9802
9870
  assertNotTruncated(response, "assessment");
9803
9871
  return MotivationParsers.parseAssessments(response.text, content);
9804
9872
  }
@@ -9829,7 +9897,7 @@ var AnnotationDetection = class {
9829
9897
  categoryInfo.examples,
9830
9898
  sourceLanguage
9831
9899
  );
9832
- const response = await client.generateTextWithMetadata(prompt, 4e3, 0.2, { format: "json" });
9900
+ const response = await boundedGenerateWithMetadata(client, prompt, 4e3, 0.2, { format: "json" });
9833
9901
  assertNotTruncated(response, "tag");
9834
9902
  const parsedTags = MotivationParsers.parseTags(response.text);
9835
9903
  return MotivationParsers.validateTagOffsets(parsedTags, content, category);
@@ -9885,7 +9953,8 @@ If no entities are found, respond with an empty array [].
9885
9953
  Example output:
9886
9954
  [{"exact":"Alice","entityType":"Person","prefix":"","suffix":" went to"},{"exact":"Paris","entityType":"Location","prefix":"went to ","suffix":" yesterday"}]`;
9887
9955
  logger2.debug("Sending entity extraction request", { entityTypes: entityTypesDescription });
9888
- const response = await client.generateTextWithMetadata(
9956
+ const response = await boundedGenerateWithMetadata(
9957
+ client,
9889
9958
  prompt,
9890
9959
  4e3,
9891
9960
  // Increased to handle many entities without truncation
@@ -10135,7 +10204,7 @@ ${formatRequirements}`;
10135
10204
  temperature: finalTemperature,
10136
10205
  maxTokens: finalMaxTokens
10137
10206
  });
10138
- const response = await client.generateText(prompt, finalMaxTokens, finalTemperature);
10207
+ const response = await boundedGenerate(client, prompt, finalMaxTokens, finalTemperature);
10139
10208
  logger2.debug("Got response from inference", { responseLength: response.length });
10140
10209
  const result = parseResponse(response);
10141
10210
  logger2.debug("Parsed response", {
@@ -10495,10 +10564,9 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger2
10495
10564
  `Unsupported outputMediaType for generation: ${outputMediaType}. Generation produces ${GENERATABLE_MEDIA_TYPES.join(" or ")}.`
10496
10565
  );
10497
10566
  }
10498
- onProgress(20, "Fetching context...", "fetching");
10499
10567
  const title = params.title ?? "Untitled";
10500
10568
  const entityTypes = (params.entityTypes ?? []).map(String);
10501
- onProgress(40, "Generating resource...", "generating");
10569
+ onProgress(5, "Generating resource...", "generating");
10502
10570
  const generated = await generateResourceFromTopic(
10503
10571
  title,
10504
10572
  entityTypes,
@@ -10522,7 +10590,7 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger2
10522
10590
  content = resolved.content;
10523
10591
  citations = resolved.citations;
10524
10592
  }
10525
- onProgress(85, "Creating resource...", "creating");
10593
+ onProgress(95, "Creating resource...", "creating");
10526
10594
  return {
10527
10595
  content,
10528
10596
  title: generated.title ?? title,
@@ -10619,6 +10687,7 @@ async function handleJobInner(adapter, config, job) {
10619
10687
  }
10620
10688
  }
10621
10689
  const onProgress = (percentage, message, stage, extra) => {
10690
+ adapter.touchActivity();
10622
10691
  emitEvent(session, "job:report-progress", {
10623
10692
  ...lifecycleBase,
10624
10693
  percentage,
@@ -10787,6 +10856,43 @@ async function handleJobInner(adapter, config, job) {
10787
10856
 
10788
10857
  // src/worker-runtime.ts
10789
10858
  var import_rxjs2 = __toESM(require_cjs());
10859
+ function buildHealthPayload(workers) {
10860
+ return {
10861
+ status: "ok",
10862
+ agents: workers.length,
10863
+ workers: workers.map((w) => w.vitals())
10864
+ };
10865
+ }
10866
+ var STALL_THRESHOLD_MS = 15 * 6e4;
10867
+ var STALL_CHECK_INTERVAL_MS = 6e4;
10868
+ function startStallWatchdog(opts) {
10869
+ const { workers, logger: logger2, exit = (code) => process.exit(code) } = opts;
10870
+ const timer = setInterval(() => {
10871
+ const now = Date.now();
10872
+ for (const worker of workers) {
10873
+ const v = worker.vitals();
10874
+ if (!v.activeJob || !v.lastActivityAt) continue;
10875
+ const silentForMs = now - Date.parse(v.lastActivityAt);
10876
+ if (silentForMs <= STALL_THRESHOLD_MS) continue;
10877
+ logger2.error("Worker stalled \u2014 exiting for restart", {
10878
+ provider: v.provider,
10879
+ model: v.model,
10880
+ did: v.did,
10881
+ jobId: v.activeJob.jobId,
10882
+ jobType: v.activeJob.type,
10883
+ processingSince: v.activeJob.since,
10884
+ lastActivityAt: v.lastActivityAt,
10885
+ silentForMs,
10886
+ thresholdMs: STALL_THRESHOLD_MS
10887
+ });
10888
+ clearInterval(timer);
10889
+ exit(1);
10890
+ return;
10891
+ }
10892
+ }, STALL_CHECK_INTERVAL_MS);
10893
+ timer.unref?.();
10894
+ return { dispose: () => clearInterval(timer) };
10895
+ }
10790
10896
  function parseBackendUrl(url) {
10791
10897
  const parsed = new URL(url);
10792
10898
  const protocol = parsed.protocol.replace(":", "") === "https" ? "https" : "http";
@@ -10795,19 +10901,34 @@ function parseBackendUrl(url) {
10795
10901
  return { protocol, host, port };
10796
10902
  }
10797
10903
  async function authenticateAgent(opts) {
10798
- const { backendBaseUrl: backendBaseUrl2, workerSecret: workerSecret2, provider, model } = opts;
10904
+ const { backendBaseUrl: backendBaseUrl2, workerSecret: workerSecret2, provider, model, logger: logger2, retry = STARTUP_FETCH_RETRY } = opts;
10799
10905
  if (!workerSecret2) {
10800
10906
  throw new Error("SEMIONT_WORKER_SECRET is required to authenticate worker agents");
10801
10907
  }
10802
- const response = await fetch(`${backendBaseUrl2}/api/tokens/agent`, {
10803
- method: "POST",
10804
- headers: { "Content-Type": "application/json" },
10805
- body: JSON.stringify({ secret: workerSecret2, provider, model })
10806
- });
10807
- if (!response.ok) {
10808
- throw new Error(`Agent authentication failed for ${provider}:${model}: ${response.status} ${response.statusText}`);
10809
- }
10810
- return await response.json();
10908
+ return retryWithBackoff(
10909
+ async () => {
10910
+ const response = await fetch(`${backendBaseUrl2}/api/tokens/agent`, {
10911
+ method: "POST",
10912
+ headers: { "Content-Type": "application/json" },
10913
+ body: JSON.stringify({ secret: workerSecret2, provider, model })
10914
+ });
10915
+ if (!response.ok) {
10916
+ throw new Error(`Agent authentication failed for ${provider}:${model}: ${response.status} ${response.statusText}`);
10917
+ }
10918
+ return await response.json();
10919
+ },
10920
+ isTransientFetchError,
10921
+ retry,
10922
+ ({ attempt, attempts, delayMs, error }) => {
10923
+ logger2?.warn("Backend unreachable, retrying agent authentication", {
10924
+ agent: `${provider}:${model}`,
10925
+ attempt,
10926
+ attempts,
10927
+ retryInMs: delayMs,
10928
+ error: error instanceof Error ? error.message : String(error)
10929
+ });
10930
+ }
10931
+ );
10811
10932
  }
10812
10933
  async function startAgentWorker(opts) {
10813
10934
  const { group, backendBaseUrl: backendBaseUrl2, workerSecret: workerSecret2, logger: logger2 } = opts;
@@ -10817,7 +10938,8 @@ async function startAgentWorker(opts) {
10817
10938
  backendBaseUrl: backendBaseUrl2,
10818
10939
  workerSecret: workerSecret2,
10819
10940
  provider: inference.type,
10820
- model: inference.model
10941
+ model: inference.model,
10942
+ logger: logger2
10821
10943
  });
10822
10944
  const generator = didToAgent(did);
10823
10945
  const kbId = `agent-${inference.type}-${inference.model}-${hostname()}`;
@@ -10850,7 +10972,8 @@ async function startAgentWorker(opts) {
10850
10972
  backendBaseUrl: backendBaseUrl2,
10851
10973
  workerSecret: workerSecret2,
10852
10974
  provider: inference.type,
10853
- model: inference.model
10975
+ model: inference.model,
10976
+ logger: logger2
10854
10977
  });
10855
10978
  return token;
10856
10979
  } catch (err) {
@@ -10881,6 +11004,13 @@ async function startAgentWorker(opts) {
10881
11004
  });
10882
11005
  return {
10883
11006
  session,
11007
+ vitals: () => ({
11008
+ provider: inference.type,
11009
+ model: inference.model,
11010
+ did,
11011
+ jobTypes: group.jobTypes,
11012
+ ...adapter.vitals()
11013
+ }),
10884
11014
  dispose: async () => {
10885
11015
  adapter.dispose();
10886
11016
  await session.dispose();
@@ -10973,7 +11103,7 @@ async function main() {
10973
11103
  const health = createServer((req, res) => {
10974
11104
  if (req.url === "/health") {
10975
11105
  res.writeHead(200, { "Content-Type": "application/json" });
10976
- res.end(JSON.stringify({ status: "ok", agents: workers.length }));
11106
+ res.end(JSON.stringify(buildHealthPayload(workers)));
10977
11107
  } else {
10978
11108
  res.writeHead(404);
10979
11109
  res.end();
@@ -10982,8 +11112,10 @@ async function main() {
10982
11112
  health.listen(healthPort, () => {
10983
11113
  logger.info("Health endpoint ready", { port: healthPort });
10984
11114
  });
11115
+ const watchdog = startStallWatchdog({ workers, logger });
10985
11116
  const shutdown = async () => {
10986
11117
  logger.info("Shutting down");
11118
+ watchdog.dispose();
10987
11119
  await Promise.all(workers.map((w) => w.dispose()));
10988
11120
  health.close();
10989
11121
  process.exit(0);