ReviewOS

also looking at this

stacks/drone

feat: add retained cargo fleet cancellation and recovery

#105
Merged chris wants to merge codex/cargo-fleet-operator-recovery into main
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.

README.mdmodified+4-1
Changes to README.md
@@ -128,7 +128,10 @@ Exit 2 retains a configured mission that did not complete a matched delivery and
128128The [two-carrier model](docs/cargo-fleet.md) and `./buddy cargo:fleet` now execute actual
129129finite station handover and the receiver's second delivery under separate sensor/authority
130130scopes. Eleven bounded cases include radio/host loss, uncertain receipt, phase-bound kill,
131GNSS loss, pad-motion interlocks and modeled fence-breach supervision. General fleet
131GNSS loss, pad-motion interlocks and modeled fence-breach supervision. Native
132`./buddy cargo:fleet-control --case cancel-resume` exercises a stopped-rail hold and
133explicit recovery on the same live receiving session and physical parcel; refused
134recovery retains attempted-intake uncertainty or lost authority. General fleet
132135fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
133136
134137## Agriculture sensing
app/Commands/CargoFleetControl.tsadded+27-0
Changes to app/Commands/CargoFleetControl.ts
@@ -0,0 +1,27 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoFleetSummary } from '../../resources/functions/drone/cargo/fleet'
4import { cargoFleetOperatorCases, simulateCargoFleetControl } from '../../resources/functions/drone/cargo/fleet-control'
5
6export default defineCommand({
7 name: 'cargo:fleet-control',
8 description: 'Exercise explicit modeled transport cancellation and recovery over the retained two-carrier world',
9 options: {
10 '--case <name>': { description: cargoFleetOperatorCases.join(', '), default: 'cancel-resume' },
11 },
12 async handle(options) {
13 try {
14 const fixture = simulateCargoFleetControl({ case: options.case })
15 const summary = { ...cargoFleetSummary(fixture.run), operatorCase: fixture.case }
16 if (summary.status !== 'completed') process.exitCode = 2
17 const wire = JSON.stringify(summary)
18 if (Buffer.byteLength(wire) + 1 > 16384) throw new RangeError('cargo-fleet-control-output-limit')
19 await printAsync(wire)
20 }
21 catch {
22 process.exitCode = 1
23 try { await printErrorAsync('Cargo fleet control unavailable: invalid case, execution or output failure') }
24 catch { /* A closed diagnostic pipe does not make the failed command succeed. */ }
25 }
26 },
27})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -14,6 +14,7 @@ import CargoLoadBench from '../app/Commands/CargoLoadBench'
1414import CargoDeliver from '../app/Commands/CargoDeliver'
1515import CargoMission from '../app/Commands/CargoMission'
1616import CargoFleet from '../app/Commands/CargoFleet'
17import CargoFleetControl from '../app/Commands/CargoFleetControl'
1718
1819import PayloadsBench from '../app/Commands/PayloadsBench'
1920import PayloadsCapture from '../app/Commands/PayloadsCapture'
@@ -98,6 +99,7 @@ CargoLoadBench(app)
9899CargoDeliver(app)
99100CargoMission(app)
100101CargoFleet(app)
102CargoFleetControl(app)
101103AuthoritySimulate(app)
102104DriversBench(app)
103105PayloadsBench(app)
docs/api.mdmodified+7-1
Changes to docs/api.md
@@ -70,7 +70,13 @@ grant vehicle authority or confirm custody.
7070`simulateCargoFleet({ scenario? })` and `cargoFleetSummary(run)` in `cargo/fleet.ts`
7171execute [two-carrier handover](cargo-fleet.md) through actual retained source and receiver
7272runtimes, a consumed station parcel, current-authority intake and captured custody. The
73six bounded cases retain incomplete outcomes and actual mass separately from receipts.
73eleven bounded fault cases retain incomplete outcomes and actual mass separately from receipts.
74`createCargoFleetSession()` exposes exact-epoch `step`, detached `snapshot`/terminal `result`,
75scoped `decide` and idempotent `close`, with the exported `CargoFleetOperatorDecision` type.
76`simulateCargoFleetControl({ case? })` in `cargo/fleet-control.ts` supports four explicit
77transport cancellation/recovery cases. The [operator contract](cargo-fleet.md#retained-world-and-explicit-transport-decisions)
78retains current authority, a single physical point, original receive deadlines and
79attempted-intake uncertainty. It does not restore a failed vehicle session.
7480
7581## Target sources
7682
docs/cargo-delivery.mdmodified+7-4
Changes to docs/cargo-delivery.md
@@ -216,8 +216,10 @@ receiving body in its own raw-sensor, estimator and authority runtime. With ACK
216216it stays at the declared pad with zero actual motor thrust and no phantom parcel mass.
217217A caller-invented ACK is not proof of mechanical transfer. The
218218[two-carrier model](cargo-fleet.md) now binds its receipt port to actual intake, same-epoch
219receiver authority and eleven bounded fleet cases through `cargo:fleet`. Its broader
220cancellation, fault/recovery and operational matrix remains open under #25.
219receiver authority and eleven bounded fleet cases through `cargo:fleet`. The retained
220world and `cargo:fleet-control` add same-session station cancellation and bounded
221recovery before intake is attempted. General vehicle abort/replan, uncertain-intake
222reconciliation and the wider fault/recovery and operational matrix remain open under #25.
221223
222224## BVLOS operations evidence
223225
@@ -249,7 +251,8 @@ establish an airspace detect-and-avoid system.
249251
250252## Remaining issue scope
251253
252[#25](https://github.com/stacksjs/drone/issues/25) remains open for actual distinct-vehicle
253fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation,
254[#25](https://github.com/stacksjs/drone/issues/25) remains open for general fleet
255handover/recovery beyond the bounded two-carrier model, richer endurance
256models/measurements, durable cargo/runtime reconciliation,
254257physical release/recipient validation and the separate operational evidence case.
255258The original acceptance scope is not reduced to passing named demonstrations.
docs/cargo-fleet.mdmodified+85-4
Changes to docs/cargo-fleet.md
@@ -120,18 +120,99 @@ Vehicle host termination retains a 1,000 ms independent board/plant tail. The ot
120120continues its own mission. Both routes and their failure alternatives count toward the
121121native finite graph/reserve budget; the world is bounded by the 180 s host limit and tails.
122122
123## Retained world and explicit transport decisions
124
125`createCargoFleetSession()` exposes `step`, `snapshot`, `decide`, `result` and
126idempotent `close`. The one-shot `simulateCargoFleet()` uses that same world. Each
127step requires exactly the next 4 ms epoch, starting at zero. Both retained vehicle
128sessions, station and single physical point advance together. Snapshots are detached;
129`result()` requires a terminal world and returns a detached complete report, including
130initial loads, fault anchors and transfer history. Mutating a returned report cannot
131rewrite later reads. Closing early revokes both actual board models
132and reports their current neutral outputs without advancing the plant, changing the
133last policy/measurement epochs or claiming that the moving parcel instantly stopped.
134A closed world cannot resume. Closing is not a durable checkpoint or physical settling
135procedure.
136
137The exported `CargoFleetOperatorDecision` is a strict, data-only declaration:
138
139```ts
140const state = world.snapshot()
141if (state.atMs === null || !state.receiver || !state.stationSourceEventId)
142 throw new Error('Wait for an active receiver and station event')
143const outcome = world.decide({
144 version: 1,
145 evidence: 'modeled-cargo-transport-decision-v1',
146 contextId: state.operator.contextId,
147 bootId: 102n,
148 sessionId: 202n,
149 sequence: 1,
150 issuedAtMs: state.atMs,
151 reference: state.receiver.reference,
152 sourceEventId: state.stationSourceEventId,
153 action: 'cancel',
154})
155```
156
157Call after observing an active receiving task and the actual station source event.
158The fresh context UUID separates two otherwise identical worlds; it does not
159authenticate an operator. Admission requires the exact world epoch, receiving
160boot/session/reference and source event, and the next sequence from 1 through 32.
161Accessors, extra fields and extra arguments are rejected. Wrong scope, old clock or
162replayed sequence cannot touch the parcel or consume a sequence. A valid scoped
163operational refusal does consume its sequence and records its reason. Closure during
164declaration capture cannot admit later work. Decision history is bounded to 32 events.
165
166`cancel` consumes any queued physical intake request and asks the station to stop.
167Actual finite braking retains position, velocity, mass and work. The receiving mission
168keeps its existing task/event deadline; the other carrier continues independently.
169Cancellation does not reset an estimator, replace a body or renew a failed lease.
170
171A later fresh `resume` needs the same pending receive task, current native authority,
172captured source custody, a fully stopped station and no prior intake grant. The same
173point resumes from its actual stopped position, with a new bounded rail duration and
174cumulative mechanical accounting. At most eight station resumptions fit within the
175original 180 s lifetime. An attempted intake remains uncertain even if cancellation
176prevents its queued mechanism from executing; `intake-already-attempted` prevents a
177second attempt. No inferred custody repair is performed.
178
179Native Buddy and the copied standalone CLI expose four explicit fictional decision cases:
180
181```sh
182./buddy cargo:fleet-control --case cancel
183./buddy cargo:fleet-control --case cancel-resume
184./buddy cargo:fleet-control --case cancel-after-grant
185./buddy cargo:fleet-control --case resume-after-navigation-loss
186```
187
188The default `cancel-resume` waits for actual moving transport, cancels it, waits at
189least 1 s after its physical stop, then resumes the same receiver session and parcel.
190It completes captured intake, final delivery and return. `cancel` retains the parcel
191and lets the original receive deadline lead to an alternate. `cancel-after-grant`
192withholds physical intake but retains attempted-intake uncertainty and refuses recovery.
193`resume-after-navigation-loss` refuses recovery after native navigation authority fails.
194These three cases exit 2. The completed case exits 0; invalid input, execution and output
195failures exit 1. Each emits one JSON line within 16 KiB, including explicit decision
196outcomes, source/receiver scopes and physical ownership. Identity authentication and
197physical outputs remain disabled. These are fixed simulation declarations, not an
198interactive control server or an arbitrary fleet mission interface.
199
123200## Evidence and remaining work
124201
125202`tests/unit/cargo-fleet.test.ts` exercises actual two-session physics, intake mass changes,
126203source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass,
127204backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion.
128205`tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire.
129`scripts/qualify-cargo-fleet-cli.ts` exercises all eleven cases through a copied standalone
130binary outside the checkout and retains output hashes and factual outcomes.
206`tests/unit/cargo-fleet-operator.test.ts` exercises same-session recovery, braking,
207foreign/stale/replayed decisions, close reentry, retained output observations and the
208grant/execution gap. `tests/unit/cargo-fleet-control-cli.test.ts` exercises native command
209exits and bounded reports. `scripts/qualify-cargo-fleet-cli.ts` exercises all eleven
210fault cases and four operator cases through a copied standalone binary outside the
211checkout and retains output hashes and factual outcomes.
131212
132213These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual
133214intervention at every phase, broader navigation degradation and containment campaigns,
134wider late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable
135whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning
215wider late/foreign/replayed receipt campaigns, general vehicle abort/replan and
216uncertain-intake reconciliation, durable whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning
136217remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies.
137218The wider measured hardware, endurance and operational evidence requirements remain open.
docs/cargo-load.mdmodified+4-2
Changes to docs/cargo-load.md
@@ -38,8 +38,10 @@ The retained source qualification passed 38 tests and 5,553 assertions across co
3838Issue #25 remains open. The separate [modeled cargo mission](cargo-delivery.md) now implements
3939configured delivery, captured mechanism/receiver evidence and custody, declared reserve budgets,
4040alternates, two modeled links and phase-bound fault cases. Those capabilities do not change this
41bench's characterization result or flags. Distinct-vehicle fleet handover, measured/electrical
42endurance, release hardware, operational authorization, detect-and-avoid and physical/HIL
41bench's characterization result or flags. The separate [two-carrier model](cargo-fleet.md)
42connects actual finite station transport, intake and captured custody, including bounded
43transport cancellation/recovery. General fleet recovery, measured/electrical endurance,
44release hardware, operational authorization, detect-and-avoid and physical/HIL
4345qualification remain open.
4446
4547The result keeps `physicalFlightEnabled` and `identityAuthenticated` false, with physical board outputs inhibited. Its six delivery, mechanism verification, custody, recipient, mission and energy-margin capability flags remain false. This characterization does not authorize physical operation or long-range transport.
docs/cargo-station.mdmodified+9-1
Changes to docs/cargo-station.md
@@ -72,6 +72,13 @@ A falling parcel continues under gravity until plane contact. These requests ret
7272epoch, current phase and fractional stop epoch. Cancellation retains the parcel and
7373prevents transfer; it does not restore an aircraft's authority or land a vehicle.
7474
75`resume()` accepts only a fully stopped cancelled parcel. It starts a fresh bounded
76quintic rail from the retained position, preserving mechanical work and the original
77180 s lifetime. A missed pickup cannot be resumed into a successful pickup. Snapshots
78retain the previous start, cancellation and stop epochs plus the new position/duration.
79The detached history contains at most eight resumptions. Further requests are refused;
80this primitive does not itself restore vehicle authority or reconcile uncertain custody.
81
7582`takeReadyParcel()` accepts only `ready`, returns a detached parcel and clears the
7683station's owned mass. Further departure attempts are refused. The receiving body's
7784`attachParcel()` still independently checks its actual current joint. A stale destination
@@ -105,5 +112,6 @@ ACK or coordinate two live vehicle mission engines. Those operations must use ea
105112vehicle's own sensor, estimator and current authority scope on the common clock. The
106113[receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds
107114a declared pad under the shared engine. The [two-carrier world and CLI](cargo-fleet.md) now connect physical intake, current
108authority and captured acknowledgement for six bounded cases. A complete cancellation,
115authority and captured acknowledgement for eleven bounded fault cases and explicit
116transport cancellation/recovery. A complete cancellation,
109117fault/recovery and general fleet matrix remains open under [#25](https://github.com/stacksjs/drone/issues/25).
docs/roadmap/README.mdmodified+6-2
Changes to docs/roadmap/README.md
@@ -24,8 +24,12 @@ companion application and simulator, not a native board autopilot.
2424 evidence remain open in [#10](https://github.com/stacksjs/drone/issues/10).
2525- Configured modeled cargo delivery uses captured release/receiver custody, finite alternate landing,
2626 declared reserves, scoped redundant links and post-release failures on the shared runtime.
27 Distinct-vehicle fleet handover and physical/electrical/operational qualification remain in
28 [#25](https://github.com/stacksjs/drone/issues/25). See [cargo contracts](../cargo-delivery.md).
27 The [two-carrier model](../cargo-fleet.md) connects finite station transport, actual intake,
28 captured custody and a second delivery under separate retained sensor/authority sessions.
29 Eleven fault cases and explicit station cancel/resume retain physical ownership and uncertainty.
30 General fleet recovery, durable world reconciliation and physical/electrical/operational
31 qualification remain in [#25](https://github.com/stacksjs/drone/issues/25). See
32 [cargo contracts](../cargo-delivery.md).
2933- TypeScript follow/behavior library, kinematic simulator, controller leases and envelope checks.
3034- Pi AI Camera acquisition bridge and explicit image-space target selection/loss policy, pending Pi hardware validation.
3135- An optional visual guard that holds metric follow logic on lost, unconfirmed, stale or changed visual selection.
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is
2929| 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) |
3030| 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) |
3131| 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 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) |
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; explicit station cancellation and same-session recovery before attempted intake; 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) |
3333| 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) |
3434| 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) |
3535| 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) |
docs/roadmap/issues.jsonmodified+21-8
Changes to docs/roadmap/issues.json
@@ -766,20 +766,33 @@
766766 "implementation": {
767767 "status": "partial",
768768 "implemented": [
769 "Owned carried-point-mass/CG physics through the existing multirotor loop",
770 "Centered/forward carriage, prescribed zero-ejection separation and native link-loss characterization",
771 "Strict typed library, bounded native CLI summary and exact legacy-output compatibility"
769 "Owned carried-point-mass/CG dynamics, actual modeled release/intake, captured mechanism/site custody and counted return or alternate landing",
770 "Configured delivery missions, declared power/reserve margins, scoped primary/backup links and observed release/receipt phase failures",
771 "Two retained raw-sensor/estimator/authority carriers on one clock with finite station handling, globally single-parcel ownership, captured intake and a second delivery",
772 "Eleven bounded fleet cases including link, host, navigation, kill, pad-motion and containment failures, with independent output expiry and retained uncertainty",
773 "Explicit scoped station cancellation and stopped-point recovery before attempted intake, same-session receive deadlines and bounded decision/history admission",
774 "Native Buddy and copied standalone commands, bounded reports, actual output compatibility and separate BVLOS evidence requirements"
772775 ],
773776 "remaining": [
774 "Delivery mission, site and recipient confirmation, release mechanism/ACK and custody",
775 "Endurance reserves, alternates, redundant links, degraded navigation, containment, diversion and fleet handover",
776 "Separate operational authorization/detect-and-avoid and physical/HIL qualification"
777 "General fleet fault/recovery and vehicle abort/replan, uncertain-intake reconciliation, durable whole-world recovery and richer rendezvous/collision planning",
778 "Electrical/measured endurance and physical release/intake/recipient qualification",
779 "Operational authorization, detect-and-avoid and hardware/HIL qualification separate from software capability"
777780 ],
778781 "evidence": [
779782 "docs/cargo-load.md",
780 "tests/unit/controls.test.ts",
783 "docs/cargo-delivery.md",
784 "docs/cargo-station.md",
785 "docs/cargo-fleet.md",
781786 "tests/unit/cargo-load.test.ts",
782 "tests/unit/cargo-load-cli.test.ts"
787 "tests/unit/cargo-delivery.test.ts",
788 "tests/unit/cargo-delivery-phase-faults.test.ts",
789 "tests/unit/cargo-intake-coordinator.test.ts",
790 "tests/unit/cargo-station.test.ts",
791 "tests/unit/cargo-fleet.test.ts",
792 "tests/unit/cargo-fleet-operator.test.ts",
793 "tests/unit/cargo-fleet-control-cli.test.ts",
794 "scripts/qualify-cargo-cli.ts",
795 "scripts/qualify-cargo-fleet-cli.ts"
783796 ]
784797 }
785798 },
resources/functions/drone/cargo/fleet-control.tsadded+37-0
Changes to resources/functions/drone/cargo/fleet-control.ts
@@ -0,0 +1,37 @@
1import { createCargoFleetSession } from './fleet'
2
3export const cargoFleetOperatorCases = ['cancel', 'cancel-resume', 'cancel-after-grant', 'resume-after-navigation-loss'] as const
4type OperatorCase = typeof cargoFleetOperatorCases[number]
5function captureCase(input: unknown): OperatorCase {
6 try {
7 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'case')) throw new TypeError()
8 const d = Object.getOwnPropertyDescriptor(input, 'case')
9 if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoFleetOperatorCases.some(name => name === d.value))) throw new TypeError()
10 return (d?.value ?? 'cancel-resume') as OperatorCase
11 }
12 catch { throw new TypeError('cargo-fleet-control-arguments') }
13}
14
15/** Explicit fictional operator decisions over the retained world, without hardware dispatch. */
16export function simulateCargoFleetControl(input: unknown = {}, ...extra: unknown[]) {
17 if (extra.length) throw new TypeError('cargo-fleet-control-arguments')
18 const name = captureCase(input), world = createCargoFleetSession({ scenario: name === 'resume-after-navigation-loss' ? 'receiver-navigation-loss-during-rail' : 'nominal' })
19 let cancelled = false, resumed = false
20 try {
21 for (let nowMs = 0; ; nowMs += 4) {
22 const progress = world.step(nowMs), state = world.snapshot()
23 if (progress.terminal) return { case: name, run: world.result() }
24 const decide = (action: 'cancel' | 'resume', sequence: number) => world.decide({ version: 1, evidence: 'modeled-cargo-transport-decision-v1', contextId: state.operator.contextId,
25 bootId: state.receiver!.authority.bootId, sessionId: state.receiver!.authority.sessionId, sequence, issuedAtMs: nowMs, reference: state.receiver!.reference, sourceEventId: state.stationSourceEventId, action })
26 const cancelNow = name === 'cancel-after-grant' ? state.intake.custody.grantedAtMs !== null
27 : state.station?.phase === 'moving' && (name === 'resume-after-navigation-loss' || Math.hypot(...state.station.parcel!.velocityMps) > .1)
28 if (!cancelled && cancelNow) { decide('cancel', 1); cancelled = true }
29 else if (cancelled && !resumed && name !== 'cancel') {
30 const resumeNow = name === 'cancel-after-grant' || (name === 'resume-after-navigation-loss' ? state.receiver?.authority.state === 'failsafe'
31 : state.station?.phase === 'cancelled' && state.station.stoppedAtMs !== null && nowMs >= state.station.stoppedAtMs + 1000)
32 if (resumeNow) { decide('resume', 2); resumed = true }
33 }
34 }
35 }
36 finally { world.close() }
37}
resources/functions/drone/cargo/fleet.tsmodified+152-56
Changes to resources/functions/drone/cargo/fleet.ts
@@ -17,6 +17,18 @@ type Station = ReturnType<typeof createCargoStationHandler>
1717type Release = ReturnType<ReturnType<typeof createReceivingRigidBody>['separateCarriedLoad']>
1818type Departure = Extract<ReturnType<Station['takeReadyParcel']>, { outcome: 'departed' }>
1919type Owner = 'source-body' | 'station-actor' | 'receiver-body' | 'final-parcel-actor' | 'loose-parcel'
20export interface CargoFleetOperatorDecision {
21 version: 1
22 evidence: 'modeled-cargo-transport-decision-v1'
23 contextId: string
24 bootId: bigint
25 sessionId: bigint
26 sequence: number
27 issuedAtMs: number
28 reference: string
29 sourceEventId: string
30 action: 'cancel' | 'resume'
31}
2032export 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
2133export type CargoFleetScenario = typeof cargoFleetScenarios[number]
2234function captureScenario(input: unknown): CargoFleetScenario {
@@ -55,7 +67,7 @@ function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs:
5567}
5668
5769/** One modeled parcel, two retained raw-sensor/estimator/authority runtimes, and finite station physics. */
58export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
70export function createCargoFleetSession(input: unknown = {}, ...extra: unknown[]) {
5971 if (extra.length) throw new TypeError('cargo-fleet-arguments')
6072 const scenario = captureScenario(input)
6173 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 } : {}) } : {}) }
@@ -64,6 +76,9 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
6476 const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot()
6577 let station: Station | null = null, recipient: Station | null = null, loose: Station | null = null
6678 const physical: { owner: Owner } = { owner: 'source-body' }
79 const operatorContextId = crypto.randomUUID()
80 const operatorEvents: { atMs: number, sequence: number, action: 'cancel' | 'resume', outcome: 'accepted' | 'refused', reason: string }[] = []
81 let operatorSequence = 0, operatorHold = false, currentAtMs: number | null = null, nextAtMs = 0, terminal = false, closed = false, stepping = false
6782 let maximumMassErrorKg = 0, massSamples = 0
6883 const transitions: { atMs: number, from: Owner, to: Owner }[] = []
6984 let sourceRelease: { atMs: number, eventId: string, event: Release } | null = null, finalRelease: { atMs: number, eventId: string, event: Release } | null = null
@@ -177,72 +192,153 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
177192 function totalMass() { return bodyA.loadSnapshot().properties.totalMassKg + bodyB.loadSnapshot().properties.totalMassKg + (station?.snapshot().ownedMassKg ?? 0) + (recipient?.snapshot().ownedMassKg ?? 0) + (loose?.snapshot().ownedMassKg ?? 0) }
178193 function sampleMass() { maximumMassErrorKg = Math.max(maximumMassErrorKg, Math.abs(totalMass() - 3.1)); massSamples++ }
179194 sampleMass()
180 try {
181 for (let nowMs = 0; nowMs <= 181000; nowMs += 4) {
182 if (!progressA?.terminal) progressA = a.step(nowMs)
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 }
192 if (station && station.snapshot().atMs < nowMs) station.step(nowMs)
193 if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs)
194 if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs)
195 if (commands.source && nowMs > commands.source.issuedAtMs) {
196 const command = commands.source; commands.source = null
197 if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) {
198 const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event }
199 if (event.outcome === 'separated') {
200 station = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: stationSite.positionM, radiusM: stationSite.radiusM }, destinationM: bodyB.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
201 changeOwner('station-actor', nowMs); sampleMass()
202 }
195 function advance(nowMs: number) {
196 if (!progressA?.terminal) progressA = a.step(nowMs)
197 if (!progressB?.terminal) progressB = b.step(nowMs)
198 if (faultInjection?.scenario === 'source-kill-after-station-receipt' && faultInjection.anchoredAtMs === null) {
199 const receipt = sourceCoordinator.snapshot().delivery
200 if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs
201 }
202 if (faultInjection?.scenario === 'receiver-containment-breach-after-intake-receipt' && faultInjection.anchoredAtMs === null) {
203 const receipt = intakeCoordinator.snapshot().custody
204 if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs
205 }
206 if (station && station.snapshot().atMs < nowMs) station.step(nowMs)
207 if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs)
208 if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs)
209 if (commands.source && nowMs > commands.source.issuedAtMs) {
210 const command = commands.source; commands.source = null
211 if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) {
212 const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event }
213 if (event.outcome === 'separated') {
214 station = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: stationSite.positionM, radiusM: stationSite.radiusM }, destinationM: bodyB.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
215 changeOwner('station-actor', nowMs); sampleMass()
203216 }
204217 }
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
218 }
219 if (!operatorHold && station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) {
220 station.start()
221 if (faultInjection && ['receiver-navigation-loss-during-rail', 'receiver-pad-motion-during-rail'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs ??= nowMs
222 }
223 if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel()
224 if (commands.intake && nowMs > commands.intake.issuedAtMs) {
225 const grant = commands.intake; commands.intake = null
226 if (physical.owner === 'station-actor' && station?.snapshot().phase === 'ready' && sourceRelease?.eventId === grant.sourceEventId && grant.receiverId === 'carrier-b' && grant.bootId === 102n && grant.sessionId === 202n && nowMs < grant.expiresAtMs
227 && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) {
228 const departure = station.takeReadyParcel()
229 if (departure.outcome === 'departed') {
230 stationDeparture = departure
231 const attachment = bodyB.attachParcel(departure.parcel)
232 intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) }
233 intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 }
234 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 }
235 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) }
236 sampleMass()
237 }
208238 }
209 if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel()
210 if (commands.intake && nowMs > commands.intake.issuedAtMs) {
211 const grant = commands.intake; commands.intake = null
212 if (physical.owner === 'station-actor' && station?.snapshot().phase === 'ready' && sourceRelease?.eventId === grant.sourceEventId && grant.receiverId === 'carrier-b' && grant.bootId === 102n && grant.sessionId === 202n && nowMs < grant.expiresAtMs
213 && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) {
214 const departure = station.takeReadyParcel()
215 if (departure.outcome === 'departed') {
216 stationDeparture = departure
217 const attachment = bodyB.attachParcel(departure.parcel)
218 intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) }
219 intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 }
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 }
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) }
222 sampleMass()
223 }
239 }
240 if (commands.receiver && nowMs > commands.receiver.issuedAtMs) {
241 const command = commands.receiver; commands.receiver = null
242 if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) {
243 const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event }
244 if (event.outcome === 'separated') {
245 recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
246 changeOwner('final-parcel-actor', nowMs); sampleMass()
224247 }
225248 }
226 if (commands.receiver && nowMs > commands.receiver.issuedAtMs) {
227 const command = commands.receiver; commands.receiver = null
228 if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) {
229 const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event }
230 if (event.outcome === 'separated') {
231 recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
232 changeOwner('final-parcel-actor', nowMs); sampleMass()
233 }
249 }
250 if (nowMs % 20 === 0) sampleMass()
251 }
252 function close() {
253 if (closed) return
254 const interrupted = !terminal
255 closed = true
256 if (interrupted) { terminal = true; station?.cancel(); commands.intake = null }
257 try { a.close() }
258 finally {
259 try { b.close() }
260 finally {
261 // Closing revokes the boards now. Keep the last observation/policy clocks
262 // and physical point, but do not expose their previously powered output.
263 if (interrupted) {
264 const source = a.result(), receiver = b.result()
265 if (progressA) progressA = { ...progressA, hostAvailable: false, terminal: true, board: source.board, authority: source.authority, mission: source.mission }
266 if (progressB) progressB = { ...progressB, hostAvailable: false, terminal: true, board: receiver.board, authority: receiver.authority, mission: receiver.mission }
234267 }
235268 }
236 if (nowMs % 20 === 0) sampleMass()
237 if (progressA?.terminal && progressB?.terminal) break
238269 }
270 }
271 function step(nowMs: number, ...extra: unknown[]) {
272 if (closed || terminal) throw new Error('Cargo fleet session is closed')
273 if (stepping) throw new Error('Cargo fleet session is already stepping')
274 if (extra.length || !Number.isSafeInteger(nowMs) || nowMs !== nextAtMs || nowMs > 181000) throw new RangeError('cargo-fleet-clock')
275 stepping = true
276 try {
277 currentAtMs = nowMs; advance(nowMs); nextAtMs += 4
278 terminal = !!progressA?.terminal && !!progressB?.terminal
279 return { atMs: nowMs, terminal }
280 }
281 catch (error) { close(); throw error }
282 finally { stepping = false }
283 }
284 function snapshot() {
285 return structuredClone({ atMs: currentAtMs, terminal, closed, source: progressA, receiver: progressB, station: station?.snapshot() ?? null, stationSourceEventId: sourceRelease?.eventId ?? null, intake: intakeCoordinator.snapshot(),
286 ownership: { owner: physical.owner, totalMassKg: totalMass() }, operator: { contextId: operatorContextId, identityAuthenticated: false as const, lastSequence: operatorSequence, hold: operatorHold, events: operatorEvents } })
287 }
288 function decide(input: unknown, ...extra: unknown[]): { outcome: 'accepted' | 'refused', reason: string } {
289 if (closed || terminal || stepping) return { outcome: 'refused', reason: 'session-unavailable' }
290 let decision: Record<string, unknown>
291 try {
292 const fields = ['version', 'evidence', 'contextId', 'bootId', 'sessionId', 'sequence', 'issuedAtMs', 'reference', 'sourceEventId', 'action']
293 if (extra.length || !input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError()
294 decision = {}
295 for (const key of fields) { const d = Object.getOwnPropertyDescriptor(input, key); if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError(); decision[key] = d.value }
296 if (decision.version !== 1 || decision.evidence !== 'modeled-cargo-transport-decision-v1' || typeof decision.contextId !== 'string' || decision.contextId.length > 80
297 || typeof decision.bootId !== 'bigint' || typeof decision.sessionId !== 'bigint' || !Number.isSafeInteger(decision.sequence) || (decision.sequence as number) < 1 || (decision.sequence as number) > 32
298 || !Number.isSafeInteger(decision.issuedAtMs) || (decision.issuedAtMs as number) < 0 || typeof decision.reference !== 'string' || decision.reference.length > 120
299 || typeof decision.sourceEventId !== 'string' || decision.sourceEventId.length > 160 || !['cancel', 'resume'].includes(decision.action as string)) throw new TypeError()
300 }
301 catch { throw new TypeError('cargo-fleet-operator-declaration') }
302 if (closed || terminal || stepping) return { outcome: 'refused', reason: 'session-unavailable' }
303 if (decision.contextId !== operatorContextId || decision.bootId !== 102n || decision.sessionId !== 202n || decision.reference !== progressB?.reference || decision.sourceEventId !== sourceRelease?.eventId) return { outcome: 'refused', reason: 'operator-scope' }
304 if (decision.issuedAtMs !== currentAtMs) return { outcome: 'refused', reason: 'operator-clock' }
305 if (decision.sequence !== operatorSequence + 1 || operatorEvents.length >= 32) return { outcome: 'refused', reason: 'operator-sequence' }
306 operatorSequence = decision.sequence as number
307 const action = decision.action as 'cancel' | 'resume'
308 const record = (outcome: 'accepted' | 'refused', reason: string) => { operatorEvents.push({ atMs: currentAtMs!, sequence: operatorSequence, action, outcome, reason }); return { outcome, reason } }
309 if (physical.owner !== 'station-actor' || !station) return record('refused', 'parcel-not-at-station')
310 if (action === 'cancel') {
311 const result = station.cancel()
312 if (result.outcome === 'refused') return record('refused', 'station-' + result.reason)
313 commands.intake = null; operatorHold = true
314 return record('accepted', 'transport-cancellation-requested')
315 }
316 const command = progressB?.mission.payload
317 if (!operatorHold || !command || command.operation !== 'material-receive' || !hasAuthority(progressB, command, currentAtMs!, 102n, 202n) || sourceCoordinator.snapshot().delivery.outcome !== 'received') return record('refused', 'recovery-authority-unavailable')
318 if (intakeCoordinator.snapshot().custody.grantedAtMs !== null) return record('refused', 'intake-already-attempted')
319 const result = station.resume()
320 if (result.outcome === 'refused') return record('refused', 'station-' + result.reason)
321 operatorHold = false
322 return record('accepted', 'transport-resumed')
323 }
324 function result() {
325 if (!terminal) throw new Error('Cargo fleet session is still active')
239326 const sourceRun = a.result(), receiverRun = b.result(), sourceDelivery = sourceCoordinator.snapshot().delivery, receiverDelivery = deliveryCoordinator.snapshot().delivery, receiverIntake = intakeCoordinator.snapshot()
240327 const completed = sourceRun.status === 'completed' && receiverRun.status === 'completed' && sourceDelivery.outcome === 'received' && receiverIntake.custody.outcome === 'received' && receiverDelivery.outcome === 'received' && physical.owner === 'final-parcel-actor' && recipient?.snapshot().phase === 'held' && maximumMassErrorKg < 1e-12
241 return { version: 1, evidence: 'owned-cargo-fleet-simulation' as const, scenario, faultInjection, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const, status: completed ? 'completed' as const : 'unqualified' as const,
328 return structuredClone({ version: 1, evidence: 'owned-cargo-fleet-simulation' as const, scenario, faultInjection, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const, status: completed ? 'completed' as const : 'unqualified' as const,
242329 source: { links: linksA.snapshot(), linkEvents: linksA.events(), initialLoad: initialA, finalLoad: bodyA.loadSnapshot(), runtime: sourceRun, delivery: sourceDelivery }, receiver: { links: linksB.snapshot(), linkEvents: linksB.events(), initialLoad: initialB, finalLoad: bodyB.loadSnapshot(), runtime: receiverRun, intake: receiverIntake, delivery: receiverDelivery },
243 transfer: { sourceRelease, stationDeparture, intake, finalRelease }, station: station?.snapshot() ?? null, recipient: recipient?.snapshot() ?? null, loose: loose?.snapshot() ?? null, ownership: { owner: physical.owner, initialMassKg: 3.1, finalMassKg: totalMass(), maximumMassErrorKg, samples: massSamples, transitions } }
330 transfer: { sourceRelease, stationDeparture, intake, finalRelease }, station: station?.snapshot() ?? null, recipient: recipient?.snapshot() ?? null, loose: loose?.snapshot() ?? null, ownership: { owner: physical.owner, initialMassKg: 3.1, finalMassKg: totalMass(), maximumMassErrorKg, samples: massSamples, transitions }, ...(operatorEvents.length ? { operator: { contextId: operatorContextId, identityAuthenticated: false as const, events: operatorEvents } } : {}) })
331 }
332 return { step, snapshot, decide, result, close }
333}
334
335/** Finite one-shot facade over the same retained shared world. */
336export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
337 const world = createCargoFleetSession(input, ...extra)
338 try {
339 for (let nowMs = 0; ; nowMs += 4) if (world.step(nowMs).terminal) return world.result()
244340 }
245 finally { a.close(); b.close() }
341 finally { world.close() }
246342}
247343
248344/** Compact actual observations for Buddy/standalone output; never serialize the full runtime journal. */
@@ -261,5 +357,5 @@ export function cargoFleetSummary(run: ReturnType<typeof simulateCargoFleet>) {
261357 transfer: { sourceRelease: release(run.transfer.sourceRelease), stationDeparture: run.transfer.stationDeparture, intake: capturedIntake ? { atMs: capturedIntake.atMs, outcome: attachment!.outcome, eventId: capturedIntake.grant.command.eventId, sourceEventId: capturedIntake.grant.sourceEventId,
262358 receiverScope: { bootId: String(capturedIntake.receiver.authority.bootId), sessionId: String(capturedIntake.receiver.authority.sessionId), epoch: capturedIntake.receiver.authority.epoch, reference: capturedIntake.receiver.reference, policyAtMs: capturedIntake.receiver.policyAtMs },
263359 massBeforeKg: attachment!.outcome === 'attached' ? attachment!.before.properties.totalMassKg : null, massAfterKg: attachment!.outcome === 'attached' ? attachment!.after.properties.totalMassKg : null } : null, finalRelease: release(run.transfer.finalRelease) },
264 station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership }
360 station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership, ...(run.operator ? { operator: run.operator } : {}) }
265361}
resources/functions/drone/cargo/station.tsmodified+14-1
Changes to resources/functions/drone/cargo/station.ts
@@ -23,6 +23,7 @@ export interface CargoStationSnapshot {
2323 parcel: IntakeParcel | null
2424 forceN: SensorVector
2525 impact: { atMs: number, positionM: SensorVector, velocityBeforeMps: SensorVector, impulseNs: SensorVector, dissipationJ: number } | null
26 resumptions?: { atMs: number, previousStartedAtMs: number | null, cancelRequestedAtMs: number, stoppedAtMs: number, positionM: SensorVector, durationMs: number }[]
2627 energy: { initialJ: number, currentJ: number, departedJ: number, stationNetWorkJ: number, positiveWorkJ: number, negativeWorkJ: number, planeDissipationJ: number, residualJ: number }
2728}
2829function record(input: unknown, names: string[]) {
@@ -77,6 +78,7 @@ export function createCargoStationHandler(input: unknown) {
7778 let forceN: SensorVector = [0, 0, 0], impact: CargoStationSnapshot['impact'] = null
7879 const mechanical = (p: IntakeParcel) => .5 * p.massKg * p.velocityMps.reduce((sum, n) => sum + n * n, 0) - p.massKg * gravity * p.positionM[2]
7980 const initialJ = mechanical(parcel)
81 const resumptions: NonNullable<CargoStationSnapshot['resumptions']> = []
8082 let departedJ = 0, stationNetWorkJ = 0, positiveWorkJ = 0, negativeWorkJ = 0, planeDissipationJ = 0
8183 function capturePlane(atMs: number) {
8284 const p = parcel!, before = [...p.velocityMps] as SensorVector
@@ -96,7 +98,7 @@ export function createCargoStationHandler(input: unknown) {
9698 function snapshot(): CargoStationSnapshot {
9799 const currentJ = parcel ? mechanical(parcel) : 0
98100 return structuredClone({ evidence: 'modeled-cargo-station-v1', physicalActuationEnabled: false, phase, atMs: nowMs, startedAtMs, durationMs: motionMs, cancelRequestedAtMs, stoppedAtMs, parcel,
99 ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ } })
101 ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ }, ...(resumptions.length ? { resumptions } : {}) })
100102 }
101103 return {
102104 snapshot,
@@ -108,6 +110,17 @@ export function createCargoStationHandler(input: unknown) {
108110 fromM = [...parcel!.positionM]; motionMs = duration; startedAtMs = nowMs; phase = 'moving'
109111 return { outcome: 'started', atMs: nowMs, durationMs: duration }
110112 },
113 resume(...args: unknown[]): { outcome: 'resumed', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-stopped' | 'pickup-missed' | 'recovery-limit' | 'deadline' } {
114 if (args.length) throw new TypeError('cargo-station-arguments')
115 if (phase !== 'cancelled' || !parcel || cancelRequestedAtMs === null || stoppedAtMs === null || nowMs < stoppedAtMs || Math.hypot(...parcel.velocityMps) !== 0) return { outcome: 'refused', reason: 'not-stopped' }
116 if (startedAtMs === null && (!impact || Math.hypot(impact.positionM[0] - config.pickup.centerM[0], impact.positionM[1] - config.pickup.centerM[1]) > config.pickup.radiusM)) return { outcome: 'refused', reason: 'pickup-missed' }
117 if (resumptions.length >= 8) return { outcome: 'refused', reason: 'recovery-limit' }
118 const duration = durationMs(distance(parcel.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2)
119 if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' }
120 resumptions.push({ atMs: nowMs, previousStartedAtMs: startedAtMs, cancelRequestedAtMs, stoppedAtMs, positionM: [...parcel.positionM], durationMs: duration })
121 fromM = [...parcel.positionM]; motionMs = duration; startedAtMs = nowMs; cancelRequestedAtMs = null; stoppedAtMs = null; brake = null; phase = 'moving'
122 return { outcome: 'resumed', atMs: nowMs, durationMs: duration }
123 },
111124 cancel(...args: unknown[]): { outcome: 'cancelling' | 'cancelled', atMs: number } | { outcome: 'refused', reason: 'already-cancelled' | 'departed' } {
112125 if (args.length) throw new TypeError('cargo-station-arguments')
113126 if (phase === 'departed') return { outcome: 'refused', reason: 'departed' }
scripts/qualify-cargo-fleet-cli.tsmodified+30-1
Changes to scripts/qualify-cargo-fleet-cli.ts
@@ -47,4 +47,33 @@ for (const [scenario, status, owner, massKg, custody] of [
4747 rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) })
4848 console.log(scenario + ' PASS')
4949}
50await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-fleet-cli-consistency', binarySha256: hash(new Uint8Array(await Bun.file(binary).arrayBuffer())), physicalFlightEnabled: false, identityAuthenticated: false, rows }, null, 2))
50const operatorRows: Record<string, unknown>[] = []
51for (const name of ['cancel', 'cancel-resume', 'cancel-after-grant', 'resume-after-navigation-loss']) {
52 const r = await runBoundedHostProcess([binary, 'cargo:fleet-control', '--case', name], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet control ' + name })
53 const bytes = Buffer.from(r.stdout, 'utf8')
54 await Bun.write(join(output, 'operator-' + name + '.stdout'), bytes); await Bun.write(join(output, 'operator-' + name + '.stderr'), r.stderr)
55 const completed = name === 'cancel-resume', s = JSON.parse(r.stdout)
56 if (r.exitCode !== (completed ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2
57 || s.evidence !== 'owned-cargo-fleet-simulation' || s.operatorCase !== name || s.status !== (completed ? 'completed' : 'unqualified') || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false
58 || s.operator.identityAuthenticated !== false || !s.operator.contextId || s.operator.events[0]?.action !== 'cancel' || s.operator.events[0]?.outcome !== 'accepted' || s.operator.events[0]?.sequence !== 1
59 || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202') throw new Error('Unqualified copied operator report: ' + name)
60 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) throw new Error('Unqualified copied operator output inhibition: ' + name)
61 if (completed) {
62 const resume = s.station.resumptions?.[0]
63 if (s.operator.events.length !== 2 || s.operator.events[1].action !== 'resume' || s.operator.events[1].outcome !== 'accepted' || s.operator.events[1].sequence !== 2 || !resume || s.station.resumptions.length !== 1
64 || resume.atMs - resume.stoppedAtMs < 1000 || s.operator.events[1].atMs !== resume.atMs || resume.cancelRequestedAtMs !== s.operator.events[0].atMs
65 || s.ownership.owner !== 'final-parcel-actor' || s.receiver.intake.outcome !== 'received' || s.receiver.delivery.outcome !== 'received' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6
66 || s.transfer.intake.atMs <= resume.atMs || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.recipient.ownedMassKg !== .1 || s.ownership.transitions.length !== 3) throw new Error('Unqualified copied operator recovery: ' + name)
67 }
68 else {
69 if (s.ownership.owner !== 'station-actor' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.station.resumptions !== undefined || s.transfer.intake !== null || s.transfer.finalRelease !== null || s.receiver.finalLoad.totalMassKg !== 1.5) throw new Error('Unqualified copied operator retention: ' + name)
70 if (name === 'cancel' && s.operator.events.length !== 1) throw new Error('Unexpected copied recovery')
71 if (name !== 'cancel' && (s.operator.events.length !== 2 || s.operator.events[1].action !== 'resume' || s.operator.events[1].outcome !== 'refused' || s.operator.events[1].sequence !== 2
72 || s.operator.events[1].reason !== (name === 'cancel-after-grant' ? 'intake-already-attempted' : 'recovery-authority-unavailable'))) throw new Error('Unqualified copied refused recovery: ' + name)
73 if (name === 'cancel-after-grant' && s.receiver.intake.outcome !== 'uncertain') throw new Error('Lost attempted intake uncertainty')
74 if (name === 'resume-after-navigation-loss' && s.receiver.fault !== 'estimator-loss') throw new Error('Lost operator authority failure')
75 }
76 operatorRows.push({ case: name, exit: r.exitCode, status: s.status, owner: s.ownership.owner, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) })
77 console.log('operator-' + name + ' PASS')
78}
79await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-fleet-cli-consistency', binarySha256: hash(new Uint8Array(await Bun.file(binary).arrayBuffer())), physicalFlightEnabled: false, identityAuthenticated: false, rows, operatorRows }, null, 2))
tests/tsconfig.runtime.jsonmodified+2-0
Changes to tests/tsconfig.runtime.json
@@ -29,6 +29,8 @@
2929 "./unit/cargo-receive-mission.test.ts",
3030 "./unit/cargo-fleet.test.ts",
3131 "./unit/cargo-fleet-cli.test.ts",
32 "./unit/cargo-fleet-operator.test.ts",
33 "./unit/cargo-fleet-control-cli.test.ts",
3234 "./unit/cargo-delivery.test.ts",
3335 "./unit/cargo-delivery-config.test.ts",
3436 "./unit/cargo-delivery-phase-faults.test.ts",
tests/unit/cargo-fleet-control-cli.test.tsadded+47-0
Changes to tests/unit/cargo-fleet-control-cli.test.ts
@@ -0,0 +1,47 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3
4test('native fleet operator command resumes the same retained parcel and reports bounded decision evidence', async () => {
5 const result = await command(['cargo:fleet-control', '--case', 'cancel-resume']).withTimeout(30000)
6 expect(result.exitCode).toBe(0)
7 const report = JSON.parse(result.stdout)
8 expect(report.status).toBe('completed')
9 expect(report.operator.identityAuthenticated).toBe(false)
10 expect(report.operator.events.map((e: { action: string, outcome: string }) => [e.action, e.outcome])).toEqual([['cancel', 'accepted'], ['resume', 'accepted']])
11 expect(report.station.resumptions).toHaveLength(1)
12 expect(report.station.resumptions[0].atMs - report.station.resumptions[0].stoppedAtMs).toBeGreaterThanOrEqual(1000)
13 expect(report.receiver.scope).toEqual({ bootId: '102', sessionId: '202' })
14 expect(report.receiver.intake.outcome).toBe('received')
15 expect(report.receiver.delivery.outcome).toBe('received')
16 expect(report.ownership.finalMassKg).toBe(3.1)
17 expect(report.physicalFlightEnabled).toBe(false)
18 expect(report.source.board.actuators).toEqual([0, 0, 0, 0])
19 expect(report.receiver.board.actuators).toEqual([0, 0, 0, 0])
20 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
21 expect(result.stdout.split('\n')).toHaveLength(2)
22}, 40000)
23
24test('operator CLI preserves cancellation, attempted-intake uncertainty and failed authority as incomplete', async () => {
25 for (const name of ['cancel', 'cancel-after-grant', 'resume-after-navigation-loss']) {
26 const result = await command(['cargo:fleet-control', '--case', name]).withTimeout(30000)
27 expect(result.exitCode).toBe(2)
28 const report = JSON.parse(result.stdout)
29 expect(report.status).toBe('unqualified')
30 expect(report.ownership.owner).toBe('station-actor')
31 expect(report.station.ownedMassKg).toBe(.1)
32 expect(report.station.phase).toBe('cancelled')
33 expect(report.station.resumptions).toBeUndefined()
34 expect(report.transfer.intake).toBeNull()
35 expect(report.ownership.finalMassKg).toBe(3.1)
36 expect(report.receiver.finalLoad.totalMassKg).toBe(1.5)
37 if (name === 'cancel-after-grant') expect(report.operator.events[1].reason).toBe('intake-already-attempted')
38 if (name === 'resume-after-navigation-loss') expect(report.operator.events[1].reason).toBe('recovery-authority-unavailable')
39 expect(result.stderr).toBe('')
40 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
41 }
42 const bad = await command(['cargo:fleet-control', '--case', 'private-invalid-marker']).withTimeout(30000)
43 expect(bad.exitCode).toBe(1)
44 expect(bad.stdout).toBe('')
45 expect(bad.stderr).toContain('Cargo fleet control unavailable')
46 expect(bad.stderr).not.toContain('private-invalid-marker')
47}, 130000)
tests/unit/cargo-fleet-operator.test.tsadded+210-0
Changes to tests/unit/cargo-fleet-operator.test.ts
@@ -0,0 +1,210 @@
1import { expect, test } from 'bun:test'
2import { createCargoFleetSession } from '../../resources/functions/drone/cargo/fleet'
3import { simulateCargoFleetControl } from '../../resources/functions/drone/cargo/fleet-control'
4
5type World = ReturnType<typeof createCargoFleetSession>
6function decision(world: World, action: 'cancel' | 'resume', sequence: number) {
7 const state = world.snapshot()
8 return { version: 1, evidence: 'modeled-cargo-transport-decision-v1', contextId: state.operator.contextId, bootId: 102n, sessionId: 202n, reference: state.receiver!.reference, sourceEventId: state.stationSourceEventId!, sequence, issuedAtMs: state.atMs!, action }
9}
10
11// Public retained world seam. Both real runtime sessions and the same point/rail
12// keep advancing; operator transport decisions do not replace a body or estimator.
13test('scoped operator cancellation and later recovery finish the same parcel on the same receiving session', () => {
14 const world = createCargoFleetSession()
15 try {
16 let nowMs = 0
17 for (; nowMs <= 60000; nowMs += 4) {
18 world.step(nowMs)
19 const station = world.snapshot().station
20 if (station?.phase === 'moving' && Math.hypot(...station.parcel!.velocityMps) > .1) break
21 }
22 expect(nowMs).toBeLessThan(60000)
23 const before = world.snapshot(), pendingId = before.receiver!.mission.payload!.eventId, epoch = before.receiver!.authority.epoch
24 expect(world.decide(decision(world, 'cancel', 1)).outcome).toBe('accepted')
25 expect(world.snapshot().station!.phase).toBe('braking')
26 expect(Math.hypot(...world.snapshot().station!.parcel!.velocityMps)).toBeGreaterThan(0)
27 while (world.snapshot().station!.phase === 'braking') { nowMs += 4; world.step(nowMs) }
28 const stopped = world.snapshot()
29 expect(stopped.ownership.owner).toBe('station-actor')
30 expect(stopped.ownership.totalMassKg).toBe(3.1)
31 expect(Math.hypot(...stopped.station!.parcel!.velocityMps)).toBe(0)
32 const waitUntil = nowMs + 1000
33 for (nowMs += 4; nowMs <= waitUntil; nowMs += 4) {
34 world.step(nowMs)
35 expect(world.snapshot().receiver!.board.actuators).toEqual([0, 0, 0, 0])
36 expect(world.snapshot().receiver!.mission.payload!.eventId).toBe(pendingId)
37 }
38 nowMs -= 4
39 expect(world.decide(decision(world, 'resume', 2)).outcome).toBe('accepted')
40 expect(world.snapshot().station!.parcel).toEqual(stopped.station!.parcel)
41 expect(world.snapshot().receiver!.authority.epoch).toBe(epoch)
42 while (!world.snapshot().terminal) { nowMs += 4; world.step(nowMs) }
43 const run = world.result()
44 expect(run.status).toBe('completed')
45 expect(run.receiver.runtime.authority.sessionId).toBe(202n)
46 expect(run.receiver.runtime.board.bootId).toBe(102n)
47 expect(run.receiver.intake.custody.outcome).toBe('received')
48 expect(run.receiver.delivery.outcome).toBe('received')
49 expect(run.station!.resumptions).toHaveLength(1)
50 expect(run.operator!.events.map(e => [e.action, e.outcome])).toEqual([['cancel', 'accepted'], ['resume', 'accepted']])
51 expect(run.ownership.transitions.map(e => [e.from, e.to])).toEqual([['source-body', 'station-actor'], ['station-actor', 'receiver-body'], ['receiver-body', 'final-parcel-actor']])
52 expect(run.ownership.finalMassKg).toBe(3.1)
53 expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12)
54 }
55 finally { world.close() }
56})
57
58function advanceUntil(world: World, predicate: (state: ReturnType<World['snapshot']>) => boolean, ceiling = 60000) {
59 for (let nowMs = (world.snapshot().atMs ?? -4) + 4; nowMs <= ceiling; nowMs += 4) {
60 const progress = world.step(nowMs), state = world.snapshot()
61 if (predicate(state)) return nowMs
62 if (progress.terminal) break
63 }
64 throw new Error('Expected bounded world phase')
65}
66const moving = (state: ReturnType<World['snapshot']>) => state.station?.phase === 'moving' && Math.hypot(...state.station.parcel!.velocityMps) > .1
67
68test('operator scope, exact clock and sequence reject stale or foreign decisions before touching the parcel', () => {
69 const world = createCargoFleetSession(), other = createCargoFleetSession()
70 try {
71 const nowMs = advanceUntil(world, moving)
72 advanceUntil(other, moving)
73 const packet = decision(world, 'cancel', 1), before = world.snapshot(), otherBefore = other.snapshot()
74 expect(other.decide(packet)).toEqual({ outcome: 'refused', reason: 'operator-scope' })
75 expect(other.snapshot()).toEqual(otherBefore)
76 for (const patch of [{ bootId: 103n }, { sessionId: 203n }, { reference: '102:1,0,0,0,0' }, { sourceEventId: 'foreign-source-event' }]) expect(world.decide({ ...packet, ...patch }).reason).toBe('operator-scope')
77 expect(world.decide({ ...packet, issuedAtMs: nowMs - 4 }).reason).toBe('operator-clock')
78 expect(world.snapshot()).toEqual(before)
79 let invoked = 0
80 const unsafe = { ...packet, get action() { invoked++; return 'cancel' } }
81 expect(() => world.decide(unsafe)).toThrow('cargo-fleet-operator-declaration')
82 expect(invoked).toBe(0)
83 expect(() => world.decide(packet, 'extra')).toThrow('cargo-fleet-operator-declaration')
84 expect(() => world.step(nowMs + 8)).toThrow('cargo-fleet-clock')
85 expect(world.snapshot()).toEqual(before)
86 expect(world.decide(packet).outcome).toBe('accepted')
87 expect(world.decide(packet).reason).toBe('operator-sequence')
88 expect(world.snapshot().operator.events).toHaveLength(1)
89 }
90 finally { world.close(); other.close() }
91 expect(world.decide({}).reason).toBe('session-unavailable')
92 expect(() => world.step(0)).toThrow('Cargo fleet session is closed')
93 expect(world.result().status).toBe('unqualified')
94 expect(world.result().source.runtime.board.actuators).toEqual([0, 0, 0, 0])
95 expect(world.result().receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
96})
97
98test('a recovery requested while the parcel still brakes is refused without jumping to the old rail endpoint', () => {
99 const world = createCargoFleetSession()
100 try {
101 advanceUntil(world, moving)
102 world.decide(decision(world, 'cancel', 1))
103 const braking = world.snapshot().station!
104 expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'station-not-stopped' })
105 expect(world.snapshot().station).toEqual(braking)
106 advanceUntil(world, state => state.station?.phase === 'cancelled')
107 expect(world.decide(decision(world, 'resume', 3)).outcome).toBe('accepted')
108 const detached = world.snapshot(); detached.operator.events[0]!.reason = 'changed'
109 expect(world.snapshot().operator.events[0]!.reason).toBe('transport-cancellation-requested')
110 }
111 finally { world.close() }
112})
113
114test('cancellation between intake grant and mechanism consumes the grant and cannot be recovered into a second attempt', () => {
115 const world = createCargoFleetSession()
116 try {
117 let nowMs = advanceUntil(world, state => state.intake.custody.grantedAtMs !== null)
118 expect(world.snapshot().ownership.owner).toBe('station-actor')
119 expect(world.decide(decision(world, 'cancel', 1)).outcome).toBe('accepted')
120 expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'intake-already-attempted' })
121 while (!world.snapshot().terminal) { nowMs += 4; world.step(nowMs) }
122 const run = world.result()
123 expect(run.status).toBe('unqualified')
124 expect(run.transfer.intake).toBeNull()
125 expect(run.transfer.stationDeparture).toBeNull()
126 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5)
127 expect(run.receiver.intake.custody.outcome).toBe('uncertain')
128 expect(run.receiver.runtime.mission.reason).toBe('payload-diversion-landed')
129 expect(run.station!.phase).toBe('cancelled')
130 expect(run.station!.ownedMassKg).toBe(.1)
131 expect(run.ownership.finalMassKg).toBe(3.1)
132 }
133 finally { world.close() }
134})
135
136test('a current operator declaration cannot recover transport after receiver navigation authority expires', () => {
137 const world = createCargoFleetSession({ scenario: 'receiver-navigation-loss-during-rail' })
138 try {
139 advanceUntil(world, state => state.station?.phase === 'moving')
140 world.decide(decision(world, 'cancel', 1))
141 advanceUntil(world, state => state.receiver?.authority.state === 'failsafe')
142 const before = world.snapshot().station
143 expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'recovery-authority-unavailable' })
144 expect(world.snapshot().station).toEqual(before)
145 expect(world.snapshot().ownership.owner).toBe('station-actor')
146 }
147 finally { world.close() }
148})
149
150test('closing the retained world reports revoked current board output without pretending the parcel instantly stopped', () => {
151 const world = createCargoFleetSession()
152 advanceUntil(world, moving)
153 const before = world.snapshot(), movingPoint = before.station!.parcel!
154 world.close(); world.close()
155 const closed = world.snapshot()
156 expect(closed.closed).toBe(true)
157 expect(closed.terminal).toBe(true)
158 expect(closed.source!.hostAvailable).toBe(false)
159 expect(closed.receiver!.hostAvailable).toBe(false)
160 expect(closed.source!.board.actuators).toEqual([0, 0, 0, 0])
161 expect(closed.receiver!.board.actuators).toEqual([0, 0, 0, 0])
162 expect(closed.atMs).toBe(before.atMs)
163 expect(closed.source!.policyAtMs).toBe(before.source!.policyAtMs)
164 expect(closed.receiver!.policyAtMs).toBe(before.receiver!.policyAtMs)
165 expect(closed.receiver!.reference).toBe(before.receiver!.reference)
166 expect(closed.station!.phase).toBe('braking')
167 expect(closed.station!.parcel).toEqual(movingPoint)
168 expect(closed.ownership.totalMassKg).toBe(3.1)
169 expect(world.decide({}).reason).toBe('session-unavailable')
170})
171
172test('a declaration capture that closes the world cannot resume or consume an operator sequence', () => {
173 const world = createCargoFleetSession()
174 advanceUntil(world, moving)
175 const packet = decision(world, 'cancel', 1)
176 const proxy = new Proxy(packet, { getOwnPropertyDescriptor(target, key) { world.close(); return Reflect.getOwnPropertyDescriptor(target, key) } })
177 expect(world.decide(proxy)).toEqual({ outcome: 'refused', reason: 'session-unavailable' })
178 expect(world.snapshot().operator.lastSequence).toBe(0)
179 expect(world.snapshot().operator.events).toHaveLength(0)
180 expect(world.snapshot().closed).toBe(true)
181})
182
183test('operator case admission rejects extra fields, accessors and extra arguments before creating a world', () => {
184 let calls = 0
185 for (const declaration of [null, { case: 'unknown' }, { case: 'cancel', extra: true }, { get case() { calls++; return 'cancel' } }]) expect(() => simulateCargoFleetControl(declaration)).toThrow('cargo-fleet-control-arguments')
186 expect(() => simulateCargoFleetControl({}, true)).toThrow('cargo-fleet-control-arguments')
187 expect(calls).toBe(0)
188})
189
190for (const completion of ['early-close', 'natural'] as const) {
191 test(`fleet terminal results remain detached after ${completion}`, () => {
192 const world = createCargoFleetSession({ scenario: completion === 'early-close' ? 'receiver-kill-after-intake' : 'nominal' })
193 try {
194 if (completion === 'early-close') world.close()
195 else for (let nowMs = 0; !world.step(nowMs).terminal; nowMs += 4) { /* Same finite native world. */ }
196 const result = world.result(), expected = structuredClone(result)
197 result.source.initialLoad.properties.totalMassKg = 0
198 result.receiver.initialLoad.properties.totalMassKg = 0
199 result.source.linkEvents.length = 0
200 result.ownership.transitions.length = 0
201 if (result.faultInjection) result.faultInjection.triggeredAtMs = 999
202 if (result.transfer.sourceRelease) result.transfer.sourceRelease.eventId = 'rewritten'
203 if (result.transfer.intake) result.transfer.intake.receiver.board.actuators[0] = 77
204 expect(world.result()).toEqual(expected)
205 world.close()
206 expect(world.result()).toEqual(expected)
207 }
208 finally { world.close() }
209 })
210}
tests/unit/cargo-station.test.tsmodified+55-0
Changes to tests/unit/cargo-station.test.ts
@@ -190,5 +190,60 @@ test('invalid and exhausted clocks cannot change the parcel, and delayed starts
190190 expect(() => station.step(180004)).toThrow('cargo-station-clock')
191191 expect(station.snapshot()).toEqual(end)
192192 expect(station.cancel()).toEqual({ outcome: 'cancelled', atMs: 180000 })
193 expect(station.resume()).toEqual({ outcome: 'refused', reason: 'deadline' })
193194 expect(station.takeReadyParcel().outcome).toBe('refused')
194195})
196
197test('an explicit station recovery preserves the braked parcel and bounded work before its single departure', () => {
198 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
199 station.start()
200 for (let atMs = 4; atMs <= 1000; atMs += 4) station.step(atMs)
201 station.cancel()
202 let nowMs = 1000
203 while (station.snapshot().phase === 'braking') { nowMs += 4; station.step(nowMs) }
204 const stopped = station.snapshot()
205 expect(stopped.phase).toBe('cancelled')
206 expect(stopped.parcel!.positionM[0]).toBeGreaterThan(0)
207 expect(stopped.parcel!.positionM[0]).toBeLessThan(1)
208 expect(stopped.parcel!.velocityMps).toEqual([0, 0, 0])
209 expect(station.resume().outcome).toBe('resumed')
210 const resumed = station.snapshot()
211 expect(resumed.parcel).toEqual(stopped.parcel)
212 expect(resumed.energy).toEqual(stopped.energy)
213 expect(resumed.resumptions).toEqual([{ atMs: nowMs, previousStartedAtMs: 0, cancelRequestedAtMs: 1000, stoppedAtMs: stopped.stoppedAtMs!, positionM: stopped.parcel!.positionM, durationMs: resumed.durationMs! }])
214 while (station.snapshot().phase === 'moving') {
215 nowMs += 4
216 const state = station.step(nowMs)
217 expect(Math.hypot(...state.parcel!.velocityMps)).toBeLessThanOrEqual(.5 + 1e-12)
218 expect(Math.hypot(state.forceN[0] / .1, state.forceN[1] / .1, state.forceN[2] / .1 + 9.80665)).toBeLessThanOrEqual(1 + 1e-12)
219 expect(Math.abs(state.energy.residualJ)).toBeLessThan(1e-12)
220 expect(nowMs).toBeLessThan(10000)
221 }
222 expect(station.snapshot().parcel!.positionM).toEqual([1, 0, 0])
223 expect(station.takeReadyParcel().outcome).toBe('departed')
224 expect(station.resume().outcome).toBe('refused')
225 expect(station.takeReadyParcel().outcome).toBe('refused')
226 expect(station.snapshot().ownedMassKg).toBe(0)
227})
228
229
230test('station recovery refuses missed pickup, motion, extra arguments and a ninth recovery without changing parcel ownership', () => {
231 const declaration = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
232 const station = createCargoStationHandler(declaration)
233 expect(station.resume()).toEqual({ outcome: 'refused', reason: 'not-stopped' })
234 expect(() => station.resume('extra')).toThrow('cargo-station-arguments')
235 station.start()
236 for (let i = 0; i < 8; i++) { station.cancel(); expect(station.resume().outcome).toBe('resumed') }
237 station.cancel()
238 const stopped = station.snapshot()
239 expect(station.resume()).toEqual({ outcome: 'refused', reason: 'recovery-limit' })
240 expect(station.snapshot()).toEqual(stopped)
241 expect(station.snapshot().ownedMassKg).toBe(.1)
242 expect(station.snapshot().resumptions).toHaveLength(8)
243 const detached = station.snapshot(); detached.resumptions![0]!.positionM[0] = 77
244 expect(station.snapshot()).toEqual(stopped)
245 const missed = createCargoStationHandler({ ...declaration, parcel: { ...declaration.parcel, positionM: [.2, 0, 0] } })
246 missed.cancel()
247 expect(missed.resume()).toEqual({ outcome: 'refused', reason: 'pickup-missed' })
248 expect(missed.snapshot().parcel!.positionM).toEqual([.2, 0, 0])
249})