ReviewOS

also looking at this

stacks/drone

feat: add shared-clock scoped vehicle simulation sessions

#98
Merged chris wants to merge codex/cargo-fleet-runtime into main
42 files +2764 -127

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.

docs/adr/0035-shared-clock-vehicle-sessions.mdadded+42-0
Changes to docs/adr/0035-shared-clock-vehicle-sessions.md
@@ -0,0 +1,42 @@
1# ADR 0035: Retained vehicle sessions on one modeled clock
2
3Status: accepted for software simulation.
4
5## Context
6
7Cargo issue #25 needs distinct vehicles with current authority and source evidence at
8the handover epoch. Running one complete fixture and relabeling its result as a fresh
9peer would discard timing, independent watchdog and sensor identity requirements.
10The shared vehicle loop previously ran to completion with fixed boot 55/session 123.
11
12## Decision
13
14Expose `createVehicleSimulationSession` over that same loop. Its caller advances exact
154 ms epochs from zero; the loop retains raw sensing, estimator updates, 20 ms policy,
16mission, controls, authority, payload ownership and independent board state. The ordinary
17`runVehicleSimulation` drains this interface to the same terminal report.
18
19Capture an optional nonzero uint64 boot/session scope before allocating a profile session.
20The actual synthetic raw packets, pipeline clock anchor, estimator reference, supervisor
21and board use that scope. Defaults retain the original identities and report projection.
22Identity labels do not authenticate a peer, prevent duplicate scope selection or establish
23distributed time synchronization.
24
25Ticks expose detached mission/authority/board snapshots, reference, phase and last completed
26host policy epoch. Host loss freezes host work while the finite independent settling tail
27can still advance the board and plant. The caller can advance another live vehicle at the
28same epochs. Payload teardown occurs once. Explicit close or a step exception revokes the
29board and closes ownership; simultaneous failures remain observable.
30
31## Consequences
32
33This is a necessary runtime prerequisite for a captured, two-vehicle custody protocol.
34It does not implement that protocol or claim fleet handover is complete. The cargo
35coordinator must still bind distinct peers, physically modeled parcel transfer, current
36qualified captures, cancellation and conservative uncertain outcomes.
37
38The caller owns stepping and cleanup and must stop stepping a terminal session. Invalid
39epochs do not mutate execution. `result` is unavailable until terminal and detached
40thereafter. Explicit close stops simulation without running recovery or a physical
41settling interval. All physical outputs remain inhibited. This clock is deterministic
42simulation time, not a wall-clock deadline guarantee or real-time hardware scheduler.
docs/vehicles.mdmodified+61-0
Changes to docs/vehicles.md
@@ -33,6 +33,67 @@ arming/engagement. Independent board leases expire to each declared neutral, inc
3333and air spring/wheel brake channels. Neutral does not imply a vehicle has physically stopped.
3434Host loss leaves a stale host mission snapshot; the independently ticking board still expires output.
3535
36## Shared-clock modeled sessions
37
38`createVehicleSimulationSession(profile, options)` in `vehicles/runtime.ts` retains the same
39owned loop behind `step(atMs)`, `result()` and `close()`. Start at zero and advance exactly
404 ms per call. Two sessions can therefore be advanced at each shared epoch before the
41coordinator evaluates their observations. Rejected duplicate, skipped or invalid epochs
42do not advance the session; a caller can supply the expected epoch afterward.
43
44An optional `scope: { bootId, sessionId }` captures two nonzero unsigned 64-bit bigints
45before session resources open. The boot identity is encoded in the actual synthetic IMU/GNSS
46packets, sensor clock anchor and estimator reference. Both identifiers also scope the
47authority supervisor and independent board. They are correlations, not authentication.
48Omitting scope preserves the original boot 55/session 123 reports.
49
50```ts
51const a = createVehicleSimulationSession(profileA, {
52 seconds: 120, scope: { bootId: 101n, sessionId: 201n },
53})
54const b = createVehicleSimulationSession(profileB, {
55 seconds: 120, scope: { bootId: 102n, sessionId: 202n },
56})
57try {
58 // Advance each live session once at every epoch; stop stepping one after terminal.
59 for (let atMs = 0, doneA = false, doneB = false; !doneA || !doneB; atMs += 4) {
60 if (!doneA) doneA = a.step(atMs).terminal
61 if (!doneB) doneB = b.step(atMs).terminal
62 }
63 console.log(a.result().status, b.result().status)
64}
65finally { a.close(); b.close() }
66```
67
68A tick returns detached mission, authority and board snapshots, the estimator reference,
69its actual epoch, phase, terminal flag and last completed host policy epoch. Policy still
70runs every 20 ms after alignment. During host loss or an opt-in independent settling tail,
71`policyAtMs` does not advance. Tail epochs run only board/plant work. Host authority and
72mission snapshots can remain stale; the board snapshot reflects its actual independent lease.
73
74`result()` refuses an active session and returns a detached terminal report. Explicit
75`close()` is idempotent: it closes owned payload state once, immediately revokes the modeled
76board and reports a blocked `simulation-session-closed` outcome. It does not invent mission
77completion, execute a recovery or settle the physical plant. A step failure also closes
78ownership and revokes output before throwing; a simultaneous cleanup failure is retained
79in an aggregate error. Naturally terminated sessions keep their existing terminal result.
80Closing from an adapter callback stops the in-flight step before another adapter or output
81acceptance runs. Recursive stepping is refused. An interrupted policy epoch is not reported
82as completed, and terminal sessions report `hostAvailable: false`.
83All adapter data results, including faults, plant state, navigation, measurements, payload
84acknowledgements and controller output, are captured inside that ownership check before use.
85A link callback that closes the session and then fails cannot hide the failure as a disconnected
86observation. Void observation/request callbacks keep their ignored-return contract.
87The initial plant snapshot and adapter method references are captured before the session
88is exposed. Replacing a method property afterward does not replace the retained adapter;
89modeled world changes belong in the adapter's own captured state.
90`runVehicleSimulation` now drains this same interface to natural termination.
91
92This enables concurrent modeled vehicle execution. It does not yet implement fleet custody
93transfer, collision avoidance, a network scheduler, distributed clocks or physical multi-vehicle
94control. Callers must avoid reusing identities. [Cargo issue #25](cargo-delivery.md#remaining-issue-scope)
95remains open for the actual handover policy and evidence.
96
3697## Payload and survey evidence
3798
3899Payload requests bind event, task, operation, container, reference, position and an expiry window.
resources/functions/drone/controls/sensors.tsmodified+3-2
Changes to resources/functions/drone/controls/sensors.ts
@@ -3,9 +3,10 @@ import { rotateNedToBody } from './physics'
33import { createSensorPipeline } from '../sensors/pipeline'
44import { runtimeCrc32 } from '../runtime/protocol'
55import { multirotorModel } from './model'
6const bootId = 55n, boardOrigin = 1000000n
6const boardOrigin = 1000000n
77/** Modeled raw IMU/GNSS only. Measurement epoch is known because this is a synthetic source. */
8export function createPhysicsSensorFeed(additionalDelayMs: number) {
8export function createPhysicsSensorFeed(additionalDelayMs: number, bootId = 55n) {
9 if (typeof bootId !== 'bigint' || bootId < 1n || bootId > 0xffffffffffffffffn) throw new TypeError('Invalid synthetic sensor boot scope')
910 if (!Number.isFinite(additionalDelayMs) || additionalDelayMs < 0 || additionalDelayMs > 200) throw new RangeError('Sensor delay must be 0..200 ms')
1011 const pipeline = createSensorPipeline({ anchor: { bootId, boardUs: boardOrigin, localMs: 0, uncertaintyUs: 0, driftPpm: 0, source: 'fixture' }, calibrations: { imu: {
1112 id: 'physics-frd-synthetic', provenance: 'fixture', axes: [1, 2, 3], referenceC: 25, minC: -20, maxC: 60, accelBias: [0, 0, 0], gyroBias: [0, 0, 0], accelScale: [1, 1, 1], gyroScale: [1, 1, 1], accelTempSlope: [0, 0, 0], gyroTempSlope: [0, 0, 0], accelNoiseStd: [0.005, 0.005, 0.005], gyroNoiseStd: [0.001, 0.001, 0.001],
resources/functions/drone/vehicles/runtime.tsmodified+183-105
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -31,8 +31,24 @@ function captureLinkObservation(input: unknown, nowMs: number): { capturedAtMs:
3131 if (!time?.enumerable || !Object.hasOwn(time, 'value') || !Number.isSafeInteger(time.value) || time.value < 0 || time.value > nowMs || !connected?.enumerable || !Object.hasOwn(connected, 'value') || typeof connected.value !== 'boolean') throw new TypeError('Unqualified vehicle link observation')
3232 return { capturedAtMs: time.value, connected: connected.value }
3333}
34/** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */
35export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) {
34export interface VehicleSimulationOptions {
35 scenario?: string
36 seconds?: number
37 neutralTailMs?: number
38 missionActions?: readonly ScheduledMissionAction[]
39 scope?: { bootId: bigint, sessionId: bigint }
40}
41function captureSimulationScope(input: unknown) {
42 if (input === undefined) return { bootId: 55n, sessionId: 123n }
43 if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Reflect.ownKeys(input).length !== 2) throw new TypeError('Invalid vehicle simulation scope')
44 const boot = Object.getOwnPropertyDescriptor(input, 'bootId'), session = Object.getOwnPropertyDescriptor(input, 'sessionId')
45 if (!boot?.enumerable || !Object.hasOwn(boot, 'value') || !session?.enumerable || !Object.hasOwn(session, 'value') || [boot.value, session.value].some(value => typeof value !== 'bigint' || value < 1n || value > 0xffffffffffffffffn)) throw new TypeError('Invalid vehicle simulation scope')
46 return { bootId: boot.value as bigint, sessionId: session.value as bigint }
47}
48/** Retained owned loop, advanced at exactly 4 ms epochs from zero by its caller. */
49export function createVehicleSimulationSession(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) {
50 const scope = captureSimulationScope(options.scope), vehicle = profileInput.vehicle
51 const runtimeFaults = profileInput.faults.bind(profileInput)
3652 const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120
3753 const neutralTailMs = options.neutralTailMs ?? 0
3854 const positionPrediction = profileInput.positionPrediction
@@ -43,21 +59,23 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
4359 const missionActions = captureMissionActions(options.missionActions, seconds * 1000)
4460 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission)
4561 const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power)
46 if (profileInput.vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin')
62 if (vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin')
4763 const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode')
4864 const captureMotion = plan.tasks.some(task => task.kind === 'vtol-mode' && task.approach === 'bounded-approach-v1')
4965 let transitionObservation: VehicleVtolTransitionObservation | null = null
5066 const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence)
5167 const session = profileInput.createSession(), navigation = session.createNavigation?.()
52 const linkProvider = session.linkObservation
53 if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
68 const linkProvider = session.linkObservation, renderMeasurement = session.renderMeasurement.bind(session)
69 const observeIndependent = session.observeIndependent?.bind(session), updateNavigation = navigation?.update.bind(navigation)
70 if (vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
5471 const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController()
55 const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence })
56 const board = createVehicleOutputWatchdog({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators })
57 const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction })
58 const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle, batteryModel ?? undefined)
72 const plantSnapshot = plant.snapshot.bind(plant), stepPlant = plant.step.bind(plant), stepController = controller.step.bind(controller)
73 const supervisor = createVehicleAuthoritySupervisor({ ...scope, vehicle: vehicle, actuators, fence })
74 const board = createVehicleOutputWatchdog({ ...scope, vehicle: vehicle, actuators })
75 const feed = createPhysicsSensorFeed(0, scope.bootId), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction })
76 const qualifier = createVehicleMeasurementQualifier(vehicle, batteryModel ?? undefined)
5977 const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 }
60 let truth = plant.snapshot(), mission = engine.snapshot()
78 let truth = structuredClone(plantSnapshot()), mission = engine.snapshot()
6179 let estimate: EstimatorSnapshot = estimator.snapshot(0)
6280 let measurement: VehicleMeasurement | null = null, measurementPose: VehicleMeasurementPose | null = null
6381 let batteryObservation: VehicleBatteryObservation | null = null
@@ -68,105 +86,165 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
6886 const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = []
6987 let previousState = '', previousTask = ''
7088 const payload = session.createPayload?.()
71 let finalAtMs = 0
72 try {
73 for (let nowMs = 0; nowMs <= seconds * 1000; nowMs += 4) {
89 const observePayload = payload?.observe?.bind(payload), pollPayload = payload?.poll.bind(payload), requestPayload = payload?.request.bind(payload), closePayload = payload?.close.bind(payload)
90 let finalAtMs = 0, nextAtMs = 0, policyAtMs: number | null = null, hostEndAtMs: number | null = null
91 let terminal = false, stepping = false
92 const interrupted = Symbol('vehicle-session-closed')
93 function owned<T>(work: () => T): T {
94 if (terminal) throw interrupted
95 const value = work()
96 if (terminal) throw interrupted
97 return value
98 }
99 function captured<T>(work: () => T): T { return owned(() => structuredClone(work())) }
100 function stepHost(nowMs: number) {
101 const faults = captured(() => runtimeFaults(scenario, nowMs, 'host')), boardState = board.tick(nowMs)
102 if (nowMs > 600) truth = captured(() => stepPlant(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }))
103 if (faults.hostLost) hostLostAt ??= nowMs
104 if (nowMs >= 600) owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null))
105 if (hostLostAt !== null) {
106 if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; return false }
107 return true
108 }
109 feed.capture(truth, nowMs, faults.dropImu, vehicle === 'subsea' || faults.dropPosition)
110 const sensors = feed.advanceTo(nowMs)
111 for (const kind of ['imu', 'gnss'] as const) {
112 const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0
113 if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence }
114 }
115 const baseEstimate = estimator.update(sensors)
116 estimate = updateNavigation ? captured(() => updateNavigation(baseEstimate, measurement, nowMs)) : baseEstimate
117 if (nowMs % 20 !== 0) return true
118 const reference = vehicleReference(estimate)
119 const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation
120 const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion })
121 if ((capturePose || vehicle === 'subsea') && !faults.dropVehicleSensor) {
122 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
123 const packet = captured(() => renderMeasurement(truth, nowMs, reference))
124 if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs')
125 queue.push({ measurement: packet, pose: capturePose })
126 queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence)
127 }
128 while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose }
129 let qualified: VehicleMeasurement | null = null
130 try { qualified = qualifier.qualify(measurement, nowMs, reference) }
131 catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null }
132 measurement = qualified
133 if (batteryModel && qualified) {
134 batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs)
135 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
136 }
137 if (updateNavigation) estimate = captured(() => updateNavigation(baseEstimate, qualified, nowMs))
138 let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false }
139 if (typeof linkProvider === 'function') {
140 try { link = captured(() => captureLinkObservation(linkProvider.call(session, nowMs), nowMs)) }
141 catch (error) { if (error === interrupted || terminal) throw error; /* An installed adapter failure is disconnected, never a replacement fixture. */ }
142 }
143 if (faults.linkLost) link.connected = false
144 if (nowMs < 600) return true
145 const b = board.snapshot()
146 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
147 const observation = { kind: 'simulation' as const, nowMs, estimate,
148 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
149 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
150 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
151 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link }
152 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
153 const s = supervisor.snapshot()
154 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
155 }
156 supervisor.update(observation)
157 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
158 const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission,
159 ...(vehicle === 'vtol' || vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
160 }
161 owned(() => observePayload?.(structuredClone(context)))
162 const ack = captured(() => pollPayload?.(nowMs)) ?? null
163 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) }
164 transitionObservation = null
165 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
166 const scheduled = missionActions[missionActionIndex]
167 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
168 mission = engine.step(missionObservation, action)
169 if (mission.payloadFailure?.triggeredAtMs === nowMs) owned(() => observePayload?.(structuredClone({ ...context, mission })))
170 if (mission.payload && !requested.has(mission.payload.eventId)) {
171 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
172 else { owned(() => observePayload?.(structuredClone({ ...context, mission }))); owned(() => requestPayload!(structuredClone(mission.payload!))); requested.add(mission.payload.eventId) }
173 }
174 const active = mission.state === 'running' || mission.state === 'paused'
175 if (active && mission.targetM && !fault) send('renew')
176 else { send('disarm'); board.revoke() }
177 const output: VehicleControlOutput = captured(() => stepController({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission }))
178 if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null
179 if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() }
180 const frame = supervisor.gate(output, nowMs)
181 if (frame && !terminal) board.accept(frame, nowMs)
182 else board.revoke()
183 policyAtMs = nowMs
184 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) })
185 if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId }
186 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; return false }
187 return true
188 }
189 function finishHost() {
190 if (hostEndAtMs !== null) return
191 hostEndAtMs = finalAtMs
192 try { closePayload?.() }
193 finally { if (status !== 'host-lost') board.revoke() }
194 }
195 function close() {
196 if (terminal) return
197 terminal = true
198 status = 'blocked'; fault ??= 'simulation-session-closed'
199 try { finishHost() }
200 finally { board.revoke() }
201 }
202 function progress(nowMs: number, phase: 'host' | 'independent-tail') {
203 return { atMs: nowMs, phase, hostAvailable: phase === 'host' && hostLostAt === null && !terminal, terminal, policyAtMs, reference: vehicleReference(estimate), mission: structuredClone(mission), authority: supervisor.snapshot(), board: board.snapshot() }
204 }
205 function step(nowMs: number) {
206 if (terminal) throw new Error('Vehicle simulation session is closed')
207 if (stepping) throw new Error('Vehicle simulation session is already stepping')
208 if (!Number.isSafeInteger(nowMs) || nowMs !== nextAtMs) throw new RangeError('Vehicle simulation requires its next 4 ms epoch')
209 const phase = hostEndAtMs === null ? 'host' as const : 'independent-tail' as const
210 stepping = true
211 try {
74212 finalAtMs = nowMs
75 const faults = profileInput.faults(scenario, nowMs, 'host'), boardState = board.tick(nowMs)
76 if (nowMs > 600) truth = plant.step(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })
77 if (faults.hostLost) hostLostAt ??= nowMs
78 if (nowMs >= 600) session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null)
79 if (hostLostAt !== null) {
80 if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; break }
81 continue
82 }
83 feed.capture(truth, nowMs, faults.dropImu, profileInput.vehicle === 'subsea' || faults.dropPosition)
84 const sensors = feed.advanceTo(nowMs)
85 for (const kind of ['imu', 'gnss'] as const) {
86 const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0
87 if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence }
88 }
89 const baseEstimate = estimator.update(sensors)
90 estimate = navigation ? navigation.update(baseEstimate, measurement, nowMs) : baseEstimate
91 if (nowMs % 20 !== 0) continue
92 const reference = vehicleReference(estimate)
93 const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation
94 const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion })
95 if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) {
96 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
97 const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference))
98 if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs')
99 queue.push({ measurement: packet, pose: capturePose })
100 queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence)
101 }
102 while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose }
103 let qualified: VehicleMeasurement | null = null
104 try { qualified = qualifier.qualify(measurement, nowMs, reference) }
105 catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null }
106 measurement = qualified
107 if (batteryModel && qualified) {
108 batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs)
109 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
110 }
111 if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs)
112 let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false }
113 if (typeof linkProvider === 'function') {
114 try { link = captureLinkObservation(linkProvider.call(session, nowMs), nowMs) }
115 catch { /* An installed adapter failure is disconnected, never a replacement fixture. */ }
213 if (phase === 'host') {
214 if (!stepHost(nowMs) || nowMs === seconds * 1000) finishHost()
215 if (hostEndAtMs !== null && neutralTailMs === 0) terminal = true
116216 }
117 if (faults.linkLost) link.connected = false
118 if (nowMs < 600) continue
119 const b = board.snapshot()
120 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
121 const observation = { kind: 'simulation' as const, nowMs, estimate,
122 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
123 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
124 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
125 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link }
126 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
127 const s = supervisor.snapshot()
128 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
217 else {
218 // No host sensor, estimation, mission or control work runs in this phase.
219 const faults = captured(() => runtimeFaults(scenario, nowMs, 'independent-tail')), boardState = board.tick(nowMs)
220 truth = captured(() => stepPlant(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }))
221 owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false))
222 if (nowMs === hostEndAtMs! + neutralTailMs) terminal = true
129223 }
130 supervisor.update(observation)
131 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
132 const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission,
133 ...(profileInput.vehicle === 'vtol' || profileInput.vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
134 }
135 payload?.observe?.(structuredClone(context))
136 const ack = payload?.poll(nowMs) ?? null
137 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) }
138 transitionObservation = null
139 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
140 const scheduled = missionActions[missionActionIndex]
141 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
142 mission = engine.step(missionObservation, action)
143 if (mission.payloadFailure?.triggeredAtMs === nowMs) payload?.observe?.(structuredClone({ ...context, mission }))
144 if (mission.payload && !requested.has(mission.payload.eventId)) {
145 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
146 else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) }
147 }
148 const active = mission.state === 'running' || mission.state === 'paused'
149 if (active && mission.targetM && !fault) send('renew')
150 else { send('disarm'); board.revoke() }
151 const output: VehicleControlOutput = controller.step({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission })
152 if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null
153 if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() }
154 const frame = supervisor.gate(output, nowMs)
155 if (frame) board.accept(frame, nowMs)
156 else board.revoke()
157 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) })
158 if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId }
159 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break }
224 nextAtMs += 4
225 return progress(nowMs, phase)
226 }
227 catch (error) {
228 if (error === interrupted) return progress(nowMs, phase)
229 terminal = true; status = 'blocked'; fault ??= 'simulation-step-failed'
230 try { finishHost() }
231 catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Vehicle simulation step and cleanup failed') }
232 finally { board.revoke() }
233 throw error
160234 }
235 finally { stepping = false }
161236 }
162 finally { payload?.close(); if (status !== 'host-lost') board.revoke() }
163 // Opt-in material/actuator settling has no host sensor, estimation, mission or control work.
164 const hostEndAtMs = finalAtMs
165 for (let elapsed = 4; elapsed <= neutralTailMs; elapsed += 4) {
166 const nowMs = hostEndAtMs + elapsed, faults = profileInput.faults(scenario, nowMs, 'independent-tail'), boardState = board.tick(nowMs)
167 truth = plant.step(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })
168 session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false)
169 finalAtMs = nowMs
237 function result() {
238 if (!terminal) throw new Error('Vehicle simulation session is still active')
239 return structuredClone({ version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) })
240 }
241 return { step, result, close }
242}
243/** Run the same retained session synchronously to its natural terminal result. */
244export function runVehicleSimulation(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) {
245 const session = createVehicleSimulationSession(profileInput, options)
246 try {
247 for (let nowMs = 0; ; nowMs += 4) { if (session.step(nowMs).terminal) return session.result() }
170248 }
171 return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) }
249 finally { session.close() }
172250}
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -80,6 +80,7 @@
8080 "./unit/vehicle-mission-checkpoints.test.ts",
8181 "./unit/vehicle-authority.test.ts",
8282 "./unit/vehicle-runtime.test.ts",
83 "./unit/vehicle-sessions.test.ts",
8384 "./unit/multirotor-vehicle.test.ts",
8485 "./unit/vehicle-links.test.ts",
8586 "./unit/air.test.ts",
tests/unit/vehicle-sessions.test.tsadded+370-0
Changes to tests/unit/vehicle-sessions.test.ts
@@ -0,0 +1,370 @@
1import { expect, test } from 'bun:test'
2import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime'
3import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile'
4import type { VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
5import type { SensorVector } from '../../resources/functions/drone/sensors/types'
6
7test('two vehicle sessions share policy epochs while their raw sensor and authority scopes remain distinct', () => {
8 const a = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 101n, sessionId: 201n } })
9 const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } })
10 try {
11 expect(() => a.result()).toThrow('still active')
12 for (let nowMs = 0; nowMs <= 1000; nowMs += 4) {
13 const left = a.step(nowMs), right = b.step(nowMs)
14 expect(left.atMs).toBe(right.atMs)
15 expect(left.board.bootId).toBe(101n)
16 expect(right.board.bootId).toBe(102n)
17 if (nowMs === 1000) {
18 expect(left.reference.startsWith('101:')).toBe(true)
19 expect(right.reference.startsWith('102:')).toBe(true)
20 expect(left.authority.sessionId).toBe(201n)
21 expect(right.authority.sessionId).toBe(202n)
22 expect(left.authority.state).toBe('autonomous')
23 expect(right.authority.state).toBe('autonomous')
24 expect(left.board.actuators.some(n => n > 0)).toBe(true)
25 expect(right.board.actuators.some(n => n > 0)).toBe(true)
26 expect(left.policyAtMs).toBe(1000)
27 expect(right.policyAtMs).toBe(1000)
28 }
29 }
30 }
31 finally { a.close(); b.close() }
32 expect(a.result().board.actuators).toEqual([0, 0, 0, 0])
33 expect(b.result().board.actuators).toEqual([0, 0, 0, 0])
34})
35
36test('one vehicle can lose its host and settle independently while the peer continues fresh policy', () => {
37 const profile = createMultirotorProfile(), original = profile.faults
38 profile.faults = (scenario, atMs, phase) => ({ ...original(scenario, atMs, phase), hostLost: atMs >= 740 })
39 const a = createVehicleSimulationSession(profile, { seconds: 2, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } })
40 const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } })
41 try {
42 for (let nowMs = 0; nowMs <= 1840; nowMs += 4) {
43 const left = a.step(nowMs), right = b.step(nowMs)
44 if (nowMs >= 844) {
45 expect(left.phase).toBe('independent-tail')
46 expect(left.hostAvailable).toBe(false)
47 expect(left.policyAtMs).toBe(720)
48 expect(left.board.actuators).toEqual([0, 0, 0, 0])
49 }
50 if (nowMs === 1840) {
51 expect(left.terminal).toBe(true)
52 expect(right.terminal).toBe(false)
53 expect(right.hostAvailable).toBe(true)
54 expect(right.policyAtMs).toBe(1840)
55 expect(right.authority.state).toBe('autonomous')
56 expect(right.board.actuators.some(n => n > 0)).toBe(true)
57 }
58 }
59 const run = a.result()
60 expect(run.status).toBe('host-lost')
61 expect(run.board.fault).toBe('output-lease-expired')
62 expect(run.path.at(-1)!.atMs).toBe(720)
63 expect(run.path.at(-1)!.measurement!.reference.startsWith('101:')).toBe(true)
64 expect(() => a.step(1844)).toThrow('closed')
65 }
66 finally { a.close(); b.close() }
67})
68
69test('vehicle sessions reject invalid scope before allocation and invalid epochs without advancing state', () => {
70 const profile = createMultirotorProfile()
71 let opened = false, getterCalled = false
72 profile.createSession = () => { opened = true; throw new Error('Unexpected allocation') }
73 for (const scope of [null, { bootId: 0n, sessionId: 1n }, { bootId: 1n, sessionId: 0x10000000000000000n }, { bootId: 1, sessionId: 2n }, { get bootId() { getterCalled = true; return 1n }, sessionId: 2n }]) {
74 expect(() => createVehicleSimulationSession(profile, { scope: scope as never })).toThrow('scope')
75 }
76 expect(opened).toBe(false)
77 expect(getterCalled).toBe(false)
78 const scope = { bootId: 101n, sessionId: 201n }, session = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope })
79 scope.bootId = 999n; scope.sessionId = 999n
80 try {
81 expect(() => session.step(4)).toThrow('next 4 ms epoch')
82 expect(session.step(0).board.bootId).toBe(101n)
83 expect(() => session.step(0)).toThrow('next 4 ms epoch')
84 expect(() => session.step(8)).toThrow('next 4 ms epoch')
85 expect(session.step(4).atMs).toBe(4)
86 }
87 finally { session.close() }
88 const result = session.result()
89 expect(result.status).toBe('blocked')
90 expect(result.fault).toBe('simulation-session-closed')
91 result.board.actuators[0] = 99
92 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
93})
94
95test('a step failure and explicit close both release payload ownership once and revoke the board', () => {
96 for (const fail of [false, true]) {
97 const profile = createMultirotorProfile(), create = profile.createSession
98 let closes = 0
99 profile.createSession = () => {
100 const base = create()
101 return { ...base, renderMeasurement(...args) { if (fail && args[1] === 1000) throw new Error('Captured renderer failure'); return base.renderMeasurement(...args) },
102 createPayload: () => ({ request() {}, poll() { return null }, close() { closes++ } }),
103 }
104 }
105 const session = createVehicleSimulationSession(profile, { seconds: 2 })
106 try {
107 for (let nowMs = 0; nowMs < 1000; nowMs += 4) session.step(nowMs)
108 if (fail) expect(() => session.step(1000)).toThrow('Captured renderer failure')
109 }
110 finally { session.close(); session.close() }
111 expect(closes).toBe(1)
112 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
113 expect(session.result().board.expiresAtMs).toBeNull()
114 expect(session.result().fault).toBe(fail ? 'simulation-step-failed' : 'simulation-session-closed')
115 expect(() => session.step(1004)).toThrow('closed')
116 }
117})
118
119test('a simultaneous step and teardown failure is retained after the independent board is revoked', () => {
120 const profile = createMultirotorProfile(), create = profile.createSession
121 let closes = 0
122 profile.createSession = () => {
123 const base = create()
124 return { ...base, renderMeasurement(...args) { if (args[1] === 1000) throw new Error('Renderer failed'); return base.renderMeasurement(...args) },
125 createPayload: () => ({ request() {}, poll() { return null }, close() { closes++; throw new Error('Payload teardown failed') } }),
126 }
127 }
128 const session = createVehicleSimulationSession(profile, { seconds: 2 })
129 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
130 let failure: unknown
131 try { session.step(1000) }
132 catch (error) { failure = error }
133 expect(failure).toBeInstanceOf(AggregateError)
134 expect((failure as AggregateError).errors.map(error => error.message)).toEqual(['Renderer failed', 'Payload teardown failed'])
135 session.close()
136 expect(closes).toBe(1)
137 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
138 expect(session.result().board.expiresAtMs).toBeNull()
139})
140
141test('a controller callback cannot reopen the board lease after closing its own session', () => {
142 const profile = createMultirotorProfile(), create = profile.createSession
143 let session: ReturnType<typeof createVehicleSimulationSession>, returnedNonzero = false
144 profile.createSession = () => {
145 const base = create()
146 return { ...base, createController() {
147 const controller = base.createController()
148 return { step(context) {
149 const output = controller.step(context)
150 if (context.nowMs === 1000) { returnedNonzero = output.actuators.some(n => n > 0); session.close() }
151 return output
152 } }
153 } }
154 }
155 session = createVehicleSimulationSession(profile, { seconds: 2 })
156 try {
157 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
158 const tick = session.step(1000)
159 expect(returnedNonzero).toBe(true)
160 expect(tick.terminal).toBe(true)
161 expect(tick.hostAvailable).toBe(false)
162 expect(tick.policyAtMs).toBe(980)
163 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
164 expect(tick.board.expiresAtMs).toBeNull()
165 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
166 expect(session.result().fault).toBe('simulation-session-closed')
167 }
168 finally { session.close() }
169})
170
171test('a controller cannot recursively advance the same in-flight session epoch', () => {
172 const profile = createMultirotorProfile(), create = profile.createSession
173 let session: ReturnType<typeof createVehicleSimulationSession>, attempted = false
174 profile.createSession = () => {
175 const base = create()
176 return { ...base, createController() {
177 const controller = base.createController()
178 return { step(context) {
179 if (context.nowMs === 1000 && !attempted) {
180 attempted = true
181 expect(() => session.step(1000)).toThrow('already stepping')
182 }
183 return controller.step(context)
184 } }
185 } }
186 }
187 session = createVehicleSimulationSession(profile, { seconds: 2 })
188 try {
189 for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs)
190 expect(attempted).toBe(true)
191 expect(session.step(1004).atMs).toBe(1004)
192 }
193 finally { session.close() }
194})
195
196test('closing from a payload observation prevents later polling and controller work in the same epoch', () => {
197 const profile = createMultirotorProfile(), create = profile.createSession
198 let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, workAfterClose = 0
199 profile.createSession = () => {
200 const base = create()
201 return { ...base, createController() {
202 const controller = base.createController()
203 return { step(context) { if (closes) workAfterClose++; return controller.step(context) } }
204 }, createPayload: () => ({
205 observe(context) { if (context.nowMs === 1000) session.close() },
206 request() { if (closes) workAfterClose++ },
207 poll() { if (closes) workAfterClose++; return null },
208 close() { closes++ },
209 }) }
210 }
211 session = createVehicleSimulationSession(profile, { seconds: 2 })
212 try {
213 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
214 const tick = session.step(1000)
215 expect(tick.terminal).toBe(true)
216 expect(tick.policyAtMs).toBe(980)
217 expect(tick.hostAvailable).toBe(false)
218 expect(workAfterClose).toBe(0)
219 expect(closes).toBe(1)
220 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
221 expect(tick.board.expiresAtMs).toBeNull()
222 }
223 finally { session.close() }
224})
225
226test('a closing link callback failure is surfaced without calling the closed payload', () => {
227 const profile = createMultirotorProfile(), create = profile.createSession
228 let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, observationsAfterClose = 0
229 profile.createSession = () => ({ ...create(),
230 linkObservation(nowMs) {
231 if (nowMs === 1000) { session.close(); throw new Error('Link callback failed after close') }
232 return { capturedAtMs: nowMs, connected: true }
233 },
234 createPayload: () => ({ observe() { if (closes) observationsAfterClose++ }, request() {}, poll() { return null }, close() { closes++ } }),
235 })
236 session = createVehicleSimulationSession(profile, { seconds: 2 })
237 try {
238 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
239 expect(() => session.step(1000)).toThrow('Link callback failed after close')
240 expect(observationsAfterClose).toBe(0)
241 expect(closes).toBe(1)
242 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
243 }
244 finally { session.close() }
245})
246
247test('a returned actuator getter cannot reopen output when capture closes its session', () => {
248 const profile = createMultirotorProfile(), create = profile.createSession
249 let session: ReturnType<typeof createVehicleSimulationSession>, getterCalled = false
250 profile.createSession = () => {
251 const base = create()
252 return { ...base, createController() {
253 const controller = base.createController()
254 return { step(context) {
255 const output = controller.step(context)
256 if (context.nowMs === 1000) {
257 const actuators = [...output.actuators]
258 Object.defineProperty(output, 'actuators', { enumerable: true, get() { getterCalled = true; session.close(); return actuators } })
259 }
260 return output
261 } }
262 } }
263 }
264 session = createVehicleSimulationSession(profile, { seconds: 2 })
265 try {
266 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
267 const tick = session.step(1000)
268 expect(getterCalled).toBe(true)
269 expect(tick.terminal).toBe(true)
270 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
271 expect(tick.board.expiresAtMs).toBeNull()
272 expect(tick.policyAtMs).toBe(980)
273 }
274 finally { session.close() }
275})
276
277test('a returned ACK getter cannot confirm payload work after closing its session', () => {
278 const profile = createMultirotorProfile(), create = profile.createSession
279 profile.mission.tasks = [{ id: 'start', kind: 'start', next: 'capture' }, { id: 'capture', kind: 'payload', operation: 'camera-still', containerId: null, timeoutMs: 1500, next: 'depart' }, ...profile.mission.tasks.filter(task => task.kind !== 'start')]
280 let session: ReturnType<typeof createVehicleSimulationSession>, pending: VehiclePayloadCommand | null = null, getterCalled = false
281 profile.createSession = () => ({ ...create(), createPayload: () => ({
282 request(command) { pending = structuredClone(command) },
283 poll(nowMs) {
284 if (!pending || nowMs <= pending.issuedAtMs) return null
285 const command = pending
286 return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference,
287 capturedAtMs: nowMs, outcome: 'confirmed', metadata: {}, get positionM(): SensorVector { getterCalled = true; session.close(); return [...command.positionM] } }
288 },
289 close() { pending = null },
290 }) })
291 session = createVehicleSimulationSession(profile, { seconds: 12 })
292 try {
293 for (let atMs = 0; ; atMs += 4) { if (session.step(atMs).terminal) break }
294 expect(getterCalled).toBe(true)
295 expect(session.result().trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
296 expect(session.result().fault).toBe('simulation-session-closed')
297 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
298 }
299 finally { session.close() }
300})
301
302test('a returned fault getter cannot advance the physical plant after closing its session', () => {
303 const profile = createMultirotorProfile(), create = profile.createSession, faults = profile.faults
304 let session: ReturnType<typeof createVehicleSimulationSession>, closed = false, stepsAfterClose = 0, getterCalled = false
305 profile.faults = (scenario, atMs, phase) => atMs !== 1000 ? faults(scenario, atMs, phase) : {
306 get windMps(): SensorVector { getterCalled = true; closed = true; session.close(); return [0, 0, 0] },
307 }
308 profile.createSession = () => {
309 const base = create()
310 return { ...base, createPlant() {
311 const plant = base.createPlant()
312 return { snapshot: plant.snapshot, step(...args) { if (closed) stepsAfterClose++; return plant.step(...args) } }
313 } }
314 }
315 session = createVehicleSimulationSession(profile, { seconds: 2 })
316 try {
317 for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs)
318 expect(getterCalled).toBe(true)
319 expect(stepsAfterClose).toBe(0)
320 expect(session.result().fault).toBe('simulation-session-closed')
321 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
322 }
323 finally { session.close() }
324})
325
326test('the initial plant snapshot is captured before a live session can close through its getters', () => {
327 const profile = createMultirotorProfile(), create = profile.createSession
328 let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false
329 profile.createSession = () => {
330 const base = create()
331 return { ...base, createPlant() {
332 const plant = base.createPlant()
333 return { step: plant.step, snapshot() {
334 const state = plant.snapshot()
335 return { ...state, get details() { if (session) { readsAfterExposure++; closed = true; session.close() } return state.details } }
336 } }
337 }, observeIndependent() { if (closed) callbacksAfterClose++ } }
338 }
339 session = createVehicleSimulationSession(profile, { seconds: 2 })
340 try {
341 for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break }
342 expect(readsAfterExposure).toBe(0)
343 expect(callbacksAfterClose).toBe(0)
344 expect(() => session!.result()).toThrow('still active')
345 }
346 finally { session.close() }
347})
348
349test('adapter methods are bound before exposure so method getters cannot close then invoke work', () => {
350 const profile = createMultirotorProfile(), create = profile.createSession
351 let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false
352 profile.createSession = () => {
353 const base = create()
354 return { ...base, createController() {
355 const controller = base.createController()
356 return { get step() {
357 if (session) { readsAfterExposure++; closed = true; session.close() }
358 return (context: Parameters<typeof controller.step>[0]) => { if (closed) callbacksAfterClose++; return controller.step(context) }
359 } }
360 } }
361 }
362 session = createVehicleSimulationSession(profile, { seconds: 2 })
363 try {
364 for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break }
365 expect(readsAfterExposure).toBe(0)
366 expect(callbacksAfterClose).toBe(0)
367 expect(() => session!.result()).toThrow('still active')
368 }
369 finally { session.close() }
370})