@semiont/jobs 0.5.12 → 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", {
@@ -10618,6 +10687,7 @@ async function handleJobInner(adapter, config, job) {
10618
10687
  }
10619
10688
  }
10620
10689
  const onProgress = (percentage, message, stage, extra) => {
10690
+ adapter.touchActivity();
10621
10691
  emitEvent(session, "job:report-progress", {
10622
10692
  ...lifecycleBase,
10623
10693
  percentage,
@@ -10786,6 +10856,43 @@ async function handleJobInner(adapter, config, job) {
10786
10856
 
10787
10857
  // src/worker-runtime.ts
10788
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
+ }
10789
10896
  function parseBackendUrl(url) {
10790
10897
  const parsed = new URL(url);
10791
10898
  const protocol = parsed.protocol.replace(":", "") === "https" ? "https" : "http";
@@ -10794,19 +10901,34 @@ function parseBackendUrl(url) {
10794
10901
  return { protocol, host, port };
10795
10902
  }
10796
10903
  async function authenticateAgent(opts) {
10797
- const { backendBaseUrl: backendBaseUrl2, workerSecret: workerSecret2, provider, model } = opts;
10904
+ const { backendBaseUrl: backendBaseUrl2, workerSecret: workerSecret2, provider, model, logger: logger2, retry = STARTUP_FETCH_RETRY } = opts;
10798
10905
  if (!workerSecret2) {
10799
10906
  throw new Error("SEMIONT_WORKER_SECRET is required to authenticate worker agents");
10800
10907
  }
10801
- const response = await fetch(`${backendBaseUrl2}/api/tokens/agent`, {
10802
- method: "POST",
10803
- headers: { "Content-Type": "application/json" },
10804
- body: JSON.stringify({ secret: workerSecret2, provider, model })
10805
- });
10806
- if (!response.ok) {
10807
- throw new Error(`Agent authentication failed for ${provider}:${model}: ${response.status} ${response.statusText}`);
10808
- }
10809
- 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
+ );
10810
10932
  }
10811
10933
  async function startAgentWorker(opts) {
10812
10934
  const { group, backendBaseUrl: backendBaseUrl2, workerSecret: workerSecret2, logger: logger2 } = opts;
@@ -10816,7 +10938,8 @@ async function startAgentWorker(opts) {
10816
10938
  backendBaseUrl: backendBaseUrl2,
10817
10939
  workerSecret: workerSecret2,
10818
10940
  provider: inference.type,
10819
- model: inference.model
10941
+ model: inference.model,
10942
+ logger: logger2
10820
10943
  });
10821
10944
  const generator = didToAgent(did);
10822
10945
  const kbId = `agent-${inference.type}-${inference.model}-${hostname()}`;
@@ -10849,7 +10972,8 @@ async function startAgentWorker(opts) {
10849
10972
  backendBaseUrl: backendBaseUrl2,
10850
10973
  workerSecret: workerSecret2,
10851
10974
  provider: inference.type,
10852
- model: inference.model
10975
+ model: inference.model,
10976
+ logger: logger2
10853
10977
  });
10854
10978
  return token;
10855
10979
  } catch (err) {
@@ -10880,6 +11004,13 @@ async function startAgentWorker(opts) {
10880
11004
  });
10881
11005
  return {
10882
11006
  session,
11007
+ vitals: () => ({
11008
+ provider: inference.type,
11009
+ model: inference.model,
11010
+ did,
11011
+ jobTypes: group.jobTypes,
11012
+ ...adapter.vitals()
11013
+ }),
10883
11014
  dispose: async () => {
10884
11015
  adapter.dispose();
10885
11016
  await session.dispose();
@@ -10972,7 +11103,7 @@ async function main() {
10972
11103
  const health = createServer((req, res) => {
10973
11104
  if (req.url === "/health") {
10974
11105
  res.writeHead(200, { "Content-Type": "application/json" });
10975
- res.end(JSON.stringify({ status: "ok", agents: workers.length }));
11106
+ res.end(JSON.stringify(buildHealthPayload(workers)));
10976
11107
  } else {
10977
11108
  res.writeHead(404);
10978
11109
  res.end();
@@ -10981,8 +11112,10 @@ async function main() {
10981
11112
  health.listen(healthPort, () => {
10982
11113
  logger.info("Health endpoint ready", { port: healthPort });
10983
11114
  });
11115
+ const watchdog = startStallWatchdog({ workers, logger });
10984
11116
  const shutdown = async () => {
10985
11117
  logger.info("Shutting down");
11118
+ watchdog.dispose();
10986
11119
  await Promise.all(workers.map((w) => w.dispose()));
10987
11120
  health.close();
10988
11121
  process.exit(0);