@trillboards/edge-sdk 0.2.140 → 0.2.151

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 (188) hide show
  1. package/README.md +2 -2
  2. package/deploy/detector/pyproject.toml +20 -2
  3. package/deploy/detector/tests/test_execution.py +149 -0
  4. package/deploy/detector/tests/test_model_identity.py +80 -0
  5. package/deploy/detector/tests/test_registry.py +18 -0
  6. package/deploy/detector/tests/test_server.py +3 -1
  7. package/deploy/detector/tests/test_version_stamp.py +4 -1
  8. package/deploy/detector/trillboards_detector/_version.py +2 -2
  9. package/deploy/detector/trillboards_detector/cli.py +3 -0
  10. package/deploy/detector/trillboards_detector/detections.py +7 -0
  11. package/deploy/detector/trillboards_detector/errors.py +4 -0
  12. package/deploy/detector/trillboards_detector/execution.py +110 -0
  13. package/deploy/detector/trillboards_detector/loader.py +6 -3
  14. package/deploy/detector/trillboards_detector/registry.py +27 -5
  15. package/deploy/detector/trillboards_detector/server.py +29 -2
  16. package/deploy/detector/trillboards_detector/session.py +15 -3
  17. package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
  18. package/deploy/litertlm/test_verify_model_proof.py +30 -0
  19. package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
  20. package/deploy/litertlm/verify_model_proof.py +30 -0
  21. package/deploy/macos/build-macos-pkg.sh +10 -10
  22. package/deploy/macos/scripts/setup-detector.sh +2 -2
  23. package/deploy/macos/scripts/setup-litertlm.sh +66 -30
  24. package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
  25. package/deploy/systemd/setup-detector.sh +2 -2
  26. package/deploy/systemd/setup-litertlm.sh +51 -27
  27. package/deploy/windows/build-windows.ps1 +10 -6
  28. package/deploy/windows/bundle-windows-installer.ps1 +3 -3
  29. package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
  30. package/deploy/windows/install-update.ps1 +59 -14
  31. package/deploy/windows/set-executable-icon.ps1 +24 -5
  32. package/deploy/windows/setup-detector.ps1 +142 -4
  33. package/deploy/windows/setup-litertlm.ps1 +53 -10
  34. package/dist/AudienceAnalyzeClient.d.ts +10 -0
  35. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  36. package/dist/AudienceAnalyzeClient.js +5 -4
  37. package/dist/AudienceAnalyzeClient.js.map +1 -1
  38. package/dist/CloudSensingManager.d.ts +38 -1
  39. package/dist/CloudSensingManager.d.ts.map +1 -1
  40. package/dist/CloudSensingManager.js +403 -25
  41. package/dist/CloudSensingManager.js.map +1 -1
  42. package/dist/DetectorFieldProducer.d.ts +5 -1
  43. package/dist/DetectorFieldProducer.d.ts.map +1 -1
  44. package/dist/DetectorFieldProducer.js +8 -1
  45. package/dist/DetectorFieldProducer.js.map +1 -1
  46. package/dist/DurableVlmCandidateSpool.d.ts +83 -2
  47. package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
  48. package/dist/DurableVlmCandidateSpool.js +596 -30
  49. package/dist/DurableVlmCandidateSpool.js.map +1 -1
  50. package/dist/EdgeAgent.js +1 -1
  51. package/dist/FrameGroupingShadow.d.ts +51 -0
  52. package/dist/FrameGroupingShadow.d.ts.map +1 -0
  53. package/dist/FrameGroupingShadow.js +158 -0
  54. package/dist/FrameGroupingShadow.js.map +1 -0
  55. package/dist/ImageClassifierFieldProducer.d.ts +36 -0
  56. package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
  57. package/dist/ImageClassifierFieldProducer.js +150 -0
  58. package/dist/ImageClassifierFieldProducer.js.map +1 -0
  59. package/dist/LiteRtVlmProvider.d.ts +5 -0
  60. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  61. package/dist/LiteRtVlmProvider.js +72 -7
  62. package/dist/LiteRtVlmProvider.js.map +1 -1
  63. package/dist/LocalDetectorClient.d.ts +6 -0
  64. package/dist/LocalDetectorClient.d.ts.map +1 -1
  65. package/dist/LocalDetectorClient.js +8 -0
  66. package/dist/LocalDetectorClient.js.map +1 -1
  67. package/dist/OrderedFrameReview.d.ts +57 -0
  68. package/dist/OrderedFrameReview.d.ts.map +1 -0
  69. package/dist/OrderedFrameReview.js +121 -0
  70. package/dist/OrderedFrameReview.js.map +1 -0
  71. package/dist/OrderedReviewClient.d.ts +38 -0
  72. package/dist/OrderedReviewClient.d.ts.map +1 -0
  73. package/dist/OrderedReviewClient.js +121 -0
  74. package/dist/OrderedReviewClient.js.map +1 -0
  75. package/dist/OrderedSequenceStore.d.ts +69 -0
  76. package/dist/OrderedSequenceStore.d.ts.map +1 -0
  77. package/dist/OrderedSequenceStore.js +257 -0
  78. package/dist/OrderedSequenceStore.js.map +1 -0
  79. package/dist/ProfileReviewWork.d.ts +68 -0
  80. package/dist/ProfileReviewWork.d.ts.map +1 -0
  81. package/dist/ProfileReviewWork.js +150 -0
  82. package/dist/ProfileReviewWork.js.map +1 -0
  83. package/dist/RetainedFieldContract.d.ts +21 -0
  84. package/dist/RetainedFieldContract.d.ts.map +1 -0
  85. package/dist/RetainedFieldContract.js +92 -0
  86. package/dist/RetainedFieldContract.js.map +1 -0
  87. package/dist/ReviewModelUse.d.ts +7 -0
  88. package/dist/ReviewModelUse.d.ts.map +1 -0
  89. package/dist/ReviewModelUse.js +30 -0
  90. package/dist/ReviewModelUse.js.map +1 -0
  91. package/dist/ReviewPriority.d.ts +12 -0
  92. package/dist/ReviewPriority.d.ts.map +1 -0
  93. package/dist/ReviewPriority.js +45 -0
  94. package/dist/ReviewPriority.js.map +1 -0
  95. package/dist/RuntimeModelSelection.d.ts +10 -0
  96. package/dist/RuntimeModelSelection.d.ts.map +1 -0
  97. package/dist/RuntimeModelSelection.js +58 -0
  98. package/dist/RuntimeModelSelection.js.map +1 -0
  99. package/dist/SceneChangeDetector.d.ts +1 -1
  100. package/dist/SceneChangeDetector.d.ts.map +1 -1
  101. package/dist/SceneChangeDetector.js +18 -6
  102. package/dist/SceneChangeDetector.js.map +1 -1
  103. package/dist/ScopedVlmReview.d.ts +76 -0
  104. package/dist/ScopedVlmReview.d.ts.map +1 -0
  105. package/dist/ScopedVlmReview.js +267 -0
  106. package/dist/ScopedVlmReview.js.map +1 -0
  107. package/dist/SensingProfileClient.d.ts +50 -0
  108. package/dist/SensingProfileClient.d.ts.map +1 -1
  109. package/dist/SensingProfileClient.js +58 -2
  110. package/dist/SensingProfileClient.js.map +1 -1
  111. package/dist/TemporalReviewClient.d.ts +43 -0
  112. package/dist/TemporalReviewClient.d.ts.map +1 -0
  113. package/dist/TemporalReviewClient.js +117 -0
  114. package/dist/TemporalReviewClient.js.map +1 -0
  115. package/dist/TemporalReviewJournal.d.ts +91 -0
  116. package/dist/TemporalReviewJournal.d.ts.map +1 -0
  117. package/dist/TemporalReviewJournal.js +337 -0
  118. package/dist/TemporalReviewJournal.js.map +1 -0
  119. package/dist/UrgentReviewTriage.d.ts +35 -0
  120. package/dist/UrgentReviewTriage.d.ts.map +1 -0
  121. package/dist/UrgentReviewTriage.js +52 -0
  122. package/dist/UrgentReviewTriage.js.map +1 -0
  123. package/dist/VideoSegmentUploader.d.ts +293 -0
  124. package/dist/VideoSegmentUploader.d.ts.map +1 -0
  125. package/dist/VideoSegmentUploader.js +1002 -0
  126. package/dist/VideoSegmentUploader.js.map +1 -0
  127. package/dist/VlmCandidateBuffer.d.ts +19 -0
  128. package/dist/VlmCandidateBuffer.d.ts.map +1 -1
  129. package/dist/VlmCandidateBuffer.js.map +1 -1
  130. package/dist/autoSense.d.ts +2 -2
  131. package/dist/autoSense.d.ts.map +1 -1
  132. package/dist/autoSense.js +16 -11
  133. package/dist/autoSense.js.map +1 -1
  134. package/dist/cli.js +1 -1
  135. package/dist/cli.js.map +1 -1
  136. package/dist/config.d.ts +2 -0
  137. package/dist/config.d.ts.map +1 -1
  138. package/dist/config.js.map +1 -1
  139. package/dist/continuousReview.d.ts +71 -0
  140. package/dist/continuousReview.d.ts.map +1 -0
  141. package/dist/continuousReview.js +53 -0
  142. package/dist/continuousReview.js.map +1 -0
  143. package/dist/detectorCountReview.d.ts +9 -0
  144. package/dist/detectorCountReview.d.ts.map +1 -0
  145. package/dist/detectorCountReview.js +79 -0
  146. package/dist/detectorCountReview.js.map +1 -0
  147. package/dist/diagnose.d.ts +1 -1
  148. package/dist/diagnose.js +1 -1
  149. package/dist/installerOnboardingMode.d.ts.map +1 -1
  150. package/dist/installerOnboardingMode.js +5 -3
  151. package/dist/installerOnboardingMode.js.map +1 -1
  152. package/dist/localInferenceSchema.d.ts +8 -0
  153. package/dist/localInferenceSchema.d.ts.map +1 -1
  154. package/dist/localInferenceSchema.js +3 -3
  155. package/dist/localInferenceSchema.js.map +1 -1
  156. package/dist/localSenseE2eHarness.d.ts +12 -0
  157. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  158. package/dist/localSenseE2eHarness.js +77 -4
  159. package/dist/localSenseE2eHarness.js.map +1 -1
  160. package/dist/localSensing.d.ts +91 -18
  161. package/dist/localSensing.d.ts.map +1 -1
  162. package/dist/localSensing.js +678 -153
  163. package/dist/localSensing.js.map +1 -1
  164. package/dist/mixedScopedCounts.d.ts +17 -0
  165. package/dist/mixedScopedCounts.d.ts.map +1 -0
  166. package/dist/mixedScopedCounts.js +90 -0
  167. package/dist/mixedScopedCounts.js.map +1 -0
  168. package/dist/sensingCatchRate.d.ts +9 -5
  169. package/dist/sensingCatchRate.d.ts.map +1 -1
  170. package/dist/sensingCatchRate.js +10 -6
  171. package/dist/sensingCatchRate.js.map +1 -1
  172. package/dist/sensingRunMode.d.ts +5 -0
  173. package/dist/sensingRunMode.d.ts.map +1 -1
  174. package/dist/sensingRunMode.js +56 -0
  175. package/dist/sensingRunMode.js.map +1 -1
  176. package/package.json +7 -4
  177. package/retained/ArchiveLifecycle.cjs +79 -0
  178. package/retained/ModelStageScheduler.cjs +66 -0
  179. package/retained/ProfileAuthorityClient.cjs +88 -0
  180. package/retained/RetainedCaptureClient.cjs +164 -0
  181. package/retained/WaitRecheckCaptureClient.cjs +109 -0
  182. package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
  183. package/retained/__tests__/runtime.test.cjs +384 -0
  184. package/retained/alert-rule-policy.cjs +37 -0
  185. package/retained/manager.cjs +133 -0
  186. package/retained/pilot-profile-client.cjs +82 -0
  187. package/retained/temporal-grouping.cjs +78 -0
  188. package/retained/website-api-capability.cjs +122 -0
