@camstack/addon-pipeline 1.1.27 → 1.1.29

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 (49) hide show
  1. package/dist/audio-analyzer/index.js +7 -6
  2. package/dist/audio-analyzer/index.mjs +7 -6
  3. package/dist/audio-codec-ffmpeg/index.js +1 -5
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -5
  5. package/dist/decoder-ffmpeg/index.js +138 -356
  6. package/dist/decoder-ffmpeg/index.mjs +118 -336
  7. package/dist/decoder-nodeav/index.js +17 -10
  8. package/dist/decoder-nodeav/index.mjs +17 -10
  9. package/dist/detection-pipeline/index.js +341 -379
  10. package/dist/detection-pipeline/index.mjs +341 -379
  11. package/dist/{dist-DAIlCdAx.js → dist-Cwc0TUQr.js} +378 -76
  12. package/dist/{dist-CgEP_0OL.mjs → dist-DjuGmyG9.mjs} +379 -65
  13. package/dist/ffmpeg-args-C5GPp8Cw.mjs +323 -0
  14. package/dist/ffmpeg-args-D6h1edXK.js +418 -0
  15. package/dist/frame-dropper-AjheBGMG.mjs +22 -0
  16. package/dist/frame-dropper-DKLM6pMz.js +27 -0
  17. package/dist/{frame-handle-plane-Dq20KtKL.mjs → frame-handle-plane-Bkxz-TTD.mjs} +13 -5
  18. package/dist/{frame-handle-plane-DtTRX_0n.js → frame-handle-plane-DQNCTrpC.js} +13 -5
  19. package/dist/{frame-ring-sink-BbzrqTWj.js → frame-ring-sink-8LLV-cvH.js} +128 -23
  20. package/dist/{frame-ring-sink-B_NvPTJZ.mjs → frame-ring-sink-ClEWjiRU.mjs} +117 -24
  21. package/dist/motion-wasm/index.js +55 -3
  22. package/dist/motion-wasm/index.mjs +55 -3
  23. package/dist/node-topology-platform-BkR_k6WT.mjs +15 -0
  24. package/dist/node-topology-platform-CFZ7F4xW.js +20 -0
  25. package/dist/pipeline-runner/index.js +50 -16
  26. package/dist/pipeline-runner/index.mjs +50 -16
  27. package/dist/recorder/index.js +36 -40
  28. package/dist/recorder/index.mjs +36 -40
  29. package/dist/stream-broker/_stub.js +2 -2
  30. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D567z31g.mjs} +3 -3
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhY3MZ2C.mjs +26 -0
  32. 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 → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DtoYGpTp.mjs} +1 -1
  33. package/dist/stream-broker/{hostInit-DyLqyJaS.mjs → hostInit-B_b3PIZB.mjs} +3 -3
  34. package/dist/stream-broker/index.js +198 -115
  35. package/dist/stream-broker/index.mjs +189 -106
  36. package/dist/stream-broker/remoteEntry.js +1 -1
  37. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js → MaskShapeCanvas-DI4BY7W2-x-DZOuQL.js} +1 -1
  38. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CoLjNiUN.js → MotionZonesSettings-NcxxQN8r-DTeuDGCU.js} +1 -1
  39. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js → PrivacyMaskSettings-APgPLF7p-CirtxO3e.js} +1 -1
  40. package/embed-dist/assets/index-C5UpuPr8.css +2 -0
  41. package/embed-dist/assets/{index-C-pL8ETk.js → index-CpUy8OIE.js} +10 -10
  42. package/embed-dist/index.html +2 -2
  43. package/package.json +1 -1
  44. package/python/inference_pool.py +93 -102
  45. package/python/test_inference_pool_device_selection.py +43 -38
  46. package/dist/frame-dropper-7RTo_YyG.js +0 -68
  47. package/dist/frame-dropper-CwkBTPGV.mjs +0 -51
  48. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +0 -26
  49. package/embed-dist/assets/index-DrJ0ee3f.css +0 -2
@@ -1,103 +1,13 @@
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-CgEP_0OL.mjs";
2
+ import { $ as sleep, B as errMsg, E as hfModelUrl, F as runtimeDevices$1, G as EventCategory, K as createEvent, M as pipelineExecutorCapability, R as supportedRuntimes$1, S as defaultDeviceFor$1, T as evaluateZoneRules, V as BaseAddon, X as parseJsonUnknown, a as COCO_TO_MACRO, i as COCO_80_LABELS, m as YAMNET_TO_MACRO, q as hydrateSchema, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, w as detectionPipelineCapability } from "../dist-DjuGmyG9.mjs";
3
+ import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
3
4
  import { a as isModelDownloaded, i as ensureModel, n as deleteModelFromDisk } from "../model-download-service-C-IHWnXx-BPy6aoAx.mjs";
5
+ import { FrameRingReaderCache } from "@camstack/shm-ring";
4
6
  import * as fs from "node:fs";
5
7
  import * as path$1 from "node:path";
6
8
  import * as os from "node:os";
7
9
  import { spawn } from "node:child_process";
8
10
  import sharp from "sharp";
9
- //#region src/detection-pipeline/engine-store-keys.ts
10
- /**
11
- * Per-node scoping for the detection-pipeline engine cascade.
12
- *
13
- * The detection addon's settings store is a single CLUSTER-SHARED blob (the
14
- * settings-store cap is hub-resident; every node's detection instance reads and
15
- * writes the same keys). That is correct for node-agnostic settings (pipeline
16
- * steps, tuning) but WRONG for the engine cascade — `engineBackend` /
17
- * `engineDevice` / `probedBestEngine` are hardware-specific, so the hub (NPU)
18
- * and a remote agent (iGPU, or no accelerator at all) must hold INDEPENDENT
19
- * selections. Sharing them lets one node's pick (e.g. `openvino/npu`) override
20
- * another node that has no NPU.
21
- *
22
- * These three keys are therefore persisted node-scoped as `<key>@<nodeId>`.
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
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.
30
- */
31
- var ENGINE_CASCADE_KEYS = [
32
- "engineBackend",
33
- "engineDevice",
34
- "probedBestEngine"
35
- ];
36
- function isEngineCascadeKey(key) {
37
- return ENGINE_CASCADE_KEYS.includes(key);
38
- }
39
- /**
40
- * Normalise a raw kernel node id to the bare node id used for scoping.
41
- * `localNodeId` can carry a `<node>/<addon>` suffix; the engine selection is
42
- * per-NODE, so strip the addon segment. Falls back to `hub`.
43
- */
44
- function normalizeEngineNodeId(rawNodeId) {
45
- const raw = rawNodeId ?? "hub";
46
- return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
47
- }
48
- /** The node-scoped store key for an engine cascade field. */
49
- function nodeEngineKey(base, nodeId) {
50
- return `${base}@${normalizeEngineNodeId(nodeId)}`;
51
- }
52
- /**
53
- * Read an engine cascade value for a node: the node-scoped key if present,
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.
60
- */
61
- function readNodeEngineValue(store, base, nodeId) {
62
- const scoped = store[nodeEngineKey(base, nodeId)];
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;
75
- }
76
- /**
77
- * Project a raw store onto the plain engine cascade keys for THIS node, so the
78
- * UI schema (whose field keys are the bare `engineBackend` etc.) hydrates from
79
- * the node's own selection. Non-engine keys are left untouched. Node-scoped
80
- * keys for OTHER nodes are dropped from the projection (not relevant to this
81
- * node's form).
82
- */
83
- function projectNodeEngine(store, nodeId) {
84
- const out = {};
85
- const scopedForAnyNode = /* @__PURE__ */ new Set();
86
- for (const key of Object.keys(store)) {
87
- const atIdx = key.indexOf("@");
88
- if (isEngineCascadeKey(atIdx >= 0 ? key.slice(0, atIdx) : key)) {
89
- scopedForAnyNode.add(key);
90
- continue;
91
- }
92
- out[key] = store[key];
93
- }
94
- for (const base of ENGINE_CASCADE_KEYS) {
95
- const value = readNodeEngineValue(store, base, nodeId);
96
- if (value !== void 0) out[base] = value;
97
- }
98
- return out;
99
- }
100
- //#endregion
101
11
  //#region src/detection-pipeline/runtimes.ts
