ReviewOS

also looking at this

stacks/drone

feat: add scoped cargo receiving mission tasks

#102
Merged chris wants to merge codex/cargo-fleet-authority into main
21 files +1186 -15

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/cargo-delivery.mdmodified+31-0
Changes to docs/cargo-delivery.md
@@ -188,6 +188,37 @@ and actual receiving-body intake. The world coordinator must still consume the o
188188check the receiver's current authority and produce correlated custody acknowledgement.
189189This model alone does not implement a fleet mission or qualify electrical endurance.
190190
191## Receiving mission task prerequisite
192
193The shared owned mission engine accepts `material-receive` only for quadcopters, with a
194non-null `containerId`, a required fixed-origin `positionM` pad anchor and an integer
195100 to 60,000 ms timeout. The anchor must be inside the mission fence and on or up to
1961 m above its declared zero surface. Normal and optional one-way alternate landing
197routes contribute to the longest-path time and declared reserve calculation.
198
199Entering intake immediately creates its scoped pending command, including the first
200control frame when the plan starts there. The quad controller uses qualified surface
201contact to keep modeled motor commands neutral during intake. This avoids a one-frame
202spin-up while waiting for the next mission evaluation. It does not erase existing motor
203lag, change mass or generate an acknowledgement.
204
205Mission observations must retain contact, speed at most 0.1 m/s and position within
2060.2 m of the pad. Losing these interlocks blocks receipt even if an ACK is present.
207A confirmed ACK still needs exact event/task/operation/container/reference correlation,
208a capture inside its deadline, freshness and a finite spatial check. ACK position requires
209three finite own coordinates; sparse arrays are rejected before acknowledgement handling. Timeout,
210foreign scope, rejection or uncertainty can follow a declared counted alternate route;
211they never advance the normal takeoff path or retry intake. A late unsolicited ACK
212blocks rather than reviving the original command.
213
214`tests/unit/cargo-receive-mission.test.ts` covers the native task and an actually empty
215receiving body in its own raw-sensor, estimator and authority runtime. With ACK withheld,
216it stays at the declared pad with zero actual motor thrust and no phantom parcel mass.
217The port that verifies an actual point intake and emits captured custody evidence is
218still unimplemented. A caller-invented ACK is not proof of mechanical transfer. The
219two-vehicle coordinator, fresh mechanism authority, cancellation/uncertainty matrix and
220bounded fleet CLI remain open under #25.
221
191222## BVLOS operations evidence
192223
193224
docs/cargo-station.mdmodified+3-2
Changes to docs/cargo-station.md
@@ -103,5 +103,6 @@ vertical work, strict admission, detached state and lifetime rejection.
103103This actor does not grant a mechanism lease, authenticate a recipient, produce a custody
104104ACK or coordinate two live vehicle mission engines. Those operations must use each
105105vehicle's own sensor, estimator and current authority scope on the common clock. The
106material-receive mission operation, correlated acknowledgement, cancellation/uncertainty
107matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25).
106[receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds
107a declared pad under the shared engine. Its physical-intake acknowledgement port, the
108two-vehicle custody protocol, cancellation/uncertainty matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25).
docs/vehicles.mdmodified+5-0
Changes to docs/vehicles.md
@@ -101,6 +101,11 @@ Modeled acknowledgements preserve capture time separately from delivery. An unce
101101result blocks progression and is never automatically retried as a new action. Recovery during a
102102pending action also blocks. These identities do not establish exactly-once delivery on hardware.
103103
104The opt-in quadcopter `material-receive` task adds a declared pad anchor and a bounded
105wait for one correlated receipt. It enters with inhibited motor commands under qualified
106surface contact. Its [interlocks and counted failure routes](cargo-delivery.md#receiving-mission-task-prerequisite)
107are distinct from the still-pending mechanical-intake and fleet custody coordinator.
108
104109Surface sounding reduction retains raw acoustic corrections, local datum, capture-pose estimator
105110and gyro epochs and declared position/attitude/clock uncertainty. It projects the beam footprint and
106111rejects unqualified capture timing or uncertainty below the sensor declaration. This is a modeled
resources/functions/drone/missions/vehicle.tsmodified+25-6
Changes to resources/functions/drone/missions/vehicle.ts
@@ -1,3 +1,4 @@
1import type { SensorVector } from '../sensors/types'
12import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types'
23import type { MissionState } from './engine'
34import { vehicleKinds } from '../vehicles/types'
@@ -41,10 +42,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
4142 }
4243 else if (t.kind === 'payload') {
4344 fields.push('operation', 'containerId', 'timeoutMs', 'next', 'onFailure')
44 const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
45 if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, 10000) || !id(t.next)) invalid()
46 if (p.vehicle === 'quadcopter' && t.operation === 'material-drop' && !id(t.containerId)) invalid()
47 if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || t.operation !== 'material-drop' || !id(t.onFailure))) invalid()
45 const receiving = t.operation === 'material-receive'
46 if (receiving) fields.push('positionM')
47 const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop', 'material-receive'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
48 if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, receiving ? 60000 : 10000) || !id(t.next)) invalid()
49 if (p.vehicle === 'quadcopter' && ['material-drop', 'material-receive'].includes(t.operation) && !id(t.containerId)) invalid()
50 if (receiving && (!Number.isSafeInteger(t.timeoutMs) || !vehicleMissionContains(p, t.positionM) || t.positionM[2] < -1 || t.positionM[2] > 0)) invalid()
51 if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || !['material-drop', 'material-receive'].includes(t.operation) || !id(t.onFailure))) invalid()
4852 durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure]
4953 }
5054 else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) {
@@ -86,11 +90,18 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
8690 return { plan: structuredClone(p), worstCaseMs, requiredWh }
8791}
8892export interface VehicleMissionTrace { version: 2 | 3, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] }
93function validAckPosition(value: unknown): value is SensorVector {
94 return validVector(value) && [0, 1, 2].every(i => Object.hasOwn(value, i))
95}
8996function validAck(value: unknown): value is VehiclePayloadAck {
90 if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validVector(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false
97 if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'material-receive', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validAckPosition(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false
9198 if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false
9299 return true
93100}
101function receivingAtPad(task: Extract<VehicleMissionTask, { operation: 'material-receive' }>, o: VehicleMissionObservation) {
102 return o.onGround && o.positionM !== null && o.velocityMps !== null && Math.hypot(...o.velocityMps) <= .1
103 && Math.hypot(...o.positionM.map((n, i) => n - task.positionM[i]!)) <= .2
104}
94105function validTransition(value: unknown): value is VehicleVtolTransitionObservation {
95106 return object(value) && keys(value, ['evidence', 'eventId', 'taskId', 'mode', 'reference', 'capturedAtMs', 'measurementCapturedAtMs', 'phase', 'outcome', 'reason'])
96107 && value.evidence === 'modeled-vtol-controller-observation' && typeof value.eventId === 'string' && value.eventId.length <= 256 && id(value.taskId) && ['hover', 'cruise'].includes(value.mode as string)
@@ -253,11 +264,13 @@ export function createVehicleMissionEngine(input: unknown) {
253264 else reason = 'vtol-transition-progress'
254265 }
255266 }
267 else if (t.kind === 'payload' && t.operation === 'material-receive' && !receivingAtPad(t, o)) block('material-receive-interlocks')
256268 else if (t.kind === 'payload') {
257269 pending ??= { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, operation: t.operation, containerId: t.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
258270 const ack = o.payloadAck
259271 if (ack) {
260 const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1
272 const separationM = Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!))
273 const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || !Number.isFinite(separationM) || separationM > 1
261274 if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') }
262275 else { advance(t.next); reason = 'payload-confirmed' }
263276 }
@@ -277,6 +290,12 @@ export function createVehicleMissionEngine(input: unknown) {
277290 }
278291 }
279292 }
293 // Intake must inhibit the first control frame, including entry directly from start.
294 const entered = tasks.get(taskId)!
295 if (state === 'running' && entered.kind === 'payload' && entered.operation === 'material-receive') {
296 if (!receivingAtPad(entered, o)) block('material-receive-interlocks')
297 else pending ??= { eventId: `${plan.id}:${reference}:${entered.id}`, taskId: entered.id, operation: entered.operation, containerId: entered.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + entered.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
298 }
280299 const result = snapshot()
281300 rows.push({ observation: o, action: action ?? null, result: structuredClone(result) })
282301 return result
resources/functions/drone/vehicles/multirotor/profile.tsmodified+1-1
Changes to resources/functions/drone/vehicles/multirotor/profile.ts
@@ -48,7 +48,7 @@ function createSession(): VehicleSession {
4848 angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity,
4949 target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } })
5050 const measurement = context.measurement
51 const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
51 const contact = !output.inhibited && (context.mission.taskKind === 'land' || context.mission.payload?.operation === 'material-receive') && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
5252 && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true
5353 return { kind: 'simulated-vehicle-control', vehicle: 'quadcopter', atMs: context.nowMs, actuators: contact ? [0, 0, 0, 0] : [...output.motorsN], inhibited: fault !== null || output.inhibited, reason: fault ?? (contact ? 'quad-qualified-contact' : output.reason), physicalFlightEnabled: false, boardOutputsInhibited: true }
5454 } }
resources/functions/drone/vehicles/types.tsmodified+3-2
Changes to resources/functions/drone/vehicles/types.ts
@@ -44,7 +44,7 @@ export interface VehicleBatteryObservation {
4444 terminalV: number
4545 cutoff: boolean
4646}
47export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture'
47export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'material-receive' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture'
4848export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
4949export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> }
5050export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void }
@@ -89,7 +89,8 @@ export type VehicleMissionTask =
8989 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
9090 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
9191 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
92 | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
92 | { id: string, kind: 'payload', operation: Exclude<VehiclePayloadOperation, 'material-receive'>, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
93 | { id: string, kind: 'payload', operation: 'material-receive', containerId: string, positionM: SensorVector, timeoutMs: number, next: string, onFailure?: string }
9394 | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string }
9495 | { id: string, kind: 'abort' }
9596/** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -25,6 +25,7 @@
2525 "./unit/cargo-load.test.ts",
2626 "./unit/cargo-intake.test.ts",
2727 "./unit/cargo-station.test.ts",
28 "./unit/cargo-receive-mission.test.ts",
2829 "./unit/cargo-delivery.test.ts",
2930 "./unit/cargo-delivery-config.test.ts",
3031 "./unit/cargo-delivery-phase-faults.test.ts",
tests/unit/cargo-receive-mission.test.tsadded+149-0
Changes to tests/unit/cargo-receive-mission.test.ts
@@ -0,0 +1,149 @@
1import { expect, test } from 'bun:test'
2import type { VehicleMissionObservation, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
3import { createReceivingRigidBody } from '../../resources/functions/drone/controls/physics'
4import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime'
5import { createMultirotorPlant, createMultirotorProfile, multirotorMission } from '../../resources/functions/drone/vehicles/multirotor/profile'
6import { createVehicleMissionEngine, parseVehicleMissionTrace, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
7
8function receivePlan() {
9 const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 10000 }
10 return { ...multirotorMission(), id: 'cargo-receiver', tasks: [
11 { id: 'start', kind: 'start', next: 'intake' },
12 { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: 'parcel-a', positionM: [3, 2, -.1], timeoutMs: 60000, next: 'depart', onFailure: 'divert' },
13 { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'land' },
14 { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial },
15 { id: 'divert', kind: 'waypoint', positionM: [4, 2, -1], ...spatial, timeoutMs: 8000, next: 'alternate' },
16 { id: 'alternate', kind: 'land', positionM: [4, 2, 0], ...spatial, timeoutMs: 8000 },
17 ] }
18}
19function observation(nowMs: number): VehicleMissionObservation {
20 return { kind: 'simulation', nowMs, reference: '102:0,0,0,0,0', origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, healthy: true, positionM: [3, 2, -.1], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 98, onGround: true, payloadAck: null }
21}
22
23// Native mission function seam: proves receipt command, anchor, scope and counted
24// plan behavior. It does not produce mechanical intake or current payload authority.
25test('a receiving mission holds its declared pad and advances only after its own correlated receipt ACK', () => {
26 const plan = receivePlan(), validated = validateVehicleMission(plan)
27 expect(validated.worstCaseMs).toBe(80020)
28 const engine = createVehicleMissionEngine(plan)
29 expect(engine.step(observation(600), 'start')).toMatchObject({ taskId: 'intake', state: 'running', targetM: [3, 2, -.1] })
30 const pending = engine.step(observation(620)).payload!
31 expect(pending).toMatchObject({ taskId: 'intake', operation: 'material-receive', containerId: 'parcel-a', reference: '102:0,0,0,0,0', issuedAtMs: 600, expiresAtMs: 60600 })
32 const result = engine.step({ ...observation(640), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 640, outcome: 'confirmed' } })
33 expect(result.taskId).toBe('depart')
34 expect(result.reason).toBe('payload-confirmed')
35 expect(result.payload).toBeNull()
36 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(result)
37 expect(parseVehicleMissionTrace(JSON.stringify(engine.trace()))).toEqual(engine.trace())
38})
39
40
41// Actual receiving body plus raw-sensor/estimator/authority runtime. The only
42// external fixture is a payload wire that withholds its ACK, not an intake mock.
43test('an unacknowledged receiving mission keeps its actual parked body neutral under its own live authority', () => {
44 const body = createReceivingRigidBody({ positionM: [3, 2, -.1], groundDownM: -.1, expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
45 const profile = createMultirotorProfile(), create = profile.createSession
46 profile.mission = validateVehicleMission(receivePlan()).plan
47 const commands: VehiclePayloadCommand[] = []
48 profile.createSession = () => ({ ...create(), createPlant: () => createMultirotorPlant(body), createPayload: () => ({ request(command) { commands.push(structuredClone(command)) }, poll() { return null }, close() {} }) })
49 const session = createVehicleSimulationSession(profile, { seconds: 3, scope: { bootId: 102n, sessionId: 202n } })
50 try {
51 for (let nowMs = 0; nowMs <= 2000; nowMs += 4) {
52 const row = session.step(nowMs)
53 if (nowMs >= 600) {
54 expect(row.authority.state).toBe('autonomous')
55 expect(row.authority.sessionId).toBe(202n)
56 expect(row.reference.startsWith('102:')).toBe(true)
57 expect(row.board.actuators).toEqual([0, 0, 0, 0])
58 expect(body.snapshot().positionM).toEqual([3, 2, -.1])
59 expect(body.snapshot().motorsN).toEqual([0, 0, 0, 0])
60 expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5)
61 }
62 }
63 expect(commands).toHaveLength(1)
64 expect(commands[0]!.operation).toBe('material-receive')
65 expect(commands[0]!.issuedAtMs).toBe(600)
66 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
67 }
68 finally { session.close() }
69})
70
71test('a foreign, stale or uncertain receipt takes only the declared counted failure route', () => {
72 for (const changed of [
73 { reference: '101:0,0,0,0,0' }, { eventId: 'source-vehicle-event' }, { containerId: 'parcel-b' },
74 { capturedAtMs: 599 }, { capturedAtMs: 641 }, { outcome: 'uncertain' as const }, { outcome: 'rejected' as const },
75 ]) {
76 const engine = createVehicleMissionEngine(receivePlan())
77 const request = engine.step(observation(600), 'start').payload!
78 const ack = { eventId: request.eventId, taskId: request.taskId, operation: request.operation, containerId: request.containerId, reference: request.reference, positionM: request.positionM, capturedAtMs: 620, outcome: 'confirmed' as 'confirmed' | 'uncertain' | 'rejected', ...changed }
79 const row = engine.step({ ...observation(640), payloadAck: ack })
80 expect(row.taskId).toBe('divert')
81 expect(row.payloadFailure).toMatchObject({ taskId: 'intake', operation: 'material-receive', approachTaskId: 'divert', landingTaskId: 'alternate' })
82 expect(row.payload).toBeNull()
83 expect(row.reason).toBe('payload-failure-diversion')
84 }
85})
86
87test('receiving cannot start or accept an ACK away from its pad, moving or without contact', () => {
88 for (const change of [{ onGround: false }, { positionM: [3, 2, -.31] as [number, number, number] }, { velocityMps: [.101, 0, 0] as [number, number, number] }]) {
89 for (const atEntry of [true, false]) {
90 const engine = createVehicleMissionEngine(receivePlan())
91 let result
92 if (atEntry) result = engine.step({ ...observation(600), ...change }, 'start')
93 else {
94 const pending = engine.step(observation(600), 'start').payload!
95 result = engine.step({ ...observation(620), ...change, payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 620, outcome: 'confirmed' } })
96 }
97 expect(result.state).toBe('blocked')
98 expect(result.payload).toBeNull()
99 expect(result.taskId).not.toBe('depart')
100 expect(result.targetM).toBeNull()
101 }
102 }
103})
104
105test('receipt expiry diverts once and cannot be cleared by a late ACK', () => {
106 const engine = createVehicleMissionEngine(receivePlan())
107 const pending = engine.step(observation(600), 'start').payload!
108 for (let atMs = 620; atMs <= 60600; atMs += 20) engine.step(observation(atMs))
109 const timedOut = engine.snapshot()
110 expect(timedOut.taskId).toBe('divert')
111 expect(timedOut.payloadFailure).toMatchObject({ reason: 'timeout', triggeredAtMs: 60600 })
112 const late = engine.step({ ...observation(60620), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 60600, outcome: 'confirmed' } })
113 expect(late.state).toBe('blocked')
114 expect(late.taskId).toBe('divert')
115 expect(late.reason).toBe('unsolicited-payload-ack')
116})
117
118test('receipt validation preserves finite graph reserve and rejects unsupported profiles, missing anchors and retry paths', () => {
119 for (const change of [
120 (p: ReturnType<typeof receivePlan>) => { p.vehicle = 'ground' },
121 (p: ReturnType<typeof receivePlan>) => { Reflect.set(p.tasks[1]!, 'containerId', null) },
122 (p: ReturnType<typeof receivePlan>) => { delete p.tasks[1]!.positionM },
123 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.positionM = [3, 2, -1.001] },
124 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 60001 },
125 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 100.5 },
126 (p: ReturnType<typeof receivePlan>) => { p.tasks[5]!.next = 'intake' },
127 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.onFailure = 'depart' },
128 (p: ReturnType<typeof receivePlan>) => { p.tasks[3]!.timeoutMs = 60000; p.tasks[5]!.timeoutMs = 60000; p.power.availableWh = 20 },
129 ]) {
130 const input = receivePlan(); change(input)
131 expect(() => validateVehicleMission(input)).toThrow()
132 }
133 const alternateBudget = receivePlan()
134 alternateBudget.tasks[3]!.timeoutMs = 1000; alternateBudget.tasks[4]!.timeoutMs = 30000; alternateBudget.tasks[5]!.timeoutMs = 30000
135 expect(validateVehicleMission(alternateBudget).worstCaseMs).toBe(120020)
136})
137
138test('missing or nonfinite ACK coordinates cannot confirm receipt or create an unreplayable accepted trace', () => {
139 for (const position of [Array<number>(3), [3, , -.1], [NaN, 2, -.1], [3, 2, Infinity]]) {
140 const engine = createVehicleMissionEngine(receivePlan()), pending = engine.step(observation(600), 'start').payload!
141 const ack = { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: position as [number, number, number], capturedAtMs: 620, outcome: 'confirmed' as const }
142 expect(() => engine.step({ ...observation(620), payloadAck: ack })).toThrow('Invalid vehicle mission observation')
143 expect(engine.snapshot().state).toBe('blocked')
144 expect(engine.snapshot().taskId).toBe('intake')
145 expect(engine.snapshot().payload).toBeNull()
146 expect(engine.snapshot().reason).not.toBe('payload-confirmed')
147 expect(() => parseVehicleMissionTrace(JSON.stringify(engine.trace()))).not.toThrow()
148 }
149})