@camstack/addon-pipeline-orchestrator 1.2.181 → 1.2.182

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17097,6 +17097,7 @@ var NcSystemEventKindSchema = _enum([
17097
17097
  "addon-crash-loop",
17098
17098
  "addon-update-available",
17099
17099
  "server-update-available",
17100
+ "wrapper-update-available",
17100
17101
  "alarm-triggered",
17101
17102
  "alarm-armed",
17102
17103
  "alarm-disarmed",
@@ -23355,6 +23356,53 @@ var ImageContractSchema = object({
23355
23356
  /** One operator-grade sentence: this node runs image X; the contract says Y. */
23356
23357
  message: string()
23357
23358
  });
23359
+ /**
23360
+ * The shell verdict (see {@link WrapperKindSchema}) with the proofs that
23361
+ * produced it.
23362
+ *
23363
+ * There is deliberately NO `canSelfUpdate` and no apply action: a wrapper
23364
+ * update is an ANNOUNCEMENT. The notification body carries the exact command
23365
+ * for a `docker` shell and says the app restarts into the new build for
23366
+ * `electron`, and that sentence is chosen from `kind` alone — a boolean
23367
+ * nothing reads is the defect this repo keeps paying for.
23368
+ */
23369
+ var WrapperIdentitySchema = object({
23370
+ kind: _enum([
23371
+ "docker",
23372
+ "electron",
23373
+ "native",
23374
+ "contradictory",
23375
+ "unknown"
23376
+ ]),
23377
+ /** Every probe that ran, in a stable order. Never empty. */
23378
+ evidence: array(object({
23379
+ /** Which probe this row reports. */
23380
+ fact: _enum([
23381
+ "dockerenv-file",
23382
+ "proc-1-cgroup",
23383
+ "platform",
23384
+ "electron-app-version-env"
23385
+ ]),
23386
+ /**
23387
+ * `observed` — this process read it itself. `declared` — the shell told the
23388
+ * process and nothing here can check it. Only the Electron app version is
23389
+ * ever `declared`, and only because a child cannot see its parent.
23390
+ */
23391
+ mode: _enum(["observed", "declared"]),
23392
+ /** Did the probe support the fact it names? A `false` row is still evidence. */
23393
+ holds: boolean(),
23394
+ /** What was actually read, verbatim enough for an operator to argue with. */
23395
+ detail: string()
23396
+ })),
23397
+ /**
23398
+ * The version of the SHELL, when the shell has one it can state: the
23399
+ * Electron app version it declared, or the baked seed closure that
23400
+ * fingerprints a container / app bundle. `null` for `contradictory` and
23401
+ * `unknown` — which version belongs to the shell is precisely what is not
23402
+ * known there.
23403
+ */
23404
+ currentVersion: string().nullable()
23405
+ });
23358
23406
  var ServerRollbackInfoSchema = object({
23359
23407
  /** The version that failed (or was manually rolled back). */
23360
23408
  fromVersion: string(),
@@ -23396,7 +23444,13 @@ var ServerPackageStatusSchema = object({
23396
23444
  * Seed-vs-contract verdict (see {@link ImageContractSchema}). Optional for
23397
23445
  * version skew: an older provider's payload simply omits it.
23398
23446
  */
23399
- imageContract: ImageContractSchema.optional()
23447
+ imageContract: ImageContractSchema.optional(),
23448
+ /**
23449
+ * What this node runs INSIDE (see {@link WrapperIdentitySchema}). Optional
23450
+ * for version skew: an older provider's payload simply omits it, and an
23451
+ * absent wrapper is unknown — never `native`.
23452
+ */
23453
+ wrapper: WrapperIdentitySchema.optional()
23400
23454
  });
23401
23455
  var ServerUpdateCheckResultSchema = object({
23402
23456
  packageName: string(),
@@ -42925,6 +42979,52 @@ var LoadShedController = class LoadShedController {
42925
42979
  }
42926
42980
  };
42927
42981
  //#endregion
42982
+ //#region src/dispatch-timing.ts
42983
+ /**
42984
+ * How long the placement census waits for ONE runner's `getLocalCameras`
42985
+ * before counting that node as not having reported. A node that did not
42986
+ * report never has its assignments dropped (`reconcilePlacement` says so), so
42987
+ * skipping a slow node costs one pass of orphan cleanup on that node, not
42988
+ * correctness. The census fans out in parallel, so this bounds the whole
42989
+ * census at one budget rather than one budget per node.
42990
+ */
42991
+ var RECONCILE_NODE_BUDGET_MS = 1500;
42992
+ /**
42993
+ * Wait for `promise`, but never longer than `budgetMs`.
42994
+ *
42995
+ * The promise is NOT cancelled — it keeps running, and its eventual rejection
42996
+ * is observed here so an overrun never becomes an unhandled rejection. The
42997
+ * caller decides what an overrun means; this only says that it happened.
42998
+ */
42999
+ function raceBudget(promise, budgetMs) {
43000
+ return new Promise((resolve) => {
43001
+ let done = false;
43002
+ const timer = setTimeout(() => {
43003
+ if (done) return;
43004
+ done = true;
43005
+ resolve({ kind: "budget-exceeded" });
43006
+ }, budgetMs);
43007
+ timer.unref?.();
43008
+ promise.then((value) => {
43009
+ if (done) return;
43010
+ done = true;
43011
+ clearTimeout(timer);
43012
+ resolve({
43013
+ kind: "settled",
43014
+ value
43015
+ });
43016
+ }, (error) => {
43017
+ if (done) return;
43018
+ done = true;
43019
+ clearTimeout(timer);
43020
+ resolve({
43021
+ kind: "rejected",
43022
+ error
43023
+ });
43024
+ });
43025
+ });
43026
+ }
43027
+ //#endregion
42928
43028
  //#region src/agent-load-service.ts
42929
43029
  var AgentLoadService = class AgentLoadService {
42930
43030
  deps;
@@ -42962,28 +43062,69 @@ var AgentLoadService = class AgentLoadService {
42962
43062
  * collects every known runner, which is what the UI wants.
42963
43063
  */
42964
43064
  async collectAgentLoad(options) {
42965
- const loads = [];
42966
43065
  const onlyEnabled = options?.onlyEnabled ?? false;
42967
- for (const nodeId of this.deps.topology.knownRunnerNodeIds()) {
42968
- if (onlyEnabled && !this.deps.topology.isNodeEnabled(nodeId)) {
42969
- this.deps.logger.debug("runner excluded by enabledNodes whitelist", { tags: { nodeId } });
42970
- continue;
42971
- }
42972
- if (!this.deps.api()) continue;
42973
- try {
42974
- const load = await this.queryLocalLoadBounded(nodeId);
42975
- loads.push(load);
42976
- } catch (err) {
42977
- const msg = errMsg(err);
42978
- this.deps.logger.debug("getLocalLoad failed", {
42979
- tags: { nodeId },
42980
- meta: { error: msg }
42981
- });
42982
- }
43066
+ const nodeIds = this.deps.topology.knownRunnerNodeIds().filter((nodeId) => {
43067
+ if (!onlyEnabled || this.deps.topology.isNodeEnabled(nodeId)) return true;
43068
+ this.deps.logger.debug("runner excluded by enabledNodes whitelist", { tags: { nodeId } });
43069
+ return false;
43070
+ });
43071
+ const budgetMs = this.agentLoadBudgetMs();
43072
+ const entries = await Promise.all(nodeIds.map((nodeId) => this.censusNode(nodeId, budgetMs)));
43073
+ const loads = entries.flatMap((e) => e.load === null ? [] : [e.load]);
43074
+ const skipped = entries.filter((e) => e.load === null).map((e) => e.nodeId);
43075
+ if (skipped.length > 0) {
43076
+ const nodes = Object.fromEntries(entries.map((e) => [e.nodeId, {
43077
+ ms: e.ms,
43078
+ outcome: e.outcome,
43079
+ ...e.error === void 0 ? {} : { error: e.error }
43080
+ }]));
43081
+ this.deps.logger.warn("agent load census skipped a runner", { meta: {
43082
+ budgetMs,
43083
+ skipped,
43084
+ nodes
43085
+ } });
42983
43086
  }
42984
43087
  this.refreshAgentLoadCache(loads);
42985
43088
  return loads;
42986
43089
  }
43090
+ /**
43091
+ * One runner's load under `budgetMs`. A wedged runner (transport up enough
43092
+ * to stay in the registry, but whose call neither resolves nor rejects) is
43093
+ * counted as `budget-exceeded` and skipped exactly like an offline one; the
43094
+ * in-flight call is left to settle on its own, as before.
43095
+ */
43096
+ async censusNode(nodeId, budgetMs) {
43097
+ const api = this.deps.api();
43098
+ const startedAt = Date.now();
43099
+ if (!api) return {
43100
+ nodeId,
43101
+ ms: 0,
43102
+ outcome: "failed",
43103
+ load: null,
43104
+ error: "addon not initialized"
43105
+ };
43106
+ const outcome = await raceBudget(api.pipelineRunner.getLocalLoad.query({ nodeId }), budgetMs);
43107
+ const ms = Date.now() - startedAt;
43108
+ if (outcome.kind === "settled") return {
43109
+ nodeId,
43110
+ ms,
43111
+ outcome: "reported",
43112
+ load: outcome.value
43113
+ };
43114
+ if (outcome.kind === "rejected") return {
43115
+ nodeId,
43116
+ ms,
43117
+ outcome: "failed",
43118
+ load: null,
43119
+ error: errMsg(outcome.error)
43120
+ };
43121
+ return {
43122
+ nodeId,
43123
+ ms,
43124
+ outcome: "budget-exceeded",
43125
+ load: null
43126
+ };
43127
+ }
42987
43128
  refreshAgentLoadCache(loads) {
42988
43129
  const next = /* @__PURE__ */ new Map();
42989
43130
  for (const load of loads) next.set(load.nodeId, {
@@ -43030,25 +43171,6 @@ var AgentLoadService = class AgentLoadService {
43030
43171
  const value = (this.deps.readGlobalSettings() ?? {}).agentLoadTimeoutMs;
43031
43172
  return typeof value === "number" && value > 0 ? value : AgentLoadService.DEFAULT_AGENT_LOAD_TIMEOUT_MS;
43032
43173
  }
43033
- /**
43034
- * `getLocalLoad` for one runner node, bounded by `agentLoadBudgetMs`. On
43035
- * timeout it rejects so `collectAgentLoad`'s catch treats the node exactly
43036
- * like an offline one (logged at debug, skipped).
43037
- */
43038
- async queryLocalLoadBounded(nodeId) {
43039
- const api = this.deps.api();
43040
- if (!api) throw new Error("queryLocalLoadBounded: addon not initialized");
43041
- const budgetMs = this.agentLoadBudgetMs();
43042
- let timer;
43043
- const timeout = new Promise((_resolve, reject) => {
43044
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`getLocalLoad exceeded ${budgetMs}ms budget for ${nodeId}`)), budgetMs);
43045
- });
43046
- try {
43047
- return await Promise.race([api.pipelineRunner.getLocalLoad.query({ nodeId }), timeout]);
43048
- } finally {
43049
- if (timer) clearTimeout(timer);
43050
- }
43051
- }
43052
43174
  /** Mirrors the former `onShutdown`'s `this.cachedAgentLoad.clear()`. */
43053
43175
  dispose() {
43054
43176
  this.cachedAgentLoad.clear();
@@ -50619,19 +50741,44 @@ var ReconcileController = class ReconcileController {
50619
50741
  async runPlacementReconcile() {
50620
50742
  const api = this.deps.api();
50621
50743
  if (!api) return;
50744
+ const censusStartedAt = Date.now();
50622
50745
  const runners = [];
50623
- for (const nodeId of this.deps.topology.collectRunnerNodeIds()) try {
50624
- const attached = await api.pipelineRunner.getLocalCameras.query(void 0, nodePin(nodeId));
50625
- runners.push({
50746
+ const census = [];
50747
+ await Promise.all(this.deps.topology.collectRunnerNodeIds().map(async (nodeId) => {
50748
+ const startedAt = Date.now();
50749
+ const outcome = await raceBudget(api.pipelineRunner.getLocalCameras.query(void 0, nodePin(nodeId)), RECONCILE_NODE_BUDGET_MS);
50750
+ const ms = Date.now() - startedAt;
50751
+ if (outcome.kind === "settled") {
50752
+ runners.push({
50753
+ nodeId,
50754
+ attached: outcome.value
50755
+ });
50756
+ census.push({
50757
+ nodeId,
50758
+ ms,
50759
+ outcome: "reported"
50760
+ });
50761
+ return;
50762
+ }
50763
+ census.push({
50626
50764
  nodeId,
50627
- attached
50765
+ ms,
50766
+ outcome: outcome.kind === "rejected" ? "failed" : "budget-exceeded"
50628
50767
  });
50629
- } catch (err) {
50630
50768
  this.deps.logger.debug("placement reconcile skipped node — getLocalCameras failed", {
50631
50769
  tags: { nodeId },
50632
- meta: { error: errMsg(err) }
50770
+ meta: {
50771
+ error: outcome.kind === "rejected" ? errMsg(outcome.error) : "budget exceeded",
50772
+ ms
50773
+ }
50633
50774
  });
50634
- }
50775
+ }));
50776
+ const censusMs = Date.now() - censusStartedAt;
50777
+ if (censusMs >= 1e3 || census.some((entry) => entry.outcome !== "reported")) this.deps.logger.info("placement reconcile: runner census was slow", { meta: {
50778
+ censusMs,
50779
+ nodes: census,
50780
+ budgetMs: RECONCILE_NODE_BUDGET_MS
50781
+ } });
50635
50782
  const { assignedNodeById, dispatching } = this.deps.ledger.snapshotForReconcile();
50636
50783
  const idleSessionDeviceIds = new Set([...this.deps.activeDeviceIds()].filter((deviceId) => this.deps.isIdleSessionCamera(deviceId)));
50637
50784
  const motionWatchAttachments = /* @__PURE__ */ new Set();
@@ -50645,7 +50792,7 @@ var ReconcileController = class ReconcileController {
50645
50792
  });
50646
50793
  if (detach.length > 0) {
50647
50794
  this.deps.logger.info("placement reconcile: detaching orphans/duplicates", { meta: { detach } });
50648
- for (const { nodeId, deviceId } of detach) await this.deps.detachOn(nodeId, deviceId).catch((err) => {
50795
+ await Promise.all(detach.map(({ nodeId, deviceId }) => this.deps.detachOn(nodeId, deviceId).catch((err) => {
50649
50796
  this.deps.logger.warn("placement reconcile: detach failed", {
50650
50797
  tags: {
50651
50798
  nodeId,
@@ -50653,7 +50800,7 @@ var ReconcileController = class ReconcileController {
50653
50800
  },
50654
50801
  meta: { error: errMsg(err) }
50655
50802
  });
50656
- });
50803
+ })));
50657
50804
  }
50658
50805
  if (dropStaleAssignment.length > 0) {
50659
50806
  this.deps.logger.info("placement reconcile: dropping stale assignments", { meta: { deviceIds: dropStaleAssignment } });
@@ -51043,6 +51190,13 @@ var TtlMemo = class {
51043
51190
  * declared wrong.
51044
51191
  */
51045
51192
  generations = /* @__PURE__ */ new Map();
51193
+ /**
51194
+ * The last value ANY read produced for a key, kept across
51195
+ * {@link invalidate}. Never served by {@link get}; it is the bounded fallback
51196
+ * of {@link getWithin} alone (D441) — a caller that would rather balance on a
51197
+ * snapshot a few seconds old than wait seconds for the re-read.
51198
+ */
51199
+ lastKnown = /* @__PURE__ */ new Map();
51046
51200
  ttlMs;
51047
51201
  now;
51048
51202
  constructor(opts) {
@@ -51076,6 +51230,50 @@ var TtlMemo = class {
51076
51230
  }
51077
51231
  }
51078
51232
  /**
51233
+ * The value for `key` within `budgetMs`: from memory when fresh, from `load`
51234
+ * when the read lands in time, and otherwise the LAST KNOWN value —
51235
+ * invalidated or not — with its age (D441).
51236
+ *
51237
+ * Three properties, each pinned by a test:
51238
+ * - a read that overruns the budget is NOT abandoned: it keeps running and
51239
+ * stores its result for the next caller, so a stale answer is served at
51240
+ * most once per read;
51241
+ * - a COLD key waits for the read past the budget, because a cold cache must
51242
+ * never be the reason a camera fails to dispatch (the `get` contract);
51243
+ * - a read that FAILS with a last-known value in hand serves it as stale.
51244
+ */
51245
+ async getWithin(key, budgetMs) {
51246
+ const at = this.now();
51247
+ const entry = this.entries.get(key);
51248
+ if (entry) this.entries.set(key, {
51249
+ ...entry,
51250
+ usedAt: at
51251
+ });
51252
+ if (entry && at - entry.readAt < this.ttlMs) return {
51253
+ value: entry.value,
51254
+ stale: false,
51255
+ ageMs: at - entry.readAt
51256
+ };
51257
+ const fallback = this.lastKnown.get(key);
51258
+ const pending = this.read(key);
51259
+ if (fallback === void 0) return {
51260
+ value: await pending,
51261
+ stale: false,
51262
+ ageMs: 0
51263
+ };
51264
+ const outcome = await raceBudget(pending, budgetMs);
51265
+ if (outcome.kind === "settled") return {
51266
+ value: outcome.value,
51267
+ stale: false,
51268
+ ageMs: 0
51269
+ };
51270
+ return {
51271
+ value: fallback.value,
51272
+ stale: true,
51273
+ ageMs: this.now() - fallback.readAt
51274
+ };
51275
+ }
51276
+ /**
51079
51277
  * Declare the cached answer for `key` WRONG — not merely old.
51080
51278
  *
51081
51279
  * The caller has just changed the thing being cached (an attach or a detach
@@ -51120,6 +51318,7 @@ var TtlMemo = class {
51120
51318
  this.entries.clear();
51121
51319
  this.inFlight.clear();
51122
51320
  this.generations.clear();
51321
+ this.lastKnown.clear();
51123
51322
  }
51124
51323
  /** One shared in-flight read per key; stores the result on success. */
51125
51324
  read(key) {
@@ -51127,14 +51326,15 @@ var TtlMemo = class {
51127
51326
  if (existing) return existing;
51128
51327
  const generation = this.generations.get(key) ?? 0;
51129
51328
  const pending = this.opts.load(key).then((value) => {
51130
- if ((this.generations.get(key) ?? 0) !== generation) return value;
51131
51329
  const at = this.now();
51132
- const prior = this.entries.get(key);
51133
- this.entries.set(key, {
51330
+ const stored = {
51134
51331
  value,
51135
51332
  readAt: at,
51136
- usedAt: prior?.usedAt ?? at
51137
- });
51333
+ usedAt: this.entries.get(key)?.usedAt ?? at
51334
+ };
51335
+ this.lastKnown.set(key, stored);
51336
+ if ((this.generations.get(key) ?? 0) !== generation) return value;
51337
+ this.entries.set(key, stored);
51138
51338
  return value;
51139
51339
  }).finally(() => {
51140
51340
  if (this.inFlight.get(key) === pending) this.inFlight.delete(key);
@@ -51602,7 +51802,7 @@ var SessionDispatchController = class {
51602
51802
  return;
51603
51803
  }
51604
51804
  this.activeRefireCountByDevice.delete(deviceId);
51605
- await this.dispatchDetectionSession(deviceId, cur, trigger);
51805
+ const timings = await this.dispatchDetectionSession(deviceId, cur, trigger);
51606
51806
  if (this.sessionRegistry.has(deviceId)) this.scheduleSessionTeardown(deviceId, cooldownMs);
51607
51807
  const doneAt = Date.now();
51608
51808
  this.deps.logger.info("session motion → attach latency", {
@@ -51611,6 +51811,7 @@ var SessionDispatchController = class {
51611
51811
  ...busLagMs !== void 0 ? { busLagMs } : {},
51612
51812
  lockWaitMs: lockAcquiredAt - lockRequestedAt,
51613
51813
  dispatchMs: doneAt - lockAcquiredAt,
51814
+ ...timings,
51614
51815
  totalMs: doneAt - receivedAt,
51615
51816
  ...emittedAt !== void 0 ? { sinceMotionMs: doneAt - emittedAt } : {}
51616
51817
  }
@@ -51643,9 +51844,32 @@ var SessionDispatchController = class {
51643
51844
  */
51644
51845
  async dispatchDetectionSession(deviceId, config, trigger) {
51645
51846
  const log = this.deps.logger.withTags({ deviceId });
51646
- await this.deps.reconcilePlacementFromRunners();
51847
+ const reconcileStartedAt = Date.now();
51848
+ const census = this.deps.reconcilePlacementFromRunners();
51849
+ const censusOutcome = await raceBudget(census, 500);
51850
+ const reconcileMs = Date.now() - reconcileStartedAt;
51851
+ const reconcileBudgetExceeded = censusOutcome.kind === "budget-exceeded";
51852
+ const dispatchGuard = this.deps.ledger.markDispatching(deviceId);
51853
+ const releaseGuard = () => {
51854
+ if (!reconcileBudgetExceeded) {
51855
+ dispatchGuard.dispose();
51856
+ return;
51857
+ }
51858
+ census.finally(() => dispatchGuard.dispose());
51859
+ };
51860
+ if (reconcileBudgetExceeded) log.warn("detection session: placement census overran its budget — placing without it", { meta: { budgetMs: 500 } });
51861
+ else if (censusOutcome.kind === "rejected") log.warn("detection session: placement census failed — placing on the ledger as it is", { meta: {
51862
+ error: errMsg(censusOutcome.error),
51863
+ reconcileMs
51864
+ } });
51865
+ const balanceStartedAt = Date.now();
51647
51866
  const preferredAgent = await this.deps.readPipelinePin(deviceId);
51648
- const loads = await this.agentLoads.get(CLUSTER_KEY);
51867
+ const loadAnswer = await this.agentLoads.getWithin(CLUSTER_KEY, 500);
51868
+ const loads = loadAnswer.value;
51869
+ if (loadAnswer.stale) log.warn("detection session: agent load read overran its budget — balancing on the last known load", { meta: {
51870
+ budgetMs: 500,
51871
+ loadAgeMs: loadAnswer.ageMs
51872
+ } });
51649
51873
  const allEligible = this.deps.topology.detectionEligibleNodes(deviceId);
51650
51874
  const usableEligible = allEligible.filter((n) => this.deps.isNodeInferenceUsable(n));
51651
51875
  const eligible = usableEligible.length > 0 ? usableEligible : allEligible;
@@ -51659,9 +51883,11 @@ var SessionDispatchController = class {
51659
51883
  });
51660
51884
  if (!decision || decision.kind !== "assigned") {
51661
51885
  log.warn("detection session: no eligible node — will retry on next motion", { meta: { reason: decision?.kind === "pending" ? decision.reason : "no-runners-online" } });
51662
- return;
51886
+ releaseGuard();
51887
+ return null;
51663
51888
  }
51664
51889
  const targetNodeId = decision.agentNodeId;
51890
+ const balancedAt = Date.now();
51665
51891
  const enabledDevices = await this.eligibleInferenceDevices.get(targetNodeId);
51666
51892
  const targetLoad = loads.find((n) => n.nodeId === targetNodeId);
51667
51893
  const deviceCaps = await this.inferenceDeviceCaps.get(targetNodeId);
@@ -51691,6 +51917,7 @@ var SessionDispatchController = class {
51691
51917
  ...pinnedDeviceKey ? { pin: pinnedDeviceKey } : {}
51692
51918
  }
51693
51919
  });
51920
+ const deviceSelectedAt = Date.now();
51694
51921
  const pipelineConfig = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, targetNodeId, deviceKey);
51695
51922
  const deviceBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(targetNodeId, deviceKey ?? "");
51696
51923
  const deviceOverride = deviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(deviceId, targetNodeId, deviceKey) : {};
@@ -51726,9 +51953,11 @@ var SessionDispatchController = class {
51726
51953
  preRollInferenceEnabled: config.preRollInferenceEnabled,
51727
51954
  preRollInferenceFrames: config.preRollInferenceFrames
51728
51955
  };
51729
- const dispatchGuard = this.deps.ledger.markDispatching(deviceId);
51956
+ const configuredAt = Date.now();
51957
+ let attachedAt = configuredAt;
51730
51958
  try {
51731
51959
  await this.deps.attachOn(targetNodeId, sessionConfig);
51960
+ attachedAt = Date.now();
51732
51961
  this.deps.recordAssignment(deviceId, targetNodeId, decision.reason, decision.reason === "manual");
51733
51962
  this.sessionRegistry.register(deviceId, targetNodeId, Date.now(), deviceKey);
51734
51963
  log.info("detection session: dispatched on motion", {
@@ -51744,9 +51973,18 @@ var SessionDispatchController = class {
51744
51973
  }
51745
51974
  });
51746
51975
  } finally {
51747
- dispatchGuard.dispose();
51976
+ releaseGuard();
51748
51977
  this.notePlacementChanged();
51749
51978
  }
51979
+ return {
51980
+ reconcileMs,
51981
+ reconcileBudgetExceeded,
51982
+ balanceMs: balancedAt - balanceStartedAt,
51983
+ balanceLoadStale: loadAnswer.stale,
51984
+ deviceSelectMs: deviceSelectedAt - balancedAt,
51985
+ configMs: configuredAt - deviceSelectedAt,
51986
+ attachMs: attachedAt - configuredAt
51987
+ };
51750
51988
  }
51751
51989
  /**
51752
51990
  * Schedule a session teardown after `cooldownMs` of no rising-edge motion.