@mcoda/mswarm 0.1.78 → 0.1.80

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/dist/runtime.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { chmod, lstat, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
- import { hostname, homedir, platform, userInfo } from "node:os";
3
+ import { cpus, freemem, hostname, homedir, loadavg, platform, totalmem, userInfo } from "node:os";
4
4
  import { spawn } from "node:child_process";
5
5
  import { createHash, createHmac, randomUUID } from "node:crypto";
6
6
  import { MswarmCodaliExecutor } from "./codali-executor.js";
@@ -16,6 +16,11 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
16
16
  const DEFAULT_JOB_TIMEOUT_MS = 3600000;
17
17
  const DEFAULT_SERVICE_COMMAND_TIMEOUT_MS = 60000;
18
18
  const DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS = 2000;
19
+ const SELF_HOSTED_RUNTIME_PROTOCOL_VERSION = 1;
20
+ const SELF_HOSTED_LOAD_BALANCER_PROTOCOL_VERSION = 1;
21
+ const SELF_HOSTED_CATALOG_METADATA_VERSION = 1;
22
+ const MAX_TELEMETRY_LATENCY_SAMPLES = 50;
23
+ const MAX_TELEMETRY_FAILURES = 20;
19
24
  const DEFAULT_MCODA_BIN = "mcoda";
20
25
  const DEFAULT_MCODA_LIST_ARGS = ["agent", "list", "--json", "--refresh-health"];
21
26
  const DEFAULT_COMMAND_MAX_BUFFER = 16 * 1024 * 1024;
@@ -460,6 +465,112 @@ function optionalBoolean(...values) {
460
465
  }
461
466
  return null;
462
467
  }
