also looking at this
feat: add retained cargo fleet cancellation and recovery
#105
32 files
+2496
-27
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -127,7 +127,8 @@ and power declarations, captured release/custody, redundant links and post-relea | ||
| 127 | 127 | Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing. |
| 128 | 128 | The [two-carrier model](docs/cargo-fleet.md) and `./buddy cargo:fleet` now execute actual |
| 129 | 129 | finite station handover and the receiver's second delivery under separate sensor/authority |
| 130 | scopes. Six bounded cases retain radio/host loss and uncertain receipt. General fleet | |
| 130 | scopes. Eleven bounded cases include radio/host loss, uncertain receipt, phase-bound kill, | |
| 131 | GNSS loss, pad-motion interlocks and modeled fence-breach supervision. General fleet | |
| 131 | 132 | fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25). |
| 132 | 133 | |
| 133 | 134 | ## Agriculture sensing |
| @@ -216,7 +216,7 @@ receiving body in its own raw-sensor, estimator and authority runtime. With ACK | ||
| 216 | 216 | it stays at the declared pad with zero actual motor thrust and no phantom parcel mass. |
| 217 | 217 | A caller-invented ACK is not proof of mechanical transfer. The |
| 218 | 218 | [two-carrier model](cargo-fleet.md) now binds its receipt port to actual intake, same-epoch |
| 219 | receiver authority and six bounded fleet cases through `cargo:fleet`. Its broader | |
| 219 | receiver authority and eleven bounded fleet cases through `cargo:fleet`. Its broader | |
| 220 | 220 | cancellation, fault/recovery and operational matrix remains open under #25. |
| 221 | 221 | |
| 222 | 222 | ## BVLOS operations evidence |
| @@ -18,6 +18,11 @@ The [single-carrier guide](cargo-delivery.md) retains the wider operations evide | ||
| 18 | 18 | ./buddy cargo:fleet --scenario receiver-host-loss-before-intake |
| 19 | 19 | ./buddy cargo:fleet --scenario intake-receipt-loss |
| 20 | 20 | ./buddy cargo:fleet --scenario receiver-joint-shift-before-intake |
| 21 | ./buddy cargo:fleet --scenario receiver-navigation-loss-during-rail | |
| 22 | ./buddy cargo:fleet --scenario source-kill-after-station-receipt | |
| 23 | ./buddy cargo:fleet --scenario receiver-kill-after-intake | |
| 24 | ./buddy cargo:fleet --scenario receiver-pad-motion-during-rail | |
| 25 | ./buddy cargo:fleet --scenario receiver-containment-breach-after-intake-receipt | |
| 21 | 26 | ``` |
| 22 | 27 | |
| 23 | 28 | The command emits one JSON line, at most 16 KiB. Exit 0 means the modeled routes and |
| @@ -92,6 +97,21 @@ station reports mechanical work; each vehicle's reserve remains the shared longe | ||
| 92 | 97 | | `receiver-host-loss-before-intake` | Incomplete | Host stops 4 ms after the actual grant; no physical intake, independent board expiry and station retention | |
| 93 | 98 | | `receiver-joint-shift-before-intake` | Incomplete | A 0.4 N force for one 4 ms step shifts the actual joint before intake; strict coincidence refuses it, the loose point falls, and B lands its counted alternate empty | |
| 94 | 99 | | `intake-receipt-loss` | Incomplete | Actual intake occurs but its receipt fields are withheld; B retains the mass and lands its counted alternate without claiming custody or making a second delivery | |
| 100 | | `receiver-navigation-loss-during-rail` | Incomplete | GNSS capture stops 4 ms after actual rail start; receiver navigation expires, station brakes and retains the point, and A continues | | |
| 101 | | `source-kill-after-station-receipt` | Incomplete | Source kill follows its captured station receipt; A inhibits while B completes intake, final delivery and return. B's success does not make the whole fleet complete | | |
| 102 | | `receiver-kill-after-intake` | Incomplete | Kill follows actual intake by 4 ms, before receipt capture; B retains the attached mass with uncertain custody and no final release | | |
| 103 | | `receiver-pad-motion-during-rail` | Incomplete | A declared constant 4 N northward force moves B; native receiving interlocks stop its mission before fence breach or intake, and the station brakes | | |
| 104 | | `receiver-containment-breach-after-intake-receipt` | Incomplete | After captured intake, an 8 N northward force ramps over 2 s. B's qualified estimated position breaches its 12 m fence; authority fails and outputs neutral while attached mass and received custody remain | | |
| 105 | ||
| 106 | The loaded containment case explicitly opts into the existing `fixture-imu` position | |
| 107 | prediction model, including its assumed initial north heading and synthetic uncertainty. | |
| 108 | It uses the actual raw IMU/GNSS feed and the native estimator/fence checks, with no | |
| 109 | substitution of plant position into policy. The receiver force is a declared world-NED | |
| 110 | model input, retained as `forceN` and `forceRampMs` in the fault report. It continues | |
| 111 | through the independent neutral tail. A neutral command cannot stop momentum or an | |
| 112 | ongoing external force; this case demonstrates fence-breach detection and inhibition, | |
| 113 | not guaranteed physical containment. The pad-motion case uses the same real body-force | |
| 114 | path and is stopped earlier by receiving contact/speed/anchor interlocks. | |
| 95 | 115 | |
| 96 | 116 | Radio observations retain capture, receipt, sequence and scope, with 1 ms clock uncertainty |
| 97 | 117 | and 20/40 ms primary/backup delays. Failure stops new packets rather than restamping a |
| @@ -106,12 +126,12 @@ native finite graph/reserve budget; the world is bounded by the 180 s host limit | ||
| 106 | 126 | source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass, |
| 107 | 127 | backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion. |
| 108 | 128 | `tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire. |
| 109 | `scripts/qualify-cargo-fleet-cli.ts` exercises all six cases through a copied standalone | |
| 129 | `scripts/qualify-cargo-fleet-cli.ts` exercises all eleven cases through a copied standalone | |
| 110 | 130 | binary outside the checkout and retains output hashes and factual outcomes. |
| 111 | 131 | |
| 112 | 132 | These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual |
| 113 | intervention at every phase, navigation degradation and containment across the transfer, | |
| 114 | late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable | |
| 133 | intervention at every phase, broader navigation degradation and containment campaigns, | |
| 134 | wider late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable | |
| 115 | 135 | whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning |
| 116 | 136 | remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies. |
| 117 | 137 | The wider measured hardware, endurance and operational evidence requirements remain open. |
| @@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 29 | 29 | | Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) | |
| 30 | 30 | | SAR, fire/disaster support and authorized police observation | Owned collection/runtime, authenticated incident roles, source-bound annotations/review, private datasets, recipient-confirmed handover, authorized workspace with live synthetic execution and deliberate source admission, and separately accepted default-disabled local purge | Sector/team responsibility and full physical/operational qualification remain open | [#23](https://github.com/stacksjs/drone/issues/23) | |
| 31 | 31 | | Perimeter patrol, scheduling, docks and charging | Partial: retained simulation, selected-pad contact, explicit partial charging, observed isolation, finite native recurrence, bounded input/summary replay and owner-only authenticated review/consumption metadata | `patrol:simulate`, `patrol:replay` with fresh consent and [four explicit review endpoints](../patrol-review.md); production scheduling, runtime handoff/recovery, weather and physical docking remain open | [#24](https://github.com/stacksjs/drone/issues/24) | |
| 32 | | Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; bounded two-carrier station/intake handover with captured custody and six model cases; general fleet fault/recovery and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md), plus the bounded [two-carrier model](../cargo-fleet.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) | | |
| 32 | | Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; bounded two-carrier station/intake handover with captured custody and eleven model cases including phase-bound navigation, kill, pad-motion and fence-breach supervision; general fleet fault/recovery and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md), plus the bounded [two-carrier model](../cargo-fleet.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) | | |
| 33 | 33 | | Gimbal/camera control, smooth paths and broadcast timecode | Partial: owned hover POI angular reframe, lagged/quantized gimbal, exposure-bound modeled stills and latched target/camera/authority faults; translational paths, physical integration, lens/video and handoff remain open | Native `cinema:simulate` [simulation semantics and limits](../cinema.md); no physical/broadcast timing qualification | [#26](https://github.com/stacksjs/drone/issues/26) | |
| 34 | 34 | | Environment, wildlife, anti-poaching observation and atmospheric samples | Partial: owned two-height synthetic cell sampling, calibration/geotags, conditional uncertainty, collection-policy faults and unsigned five-file provenance/export; physical samples, wildlife/authority and actual geofence qualification remain open | Native `environment:simulate` [semantics and limits](../environment.md); human review required | [#27](https://github.com/stacksjs/drone/issues/27) | |
| 35 | 35 | | Deep feature audit, refresh and conformance gates | Pinned directory accounting and sampled behavior audit: 89 cases, including ten transport and ten camera-feedback/capture gaps; deeper review and implementation remain open | [Audit integrity and source provenance](../parity.md); 26 bounded claims, no general parity claim | [#28](https://github.com/stacksjs/drone/issues/28) | |
| @@ -17,7 +17,7 @@ type Station = ReturnType<typeof createCargoStationHandler> | ||
| 17 | 17 | type Release = ReturnType<ReturnType<typeof createReceivingRigidBody>['separateCarriedLoad']> |
| 18 | 18 | type Departure = Extract<ReturnType<Station['takeReadyParcel']>, { outcome: 'departed' }> |
| 19 | 19 | type Owner = 'source-body' | 'station-actor' | 'receiver-body' | 'final-parcel-actor' | 'loose-parcel' |
| 20 | export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake'] as const | |
| 20 | export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake', 'receiver-navigation-loss-during-rail', 'source-kill-after-station-receipt', 'receiver-kill-after-intake', 'receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'] as const | |
| 21 | 21 | export type CargoFleetScenario = typeof cargoFleetScenarios[number] |
| 22 | 22 | function captureScenario(input: unknown): CargoFleetScenario { |
| 23 | 23 | try { |
| @@ -58,7 +58,7 @@ function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs: | ||
| 58 | 58 | export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { |
| 59 | 59 | if (extra.length) throw new TypeError('cargo-fleet-arguments') |
| 60 | 60 | const scenario = captureScenario(input) |
| 61 | const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null } | |
| 61 | const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null, forceN?: SensorVector, forceRampMs?: number } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null, ...(['receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'].includes(scenario) ? { forceN: [scenario === 'receiver-pad-motion-during-rail' ? 4 : 8, 0, 0] as SensorVector, ...(scenario === 'receiver-containment-breach-after-intake-receipt' ? { forceRampMs: 2000 } : {}) } : {}) } | |
| 62 | 62 | const bodyA = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: point }) |
| 63 | 63 | const bodyB = createReceivingRigidBody({ positionM: [...receiverPadM], groundDownM: -.1, expectedPointLoad: point }) |
| 64 | 64 | const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot() |
| @@ -115,13 +115,28 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | ||
| 115 | 115 | const profileA = createMultirotorProfile(), profileB = createMultirotorProfile(), createA = profileA.createSession, createB = profileB.createSession |
| 116 | 116 | profileA.mission = planCargoDelivery({ version: 1, id: 'cargo-fleet-source', delivery: { origin, site: stationSite, releaseHeightM: 1 }, load: { massKg: point.massKg, forwardM: point.forwardM }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } }).mission |
| 117 | 117 | profileB.mission = receiverMission() |
| 118 | if (scenario === 'receiver-containment-breach-after-intake-receipt') profileB.positionPrediction = 'fixture-imu' | |
| 119 | const baseFaultsA = profileA.faults | |
| 120 | if (scenario === 'source-kill-after-station-receipt') { | |
| 121 | profileA.scenario = scenario; profileA.scenarios = [scenario] | |
| 122 | profileA.faults = (selected, atMs, phase) => { | |
| 123 | if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed') | |
| 124 | const killed = faultInjection!.anchoredAtMs !== null && atMs > faultInjection!.anchoredAtMs | |
| 125 | if (killed && phase === 'host') faultInjection!.triggeredAtMs ??= atMs | |
| 126 | return { ...baseFaultsA('nominal', atMs, phase), kill: killed } | |
| 127 | } | |
| 128 | } | |
| 118 | 129 | const baseFaultsB = profileB.faults |
| 119 | 130 | profileB.scenario = scenario; profileB.scenarios = [scenario] |
| 120 | 131 | profileB.faults = (selected, atMs, phase) => { |
| 121 | 132 | if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed') |
| 122 | 133 | const active = faultInjection?.scenario === 'receiver-host-loss-before-intake' && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 4 |
| 123 | 134 | if (active && phase === 'host') faultInjection!.triggeredAtMs ??= atMs |
| 124 | return { ...baseFaultsB('nominal', atMs, phase), hostLost: active } | |
| 135 | const navigationLost = faultInjection?.scenario === 'receiver-navigation-loss-during-rail' && faultInjection.anchoredAtMs !== null && atMs > faultInjection.anchoredAtMs | |
| 136 | if (navigationLost && phase === 'host') faultInjection!.triggeredAtMs ??= atMs | |
| 137 | const killed = faultInjection?.scenario === 'receiver-kill-after-intake' && faultInjection.anchoredAtMs !== null && atMs > faultInjection.anchoredAtMs | |
| 138 | if (killed && phase === 'host') faultInjection!.triggeredAtMs ??= atMs | |
| 139 | return { ...baseFaultsB('nominal', atMs, phase), hostLost: active, dropPosition: navigationLost, kill: killed } | |
| 125 | 140 | } |
| 126 | 141 | const plantA = createMultirotorPlant(bodyA), adaptedB = createMultirotorPlant(bodyB) |
| 127 | 142 | let receiverPhysicalAtMs = 600 |
| @@ -130,7 +145,9 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | ||
| 130 | 145 | receiverPhysicalAtMs += 4 |
| 131 | 146 | const shift = faultInjection?.scenario === 'receiver-joint-shift-before-intake' && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs === faultInjection.anchoredAtMs + 4 |
| 132 | 147 | if (shift) faultInjection!.triggeredAtMs = receiverPhysicalAtMs |
| 133 | bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift ? { forceN: [.4, 0, 0] as SensorVector } : {}) }) | |
| 148 | const containment = faultInjection && ['receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'].includes(faultInjection.scenario) && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs > faultInjection.anchoredAtMs | |
| 149 | if (containment) faultInjection!.triggeredAtMs ??= receiverPhysicalAtMs | |
| 150 | bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift || containment ? { forceN: shift ? [.4, 0, 0] as SensorVector : faultInjection!.forceN!.map(n => n * Math.min(1, (receiverPhysicalAtMs - faultInjection!.anchoredAtMs!) / (faultInjection!.forceRampMs ?? 4))) as SensorVector } : {}) }) | |
| 134 | 151 | return adaptedB.snapshot() |
| 135 | 152 | } } |
| 136 | 153 | function releaseValues(which: 'source' | 'receiver') { |
| @@ -164,6 +181,14 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | ||
| 164 | 181 | for (let nowMs = 0; nowMs <= 181000; nowMs += 4) { |
| 165 | 182 | if (!progressA?.terminal) progressA = a.step(nowMs) |
| 166 | 183 | if (!progressB?.terminal) progressB = b.step(nowMs) |
| 184 | if (faultInjection?.scenario === 'source-kill-after-station-receipt' && faultInjection.anchoredAtMs === null) { | |
| 185 | const receipt = sourceCoordinator.snapshot().delivery | |
| 186 | if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs | |
| 187 | } | |
| 188 | if (faultInjection?.scenario === 'receiver-containment-breach-after-intake-receipt' && faultInjection.anchoredAtMs === null) { | |
| 189 | const receipt = intakeCoordinator.snapshot().custody | |
| 190 | if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs | |
| 191 | } | |
| 167 | 192 | if (station && station.snapshot().atMs < nowMs) station.step(nowMs) |
| 168 | 193 | if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs) |
| 169 | 194 | if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs) |
| @@ -177,7 +202,10 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | ||
| 177 | 202 | } |
| 178 | 203 | } |
| 179 | 204 | } |
| 180 | if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) station.start() | |
| 205 | if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) { | |
| 206 | station.start() | |
| 207 | if (faultInjection && ['receiver-navigation-loss-during-rail', 'receiver-pad-motion-during-rail'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs ??= nowMs | |
| 208 | } | |
| 181 | 209 | if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel() |
| 182 | 210 | if (commands.intake && nowMs > commands.intake.issuedAtMs) { |
| 183 | 211 | const grant = commands.intake; commands.intake = null |
| @@ -189,7 +217,7 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | ||
| 189 | 217 | const attachment = bodyB.attachParcel(departure.parcel) |
| 190 | 218 | intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) } |
| 191 | 219 | intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 } |
| 192 | if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection?.scenario === 'intake-receipt-loss') faultInjection.anchoredAtMs = nowMs } | |
| 220 | if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection && ['intake-receipt-loss', 'receiver-kill-after-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = nowMs } | |
| 193 | 221 | else { loose = createCargoStationHandler({ atMs: nowMs, parcel: departure.parcel, pickup: { centerM: [3, 2, 0], radiusM: 2 }, destinationM: [3, 2, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }); changeOwner('loose-parcel', nowMs) } |
| 194 | 222 | sampleMass() |
| 195 | 223 | } |
| @@ -17,23 +17,33 @@ for (const [scenario, status, owner, massKg, custody] of [ | ||
| 17 | 17 | ['receiver-host-loss-before-intake', 'unqualified', 'station-actor', 1.5, 'uncertain'], |
| 18 | 18 | ['intake-receipt-loss', 'unqualified', 'receiver-body', 1.6, 'uncertain'], |
| 19 | 19 | ['receiver-joint-shift-before-intake', 'unqualified', 'loose-parcel', 1.5, 'rejected'], |
| 20 | ['receiver-navigation-loss-during-rail', 'unqualified', 'station-actor', 1.5, 'rejected'], | |
| 21 | ['source-kill-after-station-receipt', 'unqualified', 'final-parcel-actor', 1.5, 'received'], | |
| 22 | ['receiver-kill-after-intake', 'unqualified', 'receiver-body', 1.6, 'uncertain'], | |
| 23 | ['receiver-pad-motion-during-rail', 'unqualified', 'station-actor', 1.5, 'rejected'], | |
| 24 | ['receiver-containment-breach-after-intake-receipt', 'unqualified', 'receiver-body', 1.6, 'received'], | |
| 20 | 25 | ] as const) { |
| 21 | 26 | const r = await runBoundedHostProcess([binary, 'cargo:fleet', '--scenario', scenario], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet ' + scenario }) |
| 22 | 27 | const bytes = Buffer.from(r.stdout, 'utf8') |
| 23 | 28 | await Bun.write(join(output, scenario + '.stdout'), bytes); await Bun.write(join(output, scenario + '.stderr'), r.stderr) |
| 24 | 29 | if (r.exitCode !== (status === 'completed' ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2) throw new Error('Unqualified copied fleet command: ' + scenario) |
| 25 | const s = JSON.parse(r.stdout) | |
| 30 | const s = JSON.parse(r.stdout), receiverDelivered = owner === 'final-parcel-actor' | |
| 26 | 31 | if (s.evidence !== 'owned-cargo-fleet-simulation' || s.scenario !== scenario || s.status !== status || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false |
| 27 | 32 | || s.ownership.owner !== owner || s.ownership.initialMassKg !== 3.1 || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.finalLoad.totalMassKg !== massKg || s.receiver.intake.outcome !== custody |
| 28 | 33 | || s.source.scope.bootId !== '101' || s.source.scope.sessionId !== '201' || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202' |
| 29 | || s.source.status !== 'completed' || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (status === 'completed' ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario) | |
| 34 | || s.source.status !== (scenario === 'source-kill-after-station-receipt' ? 'blocked' : 'completed') || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (receiverDelivered ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario) | |
| 30 | 35 | for (const v of [s.source, s.receiver]) if (v.board.physicalActuationEnabled !== false || JSON.stringify(v.board.actuators) !== '[0,0,0,0]' || v.board.expiresAtMs !== null || v.energy.evidence !== 'declared-mission-power-budget' || v.energy.measured !== false || v.energy.electricalModel !== false || v.energy.marginAboveReserveWh <= 0) throw new Error('Unqualified copied fleet authority/budget: ' + scenario) |
| 31 | if (status === 'completed' && (s.transfer.intake.outcome !== 'attached' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 || s.transfer.intake.receiverScope.sessionId !== '202' || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.transfer.stationDeparture.atMs <= s.transfer.sourceRelease.atMs + 1000 || s.station.ownedMassKg !== 0 || s.recipient.ownedMassKg !== .1 || s.recipient.phase !== 'held')) throw new Error('Unqualified copied physical transfer: ' + scenario) | |
| 36 | if (receiverDelivered && (s.transfer.intake.outcome !== 'attached' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 || s.transfer.intake.receiverScope.sessionId !== '202' || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.transfer.stationDeparture.atMs <= s.transfer.sourceRelease.atMs + 1000 || s.station.ownedMassKg !== 0 || s.recipient.ownedMassKg !== .1 || s.recipient.phase !== 'held')) throw new Error('Unqualified copied physical transfer: ' + scenario) | |
| 32 | 37 | if (scenario === 'receiver-primary-link-loss' && (s.receiver.links.selectedSource !== 'backup' || s.receiver.links.latchedLoss)) throw new Error('Unqualified copied backup continuation') |
| 33 | 38 | if (scenario === 'receiver-both-links-loss' && (!s.receiver.links.latchedLoss || s.station.phase !== 'cancelled' || s.receiver.status !== 'blocked')) throw new Error('Unqualified copied link expiry') |
| 34 | 39 | if (scenario === 'receiver-host-loss-before-intake' && (s.transfer.intake !== null || s.receiver.status !== 'host-lost' || s.receiver.board.fault !== 'output-lease-expired' || s.faultInjection.triggeredAtMs !== s.receiver.intake.grantedAtMs + 4 || s.station.ownedMassKg !== .1)) throw new Error('Unqualified copied host grant gap') |
| 35 | 40 | if (scenario === 'intake-receipt-loss' && (s.receiver.missionReason !== 'payload-diversion-landed' || s.receiver.payloadFailure.reason !== 'uncertain' || s.receiver.intake.receiptCapturedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied lost receipt') |
| 36 | 41 | if (scenario === 'receiver-joint-shift-before-intake' && (s.transfer.intake.outcome !== 'refused' || s.receiver.payloadFailure.reason !== 'rejected' || s.loose.ownedMassKg !== .1 || s.loose.phase !== 'held' || s.loose.parcel.positionM[2] !== 0 || s.loose.impact.atMs <= s.transfer.intake.atMs)) throw new Error('Unqualified copied stale joint') |
| 42 | if (scenario === 'receiver-navigation-loss-during-rail' && (s.receiver.fault !== 'estimator-loss' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.transfer.intake !== null || s.faultInjection.anchoredAtMs !== s.station.startedAtMs || s.faultInjection.triggeredAtMs !== s.station.startedAtMs + 4)) throw new Error('Unqualified copied navigation loss') | |
| 43 | if (scenario === 'source-kill-after-station-receipt' && (s.receiver.status !== 'completed' || s.faultInjection.anchoredAtMs !== s.source.delivery.acknowledgedAtMs || s.faultInjection.triggeredAtMs !== s.faultInjection.anchoredAtMs + 4 || s.transfer.intake.atMs <= s.faultInjection.triggeredAtMs)) throw new Error('Unqualified copied source kill') | |
| 44 | if (scenario === 'receiver-kill-after-intake' && (s.transfer.intake.outcome !== 'attached' || s.faultInjection.anchoredAtMs !== s.transfer.intake.atMs || s.faultInjection.triggeredAtMs !== s.transfer.intake.atMs + 4 || s.receiver.intake.receiptCapturedAtMs !== null || s.receiver.intake.acknowledgedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied receiver kill') | |
| 45 | if (scenario === 'receiver-pad-motion-during-rail' && (s.receiver.fault !== 'material-receive-interlocks' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.transfer.intake !== null || JSON.stringify(s.faultInjection.forceN) !== '[4,0,0]')) throw new Error('Unqualified copied pad motion') | |
| 46 | if (scenario === 'receiver-containment-breach-after-intake-receipt' && (s.receiver.fault !== 'mission-fence-breach' || Math.hypot(s.receiver.finalPositionM[0], s.receiver.finalPositionM[1]) <= 12 || s.faultInjection.anchoredAtMs !== s.receiver.intake.acknowledgedAtMs || s.faultInjection.triggeredAtMs !== s.faultInjection.anchoredAtMs + 4 || JSON.stringify(s.faultInjection.forceN) !== '[8,0,0]' || s.faultInjection.forceRampMs !== 2000 || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied containment') | |
| 37 | 47 | rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) }) |
| 38 | 48 | console.log(scenario + ' PASS') |
| 39 | 49 | } |
| @@ -48,3 +48,18 @@ test('native fleet failures exit incomplete while retaining the actual parcel ow | ||
| 48 | 48 | expect(invalid.stderr).toContain('Cargo fleet simulation unavailable') |
| 49 | 49 | expect(invalid.stderr).not.toContain('unknown-private-marker') |
| 50 | 50 | }, 100000) |
| 51 | ||
| 52 | test('a receiver delivery cannot hide a source kill behind a successful fleet exit', async () => { | |
| 53 | const result = await command(['cargo:fleet', '--scenario', 'source-kill-after-station-receipt']).withTimeout(30000) | |
| 54 | expect(result.timedOut).toBe(false) | |
| 55 | expect(result.exitCode).toBe(2) | |
| 56 | const report = JSON.parse(result.stdout) | |
| 57 | expect(report.status).toBe('unqualified') | |
| 58 | expect(report.source.status).toBe('blocked') | |
| 59 | expect(report.receiver.status).toBe('completed') | |
| 60 | expect(report.receiver.delivery.outcome).toBe('received') | |
| 61 | expect(report.ownership.owner).toBe('final-parcel-actor') | |
| 62 | expect(report.ownership.finalMassKg).toBe(3.1) | |
| 63 | expect(report.source.board.actuators).toEqual([0, 0, 0, 0]) | |
| 64 | expect(result.stderr).toBe('') | |
| 65 | }, 40000) | |
| @@ -171,3 +171,119 @@ test('a real receiver joint shift refuses stale rendezvous and the departed poin | ||
| 171 | 171 | expect(run.ownership.finalMassKg).toBe(3.1) |
| 172 | 172 | expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12) |
| 173 | 173 | }) |
| 174 | ||
| 175 | test('receiver GNSS loss at actual station rail start expires navigation and brakes transport while its peer continues', () => { | |
| 176 | const run = simulateCargoFleet({ scenario: 'receiver-navigation-loss-during-rail' }) | |
| 177 | expect(run.status).toBe('unqualified') | |
| 178 | expect(run.source.runtime.status).toBe('completed') | |
| 179 | expect(run.source.delivery.outcome).toBe('received') | |
| 180 | expect(run.receiver.runtime.status).toBe('blocked') | |
| 181 | expect(run.receiver.runtime.authority.state).toBe('failsafe') | |
| 182 | expect(run.receiver.runtime.authority.reason).toBe('stale-estimator') | |
| 183 | expect(run.receiver.runtime.fault).toBe('estimator-loss') | |
| 184 | expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 185 | expect(run.receiver.finalLoad.properties.phase).toBe('awaiting') | |
| 186 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 187 | expect(run.transfer.stationDeparture).toBeNull() | |
| 188 | expect(run.transfer.intake).toBeNull() | |
| 189 | expect(run.station!.phase).toBe('cancelled') | |
| 190 | expect(run.station!.ownedMassKg).toBe(.1) | |
| 191 | expect(run.station!.startedAtMs).toBe(run.faultInjection!.anchoredAtMs) | |
| 192 | expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4) | |
| 193 | expect(run.station!.cancelRequestedAtMs!).toBeGreaterThan(run.faultInjection!.triggeredAtMs!) | |
| 194 | expect(run.station!.stoppedAtMs!).toBeGreaterThanOrEqual(run.station!.cancelRequestedAtMs!) | |
| 195 | expect(Math.hypot(...run.station!.parcel!.velocityMps)).toBe(0) | |
| 196 | expect(run.ownership.owner).toBe('station-actor') | |
| 197 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 198 | }) | |
| 199 | ||
| 200 | test('source kill after captured station custody inhibits only that carrier while the receiver finishes the one parcel delivery', () => { | |
| 201 | const run = simulateCargoFleet({ scenario: 'source-kill-after-station-receipt' }) | |
| 202 | expect(run.status).toBe('unqualified') | |
| 203 | expect(run.source.runtime.status).toBe('blocked') | |
| 204 | expect(run.source.runtime.authority.state).toBe('killed') | |
| 205 | expect(run.source.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 206 | expect(run.source.delivery.outcome).toBe('received') | |
| 207 | expect(run.receiver.runtime.status).toBe('completed') | |
| 208 | expect(run.receiver.intake.custody.outcome).toBe('received') | |
| 209 | expect(run.receiver.delivery.outcome).toBe('received') | |
| 210 | expect(run.transfer.intake!.atMs).toBeGreaterThan(run.faultInjection!.triggeredAtMs!) | |
| 211 | expect(run.faultInjection!.anchoredAtMs).toBe(run.source.delivery.acknowledgedAtMs) | |
| 212 | expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4) | |
| 213 | expect(run.ownership.owner).toBe('final-parcel-actor') | |
| 214 | expect(run.station!.ownedMassKg).toBe(0) | |
| 215 | expect(run.recipient!.ownedMassKg).toBe(.1) | |
| 216 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 217 | }) | |
| 218 | ||
| 219 | test('receiver kill immediately after actual intake retains its real added mass without inventing captured custody', () => { | |
| 220 | const run = simulateCargoFleet({ scenario: 'receiver-kill-after-intake' }) | |
| 221 | expect(run.status).toBe('unqualified') | |
| 222 | expect(run.source.runtime.status).toBe('completed') | |
| 223 | expect(run.source.delivery.outcome).toBe('received') | |
| 224 | expect(run.receiver.runtime.status).toBe('blocked') | |
| 225 | expect(run.receiver.runtime.authority.state).toBe('killed') | |
| 226 | expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 227 | expect(run.transfer.intake!.attachment.outcome).toBe('attached') | |
| 228 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6) | |
| 229 | expect(run.receiver.finalLoad.properties.phase).toBe('attached') | |
| 230 | expect(run.receiver.intake.custody.outcome).toBe('uncertain') | |
| 231 | expect(run.receiver.intake.custody.receiptCapturedAtMs).toBeNull() | |
| 232 | expect(run.receiver.intake.custody.acknowledgedAtMs).toBeNull() | |
| 233 | expect(run.faultInjection!.anchoredAtMs).toBe(run.transfer.intake!.atMs) | |
| 234 | expect(run.faultInjection!.triggeredAtMs).toBe(run.transfer.intake!.atMs + 4) | |
| 235 | expect(run.transfer.finalRelease).toBeNull() | |
| 236 | expect(run.recipient).toBeNull() | |
| 237 | expect(run.station!.ownedMassKg).toBe(0) | |
| 238 | expect(run.ownership.owner).toBe('receiver-body') | |
| 239 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 240 | }) | |
| 241 | ||
| 242 | test('receiver pad motion trips receiving interlocks and brakes the station before any intake', () => { | |
| 243 | const run = simulateCargoFleet({ scenario: 'receiver-pad-motion-during-rail' }) | |
| 244 | expect(run.status).toBe('unqualified') | |
| 245 | expect(run.source.runtime.status).toBe('completed') | |
| 246 | expect(run.source.delivery.outcome).toBe('received') | |
| 247 | expect(run.receiver.runtime.status).toBe('blocked') | |
| 248 | expect(run.receiver.runtime.authority.state).toBe('disarmed') | |
| 249 | expect(run.receiver.runtime.fault).toBe('material-receive-interlocks') | |
| 250 | expect(run.receiver.runtime.finalTruthPositionM[0]).toBeGreaterThan(3) | |
| 251 | expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0], run.receiver.runtime.finalTruthPositionM[1])).toBeLessThan(12) | |
| 252 | expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 253 | expect(run.receiver.finalLoad.properties.phase).toBe('awaiting') | |
| 254 | expect(run.transfer.intake).toBeNull() | |
| 255 | expect(run.transfer.stationDeparture).toBeNull() | |
| 256 | expect(run.station!.phase).toBe('cancelled') | |
| 257 | expect(run.station!.ownedMassKg).toBe(.1) | |
| 258 | expect(run.station!.startedAtMs).toBe(run.faultInjection!.anchoredAtMs) | |
| 259 | expect(run.station!.cancelRequestedAtMs!).toBeGreaterThan(run.faultInjection!.triggeredAtMs!) | |
| 260 | expect(run.ownership.owner).toBe('station-actor') | |
| 261 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 262 | }) | |
| 263 | ||
| 264 | test('receiver containment during loaded departure revokes authority on actual fence motion while preserving received custody', () => { | |
| 265 | const run = simulateCargoFleet({ scenario: 'receiver-containment-breach-after-intake-receipt' }) | |
| 266 | expect(run.status).toBe('unqualified') | |
| 267 | expect(run.source.runtime.status).toBe('completed') | |
| 268 | expect(run.source.delivery.outcome).toBe('received') | |
| 269 | expect(run.receiver.runtime.status).toBe('blocked') | |
| 270 | expect(run.receiver.runtime.authority.state).toBe('failsafe') | |
| 271 | expect(run.receiver.runtime.authority.reason).toBe('fence-breach') | |
| 272 | expect(run.receiver.runtime.fault).toBe('mission-fence-breach') | |
| 273 | expect(run.receiver.runtime.navigationEvidence.localPositionHealthy).toBe(true) | |
| 274 | expect(run.receiver.runtime.navigationEvidence.positionPrediction?.mode).toBe('fixture-imu') | |
| 275 | expect(run.faultInjection).toMatchObject({ forceN: [8, 0, 0], forceRampMs: 2000 }) | |
| 276 | expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0], run.receiver.runtime.finalTruthPositionM[1])).toBeGreaterThan(12) | |
| 277 | expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 278 | expect(run.receiver.finalLoad.properties.phase).toBe('attached') | |
| 279 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6) | |
| 280 | expect(run.receiver.intake.custody.outcome).toBe('received') | |
| 281 | expect(run.receiver.intake.custody.acknowledgedAtMs).toBe(run.faultInjection!.anchoredAtMs) | |
| 282 | expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4) | |
| 283 | expect(run.transfer.finalRelease).toBeNull() | |
| 284 | expect(run.receiver.delivery.outcome).toBe('not-released') | |
| 285 | expect(run.station!.phase).toBe('departed') | |
| 286 | expect(run.station!.ownedMassKg).toBe(0) | |
| 287 | expect(run.ownership.owner).toBe('receiver-body') | |
| 288 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 289 | }) | |