102
12
  var KNOWN_PLATFORMS = [
103
13
  "darwin",
@@ -269,6 +179,88 @@ function pickBestRuntime(env, bestBackendHint) {
269
179
  };
270
180
  }
271
181
  //#endregion
182
+ //#region src/detection-pipeline/engine-store-keys.ts
183
+ /**
184
+ * Per-node scoping for the detection-pipeline engine cascade.
185
+ *
186
+ * The detection addon's settings store is a single CLUSTER-SHARED blob (the
187
+ * settings-store cap is hub-resident; every node's detection instance reads and
188
+ * writes the same keys). That is correct for node-agnostic settings (pipeline
189
+ * steps, tuning) but WRONG for the engine cascade — `engineBackend` /
190
+ * `engineDevice` / `probedBestEngine` are hardware-specific, so the hub (NPU)
191
+ * and a remote agent (iGPU, or no accelerator at all) must hold INDEPENDENT
192
+ * selections. Sharing them lets one node's pick (e.g. `openvino/npu`) override
193
+ * another node that has no NPU.
194
+ *
195
+ * These three keys are therefore persisted node-scoped as `<key>@<nodeId>`.
196
+ * Everything else in the store stays shared. There is NO fallback to a legacy
197
+ * un-scoped value for ANY node (the hub included) and no in-code migration: a
198
+ * bare key (written before scoping, or by an older build) is inert — invisible
199
+ * to every node. A node with no scoped key reads `undefined`, which drives its
200
+ * OWN live resolve (`resolveAutoEngine` picks the node's runtime from the local
201
+ * kernel each boot; `reprobeEngine` persists an explicit probe under
202
+ * `<key>@<node>`). Serving a bare value to any node
203
+ * would leak one node's hardware pick (e.g. `openvino/auto`) onto a node that
204
+ * cannot run it (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). This
205
+ * mirrors the decoder's `readNodeDecoderBackend` no-fallback rule.
206
+ */
207
+ var ENGINE_CASCADE_KEYS = [
208
+ "engineBackend",
209
+ "engineDevice",
210
+ "probedBestEngine"
211
+ ];
212
+ function isEngineCascadeKey(key) {
213
+ return ENGINE_CASCADE_KEYS.includes(key);
214
+ }
215
+ /**
216
+ * Normalise a raw kernel node id to the bare node id used for scoping.
217
+ * `localNodeId` can carry a `<node>/<addon>` suffix; the engine selection is
218
+ * per-NODE, so strip the addon segment. Falls back to `hub`.
219
+ */
220
+ function normalizeEngineNodeId(rawNodeId) {
221
+ const raw = rawNodeId ?? "hub";
222
+ return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
223
+ }
224
+ /** The node-scoped store key for an engine cascade field. */
225
+ function nodeEngineKey(base, nodeId) {
226
+ return `${base}@${normalizeEngineNodeId(nodeId)}`;
227
+ }
228
+ /**
229
+ * Read an engine cascade value for a node: the node-scoped key if present,
230
+ * otherwise `undefined` — for EVERY node, the hub included. There is no
231
+ * fallback to the legacy un-scoped key: a bare value in the store is inert.
232
+ * A node with no scoped key gets `undefined` so its own platform-driven
233
+ * auto-pick runs and persists a real per-node selection under `<key>@<node>`.
234
+ * (A bare fallback would leak one node's engine selection cluster-wide — CB3.)
235
+ */
236
+ function readNodeEngineValue(store, base, nodeId) {
237
+ return store[nodeEngineKey(base, nodeId)];
238
+ }
239
+ /**
240
+ * Project a raw store onto the plain engine cascade keys for THIS node, so the
241
+ * UI schema (whose field keys are the bare `engineBackend` etc.) hydrates from
242
+ * the node's own selection. Non-engine keys are left untouched. Node-scoped
243
+ * keys for OTHER nodes are dropped from the projection (not relevant to this
244
+ * node's form).
245
+ */
246
+ function projectNodeEngine(store, nodeId) {
247
+ const out = {};
248
+ const scopedForAnyNode = /* @__PURE__ */ new Set();
249
+ for (const key of Object.keys(store)) {
250
+ const atIdx = key.indexOf("@");
251
+ if (isEngineCascadeKey(atIdx >= 0 ? key.slice(0, atIdx) : key)) {
252
+ scopedForAnyNode.add(key);
253
+ continue;
254
+ }
255
+ out[key] = store[key];
256
+ }
257
+ for (const base of ENGINE_CASCADE_KEYS) {
258
+ const value = readNodeEngineValue(store, base, nodeId);
259
+ if (value !== void 0) out[base] = value;
260
+ }
261
+ return out;
262
+ }
263
+ //#endregion
272
264
  //#region src/detection-pipeline/engine/shared-inference-pool.ts
273
265
  /**
274
266
  * SharedInferencePool — TypeScript wrapper for inference_pool.py.
@@ -4341,6 +4333,15 @@ var ONNX_FLOOR = {
4341
4333
  device: "cpu"
4342
4334
  };
4343
4335
  /**
4336
+ * How long the first-boot pick waits for the node's `platform-probe` to flip
4337
+ * `ready` (= its FULL async probe done, embedded-Python install included).
4338
+ * MUST stay finite — `readinessRegistry.awaitReady` defaults to Infinity,
4339
+ * which would wedge `setApi` (and the addon's onInitialize) forever on a
4340
+ * node whose probe never completes. On timeout the pick proceeds
4341
+ * conservatively (platform floor, never persisted when non-authoritative).
4342
+ */
4343
+ var PROBE_READY_TIMEOUT_MS = 12e4;
4344
+ /**
4344
4345
  * Build the onnx-cpu floor pick using `pickBestRuntime` with a null hardware
4345
4346
  * env. Used wherever the old `detectBestEngine()` sync probe fell back — the
4346
4347
  * result is identical (onnx / cpu) but is now derived through the shared rules
@@ -4419,6 +4420,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4419
4420
  */
4420
4421
  lastStepsSignature = null;