468
+ function roundedTelemetryNumber(value, digits = 3) {
469
+ if (!Number.isFinite(value)) {
470
+ return 0;
471
+ }
472
+ const factor = 10 ** digits;
473
+ return Math.round(value * factor) / factor;
474
+ }
475
+ function nonNegativeTelemetryInteger(value) {
476
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
477
+ }
478
+ function sha256Json(value) {
479
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
480
+ }
481
+ function buildCatalogFingerprint(models) {
482
+ const projection = models
483
+ .map((model) => ({
484
+ name: optionalText(model.name) || "",
485
+ provider: optionalText(model.provider) || null,
486
+ adapter: optionalText(model.adapter) || null,
487
+ source_agent_slug: optionalText(model.source_agent_slug) || null,
488
+ model_id: optionalText(model.model_id) || optionalText(model.model) || null,
489
+ exposed: model.exposed !== false,
490
+ capabilities: normalizeCapabilities(model.capabilities).sort(),
491
+ health_status: normalizeHealthStatus(model.health_status)
492
+ }))
493
+ .sort((left, right) => `${left.provider || ""}:${left.name}`.localeCompare(`${right.provider || ""}:${right.name}`));
494
+ return `sha256:${sha256Json(projection)}`;
495
+ }
496
+ function executionClassCapacity(input) {
497
+ const maxConcurrency = Math.max(1, Math.floor(input.maxConcurrency));
498
+ const activeJobs = nonNegativeTelemetryInteger(input.activeJobs);
499
+ const queuedJobs = nonNegativeTelemetryInteger(input.queuedJobs);
500
+ return {
501
+ max_concurrency: maxConcurrency,
502
+ active_jobs: activeJobs,
503
+ queued_jobs: queuedJobs,
504
+ free_slots: input.drainMode ? 0 : Math.max(0, maxConcurrency - activeJobs - queuedJobs)
505
+ };
506
+ }
507
+ function totalHostMemoryBucket() {
508
+ const gib = totalmem() / (1024 ** 3);
509
+ if (!Number.isFinite(gib) || gib <= 0)
510
+ return "unknown";
511
+ if (gib <= 8)
512
+ return "<=8GiB";
513
+ if (gib <= 16)
514
+ return "<=16GiB";
515
+ if (gib <= 32)
516
+ return "<=32GiB";
517
+ if (gib <= 64)
518
+ return "<=64GiB";
519
+ if (gib <= 128)
520
+ return "<=128GiB";
521
+ return ">128GiB";
522
+ }
523
+ function coarsePublicVramTier(value, gpuCount) {
524
+ if (value === "none" ||
525
+ value === "lt8" ||
526
+ value === "8-15" ||
527
+ value === "16-31" ||
528
+ value === "32plus") {
529
+ return value;
530
+ }
531
+ return gpuCount > 0 ? "unknown" : "none";
532
+ }
533
+ function buildCoarseHardwarePressure(capabilityPayload) {
534
+ const cpuCount = Math.max(1, cpus().length || 1);
535
+ const totalMemory = totalmem();
536
+ const freeMemory = freemem();
537
+ const projection = capabilityPayload?.public_projection;
538
+ const projectionRecord = projection && typeof projection === "object" && !Array.isArray(projection)
539
+ ? projection
540
+ : {};
541
+ const accelerators = projectionRecord.accelerators && typeof projectionRecord.accelerators === "object"
542
+ ? projectionRecord.accelerators
543
+ : {};
544
+ const gpu = accelerators.gpu && typeof accelerators.gpu === "object" && !Array.isArray(accelerators.gpu)
545
+ ? accelerators.gpu
546
+ : null;
547
+ const rawGpuCount = gpu?.["count"];
548
+ const gpuCount = typeof rawGpuCount === "number" && Number.isFinite(rawGpuCount)
549
+ ? Math.max(0, Math.floor(rawGpuCount))
550
+ : 0;
551
+ const vramTier = coarsePublicVramTier(gpu?.["vram_tier"], gpuCount);
552
+ return {
553
+ schema_version: 1,
554
+ collected_at: new Date().toISOString(),
555
+ cpu: {
556
+ core_count: cpuCount,
557
+ load_1m_ratio: roundedTelemetryNumber((loadavg()[0] || 0) / cpuCount)
558
+ },
559
+ ram: {
560
+ used_ratio: totalMemory > 0 ? roundedTelemetryNumber((totalMemory - freeMemory) / totalMemory) : null,
561
+ total_bucket: totalHostMemoryBucket()
562
+ },
563
+ gpu: {
564
+ available: Boolean(gpu?.["available"]),
565
+ count: gpuCount,
566
+ cuda: Boolean(gpu?.["cuda"] || gpu?.["has_cuda"]),
567
+ vram: {
568
+ total_tier: vramTier,
569
+ used_ratio: null
570
+ }
571
+ }
572
+ };
573
+ }
463
574
  function normalizeCapabilities(value) {
464
575
  if (!Array.isArray(value)) {
465
576
  return [];
@@ -481,12 +592,19 @@ function normalizeHealthStatus(value) {
481
592
  return "blocked";
482
593
  return "unknown";
483
594
  }
484
- function isMswarmManagedCloudAgent(agent) {
595
+ function isMswarmManagedAgent(agent) {
485
596
  const config = agent.config && typeof agent.config === "object" ? agent.config : {};
486
- const mswarmCloud = config.mswarmCloud;
487
- return Boolean(mswarmCloud &&
488
- typeof mswarmCloud === "object" &&
489
- mswarmCloud.managed === true);
597
+ const managedConfigs = [config.mswarmCloud, config.mswarmSelfHosted, config.mswarmWorker];
598
+ if (managedConfigs.some((managedConfig) => Boolean(managedConfig) &&
599
+ typeof managedConfig === "object" &&
600
+ managedConfig.managed === true)) {
601
+ return true;
602
+ }
603
+ const slug = optionalText(agent.slug)?.toLowerCase();
604
+ return Boolean(slug &&
605
+ (slug.startsWith("mswarm-cloud-") ||
606
+ slug.startsWith("mswarm-self-hosted-") ||
607
+ slug.startsWith("mswarm-worker-")));
490
608
  }
491
609
  function isModelExposed(modelName, family, config) {
492
610
  if (config.modelBlocklist.includes(modelName)) {
@@ -631,9 +749,14 @@ function serviceEnvironment(config, env, homeDir) {
631
749
  MSWARM_SELF_HOSTED_HEARTBEAT_INTERVAL_SECONDS: String(config.heartbeatIntervalSeconds),
632
750
  MSWARM_SELF_HOSTED_REQUEST_TIMEOUT_MS: String(config.requestTimeoutMs),
633
751
  MSWARM_SELF_HOSTED_JOB_TIMEOUT_MS: String(config.jobTimeoutMs),
752
+ MSWARM_SELF_HOSTED_MAX_CONCURRENT_JOBS: String(config.maxConcurrentJobs || 1),
753
+ MSWARM_SELF_HOSTED_MAX_CONCURRENT_LLM_JOBS: String(config.maxConcurrentLlmJobs || config.maxConcurrentJobs || 1),
634
754
  MSWARM_SELF_HOSTED_GENERIC_JOBS_ENABLED: config.genericJobsEnabled ? "true" : "false",
635
755
  MSWARM_SELF_HOSTED_GENERIC_JOB_TIMEOUT_MS: String(config.genericJobTimeoutMs),
636
756
  MSWARM_SELF_HOSTED_GENERIC_JOB_MAX_CONCURRENCY: String(config.genericJobMaxConcurrency),
757
+ MSWARM_SELF_HOSTED_DRAIN_MODE: config.drainMode ? "true" : "false",
758
+ MSWARM_SELF_HOSTED_LOAD_REPORTING_ENABLED: config.loadReportingEnabled === false ? "false" : "true",
759
+ MSWARM_SELF_HOSTED_HARDWARE_TELEMETRY_ENABLED: config.hardwareTelemetryEnabled ? "true" : "false",
637
760
  MSWARM_SELF_HOSTED_CAPABILITY_PROBE_TIMEOUT_MS: config.capabilityProbeTimeoutMs
638
761
  ? String(config.capabilityProbeTimeoutMs)
639
762
  : null
@@ -1041,6 +1164,8 @@ export async function readSelfHostedNodeConfig(env = process.env) {
1041
1164
  optionalText(env.OLLAMA_HOST) ||
1042
1165
  DEFAULT_OLLAMA_BASE_URL;
1043
1166
  const packageNodeVersion = await readPackageNodeVersion();
1167
+ const maxConcurrentJobs = parsePositiveInteger(env.MSWARM_SELF_HOSTED_MAX_CONCURRENT_JOBS, state.max_concurrent_jobs || 1);
1168
+ const maxConcurrentLlmJobs = parsePositiveInteger(env.MSWARM_SELF_HOSTED_MAX_CONCURRENT_LLM_JOBS, state.max_concurrent_llm_jobs || maxConcurrentJobs);
1044
1169
  return {
1045
1170
  gatewayBaseUrl: trimTrailingSlash(gatewayBaseUrl),
1046
1171
  nodeId,
@@ -1070,10 +1195,15 @@ export async function readSelfHostedNodeConfig(env = process.env) {
1070
1195
  heartbeatIntervalSeconds: parsePositiveInteger(env.MSWARM_SELF_HOSTED_HEARTBEAT_INTERVAL_SECONDS, state.heartbeat_interval_seconds || DEFAULT_HEARTBEAT_INTERVAL_SECONDS),
1071
1196
  requestTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_REQUEST_TIMEOUT_MS, state.request_timeout_ms || DEFAULT_REQUEST_TIMEOUT_MS),
1072
1197
  jobTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_JOB_TIMEOUT_MS, state.job_timeout_ms || DEFAULT_JOB_TIMEOUT_MS),
1198
+ maxConcurrentJobs,
1199
+ maxConcurrentLlmJobs,
1073
1200
  genericJobsEnabled: parseBoolean(env.MSWARM_SELF_HOSTED_GENERIC_JOBS_ENABLED ?? env.MSWARM_SELF_HOSTED_GENERIC_JOBS, state.generic_jobs_enabled === true),
1074
1201
  genericJobTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_GENERIC_JOB_TIMEOUT_MS, state.generic_job_timeout_ms || state.job_timeout_ms || DEFAULT_JOB_TIMEOUT_MS),
1075
1202
  genericJobMaxConcurrency: parsePositiveInteger(env.MSWARM_SELF_HOSTED_GENERIC_JOB_MAX_CONCURRENCY, state.generic_job_max_concurrency || 1),
1076
1203
  capabilityProbeTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_CAPABILITY_PROBE_TIMEOUT_MS, state.capability_probe_timeout_ms || DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS),
1204
+ drainMode: parseBoolean(env.MSWARM_SELF_HOSTED_DRAIN_MODE, state.drain_mode === true),
1205
+ loadReportingEnabled: parseBoolean(env.MSWARM_SELF_HOSTED_LOAD_REPORTING_ENABLED ?? env.MSWARM_SELF_HOSTED_LOAD_REPORTING, state.load_reporting_enabled !== false),
1206
+ hardwareTelemetryEnabled: parseBoolean(env.MSWARM_SELF_HOSTED_HARDWARE_TELEMETRY_ENABLED ?? env.MSWARM_SELF_HOSTED_HARDWARE_TELEMETRY, state.hardware_telemetry_enabled === true),
1077
1207
  exposeAllModels: resolveDaemonExposeAllModels(env, state),
1078
1208
  modelAllowlist: parseList(env.MSWARM_SELF_HOSTED_MODEL_ALLOWLIST || state.model_allowlist),
1079
1209
  modelBlocklist: parseList(env.MSWARM_SELF_HOSTED_MODEL_BLOCKLIST || state.model_blocklist)
@@ -1104,6 +1234,8 @@ export async function readOwnerSetupConfig(argv = process.argv.slice(3), env = p
1104
1234
  const allowlist = parseList(options.allow || env.MSWARM_SELF_HOSTED_MODEL_ALLOWLIST);
1105
1235
  const blocklist = parseList(options.block || env.MSWARM_SELF_HOSTED_MODEL_BLOCKLIST);
1106
1236
  const packageNodeVersion = await readPackageNodeVersion();
1237
+ const maxConcurrentJobs = parsePositiveInteger(options["max-concurrent-jobs"] || env.MSWARM_SELF_HOSTED_MAX_CONCURRENT_JOBS, 1);
1238
+ const maxConcurrentLlmJobs = parsePositiveInteger(options["max-concurrent-llm-jobs"] || env.MSWARM_SELF_HOSTED_MAX_CONCURRENT_LLM_JOBS, maxConcurrentJobs);
1107
1239
  return {
1108
1240
  apiKey,
1109
1241
  gatewayBaseUrl: trimTrailingSlash(gatewayBaseUrl),
@@ -1126,10 +1258,17 @@ export async function readOwnerSetupConfig(argv = process.argv.slice(3), env = p
1126
1258
  heartbeatIntervalSeconds: parsePositiveInteger(env.MSWARM_SELF_HOSTED_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS),
1127
1259
  requestTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS),
1128
1260
  jobTimeoutMs: parsePositiveInteger(options["job-timeout-ms"] || env.MSWARM_SELF_HOSTED_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS),
1261
+ maxConcurrentJobs,
1262
+ maxConcurrentLlmJobs,
1129
1263
  genericJobsEnabled: parseBoolean(options["enable-generic-jobs"] || env.MSWARM_SELF_HOSTED_GENERIC_JOBS_ENABLED || env.MSWARM_SELF_HOSTED_GENERIC_JOBS, false),
1130
1264
  genericJobTimeoutMs: parsePositiveInteger(options["generic-job-timeout-ms"] || env.MSWARM_SELF_HOSTED_GENERIC_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS),
1131
1265
  genericJobMaxConcurrency: parsePositiveInteger(options["generic-job-max-concurrency"] || env.MSWARM_SELF_HOSTED_GENERIC_JOB_MAX_CONCURRENCY, 1),
1132
1266
  capabilityProbeTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_CAPABILITY_PROBE_TIMEOUT_MS, DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS),
1267
+ drainMode: parseBoolean(options.drain || env.MSWARM_SELF_HOSTED_DRAIN_MODE, false),
1268
+ loadReportingEnabled: parseBoolean(options["disable-load-reporting"] === true
1269
+ ? false
1270
+ : (env.MSWARM_SELF_HOSTED_LOAD_REPORTING_ENABLED ?? env.MSWARM_SELF_HOSTED_LOAD_REPORTING), true),
1271
+ hardwareTelemetryEnabled: parseBoolean(options["enable-hardware-telemetry"] || env.MSWARM_SELF_HOSTED_HARDWARE_TELEMETRY_ENABLED || env.MSWARM_SELF_HOSTED_HARDWARE_TELEMETRY, false),
1133
1272
  exposeAllModels: resolveOwnerSetupExposeAllModels(options, env),
1134
1273
  modelAllowlist: allowlist,
1135
1274
  modelBlocklist: blocklist,
@@ -1171,7 +1310,7 @@ export function mapOllamaModelToSelfHostedModel(model, config) {
1171
1310
  };
1172
1311
  }
1173
1312
  export function mapMcodaAgentToSelfHostedModel(agent, config) {
1174
- if (isMswarmManagedCloudAgent(agent)) {
1313
+ if (isMswarmManagedAgent(agent)) {
1175
1314
  return null;
1176
1315
  }
1177
1316
  const slug = optionalText(agent.slug);
@@ -1527,10 +1666,6 @@ function mapMcodaAgentToCodaliAgent(agent, fallbackSlug) {
1527
1666
  maxOutputTokens: optionalNumber(agent.maxOutputTokens, agent.max_output_tokens) ?? undefined,
1528
1667
  };
1529
1668
  }
1530
- function isExposedLocalAgent(agent, config) {
1531
- const mapped = mapMcodaAgentToSelfHostedModel(agent, config);
1532
- return Boolean(mapped?.exposed);
1533
- }
1534
1669
  function buildCodaliWorkspace(job) {
1535
1670
  const root = optionalText(job.workspace?.root);
1536
1671
  if (!root) {
@@ -1550,6 +1685,13 @@ const DOCDEX_JOB_ERROR_CODES = new Set([
1550
1685
  "docdex_repo_access_denied",
1551
1686
  "docdex_unavailable",
1552
1687
  ]);
1688
+ const PRE_START_JOB_ERROR_CODES = new Set([
1689
+ "selected_agent_unavailable",
1690
+ "selected_agent_unhealthy",
1691
+ "validation_failed",
1692
+ "docdex_context_missing",
1693
+ "docdex_api_key_missing",
1694
+ ]);
1553
1695
  class SelfHostedDocdexJobError extends Error {
1554
1696
  constructor(code, message) {
1555
1697
  super(message);
@@ -1557,6 +1699,13 @@ class SelfHostedDocdexJobError extends Error {
1557
1699
  this.code = code;
1558
1700
  }
1559
1701
  }
1702
+ class SelfHostedPreStartJobError extends Error {
1703
+ constructor(code, message) {
1704
+ super(message);
1705
+ this.name = code;
1706
+ this.code = code;
1707
+ }
1708
+ }
1560
1709
  function normalizeDocdexCapabilityMap(value) {
1561
1710
  const record = objectRecord(value);
1562
1711
  if (!record)
@@ -1614,11 +1763,14 @@ function selfHostedErrorCode(error) {
1614
1763
  if (!error || typeof error !== "object")
1615
1764
  return undefined;
1616
1765
  const code = error.code;
1617
- if (typeof code === "string" && DOCDEX_JOB_ERROR_CODES.has(code)) {
1766
+ if (typeof code === "string" &&
1767
+ (DOCDEX_JOB_ERROR_CODES.has(code) || PRE_START_JOB_ERROR_CODES.has(code))) {
1618
1768
  return code;
1619
1769
  }
1620
1770
  const name = error.name;
1621
- return typeof name === "string" && DOCDEX_JOB_ERROR_CODES.has(name) ? name : undefined;
1771
+ return typeof name === "string" && (DOCDEX_JOB_ERROR_CODES.has(name) || PRE_START_JOB_ERROR_CODES.has(name))
1772
+ ? name
1773
+ : undefined;
1622
1774
  }
1623
1775
  function redactRuntimeSecretValues(value, secrets) {
1624
1776
  let output = value;
@@ -3135,6 +3287,16 @@ export class MswarmSelfHostedNodeClient {
3135
3287
  body: JSON.stringify(payload)
3136
3288
  }, this.timeoutMs);
3137
3289
  }
3290
+ async postJobStart(runtimeToken, jobId, payload) {
3291
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/${encodeURIComponent(jobId)}/start`, {
3292
+ method: "POST",
3293
+ headers: {
3294
+ "content-type": "application/json",
3295
+ authorization: `Bearer ${runtimeToken}`
3296
+ },
3297
+ body: JSON.stringify(payload)
3298
+ }, this.timeoutMs);
3299
+ }
3138
3300
  async postJobEvents(runtimeToken, jobId, payload) {
3139
3301
  return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/${encodeURIComponent(jobId)}/events`, {
3140
3302
  method: "POST",
@@ -3148,6 +3310,12 @@ export class MswarmSelfHostedNodeClient {
3148
3310
  }
3149
3311
  export class SelfHostedNodeRuntime {
3150
3312
  constructor(config, deps) {
3313
+ this.activeLlmJobs = 0;
3314
+ this.activeGenericJobs = 0;
3315
+ this.queuedLlmJobs = 0;
3316
+ this.queuedGenericJobs = 0;
3317
+ this.latencySamplesMs = [];
3318
+ this.recentFailures = [];
3151
3319
  this.config = config;
3152
3320
  this.gateway =
3153
3321
  deps?.gateway ||
@@ -3192,6 +3360,100 @@ export class SelfHostedNodeRuntime {
3192
3360
  rootDir: config.artifactStorePath || defaultArtifactStorePath()
3193
3361
  });
3194
3362
  }
3363
+ updateLocalQueueTelemetry(input) {
3364
+ if (input.llmQueuedJobs !== undefined) {
3365
+ this.queuedLlmJobs = nonNegativeTelemetryInteger(input.llmQueuedJobs);
3366
+ }
3367
+ if (input.genericQueuedJobs !== undefined) {
3368
+ this.queuedGenericJobs = nonNegativeTelemetryInteger(input.genericQueuedJobs);
3369
+ }
3370
+ }
3371
+ beginExecutionTelemetry(executionClass) {
3372
+ if (executionClass === "generic_job") {
3373
+ this.activeGenericJobs += 1;
3374
+ return;
3375
+ }
3376
+ this.activeLlmJobs += 1;
3377
+ }
3378
+ finishExecutionTelemetry(input) {
3379
+ if (input.executionClass === "generic_job") {
3380
+ this.activeGenericJobs = Math.max(0, this.activeGenericJobs - 1);
3381
+ }
3382
+ else {
3383
+ this.activeLlmJobs = Math.max(0, this.activeLlmJobs - 1);
3384
+ }
3385
+ this.latencySamplesMs.push(Math.max(0, Date.now() - input.startedAt));
3386
+ while (this.latencySamplesMs.length > MAX_TELEMETRY_LATENCY_SAMPLES) {
3387
+ this.latencySamplesMs.shift();
3388
+ }
3389
+ if (!input.ok) {
3390
+ this.recentFailures.unshift({
3391
+ execution_class: input.executionClass === "generic_job" ? "generic_job" : "agentic",
3392
+ code: optionalText(input.code) || "upstream_error",
3393
+ at: new Date().toISOString()
3394
+ });
3395
+ this.recentFailures.splice(MAX_TELEMETRY_FAILURES);
3396
+ }
3397
+ }
3398
+ averageLatencyMs(fallback = null) {
3399
+ if (this.latencySamplesMs.length === 0) {
3400
+ return fallback;
3401
+ }
3402
+ const total = this.latencySamplesMs.reduce((sum, value) => sum + value, 0);
3403
+ return Math.round(total / this.latencySamplesMs.length);
3404
+ }
3405
+ buildLoadTelemetry(input) {
3406
+ const drainMode = this.config.drainMode === true;
3407
+ const llmMaxConcurrency = Math.max(1, Math.floor(this.config.maxConcurrentLlmJobs || this.config.maxConcurrentJobs || 1));
3408
+ const genericMaxConcurrency = Math.max(1, Math.floor(this.config.genericJobMaxConcurrency || 1));
3409
+ const maxConcurrency = Math.max(1, Math.floor(this.config.maxConcurrentJobs || 1), llmMaxConcurrency, this.config.genericJobsEnabled ? genericMaxConcurrency : 1);
3410
+ const activeLlmJobs = nonNegativeTelemetryInteger(this.activeLlmJobs);
3411
+ const activeGenericJobs = nonNegativeTelemetryInteger(this.activeGenericJobs);
3412
+ const queuedLlmJobs = nonNegativeTelemetryInteger(this.queuedLlmJobs);
3413
+ const queuedGenericJobs = nonNegativeTelemetryInteger(this.queuedGenericJobs);
3414
+ const llmCapacity = executionClassCapacity({
3415
+ maxConcurrency: llmMaxConcurrency,
3416
+ activeJobs: activeLlmJobs,
3417
+ queuedJobs: queuedLlmJobs,
3418
+ drainMode
3419
+ });
3420
+ const genericCapacity = executionClassCapacity({
3421
+ maxConcurrency: genericMaxConcurrency,
3422
+ activeJobs: activeGenericJobs,
3423
+ queuedJobs: queuedGenericJobs,
3424
+ drainMode: drainMode || !this.config.genericJobsEnabled
3425
+ });
3426
+ const activeJobs = activeLlmJobs + activeGenericJobs;
3427
+ const queuedJobs = queuedLlmJobs + queuedGenericJobs;
3428
+ const freeSlots = drainMode ? 0 : Math.max(0, maxConcurrency - activeJobs - queuedJobs);
3429
+ const failures = this.recentFailures.slice(0, 10);
3430
+ const discoveryFailureCount = nonNegativeTelemetryInteger(input.discoveryFailureCount);
3431
+ const telemetry = {
3432
+ runtime_protocol_version: SELF_HOSTED_RUNTIME_PROTOCOL_VERSION,
3433
+ load_balancer_protocol_version: SELF_HOSTED_LOAD_BALANCER_PROTOCOL_VERSION,
3434
+ catalog_metadata_version: SELF_HOSTED_CATALOG_METADATA_VERSION,
3435
+ catalog_fingerprint: buildCatalogFingerprint(input.models),
3436
+ max_concurrency: maxConcurrency,
3437
+ max_concurrent_llm_jobs: llmMaxConcurrency,
3438
+ max_concurrent_generic_jobs: this.config.genericJobsEnabled ? genericMaxConcurrency : 0,
3439
+ active_jobs: activeJobs,
3440
+ queued_jobs: queuedJobs,
3441
+ free_slots: freeSlots,
3442
+ drain_mode: drainMode,
3443
+ execution_class_capacity: {
3444
+ chat: llmCapacity,
3445
+ agentic: llmCapacity,
3446
+ generic_job: genericCapacity
3447
+ },
3448
+ avg_latency_ms: this.averageLatencyMs(input.discoveryLatencyMs ?? null),
3449
+ recent_failure_count: failures.length + discoveryFailureCount,
3450
+ recent_failures: failures
3451
+ };
3452
+ if (this.config.hardwareTelemetryEnabled === true) {
3453
+ telemetry.hardware_pressure = buildCoarseHardwarePressure(input.capabilityPayload || null);
3454
+ }
3455
+ return telemetry;
3456
+ }
3195
3457
  static async setup(setupConfig, deps) {
3196
3458
  const gateway = deps?.gateway ||
3197
3459
  new MswarmSelfHostedNodeClient({
@@ -3213,6 +3475,11 @@ export class SelfHostedNodeRuntime {
3213
3475
  model_allowlist: setupConfig.modelAllowlist,
3214
3476
  model_blocklist: setupConfig.modelBlocklist,
3215
3477
  heartbeat_interval_seconds: setupConfig.heartbeatIntervalSeconds,
3478
+ max_concurrent_jobs: setupConfig.maxConcurrentJobs,
3479
+ max_concurrent_llm_jobs: setupConfig.maxConcurrentLlmJobs,
3480
+ drain_mode: setupConfig.drainMode,
3481
+ load_reporting_enabled: setupConfig.loadReportingEnabled,
3482
+ hardware_telemetry_enabled: setupConfig.hardwareTelemetryEnabled,
3216
3483
  generic_job_max_concurrency: setupConfig.genericJobMaxConcurrency
3217
3484
  });
3218
3485
  const nodeId = optionalText(bootstrap.node?.node_id);
@@ -3242,10 +3509,15 @@ export class SelfHostedNodeRuntime {
3242
3509
  node_version: setupConfig.nodeVersion,
3243
3510
  request_timeout_ms: setupConfig.requestTimeoutMs,
3244
3511
  job_timeout_ms: setupConfig.jobTimeoutMs,
3512
+ max_concurrent_jobs: setupConfig.maxConcurrentJobs,
3513
+ max_concurrent_llm_jobs: setupConfig.maxConcurrentLlmJobs,
3245
3514
  generic_jobs_enabled: setupConfig.genericJobsEnabled,
3246
3515
  generic_job_timeout_ms: setupConfig.genericJobTimeoutMs,
3247
3516
  generic_job_max_concurrency: setupConfig.genericJobMaxConcurrency,
3248
3517
  capability_probe_timeout_ms: setupConfig.capabilityProbeTimeoutMs || DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS,
3518
+ drain_mode: setupConfig.drainMode,
3519
+ load_reporting_enabled: setupConfig.loadReportingEnabled,
3520
+ hardware_telemetry_enabled: setupConfig.hardwareTelemetryEnabled,
3249
3521
  expose_all_models: setupConfig.exposeAllModels,
3250
3522
  exposure_policy: setupConfig.exposeAllModels ? "all" : "none",
3251
3523
  model_allowlist: setupConfig.modelAllowlist,
@@ -3276,10 +3548,15 @@ export class SelfHostedNodeRuntime {
3276
3548
  heartbeatIntervalSeconds: heartbeatInterval,
3277
3549
  requestTimeoutMs: setupConfig.requestTimeoutMs,
3278
3550
  jobTimeoutMs: setupConfig.jobTimeoutMs,
3551
+ maxConcurrentJobs: setupConfig.maxConcurrentJobs,
3552
+ maxConcurrentLlmJobs: setupConfig.maxConcurrentLlmJobs,
3279
3553
  genericJobsEnabled: setupConfig.genericJobsEnabled,
3280
3554
  genericJobTimeoutMs: setupConfig.genericJobTimeoutMs,
3281
3555
  genericJobMaxConcurrency: setupConfig.genericJobMaxConcurrency,
3282
3556
  capabilityProbeTimeoutMs: setupConfig.capabilityProbeTimeoutMs || DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS,
3557
+ drainMode: setupConfig.drainMode,
3558
+ loadReportingEnabled: setupConfig.loadReportingEnabled,
3559
+ hardwareTelemetryEnabled: setupConfig.hardwareTelemetryEnabled,
3283
3560
  exposeAllModels: setupConfig.exposeAllModels,
3284
3561
  modelAllowlist: setupConfig.modelAllowlist,
3285
3562
  modelBlocklist: setupConfig.modelBlocklist
@@ -3386,9 +3663,14 @@ export class SelfHostedNodeRuntime {
3386
3663
  node_version: this.config.nodeVersion,
3387
3664
  request_timeout_ms: this.config.requestTimeoutMs,
3388
3665
  job_timeout_ms: this.config.jobTimeoutMs,
3666
+ max_concurrent_jobs: this.config.maxConcurrentJobs,
3667
+ max_concurrent_llm_jobs: this.config.maxConcurrentLlmJobs,
3389
3668
  generic_jobs_enabled: this.config.genericJobsEnabled,
3390
3669
  generic_job_timeout_ms: this.config.genericJobTimeoutMs,
3391
3670
  generic_job_max_concurrency: this.config.genericJobMaxConcurrency,
3671
+ drain_mode: this.config.drainMode === true,
3672
+ load_reporting_enabled: this.config.loadReportingEnabled !== false,
3673
+ hardware_telemetry_enabled: this.config.hardwareTelemetryEnabled === true,
3392
3674
  expose_all_models: this.config.exposeAllModels,
3393
3675
  exposure_policy: this.config.exposeAllModels ? "all" : "none",
3394
3676
  model_allowlist: this.config.modelAllowlist,
@@ -3399,26 +3681,38 @@ export class SelfHostedNodeRuntime {
3399
3681
  return { runtimeToken, state: nextState, enrolled: true };
3400
3682
  }
3401
3683
  async resolveMcodaAgentForJob(job) {
3402
- const selected = optionalText(job.source_agent_slug) ||
3403
- optionalText(job.agent_slug) ||
3404
- optionalText(job.model) ||
3405
- optionalText(job.openai_request.model);
3684
+ const selectedSourceAgentSlug = optionalText(job.source_agent_slug);
3685
+ const selectedAgentSlug = optionalText(job.agent_slug);
3686
+ const selectedModel = optionalText(job.model) || optionalText(job.openai_request.model);
3687
+ const selected = selectedSourceAgentSlug || selectedAgentSlug || selectedModel;
3406
3688
  if (!selected) {
3407
- throw new Error("mcoda source agent slug is required");
3689
+ throw new SelfHostedPreStartJobError("selected_agent_unavailable", "mcoda source agent slug is required");
3408
3690
  }
3409
3691
  const rawAgents = await this.mcoda.listRawAgents();
3692
+ const strictSelectedAgent = selectedSourceAgentSlug || selectedAgentSlug;
3410
3693
  const agent = rawAgents.find((entry) => {
3411
3694
  const slug = optionalText(entry.slug);
3695
+ if (strictSelectedAgent) {
3696
+ return slug === strictSelectedAgent;
3697
+ }
3412
3698
  const defaultModel = mcodaAgentDefaultModel(entry);
3413
3699
  return slug === selected || defaultModel === selected;
3414
3700
  });
3415
- if (!agent || !isExposedLocalAgent(agent, this.config)) {
3416
- throw new Error("selected local mcoda agent is not exposed by this node");
3701
+ if (!agent) {
3702
+ throw new SelfHostedPreStartJobError("selected_agent_unavailable", `selected local mcoda agent ${selected} is not available on this node`);
3703
+ }
3704
+ const mapped = mapMcodaAgentToSelfHostedModel(agent, this.config);
3705
+ if (!mapped?.exposed) {
3706
+ throw new SelfHostedPreStartJobError("selected_agent_unavailable", `selected local mcoda agent ${selected} is not exposed by this node`);
3707
+ }
3708
+ if (mapped.health_status && mapped.health_status !== "healthy" && mapped.health_status !== "unknown") {
3709
+ throw new SelfHostedPreStartJobError("selected_agent_unhealthy", `selected local mcoda agent ${selected} is ${mapped.health_status}`);
3417
3710
  }
3418
3711
  return mapMcodaAgentToCodaliAgent(agent, selected);
3419
3712
  }
3420
3713
  async executeGenericJob(envelope, options = {}) {
3421
3714
  const startedAt = Date.now();
3715
+ this.beginExecutionTelemetry("generic_job");
3422
3716
  const events = [];
3423
3717
  let sequence = 0;
3424
3718
  const emitEvent = async (event) => {
@@ -3449,6 +3743,12 @@ export class SelfHostedNodeRuntime {
3449
3743
  },
3450
3744
  finished_at: new Date().toISOString()
3451
3745
  };
3746
+ this.finishExecutionTelemetry({
3747
+ executionClass: "generic_job",
3748
+ startedAt,
3749
+ ok: false,
3750
+ code
3751
+ });
3452
3752
  return {
3453
3753
  job_id: envelope.job_id,
3454
3754
  request_id: envelope.request_id,
@@ -3554,6 +3854,12 @@ export class SelfHostedNodeRuntime {
3554
3854
  runner: runner.id
3555
3855
  }
3556
3856
  });
3857
+ this.finishExecutionTelemetry({
3858
+ executionClass: "generic_job",
3859
+ startedAt,
3860
+ ok: status === "succeeded",
3861
+ code: runnerResult.error?.code || status
3862
+ });
3557
3863
  return {
3558
3864
  job_id: envelope.job_id,
3559
3865
  request_id: envelope.request_id,
@@ -3579,7 +3885,9 @@ export class SelfHostedNodeRuntime {
3579
3885
  }
3580
3886
  async executeJob(job, options = {}) {
3581
3887
  const startedAt = Date.now();
3888
+ this.beginExecutionTelemetry("llm");
3582
3889
  let selectedAgent;
3890
+ let jobStarted = false;
3583
3891
  const progressEvents = [];
3584
3892
  const streamEvents = [];
3585
3893
  const recordProgress = async (event) => {
@@ -3590,13 +3898,35 @@ export class SelfHostedNodeRuntime {
3590
3898
  streamEvents.push(chunk);
3591
3899
  await options.onOpenAIChunk?.(chunk);
3592
3900
  };
3901
+ const acknowledgeStarted = async (agent) => {
3902
+ if (jobStarted) {
3903
+ return;
3904
+ }
3905
+ await options.onStarted?.({
3906
+ job_id: job.job_id,
3907
+ request_id: job.request_id,
3908
+ node_id: job.node_id,
3909
+ agent_slug: optionalText(job.agent_slug) || agent?.slug || "",
3910
+ source_agent_slug: optionalText(job.source_agent_slug) || agent?.slug || null,
3911
+ model: optionalText(job.model) || optionalText(job.openai_request.model)
3912
+ });
3913
+ jobStarted = true;
3914
+ };
3593
3915
  if (job.node_id !== this.config.nodeId) {
3594
- return {
3916
+ const result = {
3595
3917
  job_id: job.job_id,
3596
3918
  request_id: job.request_id,
3597
3919
  status: "failed",
3920
+ pre_start_failure: true,
3598
3921
  error: { code: "validation_failed", message: "job node_id does not match this node" }
3599
3922
  };
3923
+ this.finishExecutionTelemetry({
3924
+ executionClass: "llm",
3925
+ startedAt,
3926
+ ok: false,
3927
+ code: "validation_failed"
3928
+ });
3929
+ return result;
3600
3930
  }
3601
3931
  try {
3602
3932
  if (job.provider === "ollama") {
@@ -3609,7 +3939,8 @@ export class SelfHostedNodeRuntime {
3609
3939
  options.num_predict = job.openai_request.max_tokens;
3610
3940
  if (job.openai_request.stop !== undefined)
3611
3941
  options.stop = job.openai_request.stop;
3612
- const result = await this.jobOllama.chat({
3942
+ await acknowledgeStarted();
3943
+ const ollamaResult = await this.jobOllama.chat({
3613
3944
  model: job.model || job.openai_request.model,
3614
3945
  messages: job.openai_request.messages,
3615
3946
  options,
@@ -3622,7 +3953,7 @@ export class SelfHostedNodeRuntime {
3622
3953
  created: Math.floor(Date.now() / 1000),
3623
3954
  model: job.openai_request.model,
3624
3955
  choices: [
3625
- { index: 0, delta: { content: result.content }, finish_reason: null }
3956
+ { index: 0, delta: { content: ollamaResult.content }, finish_reason: null }
3626
3957
  ]
3627
3958
  });
3628
3959
  await emitOpenAIChunk({
@@ -3635,22 +3966,28 @@ export class SelfHostedNodeRuntime {
3635
3966
  ]
3636
3967
  });
3637
3968
  }
3638
- return {
3969
+ const invocationResult = {
3639
3970
  job_id: job.job_id,
3640
3971
  request_id: job.request_id,
3641
3972
  status: "success",
3642
3973
  openai_response: buildOpenAIChatCompletion({
3643
3974
  requestId: job.request_id,
3644
3975
  model: job.openai_request.model,
3645
- content: result.content,
3646
- promptTokens: result.promptTokens,
3647
- completionTokens: result.completionTokens,
3648
- metadata: { provider: "ollama", raw: result.raw }
3976
+ content: ollamaResult.content,
3977
+ promptTokens: ollamaResult.promptTokens,
3978
+ completionTokens: ollamaResult.completionTokens,
3979
+ metadata: { provider: "ollama", raw: ollamaResult.raw }
3649
3980
  }),
3650
3981
  ...(streamEvents.length ? { stream_events: streamEvents } : {}),
3651
3982
  ...(progressEvents.length ? { progress_events: progressEvents } : {}),
3652
3983
  timing: { local_latency_ms: Date.now() - startedAt }
3653
3984
  };
3985
+ this.finishExecutionTelemetry({
3986
+ executionClass: "llm",
3987
+ startedAt,
3988
+ ok: true
3989
+ });
3990
+ return invocationResult;
3654
3991
  }
3655
3992
  const taskPreview = messagesToPrompt(job.openai_request.messages);
3656
3993
  if (!taskPreview) {
@@ -3660,6 +3997,7 @@ export class SelfHostedNodeRuntime {
3660
3997
  selectedAgent = agent;
3661
3998
  validateRequiredDocdexContext(job, options.attachedMswarmApiKey);
3662
3999
  const attachedMswarmApiKey = attachedMswarmApiKeyForDocdex(job, options.attachedMswarmApiKey);
4000
+ await acknowledgeStarted(agent);
3663
4001
  await recordProgress({
3664
4002
  type: "agent_selected",
3665
4003
  job_id: job.job_id,
@@ -3698,7 +4036,7 @@ export class SelfHostedNodeRuntime {
3698
4036
  }
3699
4037
  });
3700
4038
  const tokens = usageTokens(response.usage);
3701
- return {
4039
+ const result = {
3702
4040
  job_id: job.job_id,
3703
4041
  request_id: job.request_id,
3704
4042
  status: "success",
@@ -3724,6 +4062,12 @@ export class SelfHostedNodeRuntime {
3724
4062
  ...(progressEvents.length ? { progress_events: progressEvents } : {}),
3725
4063
  timing: { local_latency_ms: Date.now() - startedAt }
3726
4064
  };
4065
+ this.finishExecutionTelemetry({
4066
+ executionClass: "llm",
4067
+ startedAt,
4068
+ ok: true
4069
+ });
4070
+ return result;
3727
4071
  }
3728
4072
  catch (error) {
3729
4073
  const message = redactRuntimeSecretValues(error instanceof Error ? error.message : String(error), [selectedAgent?.apiKey, options.attachedMswarmApiKey]);
@@ -3736,10 +4080,11 @@ export class SelfHostedNodeRuntime {
3736
4080
  : /permission|policy|denied/i.test(message)
3737
4081
  ? "policy_denied"
3738
4082
  : "upstream_error");
3739
- return {
4083
+ const result = {
3740
4084
  job_id: job.job_id,
3741
4085
  request_id: job.request_id,
3742
4086
  status: "failed",
4087
+ ...(!jobStarted ? { pre_start_failure: true } : {}),
3743
4088
  error: {
3744
4089
  code,
3745
4090
  message
@@ -3748,6 +4093,13 @@ export class SelfHostedNodeRuntime {
3748
4093
  ...(progressEvents.length ? { progress_events: progressEvents } : {}),
3749
4094
  timing: { local_latency_ms: Date.now() - startedAt }
3750
4095
  };
4096
+ this.finishExecutionTelemetry({
4097
+ executionClass: "llm",
4098
+ startedAt,
4099
+ ok: false,
4100
+ code
4101
+ });
4102
+ return result;
3751
4103
  }
3752
4104
  }
3753
4105
  async runOnce() {
@@ -3772,12 +4124,49 @@ export class SelfHostedNodeRuntime {
3772
4124
  models = [];
3773
4125
  version = null;
3774
4126
  }
4127
+ const discoveryLatencyMs = Date.now() - startedAt;
3775
4128
  const capabilityPayload = await this.buildCapabilityHeartbeatPayload(enrollment.runtimeToken);
4129
+ const loadTelemetry = this.buildLoadTelemetry({
4130
+ models,
4131
+ discoveryLatencyMs,
4132
+ discoveryFailureCount: recentFailureCount,
4133
+ capabilityPayload
4134
+ });
4135
+ const exposedModelCount = models.filter((model) => model.exposed !== false).length;
4136
+ const loadReportingEnabled = this.config.loadReportingEnabled !== false;
4137
+ const capacityPayload = loadReportingEnabled
4138
+ ? {
4139
+ protocol_version: loadTelemetry.runtime_protocol_version,
4140
+ runtime_protocol_version: loadTelemetry.runtime_protocol_version,
4141
+ load_balancer_protocol_version: loadTelemetry.load_balancer_protocol_version,
4142
+ catalog_metadata_version: loadTelemetry.catalog_metadata_version,
4143
+ catalog_fingerprint: loadTelemetry.catalog_fingerprint,
4144
+ max_concurrency: loadTelemetry.max_concurrency,
4145
+ max_concurrent_llm_jobs: loadTelemetry.max_concurrent_llm_jobs,
4146
+ max_concurrent_generic_jobs: loadTelemetry.max_concurrent_generic_jobs,
4147
+ active_jobs: loadTelemetry.active_jobs,
4148
+ queued_jobs: loadTelemetry.queued_jobs,
4149
+ free_slots: loadTelemetry.free_slots,
4150
+ drain_mode: loadTelemetry.drain_mode,
4151
+ execution_class_capacity: loadTelemetry.execution_class_capacity
4152
+ }
4153
+ : {
4154
+ active_jobs: loadTelemetry.active_jobs,
4155
+ queued_jobs: loadTelemetry.queued_jobs
4156
+ };
3776
4157
  const heartbeatPayload = {
3777
4158
  node_id: this.config.nodeId,
3778
4159
  node_version: this.config.nodeVersion,
4160
+ runtime_protocol_version: SELF_HOSTED_RUNTIME_PROTOCOL_VERSION,
3779
4161
  config_version: enrollment.state.config_version ?? null,
3780
4162
  status,
4163
+ runtime: {
4164
+ protocol_version: SELF_HOSTED_RUNTIME_PROTOCOL_VERSION,
4165
+ relay_mode: this.config.relayMode || "outbound",
4166
+ load_reporting_enabled: loadReportingEnabled,
4167
+ hardware_telemetry_enabled: this.config.hardwareTelemetryEnabled === true,
4168
+ drain_mode: this.config.drainMode === true
4169
+ },
3781
4170
  discovery: {
3782
4171
  source: discoverySource,
3783
4172
  mcoda_status: discoverySource === "mcoda" && status === "online" ? "ok" : status === "degraded" ? "error" : null
@@ -3791,20 +4180,24 @@ export class SelfHostedNodeRuntime {
3791
4180
  status: null,
3792
4181
  version: null
3793
4182
  },
3794
- capacity: {
3795
- active_jobs: 0,
3796
- queued_jobs: 0
3797
- },
4183
+ capacity: capacityPayload,
3798
4184
  health: {
3799
- avg_latency_ms: Date.now() - startedAt,
3800
- recent_failure_count: recentFailureCount,
4185
+ avg_latency_ms: loadTelemetry.avg_latency_ms ?? discoveryLatencyMs,
4186
+ recent_failure_count: loadTelemetry.recent_failure_count,
4187
+ recent_failures: loadTelemetry.recent_failures,
3801
4188
  last_success_at: status === "online" ? new Date().toISOString() : null
3802
4189
  },
4190
+ local_agent_catalog: {
4191
+ revision: loadTelemetry.catalog_fingerprint,
4192
+ metadata_version: loadTelemetry.catalog_metadata_version,
4193
+ model_count: models.length,
4194
+ exposed_model_count: exposedModelCount
4195
+ },
3803
4196
  models,
3804
- capabilities: capabilityPayload
4197
+ capabilities: capabilityPayload,
4198
+ ...(loadTelemetry.hardware_pressure ? { hardware_pressure: loadTelemetry.hardware_pressure } : {})
3805
4199
  };
3806
4200
  const heartbeatResponse = await this.gateway.heartbeat(enrollment.runtimeToken, heartbeatPayload);
3807
- const exposedModelCount = models.filter((model) => model.exposed !== false).length;
3808
4201
  return {
3809
4202
  enrolled: enrollment.enrolled,
3810
4203
  status,
@@ -3812,6 +4205,7 @@ export class SelfHostedNodeRuntime {
3812
4205
  discovery_source: discoverySource,
3813
4206
  mcoda_agent_count: discoverySource === "mcoda" ? exposedModelCount : undefined,
3814
4207
  ollama_version: version,
4208
+ capacity: loadTelemetry,
3815
4209
  heartbeat_response: heartbeatResponse
3816
4210
  };
3817
4211
  }
@@ -3846,9 +4240,17 @@ export class SelfHostedNodeRuntime {
3846
4240
  }
3847
4241
  async pollAndExecuteJob(waitMs = DEFAULT_JOB_POLL_WAIT_MS) {
3848
4242
  const enrollment = await this.ensureEnrolled();
4243
+ const pollCapacity = this.buildLoadTelemetry({ models: [] });
3849
4244
  const response = await this.gateway.pollJob(enrollment.runtimeToken, {
3850
4245
  node_id: this.config.nodeId,
3851
- capacity: { active_jobs: 0, max_jobs: 1 },
4246
+ capacity: {
4247
+ active_jobs: pollCapacity.active_jobs,
4248
+ queued_jobs: pollCapacity.queued_jobs,
4249
+ max_jobs: pollCapacity.max_concurrency,
4250
+ max_concurrency: pollCapacity.max_concurrency,
4251
+ free_slots: pollCapacity.free_slots,
4252
+ drain_mode: pollCapacity.drain_mode
4253
+ },
3852
4254
  wait_ms: waitMs
3853
4255
  });
3854
4256
  const job = response.job || null;
@@ -3876,6 +4278,14 @@ export class SelfHostedNodeRuntime {
3876
4278
  };
3877
4279
  const result = await this.executeJob(job, {
3878
4280
  attachedMswarmApiKey: optionalText(response.attached_mswarm_api_key) || undefined,
4281
+ onStarted: async (event) => {
4282
+ await this.gateway.postJobStart(enrollment.runtimeToken, job.job_id, {
4283
+ node_id: this.config.nodeId,
4284
+ agent_slug: event.agent_slug || job.agent_slug,
4285
+ source_agent_slug: event.source_agent_slug || job.source_agent_slug || null,
4286
+ model: event.model || job.model || job.openai_request.model
4287
+ });
4288
+ },
3879
4289
  onOpenAIChunk: async (chunk) => {
3880
4290
  if (job.openai_request.stream !== true || streamEventForwardingFailed) {
3881
4291
  return;