@@ -6,7 +6,14 @@ exports.buildSensingLoops = buildSensingLoops;
6
6
  exports.buildTiledCameras = buildTiledCameras;
7
7
  exports.resolveFeeds = resolveFeeds;
8
8
  const events_1 = require("events");
9
+ const crypto_1 = require("crypto");
10
+ const FrameGroupingShadow_1 = require("./FrameGroupingShadow");
11
+ const ProfileReviewWork_1 = require("./ProfileReviewWork");
12
+ const ReviewPriority_1 = require("./ReviewPriority");
13
+ const continuousReview_1 = require("./continuousReview");
9
14
  const localNetworkAccess_1 = require("./localNetworkAccess");
15
+ const LocalDetectorClient_1 = require("./LocalDetectorClient");
16
+ const UrgentReviewTriage_1 = require("./UrgentReviewTriage");
10
17
  const MosaicSource_1 = require("./MosaicSource");
11
18
  const runtimePaths_1 = require("./runtimePaths");
12
19
  const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
@@ -265,6 +272,15 @@ class CloudSensingManager extends events_1.EventEmitter {
265
272
  */
266
273
  this.localNetworkVerdict = null;
267
274
  this.localNetworkProbeInFlight = false;
275
+ this.urgentTriageClients = new Map();
276
+ this.urgentTriageBusy = false;
277
+ this.frameGroupers = new Map();
278
+ this.reviewsInFlight = new Set();
279
+ // Physical reservations outlive lease expiry and manager stop/start until
280
+ // the original promise settles, including the original feed identity.
281
+ this.modelReviewsInFlight = new Set();
282
+ this.uploadsInFlight = new Set();
283
+ this.deliveryRetryAtMs = 0;
268
284
  this.candidatePumpTimer = null;
269
285
  /**
270
286
  * What the ARMED gate actually did, connector-wide.
@@ -365,12 +381,14 @@ class CloudSensingManager extends events_1.EventEmitter {
365
381
  // cloud path (a genuinely concurrent backend) exactly as it is today; the
366
382
  // local path passes 1 because its backend is single-conversation.
367
383
  this.admissionGate = new VlmAdmissionGate_1.VlmAdmissionGate({
368
- maxInFlight: Math.max(1, Math.trunc(options.maxConcurrentAnalyses ?? this.loops.length)),
384
+ maxInFlight: Math.max(1, Math.trunc(options.candidateBuffer?.continuousReview?.parallelModelReview === true ? 2 : options.maxConcurrentAnalyses ??
385
+ (options.candidateBuffer?.continuousReview?.enabled ? 1 : this.loops.length))),
369
386
  maxInFlightMs: options.maxAnalysisInFlightMs,
370
387
  // Fairness only matters when feeds actually compete, which is exactly the
371
388
  // single-slot local path. With a slot per loop nobody is starved and the
372
389
  // policy would only add a hold window for nothing.
373
- fairShareAcrossFeeds: Math.max(1, Math.trunc(options.maxConcurrentAnalyses ?? this.loops.length)) < this.loops.length,
390
+ fairShareAcrossFeeds: options.candidateBuffer?.continuousReview?.enabled !== true &&
391
+ Math.max(1, Math.trunc(options.maxConcurrentAnalyses ?? this.loops.length)) < this.loops.length,
374
392
  // Forwarded, NOT defaulted here: the gate owns both defaults, so an
375
393
  // omitted option leaves its behaviour byte-for-byte unchanged.
376
394
  fairShareGraceMs: options.fairShareGraceMs,
@@ -438,11 +456,58 @@ class CloudSensingManager extends events_1.EventEmitter {
438
456
  // without a classifier, because that is the queue the gate refuses.
439
457
  const candidateBufferEnabled = motionGateEnabled && options.candidateBuffer?.enabled !== false;
440
458
  const diskSpool = options.candidateBuffer?.diskSpool;
459
+ const review = options.candidateBuffer?.continuousReview;
460
+ const urgentTriage = (0, UrgentReviewTriage_1.freezeUrgentTriage)(review?.urgentTriage, options.loops.map(loop => loop.name), review?.enabled === true && review.independentUploads === true);
461
+ if (urgentTriage)
462
+ for (const [name, policy] of Object.entries(urgentTriage.byFeed)) {
463
+ this.urgentTriageClients.set(name, new LocalDetectorClient_1.LocalDetectorClient({ baseUrl: urgentTriage.baseUrl,
464
+ timeoutMs: urgentTriage.timeoutMs, classes: [policy.label], minConfidence: policy.minConfidence,
465
+ expectedModel: { modelId: policy.modelId, sha256: policy.modelSha256 } }));
466
+ }
467
+ this.reviewPriorities = (0, ReviewPriority_1.freezeReviewPriorities)(review?.priorityByFeed, options.loops.map(loop => loop.name), review?.enabled === true);
468
+ this.separateProfileWork = review?.enabled === true && review.frameGrouping?.separateProfileWork === true;
469
+ this.continuousReview = review?.enabled === true ? {
470
+ enabled: true,
471
+ reviewWindowMs: review.reviewWindowMs ?? continuousReview_1.DEFAULT_REVIEW_WINDOW_MS,
472
+ retentionMs: review.retentionMs ?? continuousReview_1.DEFAULT_REVIEW_RETENTION_MS,
473
+ reuseExactPendingImages: review.reuseExactPendingImages === true,
474
+ reuseExactRecentImages: review.reuseExactRecentImages === true,
475
+ independentUploads: review.independentUploads === true,
476
+ parallelModelReview: review.parallelModelReview === true,
477
+ } : null;
478
+ if (this.continuousReview && review?.frameGrouping) {
479
+ for (const loop of options.loops)
480
+ this.frameGroupers.set(loop.name, new FrameGroupingShadow_1.FrameGroupingShadow(review.frameGrouping));
481
+ }
482
+ if (this.continuousReview && (!candidateBufferEnabled || diskSpool?.enabled === false || !diskSpool?.directory ||
483
+ [this.continuousReview.reviewWindowMs, this.continuousReview.retentionMs]
484
+ .some(v => !Number.isFinite(v) || v < 1) ||
485
+ this.continuousReview.retentionMs < this.continuousReview.reviewWindowMs))
486
+ throw new Error('Continuous review requires durable storage and valid separate freshness/retention limits');
487
+ if (this.continuousReview) {
488
+ const pairedApi = review?.deliveryTarget === 'configured-api' ? options.retainedApiBaseUrl : undefined;
489
+ if (review?.deliveryTarget === 'configured-api' && !pairedApi)
490
+ throw Error('Configured API review requires the paired API');
491
+ for (const loop of options.loops)
492
+ (0, continuousReview_1.assertRetainedReviewReceiver)(loop.client.getEndpoint(), pairedApi);
493
+ }
494
+ if (this.continuousReview?.independentUploads &&
495
+ options.loops.some(loop => typeof loop.client.reviewCandidate !== 'function')) {
496
+ throw new Error('Independent uploads require a local client with durable review support');
497
+ }
498
+ if (options.loops.some(loop => loop.client.retainOrderedCapture)
499
+ && (!this.continuousReview?.independentUploads || !candidateBufferEnabled || !diskSpool?.directory))
500
+ throw Error('Ordered capture requires the continuous durable review workflow');
501
+ if (review?.parallelModelReview && (!this.continuousReview?.independentUploads
502
+ || (options.maxConcurrentAnalyses ?? 1) !== 1 || urgentTriage
503
+ || options.detectorShadow?.isEnabled())) {
504
+ throw Error('Parallel model review requires one slot per model, independent uploads, and no auxiliary inference');
505
+ }
441
506
  const candidateTtlMs = Math.max(1, Math.trunc(options.candidateBuffer?.ttlMs ?? VlmCandidateBuffer_1.DEFAULT_CANDIDATE_TTL_MS));
442
507
  // Disk durability must never widen the alert-freshness window. Operators
443
508
  // may narrow it, but a legacy 24-hour diskSpool.retentionMs is clamped to
444
509
  // the same TTL the in-memory hold would have enforced.
445
- const durableRetentionMs = Math.min(candidateTtlMs, Math.max(1, Math.trunc(diskSpool?.retentionMs ?? candidateTtlMs)));
510
+ const durableRetentionMs = this.continuousReview?.retentionMs ?? Math.min(candidateTtlMs, Math.max(1, Math.trunc(diskSpool?.retentionMs ?? candidateTtlMs)));
446
511
  this.candidates = !candidateBufferEnabled
447
512
  ? null
448
513
  : diskSpool?.enabled !== false && diskSpool?.directory
@@ -450,6 +515,15 @@ class CloudSensingManager extends events_1.EventEmitter {
450
515
  ...diskSpool,
451
516
  directory: diskSpool.directory,
452
517
  retentionMs: durableRetentionMs,
518
+ // Do not allow the lower-level disk option to bypass the manager's
519
+ // explicit captured profile/feed policy and loopback review guard.
520
+ priorityScheduling: this.continuousReview !== null,
521
+ urgentTriagePolicies: urgentTriage?.byFeed,
522
+ ...(this.continuousReview ? {
523
+ reviewWindowMs: this.continuousReview.reviewWindowMs,
524
+ reuseExactPendingImages: this.continuousReview.reuseExactPendingImages,
525
+ reuseExactRecentImages: this.continuousReview.reuseExactRecentImages,
526
+ } : {}),
453
527
  now: this.now,
454
528
  })
455
529
  : new VlmCandidateBuffer_1.VlmCandidateBuffer({ ...options.candidateBuffer, now: this.now });
@@ -723,6 +797,11 @@ class CloudSensingManager extends events_1.EventEmitter {
723
797
  writeFailed: candidateStats?.writeFailed ?? 0,
724
798
  readFailed: candidateStats?.readFailed ?? 0,
725
799
  deleteFailed: candidateStats?.deleteFailed ?? 0,
800
+ ...(this.continuousReview ? {
801
+ reviewWindowMs: this.continuousReview.reviewWindowMs,
802
+ delayed: candidateStats?.delayed ?? 0,
803
+ capacityRejected: candidateStats?.capacityRejected ?? 0,
804
+ } : {}),
726
805
  },
727
806
  accelerator: foldAcceleratorHealth(this.loops.map((s) => acceleratorHealthFor(s)).filter((a) => a !== null)),
728
807
  detectorShadow: this.detectorShadow?.getStats() ?? EMPTY_DETECTOR_SHADOW_STATS,
@@ -758,6 +837,8 @@ class CloudSensingManager extends events_1.EventEmitter {
758
837
  // in-memory implementation resets here, preserving its historical clean
759
838
  // stop/start semantics.
760
839
  this.candidates?.startRun();
840
+ for (const grouper of this.frameGroupers.values())
841
+ grouper.reset();
761
842
  this.healthOnlyMode = opts.healthOnly === true;
762
843
  this.running = true;
763
844
  this.runStartedAtMs = this.now();
@@ -1016,6 +1097,10 @@ class CloudSensingManager extends events_1.EventEmitter {
1016
1097
  if (startupDelayMs > 0) {
1017
1098
  await sleep(startupDelayMs);
1018
1099
  }
1100
+ if (!this.isCurrentStart(generation))
1101
+ return;
1102
+ if (this.continuousReview)
1103
+ await s.deps.client.prepareReview?.();
1019
1104
  if (!this.isCurrentStart(generation))
1020
1105
  return;
1021
1106
  needsOpen = !s.deps.camera.isOpen();
@@ -1099,7 +1184,7 @@ class CloudSensingManager extends events_1.EventEmitter {
1099
1184
  // A health-only run is not sensing, so neither the face-triggered cadence
1100
1185
  // nor the motion scan interval applies — it beats on the camera-health
1101
1186
  // heartbeat, which is exactly the clock the portal ages a feed against.
1102
- if (this.healthOnlyMode)
1187
+ if (this.healthOnlyMode || s.deps.client.isReviewActive?.() === false)
1103
1188
  return this.cameraHealthHeartbeatIntervalMs;
1104
1189
  const scan = this.motionScanIntervalMs;
1105
1190
  if (scan !== null) {
@@ -1482,7 +1567,8 @@ class CloudSensingManager extends events_1.EventEmitter {
1482
1567
  frameProven: true,
1483
1568
  // Two different reasons a proven frame is not being analyzed: the gate
1484
1569
  // is full (sensing IS running), or there is no model to analyze with.
1485
- sensingState: this.healthOnlyMode ? 'unavailable' : 'waiting_for_capacity',
1570
+ sensingState: this.healthOnlyMode ? 'unavailable'
1571
+ : s.deps.client.isReviewActive?.() === false ? 'idle' : 'waiting_for_capacity',
1486
1572
  force: true,
1487
1573
  });
1488
1574
  this.emit('frameCaptured', {
@@ -1530,10 +1616,19 @@ class CloudSensingManager extends events_1.EventEmitter {
1530
1616
  * cheaper to hold than the argument about whether the delta mattered.
1531
1617
  */
1532
1618
  captureAndPost(s) {
1619
+ if (s.deps.client.isReviewActive?.() === false) {
1620
+ // Refresh in the background so saving a profile on the website arms this
1621
+ // feed without restarting the connector. Saved pending evidence keeps its
1622
+ // own old contract and is still drained by the independent review pump.
1623
+ void s.deps.client.prepareReview?.().catch(error => this.emitLoopError(s, error));
1624
+ return this.captureHealthFrame(s).then(() => null);
1625
+ }
1533
1626
  if (!s.deps.camera.isOpen()) {
1534
1627
  return Promise.reject(new Error(`CloudSensingManager: camera "${s.deps.name}" is not open`));
1535
1628
  }
1536
1629
  if (this.candidates && s.motionGate) {
1630
+ if (this.continuousReview)
1631
+ return this.lookAndRetain(s);
1537
1632
  return this.lookThenAdmit(s, this.candidates, s.motionGate);
1538
1633
  }
1539
1634
  return this.admitThenLook(s);
@@ -1546,6 +1641,100 @@ class CloudSensingManager extends events_1.EventEmitter {
1546
1641
  * goes into the bounded hold instead of onto the floor; anything the cascade
1547
1642
  * rejected is dropped exactly as before and is never offered.
1548
1643
  */
1644
+ async lookAndRetain(s) {
1645
+ const generation = this.startGeneration;
1646
+ const frame = await this.captureCameraFrame(s);
1647
+ const capturedAtMs = this.now();
1648
+ const res = safeGetResolution(s.deps.camera);
1649
+ const jpeg = await this.toJpeg(frame, res?.width, res?.height);
1650
+ if (!this.isCurrentStart(generation))
1651
+ return null;
1652
+ s.captureCount++;
1653
+ this.reportCameraOpen(s, { frameProven: true, sensingState: 'waiting_for_capacity' });
1654
+ this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
1655
+ // The gate still classifies motion/calibration, but completed analyses do
1656
+ // not veto new evidence. All expensive work runs in the shared pump.
1657
+ const grouper = this.frameGroupers.get(s.deps.name);
1658
+ let groupingFrame;
1659
+ let backgroundCalibrating = false;
1660
+ const verdict = await s.motionGate.evaluate(jpeg, undefined, true, grouper ? (grey, motion) => {
1661
+ groupingFrame = grey;
1662
+ backgroundCalibrating = motion.calibrating;
1663
+ } : undefined);
1664
+ if (!this.isCurrentStart(generation))
1665
+ return null;
1666
+ const reviewContext = s.deps.client.captureReviewContext?.();
1667
+ if (reviewContext?.profileState === 'inactive') {
1668
+ // A website refresh can finish during decode/motion evaluation. Do not
1669
+ // archive or infer a frame under the now-empty/default profile snapshot.
1670
+ this.reportCameraOpen(s, { frameProven: true, sensingState: 'idle' });
1671
+ return null;
1672
+ }
1673
+ // The explicit local temporal adapter needs observations while a scene is
1674
+ // still (e.g. an open freezer). Motion alone cannot prove elapsed state.
1675
+ const temporalObservation = !!reviewContext?.temporalReviewContract || !!reviewContext?.orderedCaptureContract || !!s.deps.client.retainOrderedCapture;
1676
+ let groupingShadow;
1677
+ let profileWork;
1678
+ if (grouper) {
1679
+ try {
1680
+ groupingShadow = grouper.observe(groupingFrame, (0, FrameGroupingShadow_1.groupingScopes)(reviewContext), reviewContext, capturedAtMs, (0, crypto_1.createHash)('sha256').update(jpeg).digest('hex'),
1681
+ // Background calibration does not invalidate these decoded raw pixels.
1682
+ // Grouping owns its reference; the first view and visible changes select.
1683
+ verdict.reason === 'audio_onset' || verdict.reason === 'undecodable');
1684
+ }
1685
+ catch (error) {
1686
+ // An optional experiment must never stop the current evidence path.
1687
+ grouper.reset();
1688
+ this.emit('frameGroupingError', { name: s.deps.name, capturedAtMs, error: String(error) });
1689
+ }
1690
+ if (groupingShadow)
1691
+ this.emit('frameGroupingShadow', {
1692
+ name: s.deps.name, currentAdmission: verdict.analyze, currentReason: verdict.reason,
1693
+ backgroundCalibrating, ...groupingShadow,
1694
+ });
1695
+ if (this.separateProfileWork && groupingShadow) {
1696
+ profileWork = (0, ProfileReviewWork_1.planProfileReview)(reviewContext, groupingShadow);
1697
+ this.emit('frameProfileWork', { name: s.deps.name, currentAdmission: verdict.analyze,
1698
+ planned: !!profileWork, work: profileWork });
1699
+ }
1700
+ }
1701
+ if (this.motionDrivesCadence)
1702
+ this.applyMotionCadence(s, verdict.motion);
1703
+ // An explicit raw-capture archive must retain quiet originals too.
1704
+ if (!verdict.analyze && !temporalObservation && !s.deps.client.retainOrderedCapture) {
1705
+ s.motionSkipCount++;
1706
+ s.runMotionSkipped++;
1707
+ this.emit('motionSkipped', { name: s.deps.name, reason: verdict.reason,
1708
+ pctMotion: verdict.pctMotion, regions: verdict.boxes.length });
1709
+ return null;
1710
+ }
1711
+ const capturedPolicy = (0, ReviewPriority_1.parseReviewPolicy)(reviewContext?.reviewPolicy);
1712
+ const reviewPriority = capturedPolicy && 'priority' in capturedPolicy
1713
+ ? capturedPolicy.priority : this.reviewPriorities?.get(s.deps.name) ?? 'normal';
1714
+ if (capturedPolicy && 'invalid' in capturedPolicy) {
1715
+ this.emit('profileReviewPolicyError', { name: s.deps.name, capturedAtMs, reason: 'invalid_profile_review_policy' });
1716
+ }
1717
+ const capturedCandidate = {
1718
+ name: s.deps.name, jpeg, enqueuedAtMs: capturedAtMs,
1719
+ captureMode: s.faceDetected ? 'face_triggered' : 'periodic',
1720
+ reason: verdict.reason, signature: verdict.signature,
1721
+ reviewKind: verdict.reason === 'calibrating' || (0, SceneChangeDetector_1.isChangeCandidate)(verdict) ? 'event' : 'sample',
1722
+ reviewContext, groupingShadow, profileWork,
1723
+ reviewPriority,
1724
+ imageWidth: res?.width, imageHeight: res?.height,
1725
+ };
1726
+ if (s.deps.client.retainOrderedCapture) {
1727
+ s.deps.client.retainOrderedCapture(capturedCandidate);
1728
+ this.emit('orderedEvidenceRetained', { name: s.deps.name, capturedAtMs });
1729
+ this.pumpCandidates(generation);
1730
+ return null;
1731
+ }
1732
+ const offered = this.candidates.offer(capturedCandidate);
1733
+ this.emit('evidenceOffered', { name: s.deps.name, capturedAtMs, outcome: offered,
1734
+ reviewPriority });
1735
+ this.pumpCandidates(generation);
1736
+ return null;
1737
+ }
1549
1738
  async lookThenAdmit(s, candidates, motionGate) {
1550
1739
  const frame = await this.captureCameraFrame(s);
1551
1740
  const capturedAtMs = this.now();
@@ -1886,7 +2075,7 @@ class CloudSensingManager extends events_1.EventEmitter {
1886
2075
  * try/catch around the call stays at each site, where its `unavailable`
1887
2076
  * report is visible next to the thing that can fail.
1888
2077
  */
1889
- recordAnalysis(s, response, signature) {
2078
+ recordAnalysis(s, response, signature, historical = false) {
1890
2079
  s.postCount++;
1891
2080
  // A RESOLVED POST IS NOT AN ACCEPTED ONE.
1892
2081
  //
@@ -1958,9 +2147,11 @@ class CloudSensingManager extends events_1.EventEmitter {
1958
2147
  // actually landed, so a frame lost to the network, to an eviction or to the
1959
2148
  // freshness bound cannot silence a feed for a heartbeat window or suppress
1960
2149
  // the rest of its event as `stationary`.
1961
- s.motionGate?.markAnalyzed(undefined, signature);
1962
- this.reportCameraOpen(s, { sensingState: 'active' });
1963
- this.emit('analyzed', { name: s.deps.name, response });
2150
+ if (!historical) {
2151
+ s.motionGate?.markAnalyzed(undefined, signature);
2152
+ this.reportCameraOpen(s, { sensingState: 'active' });
2153
+ }
2154
+ this.emit('analyzed', { name: s.deps.name, response, ...(historical ? { delayed: true } : {}) });
1964
2155
  return 'accepted';
1965
2156
  }
1966
2157
  /**
@@ -1990,6 +2181,49 @@ class CloudSensingManager extends events_1.EventEmitter {
1990
2181
  if (typeof this.candidatePumpTimer.unref === 'function')
1991
2182
  this.candidatePumpTimer.unref();
1992
2183
  }
2184
+ /** A single CPU detector request can overlap Gemma; capture never awaits it. */
2185
+ pumpUrgentTriage(generation) {
2186
+ if (this.urgentTriageBusy || !this.urgentTriageClients.size || !this.isCurrentStart(generation)
2187
+ || !(this.candidates instanceof DurableVlmCandidateSpool_1.DurableVlmCandidateSpool))
2188
+ return;
2189
+ const spool = this.candidates;
2190
+ let work;
2191
+ try {
2192
+ work = spool.nextTriage();
2193
+ }
2194
+ catch (error) {
2195
+ this.emit('urgentTriageError', { error: String(error) });
2196
+ return;
2197
+ }
2198
+ if (!work)
2199
+ return;
2200
+ const { candidate, policy } = work;
2201
+ const client = this.urgentTriageClients.get(candidate.name);
2202
+ if (!client)
2203
+ return;
2204
+ this.urgentTriageBusy = true;
2205
+ const startedAtMs = this.now();
2206
+ void (async () => {
2207
+ let result;
2208
+ const identity = { version: 1, evidenceId: candidate.spoolId,
2209
+ jpegSha256: candidate.jpegSha256, capturedAtMs: candidate.enqueuedAtMs,
2210
+ policySha256: (0, UrgentReviewTriage_1.triageHash)(policy), startedAtMs };
2211
+ try {
2212
+ const detected = await client.detect(candidate.jpeg);
2213
+ const confidence = Math.max(0, ...detected.detections.filter(d => d.label === policy.label).map(d => d.confidence));
2214
+ result = { ...identity, completedAtMs: this.now(), confidence,
2215
+ status: confidence >= policy.minConfidence ? 'positive' : 'negative' };
2216
+ }
2217
+ catch (error) {
2218
+ result = { ...identity, completedAtMs: this.now(), status: 'error', error: String(error).slice(0, 200) };
2219
+ }
2220
+ // A late result must neither resurrect a deleted item nor cross stop/start.
2221
+ const current = this.isCurrentStart(generation);
2222
+ const persisted = current && spool.checkpointTriage(result);
2223
+ this.emit('urgentTriageResult', { name: candidate.name, policy, ...result, persisted, current });
2224
+ })().catch(error => this.emit('urgentTriageError', { name: candidate.name, error: String(error) }))
2225
+ .finally(() => { this.urgentTriageBusy = false; });
2226
+ }
1993
2227
  /**
1994
2228
  * Offer the hold to every slot that is free right now.
1995
2229
  *
@@ -2000,19 +2234,45 @@ class CloudSensingManager extends events_1.EventEmitter {
2000
2234
  * capacity.
2001
2235
  */
2002
2236
  pumpCandidates(generation) {
2237
+ if (!this.isCurrentStart(generation))
2238
+ return;
2003
2239
  const buffer = this.candidates;
2004
2240
  if (!buffer)
2005
2241
  return;
2242
+ // Re-offer durable sequence jobs even if the camera is currently offline.
2243
+ // Stable IDs make the capture/archive -> spool boundary idempotent.
2244
+ for (const s of this.loops) {
2245
+ if (!s.deps.client.pendingOrderedCandidates)
2246
+ continue;
2247
+ try {
2248
+ if (!(buffer instanceof DurableVlmCandidateSpool_1.DurableVlmCandidateSpool) || !this.continuousReview?.independentUploads)
2249
+ throw Error('Ordered review requires independent uploads and a durable spool');
2250
+ for (const candidate of s.deps.client.pendingOrderedCandidates()) {
2251
+ if (buffer.offer(candidate) === 'rejected')
2252
+ break;
2253
+ }
2254
+ }
2255
+ catch (error) {
2256
+ this.emitLoopError(s, error);
2257
+ }
2258
+ }
2006
2259
  // Age out first: a candidate past its TTL must never win a slot from one
2007
2260
  // that is still current.
2008
2261
  buffer.expire();
2262
+ this.pumpUrgentTriage(generation);
2263
+ if (this.continuousReview?.independentUploads)
2264
+ this.pumpDeliveries(generation);
2009
2265
  // Bounded by the slot count — this loop can hand out capacity, never spin.
2010
2266
  for (let guard = this.admissionGate.getMaxInFlight(); guard > 0; guard--) {
2267
+ // A lease timeout or stop/start cannot create a second real model call.
2268
+ if (this.continuousReview && this.reviewsInFlight.size >= this.admissionGate.getMaxInFlight())
2269
+ return;
2011
2270
  if (buffer.size() === 0)
2012
2271
  return;
2013
2272
  if (this.admissionGate.getInFlight() >= this.admissionGate.getMaxInFlight())
2014
2273
  return;
2015
- const next = buffer.peek();
2274
+ const next = this.continuousReview?.independentUploads
2275
+ ? buffer.peek(undefined, 'review', this.continuousReview.parallelModelReview ? candidate => this.canReviewModels(candidate) : undefined) : buffer.peek();
2016
2276
  if (!next)
2017
2277
  return;
2018
2278
  const s = this.loops.find((l) => l.deps.name === next.name);
@@ -2024,12 +2284,17 @@ class CloudSensingManager extends events_1.EventEmitter {
2024
2284
  // lands in the buffer's `served` counter, which would be a lie, and a
2025
2285
  // quiet lie in the accounting is the exact defect this buffer exists to
2026
2286
  // remove; the event is what makes it findable.
2027
- const dropped = buffer.take();
2028
- if (dropped)
2029
- buffer.complete?.(dropped);
2287
+ const dropped = this.continuousReview?.independentUploads
2288
+ ? buffer.take(undefined, 'review') : buffer.take();
2289
+ if (dropped) {
2290
+ if (this.continuousReview)
2291
+ buffer.retry?.(dropped);
2292
+ else
2293
+ buffer.complete?.(dropped);
2294
+ }
2030
2295
  this.emit('error', {
2031
2296
  name: next.name,
2032
- error: new Error(`CloudSensingManager: held candidate for unknown feed "${next.name}" — dropped`),
2297
+ error: new Error(`CloudSensingManager: held candidate for unknown feed "${next.name}" — ${this.continuousReview ? 'retained for retry' : 'dropped'}`),
2033
2298
  });
2034
2299
  continue;
2035
2300
  }
@@ -2037,25 +2302,92 @@ class CloudSensingManager extends events_1.EventEmitter {
2037
2302
  if (!ticket)
2038
2303
  return; // lost the slot to a live tick — try again next pump
2039
2304
  // Nothing above awaited, so this is the candidate `peek()` named.
2040
- const candidate = buffer.take();
2305
+ const candidate = this.continuousReview?.independentUploads
2306
+ ? buffer.take(undefined, 'review', this.continuousReview.parallelModelReview ? item => item.spoolId === next.spoolId : undefined) : buffer.take();
2041
2307
  if (!candidate) {
2042
2308
  this.admissionGate.release(ticket);
2043
2309
  return;
2044
2310
  }
2045
- void this.serveCandidate(s, candidate, ticket, generation);
2311
+ const reservation = { feed: candidate.name, use: this.continuousReview?.parallelModelReview
2312
+ ? this.reviewModelUse(candidate) : 'both' };
2313
+ this.modelReviewsInFlight.add(reservation);
2314
+ const work = this.serveCandidate(s, candidate, ticket, generation);
2315
+ this.reviewsInFlight.add(work);
2316
+ void work.finally(() => {
2317
+ this.reviewsInFlight.delete(work);
2318
+ this.modelReviewsInFlight.delete(reservation);
2319
+ });
2320
+ }
2321
+ }
2322
+ reviewModelUse(candidate) {
2323
+ try {
2324
+ const client = this.loops.find(loop => loop.deps.name === candidate.name)?.deps.client;
2325
+ const use = client?.getReviewModelUse?.(candidate.reviewContext);
2326
+ return use === 'detector' || use === 'vlm' ? use : 'both';
2327
+ }
2328
+ catch {
2329
+ return 'both';
2330
+ }
2331
+ }
2332
+ canReviewModels(candidate) {
2333
+ const use = this.reviewModelUse(candidate);
2334
+ return [...this.modelReviewsInFlight].every(active => active.feed !== candidate.name
2335
+ && active.use !== 'both' && use !== 'both' && active.use !== use);
2336
+ }
2337
+ /** One bounded upload worker, independent of the actual model-call limit. */
2338
+ pumpDeliveries(generation) {
2339
+ if (!this.isCurrentStart(generation) || this.uploadsInFlight.size > 0 ||
2340
+ this.now() < this.deliveryRetryAtMs)
2341
+ return;
2342
+ const spool = this.candidates;
2343
+ const candidate = spool.take(undefined, 'delivery');
2344
+ if (!candidate)
2345
+ return;
2346
+ const s = this.loops.find(loop => loop.deps.name === candidate.name);
2347
+ if (!s) {
2348
+ spool.retry(candidate);
2349
+ this.emit('reviewDeferred', { name: candidate.name, reason: 'delivery_feed_unavailable' });
2350
+ return;
2046
2351
  }
2352
+ const work = this.serveCandidate(s, candidate, null, generation);
2353
+ this.uploadsInFlight.add(work);
2354
+ void work.finally(() => this.uploadsInFlight.delete(work));
2047
2355
  }
2048
2356
  /** Analyze one held candidate and give the slot back. Never throws. */
2049
2357
  async serveCandidate(s, candidate, ticket, generation) {
2050
2358
  const waitedMs = Math.max(0, this.now() - candidate.enqueuedAtMs);
2051
2359
  try {
2360
+ let detectorShadow = candidate.detectorShadow;
2361
+ if (this.continuousReview && !candidate.cachedAnalysis && this.detectorShadow) {
2362
+ const observed = await this.detectorShadow.observe(candidate.jpeg, s.deps.name);
2363
+ detectorShadow = observed ? DetectorShadowProbe_1.DetectorShadowProbe.toWire(observed) : undefined;
2364
+ const cascade = (0, detectorCascadeGate_1.decideCascadeGate)({
2365
+ armed: s.cascadeArmed, verdict: observed?.verdict ?? null,
2366
+ motion: { reason: candidate.reason, motion: candidate.reviewKind === 'event' },
2367
+ });
2368
+ (0, detectorCascadeGate_1.foldCascadeGateStats)(this.cascadeGateStats, cascade);
2369
+ if (cascade.outcome === 'suppress' && !candidate.reviewContext?.temporalReviewContract && !candidate.reviewContext?.orderedSequence && !candidate.reviewContext?.retainedCapture) {
2370
+ s.cascadeSuppressedCount++;
2371
+ s.runCascadeSuppressed++;
2372
+ this.candidates?.complete?.(candidate);
2373
+ return;
2374
+ }
2375
+ }
2052
2376
  let response;
2053
2377
  try {
2054
- response = await s.deps.client.analyze({
2378
+ const request = {
2055
2379
  jpeg: candidate.jpeg,
2056
2380
  capturedAt: candidate.enqueuedAtMs,
2057
2381
  evidenceId: candidate.spoolId,
2058
2382
  evidenceSha256: candidate.jpegSha256,
2383
+ ...(this.continuousReview ? {
2384
+ reviewWindowMs: this.continuousReview.reviewWindowMs,
2385
+ cachedAnalysis: candidate.cachedAnalysis,
2386
+ scopedVlmProgress: candidate.scopedVlmProgress,
2387
+ reviewContext: candidate.reviewContext,
2388
+ checkpointAnalysis: (result) => this.candidates.checkpoint(candidate, result),
2389
+ checkpointScopedReview: progress => this.candidates.checkpointScopedReview(candidate, progress),
2390
+ } : {}),
2059
2391
  captureMode: candidate.captureMode,
2060
2392
  // The dimensions recorded when this frame was CAPTURED, not whatever
2061
2393
  // the camera reports now — the frame may have waited in the buffer.
@@ -2066,23 +2398,58 @@ class CloudSensingManager extends events_1.EventEmitter {
2066
2398
  // here: shadow mode measures one detector call per analyzed frame,
2067
2399
  // and a second call would both double the cost and describe a frame
2068
2400
  // that is no longer the one being analyzed.
2069
- ...(candidate.detectorShadow ? { extra: { detectorShadow: candidate.detectorShadow } } : {}),
2070
- });
2401
+ ...(detectorShadow ? { extra: { detectorShadow } } : {}),
2402
+ };
2403
+ if (this.continuousReview?.independentUploads && ticket) {
2404
+ await s.deps.client.reviewCandidate(request);
2405
+ this.candidates.readyForDelivery(candidate);
2406
+ this.emit('candidateReviewed', {
2407
+ name: candidate.name, evidenceId: candidate.spoolId,
2408
+ capturedAtMs: candidate.enqueuedAtMs,
2409
+ });
2410
+ return;
2411
+ }
2412
+ // The independent upload lane only takes checkpointed answers. The
2413
+ // local client's cached path validates their image identity and skips models.
2414
+ if (!ticket && !candidate.cachedAnalysis)
2415
+ throw new Error('Upload has no saved model result');
2416
+ response = await s.deps.client.analyze(request);
2071
2417
  }
2072
2418
  catch (err) {
2073
- this.emit('inferenceError', {
2419
+ this.emit(ticket ? 'inferenceError' : 'deliveryError', {
2074
2420
  name: s.deps.name,
2075
2421
  error: err instanceof Error ? err : new Error(String(err)),
2076
2422
  path: 'buffered',
2077
2423
  });
2078
- this.reportCameraOpen(s, { sensingState: 'unavailable' });
2424
+ if (ticket)
2425
+ this.reportCameraOpen(s, { sensingState: 'unavailable' });
2079
2426
  throw err;
2080
2427
  }
2081
- const outcome = this.recordAnalysis(s, response, candidate.signature);
2428
+ // Older servers may acknowledge stale captures as a terminal drop.
2429
+ // Continuous review keeps those results for retry instead of deleting evidence.
2430
+ const acknowledgedReview = response.body?.evidenceReview;
2431
+ if (this.continuousReview && (response.body?.accepted !== true ||
2432
+ response.body?.evidenceId !== candidate.spoolId ||
2433
+ acknowledgedReview?.version !== 1 ||
2434
+ acknowledgedReview.capturedAtMs !== candidate.enqueuedAtMs)) {
2435
+ this.deferCandidate(candidate, ticket === null &&
2436
+ (response.statusCode === 408 || response.statusCode >= 500));
2437
+ this.emit('reviewDeferred', { name: s.deps.name, reason: response.body?.reason ?? 'review_not_acknowledged' });
2438
+ return;
2439
+ }
2440
+ const delayed = !!this.continuousReview &&
2441
+ this.now() - candidate.enqueuedAtMs > this.continuousReview.reviewWindowMs;
2442
+ const outcome = this.isCurrentStart(generation)
2443
+ ? this.recordAnalysis(s, response, candidate.signature, delayed)
2444
+ : response.ok && response.body !== null && typeof response.body === 'object'
2445
+ ? 'accepted' : 'rejected';
2082
2446
  if (outcome === 'rejected')
2083
2447
  this.candidates?.retry?.(candidate);
2084
- else
2448
+ else {
2085
2449
  this.candidates?.complete?.(candidate);
2450
+ if (!ticket && this.isCurrentStart(generation))
2451
+ this.deliveryRetryAtMs = 0;
2452
+ }
2086
2453
  if (outcome === 'accepted' && this.isCurrentStart(generation)) {
2087
2454
  const body = response.body;
2088
2455
  const observationId = body && (typeof body.observationId === 'string'
@@ -2098,16 +2465,27 @@ class CloudSensingManager extends events_1.EventEmitter {
2098
2465
  evidenceId: candidate.spoolId ?? null,
2099
2466
  jpegSha256: candidate.jpegSha256 ?? null,
2100
2467
  observationId,
2468
+ ...(candidate.reviewPriority ? { reviewPriority: candidate.reviewPriority } : {}),
2469
+ ...(this.continuousReview ? { delayed } : {}),
2101
2470
  });
2102
2471
  }
2103
2472
  }
2104
2473
  catch (err) {
2105
- this.candidates?.retry?.(candidate);
2474
+ this.deferCandidate(candidate, ticket === null);
2106
2475
  this.emitLoopError(s, err);
2107
2476
  }
2108
2477
  finally {
2109
- this.admissionGate.release(ticket);
2478
+ if (ticket)
2479
+ this.admissionGate.release(ticket);
2480
+ }
2481
+ }
2482
+ deferCandidate(candidate, deliveryUnavailable) {
2483
+ if (deliveryUnavailable && this.continuousReview?.independentUploads && candidate.cachedAnalysis) {
2484
+ this.deliveryRetryAtMs = this.candidates
2485
+ .retryDelivery(candidate);
2110
2486
  }
2487
+ else
2488
+ this.candidates?.retry?.(candidate);
2111
2489
  }
2112
2490
  /**
2113
2491
  * Convert a camera frame to JPEG bytes for the POST.