4421
4422
  /**
4423
+ * CB5 shm passthrough — the process-local, LONG-LIVED reader cache for
4424
+ * `runPipeline({ frameHandle })`. Opens each named shm segment ONCE and
4425
+ * reuses the mapping for every later handle on the same ring (never
4426
+ * open/close per-call). Lazily created on first handle, unmapped in
4427
+ * {@link shutdown}. The executor and the runner are both hub-local
4428
+ * processes sharing `/dev/shm`, so a LOCAL handle resolves to a zero-copy
4429
+ * read here.
4430
+ */
4431
+ frameHandleReaders = null;
4432
+ /**
4422
4433
  * True once the engine + models are fully ready for inference. No
4423
4434
  * longer gates runtime dispatch (Phase 4 removed the legacy `runFrame`
4424
4435
  * which read this flag); kept as a diagnostic the admin UI / tests
@@ -4444,13 +4455,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4444
4455
  */
4445
4456
  needsAutoPick = false;
4446
4457
  /**
4447
- * Unsubscribe handle for the deferred-auto-pick `platform-probe` ready
4448
- * listener (armed in `setApi` when the probe isn't ready yet). Cleared once
4449
- * the engine is resolved — either by the listener firing or by the boot
4450
- * safety-net `ensureBootEngineProvisioned`.
4451
- */
4452
- deferredAutoPickUnsub = null;
4453
- /**
4454
4458
  * Warm cache for benchmark engine-override runs.
4455
4459
  *
4456
4460
  * Each override rebuild costs a full Python pool spin-up (~300-500ms)
@@ -4551,108 +4555,71 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4551
4555
  /** Store the addon context. ctx.api is a lazy getter resolved at call time. */
