@camstack/addon-pipeline 1.1.25 → 1.1.26

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.
Files changed (39) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/audio-codec-ffmpeg/index.js +1 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/decoder-ffmpeg/index.js +1 -1
  6. package/dist/decoder-ffmpeg/index.mjs +1 -1
  7. package/dist/detection-pipeline/index.js +148 -26
  8. package/dist/detection-pipeline/index.mjs +148 -26
  9. package/dist/{dist-BecXbIzJ.mjs → dist-CgEP_0OL.mjs} +730 -14
  10. package/dist/{dist-wzWBZ26C.js → dist-DAIlCdAx.js} +730 -14
  11. package/dist/{frame-handle-plane-DGg0Aevs.mjs → frame-handle-plane-Dq20KtKL.mjs} +1 -1
  12. package/dist/{frame-handle-plane-BAKiW6t4.js → frame-handle-plane-DtTRX_0n.js} +1 -1
  13. package/dist/motion-wasm/index.js +1 -1
  14. package/dist/motion-wasm/index.mjs +1 -1
  15. package/dist/pipeline-runner/index.js +38 -9
  16. package/dist/pipeline-runner/index.mjs +38 -9
  17. package/dist/recorder/index.js +1 -1
  18. package/dist/recorder/index.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CWPU9tbs.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-B7Fx3R_6.mjs → hostInit-DyLqyJaS.mjs} +2 -2
  24. package/dist/stream-broker/index.js +2 -2
  25. package/dist/stream-broker/index.mjs +2 -2
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CPkQfKnu.js → MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DlLMQBag.js → MotionZonesSettings-NcxxQN8r-CoLjNiUN.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-kNcauIAN.js → PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js} +1 -1
  30. package/embed-dist/assets/index-C-pL8ETk.js +81 -0
  31. package/embed-dist/index.html +1 -1
  32. package/package.json +1 -1
  33. package/python/inference_pool.py +522 -27
  34. package/python/test_inference_pool_backpressure.py +121 -0
  35. package/python/test_inference_pool_coreml_cache.py +416 -0
  36. package/python/test_inference_pool_device_selection.py +256 -0
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-wfWYFiTT.mjs +0 -26
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-lpFwIH9e.mjs +0 -26
  39. package/embed-dist/assets/index-xpPLFfsT.js +0 -80
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-DxM2DSns.js");
6
- const require_dist = require("../dist-wzWBZ26C.js");
6
+ const require_dist = require("../dist-DAIlCdAx.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_model_download_service_C_IHWnXx.__toESM(node_fs);
9
9
  let node_path = require("node:path");
@@ -1,5 +1,5 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { B as errMsg, D as mapAudioLabelToMacro, V as BaseAddon, Y as hydrateSchema, _ as audioAnalyzerCapability, g as audioAnalysisCapability, n as AUDIO_BACKEND_CHOICES, o as DEFAULT_AUDIO_ANALYZER_CONFIG, u as HF_BASE_URL } from "../dist-BecXbIzJ.mjs";
2
+ import { B as errMsg, D as mapAudioLabelToMacro, V as BaseAddon, Y as hydrateSchema, _ as audioAnalyzerCapability, g as audioAnalysisCapability, n as AUDIO_BACKEND_CHOICES, o as DEFAULT_AUDIO_ANALYZER_CONFIG, u as HF_BASE_URL } from "../dist-CgEP_0OL.mjs";
3
3
  import { r as downloadFile } from "../model-download-service-C-IHWnXx-BPy6aoAx.mjs";
4
4
  import * as fs from "node:fs";
5
5
  import * as path$1 from "node:path";
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-wzWBZ26C.js");
5
+ const require_dist = require("../dist-DAIlCdAx.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let node_child_process = require("node:child_process");
8
8
  //#region src/audio-codec-ffmpeg/ffmpeg-audio-process.ts
@@ -1,4 +1,4 @@
1
- import { B as errMsg, V as BaseAddon, v as audioCodecCapability } from "../dist-BecXbIzJ.mjs";
1
+ import { B as errMsg, V as BaseAddon, v as audioCodecCapability } from "../dist-CgEP_0OL.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
4
  //#region src/audio-codec-ffmpeg/ffmpeg-audio-process.ts
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-wzWBZ26C.js");
5
+ const require_dist = require("../dist-DAIlCdAx.js");
6
6
  const require_frame_dropper = require("../frame-dropper-7RTo_YyG.js");
7
7
  let _camstack_shm_ring = require("@camstack/shm-ring");
8
8
  let node_crypto = require("node:crypto");
@@ -1,4 +1,4 @@
1
- import { B as errMsg, V as BaseAddon, d as HWACCEL_OPTIONS, p as RingBuffer, s as DEFAULT_DECODER_HWACCEL_CONFIG, x as decoderCapability } from "../dist-BecXbIzJ.mjs";
1
+ import { B as errMsg, V as BaseAddon, d as HWACCEL_OPTIONS, p as RingBuffer, s as DEFAULT_DECODER_HWACCEL_CONFIG, x as decoderCapability } from "../dist-CgEP_0OL.mjs";
2
2
  import { r as logBannerArgs, t as FrameDropper } from "../frame-dropper-CwkBTPGV.mjs";
3
3
  import { FrameRingReaderCache, FrameRingWriter, MIN_RING_SLOTS, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount } from "@camstack/shm-ring";
4
4
  import { randomUUID } from "node:crypto";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-DxM2DSns.js");
6
- const require_dist = require("../dist-wzWBZ26C.js");
6
+ const require_dist = require("../dist-DAIlCdAx.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_model_download_service_C_IHWnXx.__toESM(node_fs);
9
9
  let node_path = require("node:path");
@@ -28,8 +28,12 @@ sharp = require_model_download_service_C_IHWnXx.__toESM(sharp);
28
28
  *
29
29
  * These three keys are therefore persisted node-scoped as `<key>@<nodeId>`.
30
30
  * Everything else in the store stays shared. A legacy un-scoped value (written
31
- * before this change, or by an older build) is read as a migration fallback and
32
- * re-persisted under the node-scoped key on the next write.
31
+ * before this change, or by an older build) is read as a migration fallback
32
+ * for the HUB ONLY — the pre-scoping store was only ever written by a
33
+ * hub-only deployment, so serving the bare value to any other node leaks the
34
+ * hub's hardware pick (e.g. `openvino/auto`) onto a node that cannot run it
35
+ * (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). A non-hub node with
36
+ * no scoped key reads `undefined`, which drives its own auto-pick.
33
37
  */
34
38
  var ENGINE_CASCADE_KEYS = [
35
39
  "engineBackend",
@@ -54,11 +58,27 @@ function nodeEngineKey(base, nodeId) {
54
58
  }
55
59
  /**
56
60
  * Read an engine cascade value for a node: the node-scoped key if present,
57
- * otherwise the legacy un-scoped value (migration), otherwise undefined.
61
+ * otherwise — for the HUB only — the legacy un-scoped value (migration),
62
+ * otherwise undefined. The bare fallback is hub-restricted because only the
63
+ * hub ever wrote the pre-scoping store; serving it to other nodes leaked the
64
+ * hub's engine selection cluster-wide (CB3). A non-hub node with no scoped
65
+ * key gets `undefined` so its own platform-driven auto-pick runs and persists
66
+ * a real per-node selection.
58
67
  */
59
68
  function readNodeEngineValue(store, base, nodeId) {
60
69
  const scoped = store[nodeEngineKey(base, nodeId)];
61
- return scoped !== void 0 ? scoped : store[base];
70
+ if (scoped !== void 0) return scoped;
71
+ return normalizeEngineNodeId(nodeId) === "hub" ? store[base] : void 0;
72
+ }
73
+ /**
74
+ * Re-map a UI/settings patch so engine cascade fields persist under the
75
+ * node-scoped key; all other keys pass through unchanged. Used on the write
76
+ * path so a UI save on one node never clobbers another node's engine.
77
+ */
78
+ function scopeEnginePatch(patch, nodeId) {
79
+ const out = {};
80
+ for (const [key, value] of Object.entries(patch)) out[isEngineCascadeKey(key) ? nodeEngineKey(key, nodeId) : key] = value;
81
+ return out;
62
82
  }
63
83
  /**
64
84
  * Project a raw store onto the plain engine cascade keys for THIS node, so the
@@ -257,6 +277,19 @@ function pickBestRuntime(env, bestBackendHint) {
257
277
  }
258
278
  //#endregion
259
279
  //#region src/detection-pipeline/engine/shared-inference-pool.ts
280
+ /**
281
+ * SharedInferencePool — TypeScript wrapper for inference_pool.py.
282
+ *
283
+ * Multi-process orchestrator. Owns N `PoolWorker` instances, each
284
+ * holding its own Python subprocess + MLModel copy, and round-robins
285
+ * inference dispatches across them. Model management (load/unload/
286
+ * replace) propagates to all workers so every subprocess holds the
287
+ * same model set at the same indices.
288
+ *
289
+ * Wire format (per worker):
290
+ * Request: [4B total_len][4B req_id][1B msg_type][payload]
291
+ * Response: [4B total_len][4B req_id][JSON payload]
292
+ */
260
293
  var MSG_COMMAND = 0;
261
294
  var MSG_INFER_JPEG = 1;
262
295
  var MSG_INFER_RAW = 2;
@@ -568,6 +601,13 @@ var SharedInferencePool = class {
568
601
  nextDispatchIdx = 0;
569
602
  nextFreeIndex = 0;
570
603
  nextFrameId = 1;
604
+ /**
605
+ * Cumulative count of frames the Python pool SHED under overload
606
+ * (`"dropped": true` responses from the per-model in-flight bound in
607
+ * inference_pool.py). Without this the shed response is
608
+ * indistinguishable from a genuine "no detections" result.
609
+ */
610
+ droppedResponseCount = 0;
571
611
  log;
572
612
  concurrency;
573
613
  tuning;
@@ -624,17 +664,28 @@ var SharedInferencePool = class {
624
664
  };
625
665
  }
626
666
  async infer(modelIndex, jpeg) {
627
- return this.pickWorker().infer(this.encodeModelByte(modelIndex), jpeg);
667
+ const w = this.pickWorker();
668
+ return this.trackDroppedResponse(await w.infer(this.encodeModelByte(modelIndex), jpeg), modelIndex);
628
669
  }
629
670
  async inferRaw(modelIndex, raw, width, height, format) {
630
- return this.pickWorker().inferRaw(this.encodeModelByte(modelIndex), raw, width, height, format);
671
+ const w = this.pickWorker();
672
+ return this.trackDroppedResponse(await w.inferRaw(this.encodeModelByte(modelIndex), raw, width, height, format), modelIndex);
631
673
  }
632
674
  async inferBatch(modelIndex, items) {
633
675
  if (items.length > 255) throw new Error(`SharedInferencePool.inferBatch: max 255 items per call, got ${items.length}`);
634
676
  return this.pickWorker().inferBatch(this.encodeModelByte(modelIndex), items);
635
677
  }
636
678
  async inferCached(modelIndex, frameId) {
637
- return this.pickWorker().inferCached(this.encodeModelByte(modelIndex), frameId);
679
+ const w = this.pickWorker();
680
+ return this.trackDroppedResponse(await w.inferCached(this.encodeModelByte(modelIndex), frameId), modelIndex);
681
+ }
682
+ /**
683
+ * Total `"dropped": true` responses seen from the Python pool — frames
684
+ * shed by the per-model in-flight bound under overload. Diagnostic
685
+ * counter (monotonic for the pool's lifetime).
686
+ */
687
+ getDroppedResponseCount() {
688
+ return this.droppedResponseCount;
638
689
  }
639
690
  getHandle(modelIndex) {
640
691
  return new PoolHandle(this, modelIndex);
@@ -695,6 +746,22 @@ var SharedInferencePool = class {
695
746
  this.workers.length = 0;
696
747
  this.log.info("Pool process(es) terminated");
697
748
  }
749
+ /**
750
+ * Recognise a Python overload-shed response (`"dropped": true`) on the
751
+ * single-frame inference paths. Previously indistinguishable from a
752
+ * genuine empty detection result — now counted + debug-logged so
753
+ * overload is measurable. The response passes through unchanged.
754
+ */
755
+ trackDroppedResponse(result, modelIndex) {
756
+ if (result["dropped"] === true) {
757
+ this.droppedResponseCount++;
758
+ this.log.debug("Python pool shed frame under overload", { meta: {
759
+ modelIndex,
760
+ droppedTotal: this.droppedResponseCount
761
+ } });
762
+ }
763
+ return result;
764
+ }
698
765
  pickWorker() {
699
766
  if (this.workers.length === 0) throw new Error("SharedInferencePool: not initialized");
700
767
  const idx = this.nextDispatchIdx;
@@ -5433,6 +5500,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5433
5500
  const priorFactory = this.engineFactory;
5434
5501
  const priorExecutor = this.executor;
5435
5502
  if (!override) return () => {};
5503
+ if (!backendPossibleOnPlatform(override.backend)) {
5504
+ this.log.warn("Engine override impossible on this platform — running on own engine", { meta: {
5505
+ override: `${override.runtime}/${override.backend}/${override.device ?? "default"}`,
5506
+ platform: process.platform,
5507
+ arch: process.arch,
5508
+ engine: `${priorEngine.runtime}/${priorEngine.backend}`
5509
+ } });
5510
+ return () => {};
5511
+ }
5436
5512
  const runEngine = {
5437
5513
  runtime: override.runtime,
5438
5514
  backend: override.backend,
@@ -6128,6 +6204,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6128
6204
  stored: migratedBackend,
6129
6205
  fallback: platformDefault.backend
6130
6206
  } });
6207
+ this.writeStore({
6208
+ [nodeEngineKey("engineBackend", node)]: platformDefault.backend,
6209
+ ...platformDefault.device !== void 0 ? { [nodeEngineKey("engineDevice", node)]: platformDefault.device } : {}
6210
+ }).catch((err) => {
6211
+ this.log.warn("Failed to persist platform-default engine after impossible stored pick", { meta: { error: err instanceof Error ? err.message : String(err) } });
6212
+ });
6131
6213
  return platformDefault;
6132
6214
  }
6133
6215
  const device = storedDevice || floor.device;
@@ -6779,7 +6861,9 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6779
6861
  provider = null;
6780
6862
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
6781
6863
  * returns null so offered backends / device lists don't collapse. Keyed by
6782
- * node because the hub addon probes other nodes when serving their config. */
6864
+ * node because the hub addon probes other nodes when serving their config.
6865
+ * Carries the probed node's platform/arch too, so a transient null probe
6866
+ * doesn't flip a remote node's env back to the serving hub's platform. */
6783
6867
  lastGoodHardwareByNode = /* @__PURE__ */ new Map();
6784
6868
  /** This node's effective engine selection, cached from the node-scoped store
6785
6869
  * so the synchronous `resolveBackendTuning` and the reprobe gate don't read
@@ -7086,16 +7170,19 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7086
7170
  const hardware = await this.resolveProbeHardware(node);
7087
7171
  if (hardware) this.lastGoodHardwareByNode.set(node, hardware);
7088
7172
  const effective = hardware ?? this.lastGoodHardwareByNode.get(node) ?? null;
7173
+ const useProbedPlatform = node !== this.localNodeId();
7089
7174
  return {
7090
- platform: process.platform,
7091
- arch: process.arch,
7175
+ platform: useProbedPlatform && effective?.platform !== void 0 ? effective.platform : process.platform,
7176
+ arch: useProbedPlatform && effective?.arch !== void 0 ? effective.arch : process.arch,
7092
7177
  hardware: effective
7093
7178
  };
7094
7179
  }
7095
7180
  /**
7096
7181
  * Fetch the probed hardware from the platform-probe cap for `nodeId` (default
7097
7182
  * = self). Returns null when the cap is not reachable (caller falls back to
7098
- * the registry's safe minimum).
7183
+ * the registry's safe minimum). Carries the probed node's `platform`/`arch`
7184
+ * (from `PlatformCapabilities.hardware`) so `probeHardwareEnv` can build the
7185
+ * env for the TARGET node rather than the serving one.
7099
7186
  */
7100
7187
  async resolveProbeHardware(nodeId) {
7101
7188
  try {
@@ -7106,7 +7193,9 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7106
7193
  if (!hw) return null;
7107
7194
  return {
7108
7195
  npu: hw.npu ? { type: hw.npu.type } : null,
7109
- gpu: hw.gpu ? { type: hw.gpu.type } : null
7196
+ gpu: hw.gpu ? { type: hw.gpu.type } : null,
7197
+ platform: hw.platform,
7198
+ arch: hw.arch
7110
7199
  };
7111
7200
  } catch {
7112
7201
  return null;
@@ -7125,6 +7214,34 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7125
7214
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7126
7215
  }
7127
7216
  /**
7217
+ * One-time store migration (hub only, F1/CB3): the pre-scoping store
7218
+ * persisted the engine cascade under BARE keys (`engineBackend`, …) that
7219
+ * only the hub ever wrote. `readNodeEngineValue` used to serve those bare
7220
+ * values as a fallback to EVERY node, leaking the hub's engine (e.g.
7221
+ * `openvino/auto`) onto agents with no scoped key. The fallback is now
7222
+ * hub-only (engine-store-keys.ts) and this migration re-homes the bare
7223
+ * values to their `<key>@hub` scoped form and deletes the bare keys so the
7224
+ * legacy values cannot resurface. Guarded: runs only while a bare key is
7225
+ * still present, so post-migration boots are a no-op. Best-effort — an
7226
+ * unreadable store skips the migration (retried on the next boot).
7227
+ */
7228
+ async migrateLegacyBareEngineKeys() {
7229
+ if (this.localNodeId() !== "hub") return;
7230
+ const store = await this.resolveUiSettingsStore();
7231
+ if (store === null) return;
7232
+ const patch = {};
7233
+ for (const base of ENGINE_CASCADE_KEYS) {
7234
+ const bare = store[base];
7235
+ if (bare === void 0 || bare === null) continue;
7236
+ const scopedKey = nodeEngineKey(base, "hub");
7237
+ if (store[scopedKey] === void 0) patch[scopedKey] = bare;
7238
+ patch[base] = void 0;
7239
+ }
7240
+ if (Object.keys(patch).length === 0) return;
7241
+ await this.ctxIfReady?.settings?.writeAddonStore(patch);
7242
+ this.ctx.logger.info("Migrated legacy bare engine keys to their @hub scoped form", { meta: { keys: Object.keys(patch) } });
7243
+ }
7244
+ /**
7128
7245
  * Refresh this node's cached engine selection from the node-scoped store.
7129
7246
  * `resolveBackendTuning` is synchronous and the reprobe gate runs before the
7130
7247
  * provider exists, so both read these cached fields instead of the
@@ -7146,26 +7263,30 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7146
7263
  * cluster-central store. The hub addon serves writes for every node, so it
7147
7264
  * scopes by the requested `nodeId` (default self).
7148
7265
  *
7149
- * When the target IS this node, `super.updateGlobalSettings` drives the normal
7150
- * apply path (`resolveConfig` / `onConfigChanged` / `requiresRestart` restart),
7151
- * and the bare engine keys it writes are shadowed by the node-scoped keys on
7152
- * read. When the target is a SIBLING node, we persist the scoped engine keys +
7153
- * the non-engine bare keys but DON'T run this node's restart/reprovision the
7154
- * owning node applies its own engine selection on its next (re)start.
7266
+ * The engine cascade keys are NEVER written bare (F1, CB3): the bare keys are
7267
+ * the legacy pre-scoping store whose values used to leak to every node with
7268
+ * no scoped key. When the target IS this node, `super.updateGlobalSettings`
7269
+ * drives the normal apply path (`resolveConfig` / `onConfigChanged` /
7270
+ * `requiresRestart` restart) with the engine keys STRIPPED from its patch
7271
+ * they were already persisted node-scoped, and `onConfigChanged` re-reads
7272
+ * them from the scoped keys. When the target is a SIBLING node, one scoped
7273
+ * write persists the engine keys under `<key>@<target>` + the non-engine
7274
+ * keys bare, and this node's restart/reprovision is NOT run — the owning
7275
+ * node applies its own engine selection on its next (re)start.
7155
7276
  */
7156
7277
  async updateGlobalSettings(patch, nodeId) {
7157
7278
  const targetNode = nodeId ? normalizeEngineNodeId(nodeId) : this.localNodeId();
7158
7279
  const patchRecord = patch;
7280
+ if (targetNode !== this.localNodeId()) {
7281
+ const scoped = scopeEnginePatch(patchRecord, targetNode);
7282
+ if (Object.keys(scoped).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scoped);
7283
+ return;
7284
+ }
7159
7285
  const scopedEngine = {};
7160
7286
  for (const key of ENGINE_CASCADE_KEYS) if (key in patchRecord) scopedEngine[nodeEngineKey(key, targetNode)] = patchRecord[key];
7161
7287
  if (Object.keys(scopedEngine).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scopedEngine);
7162
- if (targetNode === this.localNodeId()) {
7163
- await super.updateGlobalSettings(patch, nodeId);
7164
- return;
7165
- }
7166
- const sharedPatch = {};
7167
- for (const [k, v] of Object.entries(patchRecord)) if (!ENGINE_CASCADE_KEYS.includes(k)) sharedPatch[k] = v;
7168
- if (Object.keys(sharedPatch).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(sharedPatch);
7288
+ const { engineBackend: _engineBackend, engineDevice: _engineDevice, probedBestEngine: _probedBestEngine, ...sharedPatch } = patch;
7289
+ await super.updateGlobalSettings(sharedPatch, nodeId);
7169
7290
  }
7170
7291
  /**
7171
7292
  * Resolve the effective pool tuning for the configured backend.
@@ -7197,6 +7318,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7197
7318
  async onInitialize() {
7198
7319
  const modelsDir = await this.resolveModelsDir();
7199
7320
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7321
+ await this.migrateLegacyBareEngineKeys();
7200
7322
  await this.refreshNodeEngineFromStore();
7201
7323
  this.pythonAddonDir = resolveAddonPythonDir();
7202
7324
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
@@ -1,5 +1,5 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { B as errMsg, E as hfModelUrl, F as runtimeDevices$1, G as EventCategory, J as createEvent, M as pipelineExecutorCapability, Q as parseJsonUnknown, R as supportedRuntimes$1, S as defaultDeviceFor$1, T as evaluateZoneRules, V as BaseAddon, Y as hydrateSchema, a as COCO_TO_MACRO, i as COCO_80_LABELS, j as nodePin, m as YAMNET_TO_MACRO, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, tt as sleep, w as detectionPipelineCapability } from "../dist-BecXbIzJ.mjs";
2
+ import { B as errMsg, E as hfModelUrl, F as runtimeDevices$1, G as EventCategory, J as createEvent, M as pipelineExecutorCapability, Q as parseJsonUnknown, R as supportedRuntimes$1, S as defaultDeviceFor$1, T as evaluateZoneRules, V as BaseAddon, Y as hydrateSchema, a as COCO_TO_MACRO, i as COCO_80_LABELS, j as nodePin, m as YAMNET_TO_MACRO, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, tt as sleep, w as detectionPipelineCapability } from "../dist-CgEP_0OL.mjs";
3
3
  import { a as isModelDownloaded, i as ensureModel, n as deleteModelFromDisk } from "../model-download-service-C-IHWnXx-BPy6aoAx.mjs";
4
4
  import * as fs from "node:fs";
5
5
  import * as path$1 from "node:path";
@@ -21,8 +21,12 @@ import sharp from "sharp";
21
21
  *
22
22
  * These three keys are therefore persisted node-scoped as `<key>@<nodeId>`.
23
23
  * Everything else in the store stays shared. A legacy un-scoped value (written
24
- * before this change, or by an older build) is read as a migration fallback and
25
- * re-persisted under the node-scoped key on the next write.
24
+ * before this change, or by an older build) is read as a migration fallback
25
+ * for the HUB ONLY — the pre-scoping store was only ever written by a
26
+ * hub-only deployment, so serving the bare value to any other node leaks the
27
+ * hub's hardware pick (e.g. `openvino/auto`) onto a node that cannot run it
28
+ * (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). A non-hub node with
29
+ * no scoped key reads `undefined`, which drives its own auto-pick.
26
30
  */
27
31
  var ENGINE_CASCADE_KEYS = [
28
32
  "engineBackend",
@@ -47,11 +51,27 @@ function nodeEngineKey(base, nodeId) {
47
51
  }
48
52
  /**
49
53
  * Read an engine cascade value for a node: the node-scoped key if present,
50
- * otherwise the legacy un-scoped value (migration), otherwise undefined.
54
+ * otherwise — for the HUB only — the legacy un-scoped value (migration),
55
+ * otherwise undefined. The bare fallback is hub-restricted because only the
56
+ * hub ever wrote the pre-scoping store; serving it to other nodes leaked the
57
+ * hub's engine selection cluster-wide (CB3). A non-hub node with no scoped
58
+ * key gets `undefined` so its own platform-driven auto-pick runs and persists
59
+ * a real per-node selection.
51
60
  */
52
61
  function readNodeEngineValue(store, base, nodeId) {
53
62
  const scoped = store[nodeEngineKey(base, nodeId)];
54
- return scoped !== void 0 ? scoped : store[base];
63
+ if (scoped !== void 0) return scoped;
64
+ return normalizeEngineNodeId(nodeId) === "hub" ? store[base] : void 0;
65
+ }
66
+ /**
67
+ * Re-map a UI/settings patch so engine cascade fields persist under the
68
+ * node-scoped key; all other keys pass through unchanged. Used on the write
69
+ * path so a UI save on one node never clobbers another node's engine.
70
+ */
71
+ function scopeEnginePatch(patch, nodeId) {
72
+ const out = {};
73
+ for (const [key, value] of Object.entries(patch)) out[isEngineCascadeKey(key) ? nodeEngineKey(key, nodeId) : key] = value;
74
+ return out;
55
75
  }
56
76
  /**
57
77
  * Project a raw store onto the plain engine cascade keys for THIS node, so the
@@ -250,6 +270,19 @@ function pickBestRuntime(env, bestBackendHint) {
250
270
  }
251
271
  //#endregion
252
272
  //#region src/detection-pipeline/engine/shared-inference-pool.ts
273
+ /**
274
+ * SharedInferencePool — TypeScript wrapper for inference_pool.py.
275
+ *
276
+ * Multi-process orchestrator. Owns N `PoolWorker` instances, each
277
+ * holding its own Python subprocess + MLModel copy, and round-robins
278
+ * inference dispatches across them. Model management (load/unload/
279
+ * replace) propagates to all workers so every subprocess holds the
280
+ * same model set at the same indices.
281
+ *
282
+ * Wire format (per worker):
283
+ * Request: [4B total_len][4B req_id][1B msg_type][payload]
284
+ * Response: [4B total_len][4B req_id][JSON payload]
285
+ */
253
286
  var MSG_COMMAND = 0;
254
287
  var MSG_INFER_JPEG = 1;
255
288
  var MSG_INFER_RAW = 2;
@@ -561,6 +594,13 @@ var SharedInferencePool = class {
561
594
  nextDispatchIdx = 0;
562
595
  nextFreeIndex = 0;
563
596
  nextFrameId = 1;
597
+ /**
598
+ * Cumulative count of frames the Python pool SHED under overload
599
+ * (`"dropped": true` responses from the per-model in-flight bound in
600
+ * inference_pool.py). Without this the shed response is
601
+ * indistinguishable from a genuine "no detections" result.
602
+ */
603
+ droppedResponseCount = 0;
564
604
  log;
565
605
  concurrency;
566
606
  tuning;
@@ -617,17 +657,28 @@ var SharedInferencePool = class {
617
657
  };
618
658
  }
619
659
  async infer(modelIndex, jpeg) {
620
- return this.pickWorker().infer(this.encodeModelByte(modelIndex), jpeg);
660
+ const w = this.pickWorker();
661
+ return this.trackDroppedResponse(await w.infer(this.encodeModelByte(modelIndex), jpeg), modelIndex);
621
662
  }
622
663
  async inferRaw(modelIndex, raw, width, height, format) {
623
- return this.pickWorker().inferRaw(this.encodeModelByte(modelIndex), raw, width, height, format);
664
+ const w = this.pickWorker();
665
+ return this.trackDroppedResponse(await w.inferRaw(this.encodeModelByte(modelIndex), raw, width, height, format), modelIndex);
624
666
  }
625
667
  async inferBatch(modelIndex, items) {
626
668
  if (items.length > 255) throw new Error(`SharedInferencePool.inferBatch: max 255 items per call, got ${items.length}`);
627
669
  return this.pickWorker().inferBatch(this.encodeModelByte(modelIndex), items);
628
670
  }
629
671
  async inferCached(modelIndex, frameId) {
630
- return this.pickWorker().inferCached(this.encodeModelByte(modelIndex), frameId);
672
+ const w = this.pickWorker();
673
+ return this.trackDroppedResponse(await w.inferCached(this.encodeModelByte(modelIndex), frameId), modelIndex);
674
+ }
675
+ /**
676
+ * Total `"dropped": true` responses seen from the Python pool — frames
677
+ * shed by the per-model in-flight bound under overload. Diagnostic
678
+ * counter (monotonic for the pool's lifetime).
679
+ */
680
+ getDroppedResponseCount() {
681
+ return this.droppedResponseCount;
631
682
  }
632
683
  getHandle(modelIndex) {
633
684
  return new PoolHandle(this, modelIndex);
@@ -688,6 +739,22 @@ var SharedInferencePool = class {
688
739
  this.workers.length = 0;
689
740
  this.log.info("Pool process(es) terminated");
690
741
  }
742
+ /**
743
+ * Recognise a Python overload-shed response (`"dropped": true`) on the
744
+ * single-frame inference paths. Previously indistinguishable from a
745
+ * genuine empty detection result — now counted + debug-logged so
746
+ * overload is measurable. The response passes through unchanged.
747
+ */
748
+ trackDroppedResponse(result, modelIndex) {
749
+ if (result["dropped"] === true) {
750
+ this.droppedResponseCount++;
751
+ this.log.debug("Python pool shed frame under overload", { meta: {
752
+ modelIndex,
753
+ droppedTotal: this.droppedResponseCount
754
+ } });
755
+ }
756
+ return result;
757
+ }
691
758
  pickWorker() {
692
759
  if (this.workers.length === 0) throw new Error("SharedInferencePool: not initialized");
693
760
  const idx = this.nextDispatchIdx;
@@ -5426,6 +5493,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5426
5493
  const priorFactory = this.engineFactory;
5427
5494
  const priorExecutor = this.executor;
5428
5495
  if (!override) return () => {};
5496
+ if (!backendPossibleOnPlatform(override.backend)) {
5497
+ this.log.warn("Engine override impossible on this platform — running on own engine", { meta: {
5498
+ override: `${override.runtime}/${override.backend}/${override.device ?? "default"}`,
5499
+ platform: process.platform,
5500
+ arch: process.arch,
5501
+ engine: `${priorEngine.runtime}/${priorEngine.backend}`
5502
+ } });
5503
+ return () => {};
5504
+ }
5429
5505
  const runEngine = {
5430
5506
  runtime: override.runtime,
5431
5507
  backend: override.backend,
@@ -6121,6 +6197,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6121
6197
  stored: migratedBackend,
6122
6198
  fallback: platformDefault.backend
6123
6199
  } });
6200
+ this.writeStore({
6201
+ [nodeEngineKey("engineBackend", node)]: platformDefault.backend,
6202
+ ...platformDefault.device !== void 0 ? { [nodeEngineKey("engineDevice", node)]: platformDefault.device } : {}
6203
+ }).catch((err) => {
6204
+ this.log.warn("Failed to persist platform-default engine after impossible stored pick", { meta: { error: err instanceof Error ? err.message : String(err) } });
6205
+ });
6124
6206
  return platformDefault;
6125
6207
  }
6126
6208
  const device = storedDevice || floor.device;
@@ -6772,7 +6854,9 @@ var DetectionPipelineAddon = class extends BaseAddon {
6772
6854
  provider = null;
6773
6855
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
6774
6856
  * returns null so offered backends / device lists don't collapse. Keyed by
6775
- * node because the hub addon probes other nodes when serving their config. */
6857
+ * node because the hub addon probes other nodes when serving their config.
6858
+ * Carries the probed node's platform/arch too, so a transient null probe
6859
+ * doesn't flip a remote node's env back to the serving hub's platform. */
6776
6860
  lastGoodHardwareByNode = /* @__PURE__ */ new Map();
6777
6861
  /** This node's effective engine selection, cached from the node-scoped store
6778
6862
  * so the synchronous `resolveBackendTuning` and the reprobe gate don't read
@@ -7079,16 +7163,19 @@ var DetectionPipelineAddon = class extends BaseAddon {
7079
7163
  const hardware = await this.resolveProbeHardware(node);
7080
7164
  if (hardware) this.lastGoodHardwareByNode.set(node, hardware);
7081
7165
  const effective = hardware ?? this.lastGoodHardwareByNode.get(node) ?? null;
7166
+ const useProbedPlatform = node !== this.localNodeId();
7082
7167
  return {
7083
- platform: process.platform,
7084
- arch: process.arch,
7168
+ platform: useProbedPlatform && effective?.platform !== void 0 ? effective.platform : process.platform,
7169
+ arch: useProbedPlatform && effective?.arch !== void 0 ? effective.arch : process.arch,
7085
7170
  hardware: effective
7086
7171
  };
7087
7172
  }
7088
7173
  /**
7089
7174
  * Fetch the probed hardware from the platform-probe cap for `nodeId` (default
7090
7175
  * = self). Returns null when the cap is not reachable (caller falls back to
7091
- * the registry's safe minimum).
7176
+ * the registry's safe minimum). Carries the probed node's `platform`/`arch`
7177
+ * (from `PlatformCapabilities.hardware`) so `probeHardwareEnv` can build the
7178
+ * env for the TARGET node rather than the serving one.
7092
7179
  */
7093
7180
  async resolveProbeHardware(nodeId) {
7094
7181
  try {
@@ -7099,7 +7186,9 @@ var DetectionPipelineAddon = class extends BaseAddon {
7099
7186
  if (!hw) return null;
7100
7187
  return {
7101
7188
  npu: hw.npu ? { type: hw.npu.type } : null,
7102
- gpu: hw.gpu ? { type: hw.gpu.type } : null
7189
+ gpu: hw.gpu ? { type: hw.gpu.type } : null,
7190
+ platform: hw.platform,
7191
+ arch: hw.arch
7103
7192
  };
7104
7193
  } catch {
7105
7194
  return null;
@@ -7118,6 +7207,34 @@ var DetectionPipelineAddon = class extends BaseAddon {
7118
7207
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7119
7208
  }
7120
7209
  /**
7210
+ * One-time store migration (hub only, F1/CB3): the pre-scoping store
7211
+ * persisted the engine cascade under BARE keys (`engineBackend`, …) that
7212
+ * only the hub ever wrote. `readNodeEngineValue` used to serve those bare
7213
+ * values as a fallback to EVERY node, leaking the hub's engine (e.g.
7214
+ * `openvino/auto`) onto agents with no scoped key. The fallback is now
7215
+ * hub-only (engine-store-keys.ts) and this migration re-homes the bare
7216
+ * values to their `<key>@hub` scoped form and deletes the bare keys so the
7217
+ * legacy values cannot resurface. Guarded: runs only while a bare key is
7218
+ * still present, so post-migration boots are a no-op. Best-effort — an
7219
+ * unreadable store skips the migration (retried on the next boot).
7220
+ */
7221
+ async migrateLegacyBareEngineKeys() {
7222
+ if (this.localNodeId() !== "hub") return;
7223
+ const store = await this.resolveUiSettingsStore();
7224
+ if (store === null) return;
7225
+ const patch = {};
7226
+ for (const base of ENGINE_CASCADE_KEYS) {
7227
+ const bare = store[base];
7228
+ if (bare === void 0 || bare === null) continue;
7229
+ const scopedKey = nodeEngineKey(base, "hub");
7230
+ if (store[scopedKey] === void 0) patch[scopedKey] = bare;
7231
+ patch[base] = void 0;
7232
+ }
7233
+ if (Object.keys(patch).length === 0) return;
7234
+ await this.ctxIfReady?.settings?.writeAddonStore(patch);
7235
+ this.ctx.logger.info("Migrated legacy bare engine keys to their @hub scoped form", { meta: { keys: Object.keys(patch) } });
7236
+ }
7237
+ /**
7121
7238
  * Refresh this node's cached engine selection from the node-scoped store.
7122
7239
  * `resolveBackendTuning` is synchronous and the reprobe gate runs before the
7123
7240
  * provider exists, so both read these cached fields instead of the
@@ -7139,26 +7256,30 @@ var DetectionPipelineAddon = class extends BaseAddon {
7139
7256
  * cluster-central store. The hub addon serves writes for every node, so it
7140
7257
  * scopes by the requested `nodeId` (default self).
7141
7258
  *
7142
- * When the target IS this node, `super.updateGlobalSettings` drives the normal
7143
- * apply path (`resolveConfig` / `onConfigChanged` / `requiresRestart` restart),
7144
- * and the bare engine keys it writes are shadowed by the node-scoped keys on
7145
- * read. When the target is a SIBLING node, we persist the scoped engine keys +
7146
- * the non-engine bare keys but DON'T run this node's restart/reprovision the
7147
- * owning node applies its own engine selection on its next (re)start.
7259
+ * The engine cascade keys are NEVER written bare (F1, CB3): the bare keys are
7260
+ * the legacy pre-scoping store whose values used to leak to every node with
7261
+ * no scoped key. When the target IS this node, `super.updateGlobalSettings`
7262
+ * drives the normal apply path (`resolveConfig` / `onConfigChanged` /
7263
+ * `requiresRestart` restart) with the engine keys STRIPPED from its patch
7264
+ * they were already persisted node-scoped, and `onConfigChanged` re-reads
7265
+ * them from the scoped keys. When the target is a SIBLING node, one scoped
7266
+ * write persists the engine keys under `<key>@<target>` + the non-engine
7267
+ * keys bare, and this node's restart/reprovision is NOT run — the owning
7268
+ * node applies its own engine selection on its next (re)start.
7148
7269
  */
7149
7270
  async updateGlobalSettings(patch, nodeId) {
7150
7271
  const targetNode = nodeId ? normalizeEngineNodeId(nodeId) : this.localNodeId();
7151
7272
  const patchRecord = patch;
7273
+ if (targetNode !== this.localNodeId()) {
7274
+ const scoped = scopeEnginePatch(patchRecord, targetNode);
7275
+ if (Object.keys(scoped).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scoped);
7276
+ return;
7277
+ }
7152
7278
  const scopedEngine = {};
7153
7279
  for (const key of ENGINE_CASCADE_KEYS) if (key in patchRecord) scopedEngine[nodeEngineKey(key, targetNode)] = patchRecord[key];
7154
7280
  if (Object.keys(scopedEngine).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scopedEngine);
7155
- if (targetNode === this.localNodeId()) {
7156
- await super.updateGlobalSettings(patch, nodeId);
7157
- return;
7158
- }
7159
- const sharedPatch = {};
7160
- for (const [k, v] of Object.entries(patchRecord)) if (!ENGINE_CASCADE_KEYS.includes(k)) sharedPatch[k] = v;
7161
- if (Object.keys(sharedPatch).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(sharedPatch);
7281
+ const { engineBackend: _engineBackend, engineDevice: _engineDevice, probedBestEngine: _probedBestEngine, ...sharedPatch } = patch;
7282
+ await super.updateGlobalSettings(sharedPatch, nodeId);
7162
7283
  }
7163
7284
  /**
7164
7285
  * Resolve the effective pool tuning for the configured backend.
@@ -7190,6 +7311,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7190
7311
  async onInitialize() {
7191
7312
  const modelsDir = await this.resolveModelsDir();
7192
7313
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7314
+ await this.migrateLegacyBareEngineKeys();
7193
7315
  await this.refreshNodeEngineFromStore();
7194
7316
  this.pythonAddonDir = resolveAddonPythonDir();
7195
7317
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);