4552
4556
  async setApi(addonCtx) {
4553
4557
  this.addonCtx = addonCtx;
4554
- if (this.needsAutoPick) if (this.addonCtx.useCapability("platform-probe").isReady) {
4555
- await this.autoPickAndPersist();
4556
- this.needsAutoPick = false;
4558
+ if (!this.needsAutoPick) {
4557
4559
  this.startProvisioningForCurrentEngine();
4558
- } else {
4559
- const unsubscribe = this.addonCtx.onCapabilityStateChange("platform-probe", { type: "global" }, (state) => {
4560
- if (state !== "ready") return;
4561
- this.cancelDeferredAutoPick();
4562
- if (!this.needsAutoPick) return;
4563
- this.autoPickAndPersist().then(() => {
4564
- this.needsAutoPick = false;
4565
- this.startProvisioningForCurrentEngine();
4566
- });
4567
- });
4568
- this.deferredAutoPickUnsub = unsubscribe;
4569
- this.addonCtx.addDisposer(() => this.cancelDeferredAutoPick());
4560
+ return;
4570
4561
  }
4571
- else this.startProvisioningForCurrentEngine();
4572
- }
4573
- /** Tear down the deferred-auto-pick probe listener, if still armed. */
4574
- cancelDeferredAutoPick() {
4575
- if (this.deferredAutoPickUnsub) {
4576
- this.deferredAutoPickUnsub();
4577
- this.deferredAutoPickUnsub = null;
4562
+ const nodeId = this.localProbeNodeId();
4563
+ const registry = this.addonCtx.kernel?.readinessRegistry;
4564
+ if (registry) try {
4565
+ await registry.awaitReady("platform-probe", {
4566
+ type: "node",
4567
+ nodeId
4568
+ }, { timeoutMs: PROBE_READY_TIMEOUT_MS });
4569
+ } catch (err) {
4570
+ this.log.warn("platform-probe readiness gate timed out — conservative engine pick (floor is not persisted)", { meta: {
4571
+ nodeId,
4572
+ timeoutMs: PROBE_READY_TIMEOUT_MS,
4573
+ error: errMsg(err)
4574
+ } });
4578
4575
  }
4579
- }
4580
- /**
4581
- * Boot safety-net: deterministically provision the hardware-probed engine.
4582
- *
4583
- * Called from the addon's `onInitialize` right after `reprobeEngine` has
4584
- * written this node's probe-driven selection (e.g. `engineBackend=openvino`)
4585
- * to the store. When first-boot auto-pick was DEFERRED (probe not ready at
4586
- * `setApi`), the engine would otherwise stay `idle` — selected but never
4587
- * provisioned — because the `onCapabilityStateChange('platform-probe')` ready
4588
- * edge can be missed when the cap is already 'ready' by the time we subscribe
4589
- * (the old eager-onnx-floor masked this; removing it surfaced a node that
4590
- * boots with no engine at all). This loads the now-persisted selection and
4591
- * starts provisioning it, so the node reliably comes up on its real best
4592
- * engine (openvino on Intel) with no onnx floor and no missed boot. No-op
4593
- * when provisioning already started (persisted-engine path / listener fired)
4594
- * or when nothing has been selected yet.
4595
- */
4596
- async ensureBootEngineProvisioned() {
4597
- if (this.getEngineProvisioning().state !== "idle") return;
4598
- const stored = await this.loadEngine();
4599
- if (!stored) return;
4600
- this.currentEngine = stored;
4576
+ await this.resolveAutoEngine();
4601
4577
  this.needsAutoPick = false;
4602
- this.cancelDeferredAutoPick();
4603
- this.log.info("Boot engine provisioning from probed selection", { meta: {
4604
- runtime: stored.runtime,
4605
- backend: stored.backend,
4606
- device: stored.device ?? null
4607
- } });
4608
4578
  this.startProvisioningForCurrentEngine();
4609
4579
  }
4610
4580
  /**
4611
- * Auto-pick the best supported runtime at first boot (no stored engine).
4612
- * Uses the platform-probe cap's hardware + bestScore hint when available;
4613
- * falls back to platform/arch when the probe cap is not yet reachable.
4581
+ * Resolve the `'auto'` engine LIVE from this node's own kernel inference
4582
+ * resolver (`ctx.kernel.inferenceEngine`) the runtime mirror of
4583
+ * `ctx.kernel.hwaccel`. Each node (hub + forked worker + remote agent)
4584
+ * resolves its OWN accelerators in-process, so a forked agent child never
4585
+ * adopts the hub's runtime from the `platform-probe` singleton.
4614
4586
  *
4615
- * Persists the selection as `engineBackend` + `engineDevice` so subsequent
4616
- * boots load it via `loadEngine()` and skip this path but ONLY when the
4617
- * probe actually answered (real `hardware` or a `bestScore` hint). If the
4618
- * probe query failed (cold-start race, cap momentarily unreachable), the
4619
- * pick floors to onnx purely for lack of information; persisting that would
4620
- * LOCK onnx and skip auto-pick on every future boot even after the
4621
- * accelerator surfaces. In that case we set the in-memory floor for liveness
4622
- * but leave the store untouched so the next boot re-attempts the pick.
4587
+ * NEVER persists. `'auto'` is a live-resolved sentinel: the pick is recomputed
4588
+ * every boot from the local hardware, so it is inherently self-healing the
4589
+ * old `autoPickAndPersist` sticky-onnx bug (a premature onnx pick persisted
4590
+ * during a boot race, then locked forever) cannot recur because the store is
4591
+ * never written here. Only an explicit operator override is persisted, and
4592
+ * that write happens through the settings UI, not this path.
4623
4593
  */
4624
- async autoPickAndPersist() {
4594
+ async resolveAutoEngine() {
4625
4595
  let hardware = null;
4626
4596
  try {
4627
- const api = this.addonCtx?.api;
4628
- if (api) hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
4597
+ hardware = await this.addonCtx?.kernel?.inferenceEngine?.resolveHardware() ?? null;
4629
4598
  } catch {}
4630
- const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
4631
- const engine = {
4599
+ const pick = pickBestRuntime(runtimeEnvFromProcess(hardware ? toProbedHardware(hardware) : null), null);
4600
+ this.currentEngine = {
4632
4601
  runtime: "python",
4633
4602
  backend: pick.runtimeId,
4634
4603
  format: modelFormatFor(pick.runtimeId),
4635
4604
  device: pick.device
4636
4605
  };
4637
- this.currentEngine = engine;
4638
- if (!(pick.runtimeId !== "onnx" || hardware !== null)) {
4639
- this.log.info("Auto-pick: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
4640
- backend: pick.runtimeId,
4641
- device: pick.device
4642
- } });
4643
- return;
4644
- }
4645
- const apNode = this.localProbeNodeId();
4646
- await this.writeStore({
4647
- [nodeEngineKey("engineBackend", apNode)]: pick.runtimeId,
4648
- [nodeEngineKey("engineDevice", apNode)]: pick.device
4649
- });
4650
- this.log.info("Auto-picked engine (platform-deterministic)", { meta: {
4606
+ this.log.info("Resolved auto engine (local kernel)", { meta: {
4651
4607
  backend: pick.runtimeId,
4652
4608
  device: pick.device,
4653
- hadProbeHardware: hardware !== null
4609
+ hadHardware: hardware !== null
4654
4610
  } });
4655
4611
  }
4612
+ /**
4613
+ * The RUNTIME that `'auto'` actually resolved to on this node (the live
4614
+ * `currentEngine.backend`), narrowed to a {@link RuntimeId}. The addon feeds
4615
+ * this into `resolveBackendTuning` when the stored config is `'auto'`, so the
4616
+ * pool tuning + respawn detection track the RESOLVED backend rather than the
4617
+ * `'auto'` sentinel (which would otherwise floor to onnx tuning).
4618
+ */
4619
+ resolvedEngineBackend() {
4620
+ const backend = this.currentEngine.backend;
4621
+ return backend === "openvino" || backend === "coreml" ? backend : "onnx";
4622
+ }
4656
4623
  /** Map a backend string to a known RuntimeId, flooring to onnx. */
4657
4624
  toRuntimeId(backend) {
4658
4625
  return [
@@ -4726,6 +4693,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4726
4693
  const prev = this.currentEngine;
4727
4694
  const stored = await this.loadEngine();
4728
4695
  if (stored) this.currentEngine = stored;
4696
+ else await this.resolveAutoEngine();
4729
4697
  if ((prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "")) && this.engineFactory) {
4730
4698
  this.log.info("engine selection changed — rebuilding pool in place", { meta: {
4731
4699
  from: `${prev.backend}/${prev.device ?? "default"}`,
@@ -4827,17 +4795,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4827
4795
  }, { ...state }));
4828
4796
  }
4829
4797
  /**
4830
- * Fetch the probed HARDWARE for engine + device gating. Returns null when the
4831
- * probe cap is not yet reachable (cold-start / probe addon not installed), so
4832
- * callers fall back to the registry's safe minimum. The engine OFFER derives
4833
- * from hardware ONLY — install state (probe `scores`) never gates it.
4798
+ * Fetch this node's inference HARDWARE (gpu/npu) for engine + device gating,
4799
+ * from the local kernel resolver. Returns null when the resolver is not wired
4800
+ * yet (cold-start), so callers fall back to the registry's safe minimum. The
4801
+ * engine OFFER derives from hardware ONLY — install state never gates it.
4834
4802
  */
4835
4803
  /**
4836
- * The local Moleculer node id (child-suffix stripped). MUST be passed to every
4837
- * `platformProbe.getCapabilities` query: the cap is a singleton and a query
4838
- * with no nodeId resolves to the HUB's probe — so on a remote agent the engine
4839
- * decision would use the HUB's hardware (e.g. an Intel NPU the agent doesn't
4840
- * have) and pin a device the node can't run, breaking provisioning.
4804
+ * The local Moleculer node id (child-suffix stripped). Used to scope this
4805
+ * node's engine-cascade store keys (`<key>@<node>`). Hardware itself is now
4806
+ * resolved in-process via `ctx.kernel.inferenceEngine` (never the
4807
+ * `platform-probe` singleton, whose no-nodeId query resolved to the HUB and
4808
+ * pinned a device a remote agent couldn't run) so this id is a store-scoping
4809
+ * concern, not a probe-routing one.
4841
4810
  */
4842
4811
  localProbeNodeId() {
4843
4812
  const raw = this.addonCtx?.kernel?.localNodeId ?? "hub";
@@ -4845,9 +4814,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4845
4814
  }
4846
4815
  async fetchProbeGatingData() {
4847
4816
  try {
4848
- const api = this.addonCtx?.api;
4849
- if (!api) return { hardware: null };
4850
- return { hardware: (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null };
4817
+ return { hardware: await this.addonCtx?.kernel?.inferenceEngine?.resolveHardware() ?? null };
4851
4818
  } catch {
4852
4819
  return { hardware: null };
4853
4820
  }
@@ -5187,6 +5154,53 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5187
5154
  } });
5188
5155
  return { success: true };
5189
5156
  }
5157
+ /** This executor's cluster node id (suffix-stripped, matching the runner). */
5158
+ ownNodeId() {
5159
+ const raw = this.addonCtx?.kernel?.localNodeId ?? "hub";
5160
+ return raw.includes("/") ? raw.split("/")[0] : raw;
5161
+ }
5162
+ /**
5163
+ * CB5 — resolve a shm `FrameHandle` to pixels zero-copy. Returns `null`
5164
+ * when:
5165
+ * - the handle is FOREIGN (its segment lives on another node — a local
5166
+ * `shm_open` would flood ENOENT; the runner only sends LOCAL handles, so
5167
+ * this is a defensive guard against an unexpected topology);
5168
+ * - the ring slot was recycled before the read (latest-wins drop) or the
5169
+ * segment could not be mapped.
5170
+ * A `null` degrades the caller to an empty `FrameResult` — the runner ships
5171
+ * pixels via `frame` as the fallback.
5172
+ */
5173
+ resolveFrameHandle(handle) {
5174
+ if (handle.nodeId !== this.ownNodeId()) return null;
5175
+ if (!this.frameHandleReaders) this.frameHandleReaders = new FrameRingReaderCache(this.log);
5176
+ const decoded = this.frameHandleReaders.read(handle);
5177
+ if (!decoded) return null;
5178
+ return {
5179
+ data: decoded.data,
5180
+ width: decoded.width,
5181
+ height: decoded.height,
5182
+ format: decoded.format,
5183
+ timestamp: decoded.timestamp
5184
+ };
5185
+ }
5186
+ /**
5187
+ * Minimal detection-free `FrameResult` returned when a `frameHandle` read
5188
+ * misses (recycled slot / foreign / unmappable). Shape-identical to a
5189
+ * zero-detection runtime result so downstream consumers treat it as a
5190
+ * dropped frame, not an error.
5191
+ */
5192
+ emptyFrameHandleResult(input) {
5193
+ return {
5194
+ kind: "frame",
5195
+ frameId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
5196
+ deviceId: input.deviceId ?? 0,
5197
+ timestamp: Date.now(),
5198
+ width: input.frameHandle?.width ?? 0,
5199
+ height: input.frameHandle?.height ?? 0,
5200
+ detections: [],
5201
+ debug: { totalInferenceMs: 0 }
5202
+ };
5203
+ }
5190
5204
  async runPipeline(input, onProgress) {
5191
5205
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
5192
5206
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -5222,15 +5236,22 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5222
5236
  let jpegProvider;
5223
5237
  const sources = [
5224
5238
  input.frame ? "frame" : null,
5239
+ input.frameHandle ? "frameHandle" : null,
5225
5240
  input.image ? "image" : null,
5226
5241
  input.imageBase64 ? "imageBase64" : null,
5227
5242
  input.referenceImage ? "referenceImage" : null
5228
5243
  ].filter((s) => s !== null);
5229
- if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, image, imageBase64, referenceImage");
5244
+ if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, frameHandle, image, imageBase64, referenceImage");
5230
5245
  if (sources.length > 1) throw new Error(`runPipeline received conflicting image sources: ${sources.join(", ")}`);
5246
+ let handleFrame = null;
5247
+ if (input.frameHandle) {
5248
+ handleFrame = this.resolveFrameHandle(input.frameHandle);
5249
+ if (!handleFrame) return this.emptyFrameHandleResult(input);
5250
+ }
5251
+ const runtimeFrame = input.frame ?? handleFrame ?? void 0;
5231
5252
  const decodeT0 = performance.now();
5232
- if (input.frame) {
5233
- const frame = input.frame;
5253
+ if (runtimeFrame) {
5254
+ const frame = runtimeFrame;
5234
5255
  imageWidth = frame.width;
5235
5256
  imageHeight = frame.height;
5236
5257
  const data = Buffer.from(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength);
@@ -5424,7 +5445,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5424
5445
  emit("Running inference...");
5425
5446
  const tree = buildExecutableTree(benchmarkSteps, (stepId) => this.engineFactory.getEngine(stepId));
5426
5447
  setupMs = performance.now() - wallT0 - decodeMs;
5427
- const isRuntime = Boolean(input.frame);
5448
+ const isRuntime = Boolean(runtimeFrame);
5428
5449
  const effectiveDeviceId = input.deviceId ?? 0;
5429
5450
  const deviceOverrides = effectiveDeviceId > 0 ? await this.readDeviceStore(effectiveDeviceId) : {};
5430
5451
  const effectiveTree = Object.keys(deviceOverrides).length > 0 ? applyDeviceOverridesToTree(tree, "object-detection", deviceOverrides) : tree;
@@ -5995,6 +6016,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5995
6016
  this.executor = null;
5996
6017
  }
5997
6018
  for (const id of Array.from(this.deviceProxies.keys())) this.releaseDeviceProxy(id);
6019
+ this.frameHandleReaders?.close();
6020
+ this.frameHandleReaders = null;
5998
6021
  }
5999
6022
  /**
6000
6023
  * Resolve and cache a {@link DeviceProxy} for the given camera. Pins
@@ -6177,15 +6200,20 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6177
6200
  * Phase 2b — resolve the engine from the addon's new schema-backed
6178
6201
  * fields (`engineRuntime`, `engineBackend`, `engineDevice`). Fallback
6179
6202
  * to the legacy `KEY_ENGINE` JSON blob for pre-migration stores.
6180
- * Returns null when neither source has anything; the caller keeps
6181
- * the onnx floor set at construction until autoPickAndPersist() runs.
6203
+ *
6204
+ * The `'auto'` sentinel is treated as NO override (returns null) so the
6205
+ * caller re-resolves LIVE via `resolveAutoEngine()` — `'auto'` is the
6206
+ * self-healing default, never a persisted concrete backend. An explicit
6207
+ * concrete backend (onnx/openvino/coreml) is honored as an operator override.
6208
+ * Returns null when neither source has a concrete selection; the caller keeps
6209
+ * the onnx floor set at construction until `resolveAutoEngine()` runs.
6182
6210
  */
6183
6211
  async loadEngine() {
6184
6212
  const store = await this.readStore();
6185
6213
  const storedRuntime = store["engineRuntime"];
6186
6214
  const node = this.localProbeNodeId();
6187
6215
  const storedBackend = readNodeEngineValue(store, "engineBackend", node);
6188
- if (typeof storedBackend === "string" && storedBackend.length > 0) {
6216
+ if (typeof storedBackend === "string" && storedBackend.length > 0 && storedBackend !== "auto") {
6189
6217
  const backend = storedBackend;
6190
6218
  const storedDeviceRaw = readNodeEngineValue(store, "engineDevice", node);
6191
6219
  const storedDevice = typeof storedDeviceRaw === "string" ? storedDeviceRaw : "";
@@ -6339,12 +6367,11 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6339
6367
  return { success: true };
6340
6368
  }
6341
6369
  async reprobeEngine() {
6342
- const api = this.addonCtx?.api;
6343
6370
  let hardware = null;
6344
- if (api) try {
6345
- hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
6371
+ try {
6372
+ hardware = await this.addonCtx?.kernel?.inferenceEngine?.resolveHardware() ?? null;
6346
6373
  } catch {}
6347
- const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
6374
+ const pick = pickBestRuntime(runtimeEnvFromProcess(hardware ? toProbedHardware(hardware) : null), null);
6348
6375
  const best = {
6349
6376
  runtime: "python",
6350
6377
  backend: pick.runtimeId,
@@ -6354,47 +6381,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6354
6381
  const probedLabel = `${best.backend}/${best.device ?? "default"}`;
6355
6382
  const rpNode = this.localProbeNodeId();
6356
6383
  if (pick.runtimeId !== "onnx" || hardware !== null) {
6357
- await this.writeStore({
6358
- [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel,
6359
- [nodeEngineKey("engineBackend", rpNode)]: best.backend,
6360
- [nodeEngineKey("engineDevice", rpNode)]: best.device ?? "cpu"
6361
- });
6362
- this.log.info("Re-probed engine (platform-deterministic) — wrote back", { meta: {
6384
+ await this.writeStore({ [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel });
6385
+ this.log.info("Re-probed engine (local kernel) — updated probedBestEngine hint", { meta: {
6363
6386
  backend: best.backend,
6364
6387
  device: best.device ?? null,
6365
6388
  probedBestEngine: probedLabel
6366
6389
  } });
6367
- } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
6390
+ } else this.log.info("Re-probe: onnx floor pending gpu probe — hint NOT persisted", { meta: {
6368
6391
  backend: best.backend,
6369
6392
  device: best.device ?? null
6370
6393
  } });
6371
6394
  return best;
6372
6395
  }
6373
- /**
6374
- * Re-pick the engine when the platform-probe finishes its async hardware +
6375
- * Python detection (the `platform-probe.phase` `done` event). At boot the
6376
- * probe's accelerator result may not be ready yet, so the engine floored to
6377
- * onnx; once the probe answers (e.g. a Mac's CoreML/ANE surfaces after the
6378
- * embedded Python is installed) this re-runs the probe-driven pick and
6379
- * re-provisions. Idempotent: `startProvisioningForCurrentEngine` skips a
6380
- * no-op when the selection is unchanged.
6381
- */
6382
- async repickEngineOnProbeReady() {
6383
- const before = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6384
- await this.reprobeEngine();
6385
- const stored = await this.loadEngine();
6386
- if (stored) {
6387
- this.currentEngine = stored;
6388
- this.needsAutoPick = false;
6389
- this.cancelDeferredAutoPick();
6390
- }
6391
- const after = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6392
- if (before !== after) this.log.info("Engine re-picked after platform-probe completed", { meta: {
6393
- before,
6394
- after
6395
- } });
6396
- this.startProvisioningForCurrentEngine();
6397
- }
6398
6396
  async getReferenceAudioFiles() {
6399
6397
  const dir = resolveReferenceAudioDir();
6400
6398
  if (!dir) return [];
@@ -6806,7 +6804,7 @@ var DEFAULT_CONFIG = {
6806
6804
  intraOpThreads: 0,
6807
6805
  numWorkers: 0,
6808
6806
  engineRuntime: "python",
6809
- engineBackend: "onnx",
6807
+ engineBackend: "auto",
6810
6808
  engineDevice: "cpu",
6811
6809
  probedBestEngine: "",
6812
6810
  activeEngine: ""
@@ -6836,6 +6834,15 @@ function stableEngineKey(engines) {
6836
6834
  return JSON.stringify(stripped);
6837
6835
  }
6838
6836
  /**
6837
+ * Narrow a topology-reported platform string to the runtime-relevant
6838
+ * `NodeJS.Platform` values the engine cascade branches on (darwin/linux/win32).
6839
+ * A type guard, not a cast; any other value → undefined so the caller floors to
6840
+ * `process.platform`. These three match `HardwarePlatformSchema` in the cap.
6841
+ */
6842
+ function toNodePlatform(value) {
6843
+ return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
6844
+ }
6845
+ /**
6839
6846
  * Tuning fields that bind into the Python pool at construction. Any
6840
6847
  * change requires a pool respawn to take effect (the executor caches
6841
6848
  * `tuning` at engine-factory build time). `onConfigChanged` watches
@@ -6850,7 +6857,7 @@ var POOL_BOUND_KEYS = [
6850
6857
  "intraOpThreads",
6851
6858
  "numWorkers"
6852
6859
  ];
6853
- var DetectionPipelineAddon = class extends BaseAddon {
6860
+ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
6854
6861
  provider = null;
6855
6862
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
6856
6863
  * returns null so offered backends / device lists don't collapse. Keyed by
@@ -6858,13 +6865,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6858
6865
  * Carries the probed node's platform/arch too, so a transient null probe
6859
6866
  * doesn't flip a remote node's env back to the serving hub's platform. */
6860
6867
  lastGoodHardwareByNode = /* @__PURE__ */ new Map();
6861
- /** This node's effective engine selection, cached from the node-scoped store
6862
- * so the synchronous `resolveBackendTuning` and the reprobe gate don't read
6863
- * the cluster-shared bare keys. Refreshed at init + on every config change. */
6864
- nodeEngineBackend = DEFAULT_CONFIG.engineBackend;
6865
- nodeProbedBestEngine = "";
6866
6868
  engineMetricsTimer = null;
6867
- probePhaseUnsub = null;
6868
6869
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
6869
6870
  * the engine inventory is unchanged (no model load/unload), so
6870
6871
  * we skip the bus emit and let the heartbeat re-emit at
@@ -6912,6 +6913,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6912
6913
  description: "Auto-detected best engine for this host (format: provider/device, e.g. \"openvino/npu\"). Click the refresh icon to re-run the probe — the detected provider and hardware device are written back into the two fields below, overwriting any manual override.",
6913
6914
  readonlyField: true,
6914
6915
  default: "",
6916
+ perNode: true,
6915
6917
  actions: [{
6916
6918
  action: "reprobe-engine",
6917
6919
  icon: "refresh-cw",
@@ -6930,9 +6932,13 @@ var DetectionPipelineAddon = class extends BaseAddon {
6930
6932
  type: "select",
6931
6933
  key: "engineBackend",
6932
6934
  label: "Execution provider",
6933
- options: [...STATIC_BACKEND_OPTIONS],
6935
+ options: [{
6936
+ value: "auto",
6937
+ label: "Auto (best for this node)"
6938
+ }, ...STATIC_BACKEND_OPTIONS],
6934
6939
  default: DEFAULT_CONFIG.engineBackend,
6935
- immediate: true
6940
+ immediate: true,
6941
+ perNode: true
6936
6942
  }),
6937
6943
  this.field({
6938
6944
  type: "select",
@@ -6940,7 +6946,8 @@ var DetectionPipelineAddon = class extends BaseAddon {
6940
6946
  label: "Hardware device",
6941
6947
  options: [...STATIC_DEFAULT_DEVICE_OPTIONS],
6942
6948
  default: DEFAULT_CONFIG.engineDevice,
6943
- immediate: true
6949
+ immediate: true,
6950
+ perNode: true
6944
6951
  })
6945
6952
  ]
6946
6953
  }, {
@@ -7069,12 +7076,17 @@ var DetectionPipelineAddon = class extends BaseAddon {
7069
7076
  const env = await this.probeHardwareEnv(targetNode);
7070
7077
  const hardware = env.hardware;
7071
7078
  const offered = supportedRuntimes(env);
7072
- const runtimeBackends = offered.map((id) => ({
7079
+ const runtimeBackends = [{
7080
+ value: "auto",
7081
+ label: "Auto (best for this node)"
7082
+ }, ...offered.map((id) => ({
7073
7083
  value: id,
7074
7084
  label: runtimeLabel(id)
7075
- }));
7085
+ }))];
7076
7086
  const storedBackend = typeof merged.engineBackend === "string" ? merged.engineBackend : "";
7087
+ const isAuto = storedBackend === "auto";
7077
7088
  const backend = (() => {
7089
+ if (isAuto) return pickBestRuntime(env, null).runtimeId;
7078
7090
  const rid = toRuntimeId(storedBackend);
7079
7091
  if (rid === "onnx") return "onnx";
7080
7092
  if (offered.includes(rid)) return rid;
@@ -7085,9 +7097,10 @@ var DetectionPipelineAddon = class extends BaseAddon {
7085
7097
  const device = deviceOptions.find((d) => d.value === storedDevice)?.value ?? defaultDeviceFor(backend);
7086
7098
  const prov = targetNode === this.localNodeId() ? this.provider?.getEngineProvisioning() : void 0;
7087
7099
  const activeEngine = prov && prov.runtimeId ? `${prov.runtimeId}/${prov.device ?? "default"} (${prov.state})` : "";
7100
+ const displayBackend = isAuto ? "auto" : backend;
7088
7101
  const raw = {
7089
7102
  ...merged,
7090
- engineBackend: backend,
7103
+ engineBackend: displayBackend,
7091
7104
  engineDevice: device,
7092
7105
  activeEngine
7093
7106
  };
@@ -7163,14 +7176,37 @@ var DetectionPipelineAddon = class extends BaseAddon {
7163
7176
  const hardware = await this.resolveProbeHardware(node);
7164
7177
  if (hardware) this.lastGoodHardwareByNode.set(node, hardware);
7165
7178
  const effective = hardware ?? this.lastGoodHardwareByNode.get(node) ?? null;
7166
- const useProbedPlatform = node !== this.localNodeId();
7179
+ const isRemote = node !== this.localNodeId();
7180
+ const topo = isRemote ? await this.resolveTopologyPlatform(node) : null;
7167
7181
  return {
7168
- platform: useProbedPlatform && effective?.platform !== void 0 ? effective.platform : process.platform,
7169
- arch: useProbedPlatform && effective?.arch !== void 0 ? effective.arch : process.arch,
7182
+ platform: (isRemote ? topo?.platform ?? effective?.platform : void 0) ?? process.platform,
7183
+ arch: (isRemote ? topo?.arch ?? effective?.arch : void 0) ?? process.arch,
7170
7184
  hardware: effective
7171
7185
  };
7172
7186
  }
7173
7187
  /**
7188
+ * Read the TARGET node's platform/arch from cluster topology — the
7189
+ * registration self-report (`nodes.topology`), which is hub-local and
7190
+ * available immediately, independent of the node's async accelerator probe.
7191
+ * Mirrors the audio analyzer's `resolveNodePlatform` (the D6 reference
7192
+ * pattern) so detection + audio share ONE topology-read path. Returns null
7193
+ * when topology is unreachable (caller falls back to the probe report, then
7194
+ * `process.platform`). Narrows the reported platform to the runtime-relevant
7195
+ * `NodeJS.Platform` values via a type guard (no cast); any other value is
7196
+ * dropped so the caller floors safely.
7197
+ */
7198
+ async resolveTopologyPlatform(nodeId) {
7199
+ try {
7200
+ const picked = pickNodePlatformArch(await this.ctxIfReady?.api?.nodes?.topology.query(), nodeId);
7201
+ return {
7202
+ platform: toNodePlatform(picked.platform),
7203
+ arch: picked.arch
7204
+ };
7205
+ } catch {
7206
+ return null;
7207
+ }
7208
+ }
7209
+ /**
7174
7210
  * Fetch the probed hardware from the platform-probe cap for `nodeId` (default
7175
7211
  * = self). Returns null when the cap is not reachable (caller falls back to
7176
7212
  * the registry's safe minimum). Carries the probed node's `platform`/`arch`
@@ -7182,7 +7218,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7182
7218
  const api = this.ctxIfReady?.api;
7183
7219
  if (!api) return null;
7184
7220
  const node = nodeId ?? this.localNodeId();
7185
- const hw = (node === this.localNodeId() ? await api.platformProbe.getCapabilities.query() : await api.platformProbe.getCapabilities.query(void 0, nodePin(node)))?.hardware;
7221
+ const hw = (node === this.localNodeId() ? await api.platformProbe.getCapabilities.query() : await api.platformProbe.getCapabilities.query({ nodeId: node }))?.hardware;
7186
7222
  if (!hw) return null;
7187
7223
  return {
7188
7224
  npu: hw.npu ? { type: hw.npu.type } : null,
@@ -7207,81 +7243,6 @@ var DetectionPipelineAddon = class extends BaseAddon {
7207
7243
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7208
7244
  }
7209
7245
  /**
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
- /**
7238
- * Refresh this node's cached engine selection from the node-scoped store.
7239
- * `resolveBackendTuning` is synchronous and the reprobe gate runs before the
7240
- * provider exists, so both read these cached fields instead of the
7241
- * cluster-shared bare keys (which belong to no single node). Best-effort: a
7242
- * transiently-unreadable store leaves the last cached values in place.
7243
- */
7244
- async refreshNodeEngineFromStore() {
7245
- const store = await this.resolveUiSettingsStore();
7246
- if (store === null) return;
7247
- const node = this.localNodeId();
7248
- const backend = readNodeEngineValue(store, "engineBackend", node);
7249
- if (typeof backend === "string" && backend !== "") this.nodeEngineBackend = backend;
7250
- const probed = readNodeEngineValue(store, "probedBestEngine", node);
7251
- this.nodeProbedBestEngine = typeof probed === "string" ? probed : "";
7252
- }
7253
- /**
7254
- * Persist a settings patch, mirroring the engine cascade fields to the TARGET
7255
- * node's scoped keys so each node keeps an INDEPENDENT engine selection in the
7256
- * cluster-central store. The hub addon serves writes for every node, so it
7257
- * scopes by the requested `nodeId` (default self).
7258
- *
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.
7269
- */
7270
- async updateGlobalSettings(patch, nodeId) {
7271
- const targetNode = nodeId ? normalizeEngineNodeId(nodeId) : this.localNodeId();
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
- }
7278
- const scopedEngine = {};
7279
- for (const key of ENGINE_CASCADE_KEYS) if (key in patchRecord) scopedEngine[nodeEngineKey(key, targetNode)] = patchRecord[key];
7280
- if (Object.keys(scopedEngine).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scopedEngine);
7281
- const { engineBackend: _engineBackend, engineDevice: _engineDevice, probedBestEngine: _probedBestEngine, ...sharedPatch } = patch;
7282
- await super.updateGlobalSettings(sharedPatch, nodeId);
7283
- }
7284
- /**
7285
7246
  * Resolve the effective pool tuning for the configured backend.
7286
7247
  *
7287
7248
  * Reads the registry's `tuningFor(backend)` and ignores any persisted
@@ -7295,7 +7256,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7295
7256
  * a reason to disagree.
7296
7257
  */
7297
7258
  resolveBackendTuning() {
7298
- const t = tuningFor(toRuntimeId(this.nodeEngineBackend ?? DEFAULT_CONFIG.engineBackend));
7259
+ const t = tuningFor(this.config.engineBackend === "auto" ? this.provider?.resolvedEngineBackend() ?? "onnx" : toRuntimeId(this.config.engineBackend));
7299
7260
  const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7300
7261
  const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7301
7262
  return {
@@ -7308,44 +7269,59 @@ var DetectionPipelineAddon = class extends BaseAddon {
7308
7269
  numWorkers: num(t["numWorkers"], 1)
7309
7270
  };
7310
7271
  }
7272
+ /**
7273
+ * Construct a fresh provider with the given effective tuning. Single source of
7274
+ * truth for the provider wiring — shared by `onInitialize`, the post-resolve
7275
+ * tuning reconciliation, and the `onConfigChanged` pool respawn.
7276
+ */
7277
+ buildProvider(modelsDir, tuning) {
7278
+ if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7279
+ return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7280
+ concurrency: tuning.concurrency,
7281
+ tuning: {
7282
+ batchMode: tuning.batchMode,
7283
+ windowMs: tuning.windowMs,
7284
+ maxBatchSize: tuning.maxBatchSize,
7285
+ numStreams: tuning.numStreams,
7286
+ intraOpThreads: tuning.intraOpThreads
7287
+ },
7288
+ numWorkers: tuning.numWorkers,
7289
+ pythonPath: this.pythonPath,
7290
+ pythonAddonDir: this.pythonAddonDir
7291
+ });
7292
+ }
7293
+ /**
7294
+ * Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
7295
+ * post-resolve / config-change respawn is actually needed).
7296
+ */
7297
+ static poolTuningEqual(a, b) {
7298
+ return a.concurrency === b.concurrency && a.batchMode === b.batchMode && a.windowMs === b.windowMs && a.maxBatchSize === b.maxBatchSize && a.numStreams === b.numStreams && a.intraOpThreads === b.intraOpThreads && a.numWorkers === b.numWorkers;
7299
+ }
7311
7300
  async onInitialize() {
7312
7301
  const modelsDir = await this.resolveModelsDir();
7313
7302
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7314
- await this.migrateLegacyBareEngineKeys();
7315
- await this.refreshNodeEngineFromStore();
7316
7303
  this.pythonAddonDir = resolveAddonPythonDir();
7317
7304
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
7318
7305
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
7319
7306
  else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
7320
7307
  await this.proactivelyInstallOpenvino();
7321
- const effectiveTuning = this.resolveBackendTuning();
7322
- this.provider = new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7323
- concurrency: effectiveTuning.concurrency,
7324
- tuning: {
7325
- batchMode: effectiveTuning.batchMode,
7326
- windowMs: effectiveTuning.windowMs,
7327
- maxBatchSize: effectiveTuning.maxBatchSize,
7328
- numStreams: effectiveTuning.numStreams,
7329
- intraOpThreads: effectiveTuning.intraOpThreads
7330
- },
7331
- numWorkers: effectiveTuning.numWorkers,
7332
- pythonPath: this.pythonPath,
7333
- pythonAddonDir: this.pythonAddonDir
7334
- });
7308
+ const initialTuning = this.resolveBackendTuning();
7309
+ this.provider = this.buildProvider(modelsDir, initialTuning);
7335
7310
  await this.provider.init();
7336
7311
  await this.provider.setApi(this.ctx);
7337
- if (!this.nodeProbedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7312
+ const resolvedTuning = this.resolveBackendTuning();
7313
+ if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
7314
+ this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
7315
+ from: initialTuning,
7316
+ to: resolvedTuning
7317
+ } });
7318
+ this.provider = this.buildProvider(modelsDir, resolvedTuning);
7319
+ await this.provider.init();
7320
+ await this.provider.setApi(this.ctx);
7321
+ }
7322
+ if (!this.config.probedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7338
7323
  this.ctx.logger.warn("auto-reprobe engine failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7339
7324
  });
7340
- await this.provider.ensureBootEngineProvisioned().catch((err) => {
7341
- this.ctx.logger.warn("ensureBootEngineProvisioned failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7342
- });
7343
- this.probePhaseUnsub = this.ctx.eventBus?.subscribe({ category: EventCategory.PlatformProbePhase }, (event) => {
7344
- if (event.data?.phase !== "done") return;
7345
- this.provider?.repickEngineOnProbeReady().catch((err) => {
7346
- this.ctx.logger.warn("repick on platform-probe done failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7347
- });
7348
- }) ?? null;
7349
7325
  await this.provider.warmPool();
7350
7326
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7351
7327
  this.lastAppliedPoolConfig = this.snapshotPoolConfig();
@@ -7399,13 +7375,18 @@ var DetectionPipelineAddon = class extends BaseAddon {
7399
7375
  * first loaded (gated by `loadEngine`'s `isPythonBackendAvailable` check).
7400
7376
  *
7401
7377
  * Failure is non-fatal: a warning is logged and the addon continues with
7402
- * the onnx-cpu baseline. The hardware query itself is also best-effort —
7403
- * if the platform-probe cap isn't wired yet the error is caught here.
7378
+ * the onnx-cpu baseline. The hardware resolve itself is also best-effort —
7379
+ * if the kernel resolver isn't wired yet the error is caught here.
7404
7380
  */
7405
7381
  async proactivelyInstallOpenvino() {
7406
7382
  if (!this.pythonAddonDir || !this.pythonPath) return;
7407
7383
  try {
7408
- const hardware = await this.ctx.api.platformProbe.getHardware.query();
7384
+ const hw = await this.ctx.kernel?.inferenceEngine?.resolveHardware();
7385
+ const hardware = {
7386
+ platform: process.platform,
7387
+ gpu: hw?.gpu ?? null,
7388
+ npu: hw?.npu ?? null
7389
+ };
7409
7390
  if (!shouldInstallOpenvino(hardware)) return;
7410
7391
  this.ctx.logger.info("Intel hardware detected — proactively installing OpenVINO Python package", { meta: {
7411
7392
  gpu: hardware.gpu?.type ?? null,
@@ -7419,10 +7400,6 @@ var DetectionPipelineAddon = class extends BaseAddon {
7419
7400
  }
7420
7401
  }
7421
7402
  async onShutdown() {
7422
- if (this.probePhaseUnsub) {
7423
- this.probePhaseUnsub();
7424
- this.probePhaseUnsub = null;
7425
- }
7426
7403
  if (this.engineMetricsTimer) {
7427
7404
  clearInterval(this.engineMetricsTimer);
7428
7405
  this.engineMetricsTimer = null;
@@ -7470,7 +7447,6 @@ var DetectionPipelineAddon = class extends BaseAddon {
7470
7447
  * re-spin, frames are dropped (never crashed), no addon bounce.
7471
7448
  */
7472
7449
  async onConfigChanged() {
7473
- await this.refreshNodeEngineFromStore();
7474
7450
  if (this.provider) await this.provider.onEngineSelectionChanged().catch((err) => {
7475
7451
  this.ctx.logger.warn("engine provisioning re-select failed on config change", { meta: { error: err instanceof Error ? err.message : String(err) } });
7476
7452
  });
@@ -7489,21 +7465,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7489
7465
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
7490
7466
  }
7491
7467
  const modelsDir = await this.resolveModelsDir();
7492
- if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available during respawn");
7493
- const effectiveTuning = this.resolveBackendTuning();
7494
- this.provider = new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7495
- concurrency: effectiveTuning.concurrency,
7496
- tuning: {
7497
- batchMode: effectiveTuning.batchMode,
7498
- windowMs: effectiveTuning.windowMs,
7499
- maxBatchSize: effectiveTuning.maxBatchSize,
7500
- numStreams: effectiveTuning.numStreams,
7501
- intraOpThreads: effectiveTuning.intraOpThreads
7502
- },
7503
- numWorkers: effectiveTuning.numWorkers,
7504
- pythonPath: this.pythonPath,
7505
- pythonAddonDir: this.pythonAddonDir
7506
- });
7468
+ this.provider = this.buildProvider(modelsDir, this.resolveBackendTuning());
7507
7469
  await this.provider.init();
7508
7470
  await this.provider.setApi(this.ctx);
7509
7471
  await this.provider.warmPool();