ReviewOS

also looking at this

stacks/drone

feat: add declared VTOL recovery and landing routes

#80
Merged chris wants to merge codex/owned-vtol-recovery-route into main
13 files +558 -52
.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -491,6 +491,8 @@ jobs:
491491 ${{ runner.temp }}/execution-browser-evidence/*.png
492492 ${{ runner.temp }}/execution-browser-evidence/compiled.html
493493 ${{ runner.temp }}/execution-browser-evidence/browser-launch.json
494 ${{ runner.temp }}/execution-browser-evidence/failure-diagnostics.json
495 ${{ runner.temp }}/execution-browser-evidence/admission-renewal.json
494496 ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log
495497 purge-namespace:
496498 runs-on: ubuntu-latest
README.mdmodified+2-2
Changes to README.md
@@ -79,8 +79,8 @@ outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air mod
7979`./buddy vtol:hover` separately exercises [VTOL development](docs/vehicles-vtol.md):
8080vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also
8181includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. The
82command stays hover-only. Controlled transition recovery and abort landing, complete hybrid energy
83qualification and a selectable VTOL mission workspace remain open.
82command stays hover-only. Plans can explicitly declare bounded hover-and-land transition recovery.
83Complete recovery/abort envelopes, hybrid energy qualification and a selectable VTOL mission workspace remain open.
8484
8585## Simulated carried load
8686
docs/index.mdmodified+3-2
Changes to docs/index.md
@@ -58,8 +58,9 @@ from [firmware SITL](sitl.md) and physical testing.
5858
5959The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only command and an
6060experimental TypeScript library profile for modeled front transition, cruise, back transition and
61vertical landing, with stable-phase pause and resume. Controlled transition recovery and abort
62landing, complete hybrid energy qualification and the common VTOL mission workspace remain open.
61vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery
62routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission
63workspace remain open.
6364
6465## Explore a workflow
6566
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1616| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification remain pending | Named parameter read/write with bounded readback in the SITL adapter; no log/FTP services, full-table synchronization or reboot persistence qualification | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | Bounded SITL waypoint upload/download and acknowledged item selection/resume; no arming, automatic flight-mode selection or owned-plan translation | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
19| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only VTOL command and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing and stable-phase pause/resume; controlled transition recovery/abort landing, complete hybrid energy, native transition CLI/workspace, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
19| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only VTOL command and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing, stable-phase pause/resume and opt-in hover-and-land recovery; complete recovery/abort envelopes, hybrid energy, native transition CLI/workspace, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
2020| Rovers, mowing, logistics and materials handling | Owned differential traction/braking, encoder/GNSS odometry, guarded mowing coverage and scoped material-drop simulation; hardware and broader chassis/implements pending | Not exposed | [#13](https://github.com/stacksjs/drone/issues/13) |
2121| Surface vessels, bathymetry, water sampling and harbours | Owned planar hull/current/wind, collision inhibition, corrected single-beam export, scoped sample ACK and energy-return simulation; physical survey/sampling and broader hulls pending | Not exposed | [#14](https://github.com/stacksjs/drone/issues/14) |
2222| Subsea ROV, depth hold, tether and leak handling | Owned eight-thruster 6DOF physics, pressure/DVL local estimation, depth hold, tether/leak supervision and scoped synthetic camera/sonar evidence; hardware, full hydrodynamics and physical inspection pending | Not exposed | [#15](https://github.com/stacksjs/drone/issues/15) |
docs/vehicles-vtol.mdmodified+60-5
Changes to docs/vehicles-vtol.md
@@ -140,20 +140,75 @@ before that return. Sensor loss, RC takeover, kill and host loss retain authorit
140140
141141`recover` pauses the already running session until explicit `resume`. Fresh-process restoration after
142142host loss or a changed estimator reference remains a separate gate. Pending initial-hover, front or
143back mode work retains uncertain-outcome refusal for pause/recover. Those refusals release modeled
144outputs; a controlled airborne recovery and observed landing still require an explicit route.
145`abort` terminates the modeled mission and releases outputs as well.
143back mode work without a declared recovery route retains uncertain-outcome refusal for pause/recover.
144Those refusals release modeled outputs. Without that route, `abort` also terminates the modeled mission
145and releases outputs. Neither result establishes an airborne landing.
146146
147147Task execution time stops during pause. Global endurance and the declared energy charge continue,
148148so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal.
149149Operator actions never extend the original planning allowance or rearm revoked authority. The
150150transition library and these actions remain separate from the hover-only CLI and common workspace.
151151
152## Declared transition recovery routes
153
154A `vtol-mode` task can declare `onFailure` as the ID of a dedicated hover task followed by a
155terminal landing task on the declared contact plane. The default development profile has no such
156route. Plans opt in explicitly, and every recovery edge participates in the existing acyclic time
157and reserve calculation before session resources are created. Normal success edges cannot enter
158the recovery hover, and recovery cannot retry the interrupted task or declare another failure route.
159
160For example, declare a return to the origin after an interrupted front transition:
161
162```ts
163const profile = createVtolTransitionProfile()
164const front = profile.mission.tasks.find(task => task.id === 'front')!
165if (front.kind !== 'vtol-mode') throw new Error('Expected front mode task')
166front.onFailure = 'recover-hover'
167profile.mission.tasks.push(
168 { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 45000, next: 'recover-land' },
169 { id: 'recover-land', kind: 'land', positionM: [0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 35000 },
170)
171const aborted = runVehicleSimulation(profile, {
172 seconds: 160,
173 missionActions: [{ atMs: 13000, action: 'abort' }],
174})
175```
176
177The imports are the same as in the previous example. Timeout or a correlated controller abort uses
178the declared route. An operator `abort`, `pause` or `recover` while that mode task owns execution
179uses it too. A new hover request binds `supersedesEventId` to the actual cancelled request and records
180its original task, cause and epoch in `mission.recovery`. Before the first command dispatch, the
181failed event is null and no cancellation ID is invented. Late old feedback cannot satisfy the new
182request. Qualified source and heading dwell starts again for that new observation; it never retries
183the original transition or regains revoked authority.
184
185Pause and recover continue controlled motion until hover is observed, then hold for a **fresh explicit
186resume**. Resuming follows the recovery landing route. An early resume cannot skip that qualification,
187and repeated recovery does not reset its deadline. Abort continues to observed contact and ends with
188`mission.state: 'aborted'` and reason `vtol-abort-landed`. The simulation report keeps its existing
189`blocked` summary for an aborted mission. This is distinct from successful completion of the original
190route. A timeout/controller rejection that recovers to contact ends with `vtol-recovery-landed`.
191
192Landing holds the captured estimated approach height until horizontal position and speed qualify
193descent to the selected site. Actual failed-pusher and expired-back campaigns retain rotor-supported
194hover and fresh contact. The original 25-second back-recovery approach/landing case still times out
195airborne. A separately declared 35-second allowance completes after about 26.94 seconds, charged to
196the complete 156,520 ms / 52.1733 Wh worst-case plan. A normal 45-second back plus 45-second recovery
197hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase
198abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting
199176,520 ms / 58.84 Wh. These remain declared 1200 W planning charges, without identified electrical
200power or measured battery endurance.
201
202Sensor loss, reference/clock guards, RC takeover, kill, fence, reserve and global endurance remain
203authoritative during recovery. Host loss still produces a stale host snapshot and separate independent
204lease expiry. Fault-induced neutral outputs do not become a landing claim. These bounded library
205campaigns do not qualify arbitrary wind, failed lift rotors, every transition state or physical recovery.
206
152207## Remaining implementation
153208
154209[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal
155controller does not establish complete VTOL support. Controlled front/back recovery and fresh-process
156restoration, explicit bounded transition abort and landing, the complete hybrid power/reserve model, native transition CLI and
210controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort
211envelopes, fresh-process restoration, the complete hybrid power/reserve model, native transition CLI and
157212the common mission workspace still require implementation and qualification. Wind campaigns and
158213broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
159214helicopters and other airframe families remain separate work. Native board control, measured airframe
docs/vehicles.mdmodified+2-1
Changes to docs/vehicles.md
@@ -63,7 +63,8 @@ TypeScript controllers. Native board execution, external firmware SITL, HIL and
6363vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md)
6464covers the hover-only command and an experimental lift-and-cruise library profile. The latter
6565executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and
66resume. Controlled transition recovery and abort landing, complete hybrid energy qualification,
66resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort
67envelopes, hybrid energy qualification,
6768a native transition command and a VTOL selection in the common workspace remain open. Helicopter,
6869soaring, blimp and other airframe families remain explicit roadmap work.
6970
resources/functions/drone/missions/vehicle.tsmodified+77-25
Changes to resources/functions/drone/missions/vehicle.ts
@@ -14,7 +14,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) {
1414}
1515export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null }
1616export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } {
17 const invalid = (): never => { throw new TypeError('Invalid vehicle mission or unsupported profile') }
17 function invalid(): never { throw new TypeError('Invalid vehicle mission or unsupported profile') }
1818 if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(input.vehicle as string) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid()
1919 const p = input as unknown as VehicleMissionPlan
2020 if (!object(p.origin) || !keys(p.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || !number(p.origin.latitudeDeg, -90, 90) || !number(p.origin.longitudeDeg, -180, 180) || !number(p.origin.ellipsoidHeightM, -500, 10000)
@@ -27,9 +27,10 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
2727 if (t.kind === 'start') { fields.push('next'); if (t.id !== p.start || !id(t.next)) invalid(); edges = [t.next] }
2828 else if (t.kind === 'abort') { /* Terminal release, never an emergency landing. */ }
2929 else if (t.kind === 'vtol-mode') {
30 fields.push('mode', 'headingRad', 'timeoutMs', 'next')
30 fields.push('mode', 'headingRad', 'timeoutMs', 'next', 'onFailure')
3131 if (p.vehicle !== 'vtol' || !['hover', 'cruise'].includes(t.mode) || !number(t.headingRad, -Math.PI, Math.PI) || !Number.isSafeInteger(t.timeoutMs) || !number(t.timeoutMs, 100, t.mode === 'hover' ? 45000 : 15000) || !id(t.next)) invalid()
32 durationMs = t.timeoutMs; edges = [t.next]
32 if (t.onFailure !== undefined && !id(t.onFailure)) invalid()
33 durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure]
3334 }
3435 else if (t.kind === 'branch') {
3536 fields.push('batteryAtLeast', 'then', 'otherwise')
@@ -58,6 +59,15 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
5859 if (!keys(t, fields)) invalid()
5960 nodes.push({ id: t.id, durationMs, edges })
6061 }
62 for (const task of p.tasks) {
63 if (task.kind !== 'vtol-mode' || task.onFailure === undefined) continue
64 const hover = p.tasks.find(candidate => candidate.id === task.onFailure)
65 if (!hover || hover.kind !== 'vtol-mode' || hover.mode !== 'hover' || hover.onFailure !== undefined) invalid()
66 const land = p.tasks.find(candidate => candidate.id === hover.next)
67 if (!land || land.kind !== 'land' || land.next !== undefined || land.positionM[2] !== 0) invalid()
68 // Failure routes are one-way, finite hover -> contact paths, never normal success or retries.
69 if (p.tasks.some(candidate => 'next' in candidate && candidate.next === hover.id || candidate.kind === 'branch' && [candidate.then, candidate.otherwise].includes(hover.id))) invalid()
70 }
6171 if (p.tasks.find(t => t.id === p.start)?.kind !== 'start') invalid()
6272 const worstCaseMs = missionGraphBudget(nodes, p.start, vehicleMissionLimits.maximumMs)
6373 const requiredWh = p.power.maxW * worstCaseMs / 3600000
@@ -87,9 +97,10 @@ export function createVehicleMissionEngine(input: unknown) {
8797 let lastNow: number | null = null, startedAt: number | null = null, reference: string | null = null, taskElapsedMs = 0, settledMs = 0
8898 let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null
8999 let transition: VehicleVtolTransitionCommand | null = null
100 let recovery: VehicleMissionSnapshot['recovery']
90101 let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null
91102 const rows: VehicleMissionTrace['rows'] = []
92 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt, ...(hasTransitions ? { transition: transition ? structuredClone(transition) : null } : {}) } }
103 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt, ...(hasTransitions ? { transition: transition ? structuredClone(transition) : null } : {}), ...(recovery ? { recovery: { ...recovery } } : {}) } }
93104 function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null }
94105 function advance(next: string) {
95106 taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered'
@@ -98,6 +109,30 @@ export function createVehicleMissionEngine(input: unknown) {
98109 if (goal) targetM = goal as VehicleMissionSnapshot['targetM']
99110 if (task.kind === 'abort') { state = 'aborted'; targetM = null; reason = 'mission-abort-task' }
100111 }
112 function readTransitionObservation(o: VehicleMissionObservation) {
113 const observed = o.transition
114 if (!observed) return null
115 const command = transition!
116 const phases = command.mode === 'hover' ? ['hover', 'back-transition'] : ['front-transition', 'cruise']
117 if (observed.eventId !== command.eventId || observed.taskId !== command.taskId || observed.mode !== command.mode || observed.reference !== command.reference
118 || observed.capturedAtMs < command.issuedAtMs || observed.capturedAtMs >= command.expiresAtMs || observed.capturedAtMs > o.nowMs || o.nowMs - observed.capturedAtMs > 120
119 || observed.measurementCapturedAtMs < command.issuedAtMs || o.nowMs - observed.measurementCapturedAtMs > 120
120 || observed.outcome === 'confirmed' && observed.phase !== command.mode || observed.outcome === 'progress' && !phases.includes(observed.phase)) { block('vtol-transition-outcome-uncertain'); return null }
121 const signature = JSON.stringify(Object.entries(observed).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
122 if (transitionObservation && (observed.capturedAtMs < transitionObservation.capturedAtMs || observed.measurementCapturedAtMs < transitionObservation.measurementCapturedAtMs || observed.capturedAtMs === transitionObservation.capturedAtMs && signature !== transitionObservation.signature)) { block('vtol-transition-observation-replayed'); return null }
123 transitionObservation = { capturedAtMs: observed.capturedAtMs, measurementCapturedAtMs: observed.measurementCapturedAtMs, signature }
124 return observed
125 }
126 function beginRecovery(task: Extract<VehicleMissionTask, { kind: 'vtol-mode' }>, o: VehicleMissionObservation, cause: NonNullable<VehicleMissionSnapshot['recovery']>['cause']) {
127 if (!task.onFailure || recovery) return false
128 const failedEventId = transition?.eventId ?? null, hover = tasks.get(task.onFailure)!
129 if (hover.kind !== 'vtol-mode') return false
130 advance(hover.id)
131 recovery = { failedEventId, failedTaskId: task.id, hoverTaskId: hover.id, triggeredAtMs: o.nowMs, cause, ...(cause === 'operator-abort' ? { abortRequestedAtMs: o.nowMs } : {}) }
132 transition = { eventId: `${plan.id}:${reference}:${hover.id}`, taskId: hover.id, mode: 'hover', headingRad: hover.headingRad, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + hover.timeoutMs, positionM: [...o.positionM], reference: reference!, ...(failedEventId ? { supersedesEventId: failedEventId } : {}) }
133 targetM = [...transition.positionM]; reason = 'vtol-transition-recovery-requested'
134 return true
135 }
101136 return {
102137 snapshot,
103138 trace(): VehicleMissionTrace { return structuredClone({ version: 2, evidence: 'owned-vehicle-mission-simulation', plan, rows }) },
@@ -120,7 +155,16 @@ export function createVehicleMissionEngine(input: unknown) {
120155 else if (o.payloadAck !== null && pending === null) block('unsolicited-payload-ack')
121156 else if (o.transition && transition === null) block('unsolicited-vtol-transition')
122157 }
123 if (action === 'abort' && (active(state) || state === 'ready')) { state = 'aborted'; reason = 'operator-abort'; targetM = null; pending = null; transition = null; transitionObservation = null }
158 const currentTask = tasks.get(taskId)!
159 if (action === 'abort' && (active(state) || state === 'ready')) {
160 const task = currentTask
161 if (recovery && active(state)) { recovery.abortRequestedAtMs ??= o.nowMs; state = 'running'; reason = 'vtol-recovery-abort-requested' }
162 else if (state === 'running' && task.kind === 'vtol-mode' && task.onFailure) {
163 if (transition) readTransitionObservation(o)
164 if (active(state)) beginRecovery(task, o, 'operator-abort')
165 }
166 else { state = 'aborted'; reason = 'operator-abort'; targetM = null; pending = null; transition = null; transitionObservation = null }
167 }
124168 else if (action === 'start' && state === 'ready') {
125169 const needed = plan.power.reservePercent + 100 * plan.power.maxW * worstCaseMs / 3600000 / plan.power.availableWh
126170 if (!o.healthy || o.authority !== 'autonomous' || !o.onGround || Math.hypot(...o.velocityMps) > 0.1 || !vehicleMissionContains(plan, o.positionM) || o.batteryPercent < needed || o.payloadAck !== null || o.transition) block('mission-start-interlocks')
@@ -128,15 +172,29 @@ export function createVehicleMissionEngine(input: unknown) {
128172 }
129173 else if ((action === 'pause' && state === 'running') || (action === 'recover' && active(state))) {
130174 if (pending) block('payload-recovery-uncertain')
131 else if (transition) block('vtol-transition-recovery-uncertain')
175 else if (transition || currentTask.kind === 'vtol-mode' && currentTask.onFailure) {
176 const task = currentTask
177 if (recovery && recovery.hoverTaskId === task.id) reason = 'vtol-recovery-already-in-progress'
178 else if (task.kind === 'vtol-mode' && task.onFailure) {
179 if (transition) readTransitionObservation(o)
180 if (active(state) && !beginRecovery(task, o, action === 'pause' ? 'operator-pause' : 'operator-recover')) block('vtol-transition-recovery-uncertain')
181 }
182 else block('vtol-transition-recovery-uncertain')
183 }
132184 else { state = 'paused'; holdM = [...o.positionM]; settledMs = 0; reason = action === 'recover' ? 'checkpoint-recovered-awaiting-resume' : 'operator-pause' }
133185 }
134186 else if (action === 'resume' && state === 'paused') { state = 'running'; settledMs = 0; reason = 'operator-resume' }
135187 if (state === 'paused') targetM = holdM ? [...holdM] : null
136 if (state === 'running') {
188 if (state === 'running' && recovery?.triggeredAtMs !== o.nowMs) {
137189 const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt
138190 taskElapsedMs += executionDt
139 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) block(t.kind === 'payload' ? 'payload-outcome-uncertain' : t.kind === 'vtol-mode' ? 'vtol-transition-outcome-uncertain' : 'task-timeout')
191 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) {
192 if (t.kind === 'vtol-mode' && transition && t.onFailure) {
193 readTransitionObservation(o)
194 if (active(state) && !beginRecovery(t, o, 'timeout')) block('vtol-transition-outcome-uncertain')
195 }
196 else block(t.kind === 'payload' ? 'payload-outcome-uncertain' : t.kind === 'vtol-mode' ? 'vtol-transition-outcome-uncertain' : 'task-timeout')
197 }
140198 else if (t.kind === 'abort') { state = 'aborted'; reason = 'mission-abort-task'; targetM = null }
141199 else if (t.kind === 'start') advance(t.next)
142200 else if (t.kind === 'branch') { advance(o.batteryPercent >= t.batteryAtLeast ? t.then : t.otherwise); reason = 'battery-branch' }
@@ -146,24 +204,15 @@ export function createVehicleMissionEngine(input: unknown) {
146204 reason = 'vtol-transition-requested'
147205 }
148206 targetM = [...transition.positionM]
149 const observed = o.transition
207 const observed = readTransitionObservation(o)
150208 if (observed) {
151 const phases = transition.mode === 'hover' ? ['hover', 'back-transition'] : ['front-transition', 'cruise']
152 if (observed.eventId !== transition.eventId || observed.taskId !== transition.taskId || observed.mode !== transition.mode || observed.reference !== transition.reference
153 || observed.capturedAtMs < transition.issuedAtMs || observed.capturedAtMs >= transition.expiresAtMs || observed.capturedAtMs > o.nowMs || o.nowMs - observed.capturedAtMs > 120
154 || observed.measurementCapturedAtMs < transition.issuedAtMs || o.nowMs - observed.measurementCapturedAtMs > 120
155 || observed.outcome === 'confirmed' && observed.phase !== transition.mode || observed.outcome === 'progress' && !phases.includes(observed.phase)) block('vtol-transition-outcome-uncertain')
156 else {
157 // Wire object order is not evidence identity; all accepted fields are scalar and closed above.
158 const signature = JSON.stringify(Object.entries(observed).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
159 if (transitionObservation && (observed.capturedAtMs < transitionObservation.capturedAtMs || observed.measurementCapturedAtMs < transitionObservation.measurementCapturedAtMs || observed.capturedAtMs === transitionObservation.capturedAtMs && signature !== transitionObservation.signature)) block('vtol-transition-observation-replayed')
160 else {
161 transitionObservation = { capturedAtMs: observed.capturedAtMs, measurementCapturedAtMs: observed.measurementCapturedAtMs, signature }
162 if (observed.outcome === 'aborted') block('vtol-transition-aborted')
163 else if (observed.outcome === 'confirmed') { advance(t.next); reason = 'vtol-transition-confirmed' }
164 else reason = 'vtol-transition-progress'
165 }
209 if (observed.outcome === 'aborted') { if (!beginRecovery(t, o, 'controller-aborted')) block('vtol-transition-aborted') }
210 else if (observed.outcome === 'confirmed') {
211 const holdAfterRecovery = recovery?.hoverTaskId === t.id && ['operator-pause', 'operator-recover'].includes(recovery.cause) && recovery.abortRequestedAtMs === undefined
212 advance(t.next); reason = 'vtol-transition-confirmed'
213 if (holdAfterRecovery) { state = 'paused'; holdM = [...o.positionM]; targetM = [...holdM]; reason = 'vtol-recovery-hover-awaiting-resume' }
166214 }
215 else reason = 'vtol-transition-progress'
167216 }
168217 }
169218 else if (t.kind === 'payload') {
@@ -182,7 +231,10 @@ export function createVehicleMissionEngine(input: unknown) {
182231 if (reached && settledMs >= t.settleMs) {
183232 holdM = [...goal] as VehicleMissionSnapshot['targetM']
184233 if (t.next) advance(t.next)
185 else { state = 'completed'; reason = t.kind === 'land' ? 'landed' : 'stopped'; targetM = null }
234 else {
235 const abortLanded = t.kind === 'land' && recovery?.abortRequestedAtMs !== undefined
236 state = abortLanded ? 'aborted' : 'completed'; reason = t.kind === 'land' ? abortLanded ? 'vtol-abort-landed' : recovery ? 'vtol-recovery-landed' : 'landed' : 'stopped'; targetM = null
237 }
186238 }
187239 }
188240 }
resources/functions/drone/vehicles/types.tsmodified+4-2
Changes to resources/functions/drone/vehicles/types.ts
@@ -32,11 +32,11 @@ export interface VehiclePayloadCommand { eventId: string, taskId: string, operat
3232export 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> }
3333export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void }
3434export type VehicleVtolMode = 'hover' | 'cruise'
35export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
35export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string, supersedesEventId?: string }
3636export interface VehicleVtolTransitionObservation { evidence: 'modeled-vtol-controller-observation', eventId: string, taskId: string, mode: VehicleVtolMode, reference: string, capturedAtMs: number, measurementCapturedAtMs: number, phase: 'hover' | 'front-transition' | 'cruise' | 'back-transition', outcome: 'progress' | 'confirmed' | 'aborted', reason: string }
3737export type VehicleMissionTask =
3838 | { id: string, kind: 'start', next: string }
39 | { id: string, kind: 'vtol-mode', mode: VehicleVtolMode, headingRad: number, timeoutMs: number, next: string }
39 | { id: string, kind: 'vtol-mode', mode: VehicleVtolMode, headingRad: number, timeoutMs: number, next: string, onFailure?: string }
4040 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
4141 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
4242 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
@@ -71,6 +71,8 @@ export interface VehicleMissionSnapshot {
7171 payload: VehiclePayloadCommand | null
7272 /** Present only for plans with explicit VTOL mode tasks; legacy recording shapes stay unchanged. */
7373 transition?: VehicleVtolTransitionCommand | null
74 /** Present after entering an explicitly planned transition-failure hover/landing route. */
75 recovery?: { failedEventId: string | null, failedTaskId: string, hoverTaskId: string, triggeredAtMs: number, cause: 'timeout' | 'controller-aborted' | 'operator-abort' | 'operator-pause' | 'operator-recover', abortRequestedAtMs?: number }
7476 reason: string
7577 elapsedMs: number
7678}
resources/functions/drone/vehicles/vtol/flight-controller.tsmodified+30-3
Changes to resources/functions/drone/vehicles/vtol/flight-controller.ts
@@ -22,9 +22,11 @@ export function createVtolFlightController(): VehicleController {
2222 const velocityPid = [0, 1, 2].map(() => createBoundedPid({ kp: 1.6, ki: 0.25, kd: 0, integralLimit: 1.5, outputLimit: 3, derivativeTauS: 0.03, trackingGain: 1 }))
2323 let hasWingControl = false, backSlow = false
2424 let loiterCenter: SensorVector | null = null
25 let landingTask = '', approachDownM: number | null = null
2526 let lastNow: number | null = null, identity: string | null = null, fault = ''
2627 let phase: VehicleVtolTransitionObservation['phase'] = 'hover', request: VehicleVtolTransitionCommand | null = null, signature = '', confirmedEvent = ''
2728 let stableSource: number | null = null, stableDecision: number | null = null
29 let lastMissionTask: string | null = null, recoveryBinding: { eventId: string, signature: string } | null = null
2830 function output(c: VehicleControllerContext, actuators: number[], reason: string, inhibited = false): VehicleControlOutput { return { kind: 'simulated-vehicle-control', vehicle: 'vtol', atMs: c.nowMs, actuators: [...actuators], reason, inhibited, physicalFlightEnabled: false, boardOutputsInhibited: true } }
2931 function neutral(c: VehicleControllerContext, reason: string) { fault ||= reason; return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) }
3032 return {
@@ -57,6 +59,8 @@ export function createVtolFlightController(): VehicleController {
5759 if ((v.airspeedMps as number) < 0 || (v.airspeedMps as number) > 60 || (v.differentialPressurePa as number) < 0 || Math.abs(Math.sqrt(2 * (v.differentialPressurePa as number) / airModel.densityKgM3) - (v.airspeedMps as number)) > 0.02 || Math.abs(v.alphaRad as number) > Math.PI || Math.abs(v.betaRad as number) > Math.PI || (v.rotorThrustN as number) < 0 || (v.rotorThrustN as number) > 48 || (v.pusherThrustN as number) < 0 || (v.pusherThrustN as number) > 14) throw new TypeError('Invalid VTOL air measurement')
5860 }
5961 catch { return neutral(c, 'vtol-flight-observation-unqualified') }
62 const priorTask = lastMissionTask
63 lastMissionTask = c.mission.taskId
6064 const incoming = c.mission.transition
6165 if (incoming) {
6266 let anchor: number[]
@@ -65,20 +69,39 @@ export function createVtolFlightController(): VehicleController {
6569 if (c.mission.taskKind !== 'vtol-mode' || incoming.taskId !== c.mission.taskId || typeof incoming.eventId !== 'string' || !incoming.eventId || incoming.eventId.length > 256 || incoming.reference !== measurement!.reference || !['hover', 'cruise'].includes(incoming.mode) || !Number.isFinite(incoming.headingRad) || Math.abs(incoming.headingRad) > Math.PI || anchor.some((n, i) => n !== target[i]) || !Number.isSafeInteger(incoming.issuedAtMs) || incoming.issuedAtMs < 0 || !Number.isSafeInteger(incoming.expiresAtMs) || incoming.issuedAtMs > c.nowMs || incoming.expiresAtMs <= c.nowMs || incoming.expiresAtMs <= incoming.issuedAtMs || incoming.expiresAtMs - incoming.issuedAtMs > (incoming.mode === 'hover' ? 45000 : 15000)) return neutral(c, 'vtol-flight-request-unqualified')
6670 const encoded = JSON.stringify(incoming)
6771 if (request?.eventId === incoming.eventId && signature !== encoded) return neutral(c, 'vtol-flight-request-changed')
72 const superseding = incoming.supersedesEventId !== undefined, replacing = request?.eventId !== incoming.eventId
73 const recovery = c.mission.recovery
74 const recoverySignature = recovery ? JSON.stringify([recovery.failedEventId, recovery.failedTaskId, recovery.hoverTaskId, recovery.triggeredAtMs, recovery.cause]) : ''
75 if (recoveryBinding?.eventId === incoming.eventId && recoveryBinding.signature !== recoverySignature) return neutral(c, 'vtol-recovery-request-changed')
76 if (replacing && (recovery || superseding)) {
77 if (!recovery || incoming.mode !== 'hover' || recovery.hoverTaskId !== incoming.taskId || recovery.triggeredAtMs !== incoming.issuedAtMs || !['timeout', 'controller-aborted', 'operator-abort', 'operator-pause', 'operator-recover'].includes(recovery.cause)) return neutral(c, 'vtol-recovery-request-unqualified')
78 if (recovery.failedEventId === null) {
79 if (superseding || recovery.failedTaskId !== priorTask || request && confirmedEvent !== request.eventId || !['operator-abort', 'operator-pause', 'operator-recover'].includes(recovery.cause)) return neutral(c, 'vtol-recovery-request-unqualified')
80 }
81 else if (!superseding || !request || incoming.supersedesEventId !== request.eventId || recovery.failedEventId !== request.eventId || recovery.failedTaskId !== request.taskId || incoming.reference !== request.reference || incoming.issuedAtMs <= request.issuedAtMs) return neutral(c, 'vtol-recovery-request-unqualified')
82 recoveryBinding = { eventId: incoming.eventId, signature: recoverySignature }
83 }
84 if (replacing && request && confirmedEvent !== request.eventId && !superseding) return neutral(c, 'vtol-pending-request-not-cancelled')
85 if (request?.eventId !== incoming.eventId && incoming.mode === 'hover' && !hasWingControl) { request = structuredClone(incoming); signature = encoded }
6886 if (request?.eventId !== incoming.eventId && incoming.mode === 'cruise') {
6987 if (phase !== 'hover' || measurement!.values.onSurface) return neutral(c, 'vtol-front-requires-airborne-hover')
7088 request = structuredClone(incoming); signature = encoded; phase = 'front-transition'; hasWingControl = true; stableSource = null; stableDecision = null
7189 }
7290 if (request?.eventId !== incoming.eventId && incoming.mode === 'hover' && hasWingControl) {
73 if (phase !== 'cruise') return neutral(c, 'vtol-back-requires-observed-cruise')
91 if (phase !== 'cruise' && !superseding) return neutral(c, 'vtol-back-requires-observed-cruise')
7492 request = structuredClone(incoming); signature = encoded; phase = 'back-transition'; backSlow = false; stableSource = null; stableDecision = null
7593 for (const pid of velocityPid) pid.reset()
7694 }
7795 }
78 if (phase === 'hover' && !hasWingControl) return hover.step(c)
96 if (phase === 'hover' && !hasWingControl) {
97 const response = hover.step(c)
98 if (response.transition?.outcome === 'confirmed') confirmedEvent = response.transition.eventId
99 return response
100 }
79101 const [roll, pitch, yaw] = quaternionEuler(q!), values = measurement!.values
80102 const speed = values.airspeedMps as number, alpha = values.alphaRad as number, beta = values.betaRad as number
81103 const landing = phase === 'hover' && c.mission.state === 'running' && c.mission.taskKind === 'land'
104 if (landing && landingTask !== c.mission.taskId) { landingTask = c.mission.taskId; approachDownM = position![2] }
82105 const pausedContact = phase === 'hover' && c.mission.state === 'paused' && values.onSurface === true && values.stopped === true
83106 if (measurement!.values.onSurface && !landing && !pausedContact || speed > 22 || Math.abs(roll) > 0.6 || Math.abs(pitch) > 0.5 || speed > 8 && (Math.abs(alpha) > 0.32 || Math.abs(beta) > 0.4) || phase !== 'hover' && Math.abs(position![2] - request!.positionM[2]) > 2) return neutral(c, 'vtol-transition-envelope')
84107 if (landing && measurement!.values.onSurface || pausedContact) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], pausedContact ? 'ground-paused' : 'vertical-contact')
@@ -120,7 +143,11 @@ export function createVtolFlightController(): VehicleController {
120143 }
121144 else {
122145 const goal = [...target!] as SensorVector
123 if (landing) goal[2] += 0.1
146 if (landing) {
147 const aligned = Math.hypot(target![0] - position![0], target![1] - position![1]) <= 0.5 && groundSpeed <= 0.2
148 if (approachDownM !== null && !aligned) goal[2] = approachDownM
149 else { approachDownM = null; goal[2] += 0.1 }
150 }
124151 const desiredVelocity = goal.map((n, i) => n - position![i]!) as SensorVector
125152 const horizontal = Math.hypot(desiredVelocity[0], desiredVelocity[1])
126153 if (horizontal > 3) { desiredVelocity[0] *= 3 / horizontal; desiredVelocity[1] *= 3 / horizontal }
tests/fixtures/public-safety-access/execution-browser.tsmodified+26-7
Changes to tests/fixtures/public-safety-access/execution-browser.ts
@@ -21,6 +21,8 @@ const { executionWords: words } = await import('../../../resources/functions/dro
2121let holdArmed = false, holdUsed = false, held = false, releaseCredit: (() => void) | undefined
2222let delayStart = true, releaseStart: (() => void) | undefined
2323let streamStarts = 0, startRequests = 0, admissionRequests = 0, closeStream: (() => Promise<void>) | undefined
24let closeWorkspaceStream: (() => Promise<void>) | undefined, releaseWorkspace: (() => void) | undefined
25let holdWorkspace = false, workspaceHeld = false
2426
2527// Diagnostic state belongs only to this disposable fixture, never the execution controller.
2628type RouteClass = 'execution-start' | 'execution-close' | 'execution-admission' | 'execution-events' | 'execution-status' | 'workspace-read'
@@ -51,7 +53,7 @@ function diagnosticSnapshot() {
5153 }
5254 return {
5355 available: true, pendingChecks: scalar(owner.pendingChecks),
54 fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed }, lastStart, lastAdmission, responses: responses.slice(),
56 fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed, holdWorkspace, workspaceHeld }, lastStart, lastAdmission, responses: responses.slice(),
5557 slot: slot === null ? null : {
5658 ordinal: ordinal(slot),
5759 phase: choice(field(slot, 'phase'), ['waiting-for-consumer', 'running', 'stopping', 'completed', 'canceled', 'failed', 'discarded'] satisfies ExecutionView['phase'][]),
@@ -138,8 +140,15 @@ const control = async (request: Request) => {
138140 assert(rows.some(row => row.digest !== seeded.source.digest))
139141 const committed = await fixture.snapshot(seeded.key)
140142 assert.equal(committed.audits.filter(audit => audit.action === 'source-registered').length, 2)
143 holdWorkspace = true
144 assert(closeWorkspaceStream, 'The real workspace stream must exist before renewal')
145 await closeWorkspaceStream()
146 const until = Date.now() + 5000
147 while (!workspaceHeld && Date.now() < until) await Bun.sleep(5)
148 assert(workspaceHeld, 'Native workspace renewal must request a fresh authorized snapshot')
141149 return Response.json({ ok: true })
142150 }
151 if (action === 'release-workspace') { assert(workspaceHeld, 'Release only the held native renewal'); releaseWorkspace?.(); return Response.json({ ok: true }) }
143152 if (action === 'read-only') return Response.json({ token: await fixture.credential(fixture.sender.id, ['public-safety:read']) })
144153 if (action === 'revoke') { await fixture.tokens.revokeToken(fixture.senderToken); return Response.json({ ok: true }) }
145154 return new Response(null, { status: 404 })
@@ -153,15 +162,25 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon
153162 if (admissionOrdinal !== null) lastAdmission = { ordinal: admissionOrdinal, settled: false, status: null, elapsedMs: null }
154163 let status: number | null = null
155164 try {
165 if (kind === 'workspace-read' && holdWorkspace) {
166 holdWorkspace = false; workspaceHeld = true
167 await new Promise<void>(resolve => {
168 const release = () => { workspaceHeld = false; resolve() }
169 const timeout = setTimeout(release, 5000)
170 releaseWorkspace = () => { clearTimeout(timeout); release() }
171 })
172 }
156173 const response = await next()
157174 status = response?.status ?? null
158175 if (delayStart && request.method === 'POST' && pathname.endsWith('/workspace/execution') && response?.ok) { delayStart = false; await new Promise<void>(resolve => { releaseStart = resolve }) }
159 if (request.method !== 'GET' || !/\/workspace\/execution\/[a-f0-9-]+\/events$/.test(pathname) || !response?.ok || !response.body) return response
160 streamStarts++
176 const executionEvents = /\/workspace\/execution\/[a-f0-9-]+\/events$/.test(pathname), workspaceEvents = pathname.endsWith('/workspace/events')
177 if (request.method !== 'GET' || !(executionEvents || workspaceEvents) || !response?.ok || !response.body) return response
178 if (executionEvents) streamStarts++
161179 const reader = response.body.getReader()
162180 let closed = false, output: ReadableStreamDefaultController<Uint8Array> | undefined
163181 const close = async () => { if (closed) return; closed = true; output?.close(); await reader.cancel().catch(() => {}) }
164 closeStream = close
182 if (executionEvents) closeStream = close
183 else closeWorkspaceStream = close
165184 // Opaque native chunks pass unchanged. Only the private fixture can end a connection.
166185 const body = new ReadableStream<Uint8Array>({
167186 start(controller) { output = controller },
@@ -188,7 +207,7 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon
188207})
189208const page = () => new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } })
190209route.get('/incidents', page)
191for (const action of ['status', 'diagnostics', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'verify-admitted', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control)
210for (const action of ['status', 'diagnostics', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'verify-admitted', 'release-workspace', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control)
192211const server = await route.serve({ hostname: '127.0.0.1', port: 0, nativeRoutes: false })
193212if (process.env.EXECUTION_BROWSER_EVIDENCE) { await mkdir(process.env.EXECUTION_BROWSER_EVIDENCE, { recursive: true }); await writeFile(join(process.env.EXECUTION_BROWSER_EVIDENCE, 'compiled.html'), html) }
194213await writeFile(join(root, 'execution-credentials.json'), JSON.stringify({ url: `http://127.0.0.1:${server.port}`, key: seeded.key, sourceDigest: seeded.source.digest, token: fixture.senderToken, control: secret }), { mode: 0o600 })
@@ -197,8 +216,8 @@ try {
197216 assert.equal(await child.exited, 0, 'Isolated real execution browser driver must pass')
198217}
199218finally {
200 releaseStart?.(); releaseCredit?.()
201 await closeStream?.(); await server.stop(true)
219 releaseStart?.(); releaseCredit?.(); releaseWorkspace?.()
220 await closeStream?.(); await closeWorkspaceStream?.(); await server.stop(true)
202221 const slot = executionOwner().slot as { execution: string, binding: { scope: string }, producer: string, worker: Worker | null } | null
203222 if (slot?.producer === 'pending') {
204223 const core = await import('../../../resources/functions/drone/public-safety/execution/service')
tests/helpers/execution-browser-driver.tsmodified+28-3
Changes to tests/helpers/execution-browser-driver.ts
@@ -1,5 +1,5 @@
11import assert from 'node:assert/strict'
2import { mkdir, readFile } from 'node:fs/promises'
2import { mkdir, readFile, writeFile } from 'node:fs/promises'
33import { basename, join } from 'node:path'
44import { startWorkspaceBrowser } from './workspace-browser'
55import { settleExecutionDiagnostics } from './execution-diagnostics'
@@ -41,6 +41,7 @@ async function failureDiagnostics() {
4141 workspaceStatus: selected('[data-workspace-status]', ['disconnected', 'connecting', 'synchronizing', 'current', 'reconnecting', 'refused']),
4242 executionPhase: selected('[data-execution-phase]', ['no current run', 'waiting-for-consumer', 'running', 'stopping', 'completed', 'canceled', 'failed', 'discarded']),
4343 hasExecutionId: !!document.querySelector('[data-execution-id]'), hasCompletion: !!document.querySelector('[data-execution-completion]'),
44 hasSelectedSource: !!document.querySelector('[data-workspace-source]'),
4445 startDisabled: start ? start.disabled === true : null, admitDisabled: admit ? admit.disabled === true : null,
4546 hasSourceExists: !!document.querySelector('[data-execution-source-exists]'), hasExecutionError: !!document.querySelector('[data-execution-error]')
4647 };
@@ -53,7 +54,9 @@ async function failureDiagnostics() {
5354 admissionRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution/admission')).length : null,
5455 }
5556 const text = JSON.stringify({ ...base, ...parts })
56 console.error(Buffer.byteLength(text, 'utf8') <= 8192 ? text : JSON.stringify({ ...base, server: { available: false }, dom: null, byteBudgetExceeded: true }))
57 const bounded = Buffer.byteLength(text, 'utf8') <= 8192 ? text : JSON.stringify({ ...base, server: { available: false }, dom: null, byteBudgetExceeded: true })
58 console.error(bounded)
59 await writeFile(join(evidence, 'failure-diagnostics.json'), `${bounded}\n`, { mode: 0o600, flag: 'wx' })
5760 }
5861 catch { console.error(JSON.stringify({ label: 'execution-browser-failure-diagnostics', phase, available: false })) }
5962 finally {
@@ -130,10 +133,32 @@ try {
130133 await first.screenshot(join(evidence, 'execution-completed-mobile.png'), 375, 900)
131134 assert.equal(await first.evaluate('Math.max(0, document.documentElement.scrollWidth - innerWidth)'), 0, 'Execution UUID and digest wrap at375px')
132135 mark('explicit source admission')
136 await first.evaluate(`(() => {
137 const probe = { samples: [], overflow: false };
138 const sample = () => {
139 const value = document.querySelector('[data-workspace-source]')?.textContent.trim();
140 const selected = !value ? 'absent' : value === ${JSON.stringify(data.sourceDigest)} ? 'original' : 'other';
141 if (probe.samples.at(-1) === selected) return;
142 if (probe.samples.length >= 64) { probe.overflow = true; return; }
143 probe.samples.push(selected);
144 };
145 const observer = new MutationObserver(sample);
146 observer.observe(document.body, { childList: true, characterData: true, subtree: true });
147 sample(); globalThis.executionSourceProbe = { probe, stop: () => observer.disconnect() };
148 })()`)
133149 await first.click('Admit completed source')
134150 await first.wait("!!document.querySelector('[data-execution-source-exists]')", 'Explicit admission must refresh actual source catalog', 15000)
135151 await control('verify-admitted')
136 assert.equal(await first.evaluate("document.querySelector('[data-workspace-source]')?.textContent.trim()"), data.sourceDigest, 'Admission cannot select its new source')
152 mark('fresh workspace after admission renewal')
153 assert.equal(await first.evaluate("!!document.querySelector('[data-workspace-source]') || !!document.querySelector('[data-execution-completion]')"), false, 'Native renewal must clear private panes before fresh authorization')
154 await control('release-workspace')
155 await first.wait(`document.querySelector('[data-workspace-status]')?.textContent.trim() === 'current' && !!document.querySelector('[data-execution-source-exists]') && document.querySelector('[data-workspace-source]')?.textContent.trim() === ${JSON.stringify(data.sourceDigest)}`, 'Fresh authorization must restore the original selection after admission', 15000)
156 const selections = await first.evaluate<{ samples: string[], overflow: boolean }>('(() => { globalThis.executionSourceProbe.stop(); return globalThis.executionSourceProbe.probe; })()')
157 assert.equal(selections.overflow, false, 'The complete bounded selection history must be available')
158 assert(selections.samples.includes('absent'), 'The actual native renewal must hide selected imagery')
159 assert.equal(selections.samples.at(-1), 'original')
160 assert(selections.samples.every(value => value === 'original' || value === 'absent'), 'Admission must never select the new source, including during renewal')
161 await writeFile(join(evidence, 'admission-renewal.json'), `${JSON.stringify(selections)}\n`, { mode: 0o600, flag: 'wx' })
137162 assert.equal(await first.evaluate("document.querySelector('#incident-note')?.value"), 'Later unsent observation survives execution success')
138163 mark('read-only credential')
139164 const { token } = await control('read-only')
tests/tsconfig.runtime.jsonmodified+2-1
Changes to tests/tsconfig.runtime.json
@@ -116,6 +116,7 @@
116116 "./unit/cinema-managed.test.ts",
117117 "./unit/cinema-counted-encoder.test.ts",
118118 "./unit/cinema-counted-translation.test.ts",
119 "./unit/vehicle-lifecycle.test.ts"
119 "./unit/vehicle-lifecycle.test.ts",
120 "./unit/vtol-recovery.test.ts"
120121 ]
121122}
tests/unit/vtol-recovery.test.tsadded+321-0
Changes to tests/unit/vtol-recovery.test.ts
@@ -0,0 +1,321 @@
1import { expect, test } from 'bun:test'
2import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { createVehicleMissionEngine, parseVehicleMissionTrace, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
5import { createVtolVehicleHoverController } from '../../resources/functions/drone/vehicles/vtol/controller'
6import { createVtolFlightController } from '../../resources/functions/drone/vehicles/vtol/flight-controller'
7import type { VehicleControllerContext } from '../../resources/functions/drone/vehicles/types'
8
9function declaredRecovery(sourceTask: 'initial-hover' | 'front' | 'back') {
10 const profile = createVtolTransitionProfile(), source = profile.mission.tasks.find(task => task.id === sourceTask)!
11 if (source.kind !== 'vtol-mode') throw new Error('Expected a VTOL mode task')
12 source.onFailure = 'recover-hover'
13 profile.mission.tasks.push(
14 { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: sourceTask === 'back' ? 40000 : 45000, next: 'recover-land' },
15 { id: 'recover-land', kind: 'land', positionM: [sourceTask === 'back' ? 180 : 0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: sourceTask === 'back' ? 30000 : 35000 },
16 )
17 return profile
18}
19
20for (const mutation of ['missing-cancel', 'foreign-failed-task', 'missing-both-records', 'changed-after-admission'] as const) test(`pending initial-hover recovery rejects ${mutation} before confirmation`, () => {
21 const profile = declaredRecovery('initial-hover'), createSession = profile.createSession, inputs: VehicleControllerContext[] = []
22 profile.createSession = () => {
23 const session = createSession(), controller = session.createController()
24 return { ...session, createController: () => ({ step(c) { if (c.nowMs <= 740) inputs.push(structuredClone(c)); return controller.step(c) } }) }
25 }
26 expect(runVehicleSimulation(profile, { seconds: 180, missionActions: [{ atMs: 700, action: 'abort' }] }).mission.reason).toBe('vtol-abort-landed')
27 const index = inputs.findIndex(c => c.nowMs === (mutation === 'changed-after-admission' ? 720 : 700)), controller = createVtolFlightController()
28 expect(index).toBeGreaterThan(0)
29 for (const c of inputs.slice(0, index)) expect(controller.step(c).inhibited).toBe(false)
30 const altered = structuredClone(inputs[index]!)
31 if (mutation === 'foreign-failed-task' || mutation === 'changed-after-admission') altered.mission.recovery!.failedTaskId = 'foreign-task'
32 else {
33 delete altered.mission.transition!.supersedesEventId
34 if (mutation === 'missing-both-records') delete altered.mission.recovery
35 }
36 const refused = controller.step(altered)
37 expect(refused.inhibited).toBe(true)
38 expect(refused.transition).toBeUndefined()
39 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
40 expect(controller.step(inputs[index + 1]!).inhibited).toBe(true)
41})
42
43test('replacement hover cannot invent or change the cancelled request, source reference or recovery scope', () => {
44 const profile = declaredRecovery('front'), createSession = profile.createSession, inputs: VehicleControllerContext[] = []
45 profile.createSession = () => {
46 const session = createSession(), controller = session.createController()
47 return { ...session, createController: () => ({ step(c) { if (c.nowMs <= 13040) inputs.push(structuredClone(c)); return controller.step(c) } }) }
48 }
49 expect(runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 13000, action: 'abort' }] }).mission.reason).toBe('vtol-abort-landed')
50 const index = inputs.findIndex(c => c.nowMs === 13000)
51 for (const change of [
52 (c: VehicleControllerContext) => { c.mission.transition!.supersedesEventId = 'foreign-event' },
53 (c: VehicleControllerContext) => { delete c.mission.transition!.supersedesEventId },
54 (c: VehicleControllerContext) => { c.mission.transition!.mode = 'cruise' },
55 (c: VehicleControllerContext) => { c.mission.transition!.eventId = c.mission.transition!.supersedesEventId! },
56 (c: VehicleControllerContext) => { delete c.mission.recovery },
57 (c: VehicleControllerContext) => { c.mission.recovery!.failedEventId = 'foreign-event' },
58 (c: VehicleControllerContext) => { c.mission.recovery!.hoverTaskId = 'other-hover' },
59 (c: VehicleControllerContext) => { c.mission.recovery!.triggeredAtMs += 20 },
60 (c: VehicleControllerContext) => { c.mission.transition!.reference = 'foreign-reference' },
61 (c: VehicleControllerContext) => { c.controlRate!.capturedAtMs -= 1000 },
62 (c: VehicleControllerContext) => { c.estimate.localPosition!.origin!.ellipsoidHeightM += 1 },
63 (c: VehicleControllerContext) => { c.nowMs -= 20 },
64 ]) {
65 const controller = createVtolFlightController()
66 for (const c of inputs.slice(0, index)) expect(controller.step(c).inhibited).toBe(false)
67 const altered = structuredClone(inputs[index]!)
68 change(altered)
69 const refused = controller.step(altered)
70 expect(refused.inhibited).toBe(true)
71 expect(refused.transition).toBeUndefined()
72 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
73 expect(controller.step(inputs[index + 1]!).inhibited).toBe(true)
74 }
75})
76
77test('recovered hover cannot pause global endurance or evade the bounded serialized replay contract', () => {
78 const report = runVehicleSimulation(declaredRecovery('front'), { seconds: 180, missionActions: [{ atMs: 13000, action: 'pause' }] })
79 expect(report.status).toBe('blocked')
80 expect(report.mission.reason).toBe('mission-endurance-exhausted')
81 expect(report.mission.elapsedMs).toBeGreaterThan(report.budget.worstCaseMs)
82 expect(report.mission.elapsedMs).toBeLessThanOrEqual(report.budget.worstCaseMs + 20)
83 const paused = report.trace.rows.filter(row => row.result.state === 'paused')
84 expect(paused.at(-1)!.observation.batteryPercent).toBeLessThan(paused[0]!.observation.batteryPercent)
85 expect(report.path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running')).toBe(false)
86 expect(report.finalTruthPositionM[2]).toBeLessThan(-1)
87 const serialized = JSON.stringify(report.trace)
88 expect(new TextEncoder().encode(serialized).length).toBeLessThanOrEqual(16 * 1024 * 1024)
89 expect(replayVehicleMission(parseVehicleMissionTrace(serialized)).snapshot()).toEqual(report.mission)
90 const altered = structuredClone(report.trace)
91 altered.rows.find(row => row.result.recovery)!.result.recovery!.failedEventId = 'foreign-event'
92 expect(() => parseVehicleMissionTrace(JSON.stringify(altered))).toThrow('decision mismatch')
93 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
94})
95
96test('late feedback from a cancelled transition cannot complete or resume its replacement', () => {
97 const report = runVehicleSimulation(declaredRecovery('front'), { seconds: 160, missionActions: [{ atMs: 13000, action: 'abort' }] })
98 const engine = createVehicleMissionEngine(report.trace.plan)
99 for (const row of report.trace.rows.filter(row => row.observation.nowMs <= 13000)) engine.step(row.observation, row.action ?? undefined)
100 const late = structuredClone(report.trace.rows.find(row => row.observation.nowMs === 13020)!.observation)
101 late.transition = structuredClone(report.trace.rows.find(row => row.observation.nowMs === 13000)!.observation.transition!)
102 expect(late.transition!.taskId).toBe('front')
103 expect(engine.step(late).reason).toBe('vtol-transition-outcome-uncertain')
104 expect(engine.snapshot().state).toBe('blocked')
105 const next = report.trace.rows.find(row => row.observation.nowMs === 13040)!.observation
106 expect(engine.step(next, 'resume').state).toBe('blocked')
107})
108
109test('a real hover-only controller rejection follows the declared failure route without certifying cruise', () => {
110 const profile = declaredRecovery('front'), createSession = profile.createSession
111 profile.createSession = () => ({ ...createSession(), createController: createVtolVehicleHoverController })
112 const report = runVehicleSimulation(profile, { seconds: 160 })
113 expect(report.mission.reason).toBe('vtol-recovery-landed')
114 expect(report.mission.recovery?.cause).toBe('controller-aborted')
115 expect(report.trace.rows.some(row => row.observation.transition?.outcome === 'aborted' && row.observation.transition.reason === 'hover-only-controller')).toBe(true)
116 expect(report.path.some(row => row.mission.taskId === 'cruise-leg')).toBe(false)
117 expect(report.path.every(row => row.actuators[4] === 0)).toBe(true)
118 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
119 expect(report.finalTruthPositionM[2]).toBe(0)
120 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
121})
122
123test('invalid or unaffordable failure routes are rejected before allocating session resources', () => {
124 for (const mutate of [
125 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'back')!, { onFailure: 'missing' }) },
126 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'recover-hover')!, { mode: 'cruise' }) },
127 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'recover-hover')!, { onFailure: 'recover-hover' }) },
128 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'recover-land')!, { kind: 'waypoint' }) },
129 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'recover-land')!, { next: 'back' }) },
130 (p: ReturnType<typeof declaredRecovery>) => { const land = p.mission.tasks.find(t => t.id === 'recover-land')!; if ('positionM' in land) land.positionM[2] = -1 },
131 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'front')!, { next: 'recover-hover' }) },
132 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'back')!, { next: 'back' }) },
133 (p: ReturnType<typeof declaredRecovery>) => { Object.assign(p.mission.tasks.find(t => t.id === 'recover-hover')!, { timeoutMs: 45000 }); Object.assign(p.mission.tasks.find(t => t.id === 'recover-land')!, { timeoutMs: 35000 }) },
134 (p: ReturnType<typeof declaredRecovery>) => { p.mission.power.availableWh = 84 },
135 ]) {
136 const profile = declaredRecovery('back')
137 mutate(profile)
138 let allocations = 0
139 profile.createSession = () => { allocations++; throw new Error('Resources must remain unallocated') }
140 expect(() => runVehicleSimulation(profile, { seconds: 180 })).toThrow()
141 expect(allocations).toBe(0)
142 }
143 const budget = validateVehicleMission(declaredRecovery('back').mission)
144 expect(budget.worstCaseMs).toBe(176520)
145 expect(budget.requiredWh).toBeCloseTo(58.84, 8)
146})
147
148for (const [atMs, taskId] of [[15000, 'recover-hover'], [40500, 'recover-land']] as const) {
149 for (const failure of ['dropImu', 'dropPosition', 'dropVehicleSensor', 'rcOverride', 'kill', 'hostLost'] as const) test(`recovery ${taskId} cannot bypass ${failure}`, () => {
150 const profile = declaredRecovery('front')
151 profile.faults = (_scenario, nowMs) => nowMs >= atMs ? { [failure]: true } : {}
152 const report = runVehicleSimulation(profile, { seconds: 180, missionActions: [{ atMs: 13000, action: 'pause' }, { atMs: 40000, action: 'resume' }] })
153 expect(report.path.filter(row => row.atMs < atMs).at(-1)!.mission.taskId).toBe(taskId)
154 expect(report.status).toBe(failure === 'hostLost' ? 'host-lost' : 'blocked')
155 expect(report.mission.reason).not.toBe('vtol-recovery-landed')
156 expect(report.mission.reason).not.toBe('vtol-abort-landed')
157 expect(report.finalTruthPositionM[2]).toBeLessThan(-1)
158 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
159 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
160 expect(report.board.expiresAtMs).toBeNull()
161 if (failure === 'hostLost') {
162 expect(report.board.fault).toBe('output-lease-expired')
163 expect(report.authority.lastCheckedAtMs).toBeLessThan(atMs)
164 }
165 else {
166 expect(report.mission.state).toBe('blocked')
167 expect(report.path.at(-1)!.atMs).toBeLessThanOrEqual(atMs + 500)
168 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
169 }
170 })
171}
172
173test('an early resume and repeated recovery cannot skip observed hover or extend its request', () => {
174 const report = runVehicleSimulation(declaredRecovery('front'), { seconds: 180, missionActions: [{ atMs: 13000, action: 'pause' }, { atMs: 13200, action: 'resume' }, { atMs: 13400, action: 'recover' }, { atMs: 40000, action: 'resume' }] })
175 const requests = report.path.filter(row => row.mission.taskId === 'recover-hover').map(row => row.mission.transition!)
176 expect(new Set(requests.map(request => request.eventId)).size).toBe(1)
177 expect(requests.every(request => request.issuedAtMs === 13000 && request.expiresAtMs === 58000)).toBe(true)
178 expect(report.path.find(row => row.atMs === 13200)!.mission.state).toBe('running')
179 expect(report.path.find(row => row.atMs === 20000)!.mission.state).toBe('paused')
180 expect(report.path.find(row => row.atMs === 39980)!.mission.state).toBe('paused')
181 expect(report.mission.reason).toBe('vtol-recovery-landed')
182 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
183})
184
185for (const abortAtMs of [15000, 20000]) test(`an abort at ${abortAtMs} upgrades recovery to contact without resetting its original epoch`, () => {
186 const report = runVehicleSimulation(declaredRecovery('front'), { seconds: 180, missionActions: [{ atMs: 13000, action: 'pause' }, { atMs: abortAtMs, action: 'abort' }] })
187 expect(report.mission.state).toBe('aborted')
188 expect(report.mission.reason).toBe('vtol-abort-landed')
189 expect(report.mission.recovery).toMatchObject({ triggeredAtMs: 13000, cause: 'operator-pause', abortRequestedAtMs: abortAtMs })
190 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
191 expect(report.finalTruthPositionM[2]).toBe(0)
192 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
193 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
194})
195
196for (const [sourceTask, atMs] of [['initial-hover', 620], ['front', 11320], ['back', 27400]] as const) test(`a ${sourceTask} abort before its first command dispatch does not invent a cancelled event`, () => {
197 const report = runVehicleSimulation(declaredRecovery(sourceTask), { seconds: 180, missionActions: [{ atMs, action: 'abort' }] })
198 expect(report.mission.reason).toBe('vtol-abort-landed')
199 expect(report.mission.recovery?.failedEventId).toBeNull()
200 const replacement = report.path.find(row => row.mission.taskId === 'recover-hover')!.mission.transition!
201 expect(replacement.supersedesEventId).toBeUndefined()
202 expect(report.path.some(row => row.mission.transition?.taskId === sourceTask)).toBe(false)
203 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
204 expect(report.finalTruthPositionM[2]).toBe(0)
205 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
206 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
207})
208
209// Established public runtime seam: a real failed modeled propulsor, estimator-driven recovery
210// and observed contact. This does not qualify hardware or identify a physical emergency envelope.
211test('a declared front-transition failure route cancels failed wing support, observes hover and lands without retry', () => {
212 const profile = declaredRecovery('front'), createSession = profile.createSession
213 profile.createSession = () => {
214 const session = createSession(), plant = session.createPlant()
215 return { ...session, createPlant: () => ({ snapshot: () => plant.snapshot(), step(actuators, dtS, environment) {
216 const failed = [...actuators]
217 failed[4] = 0
218 return plant.step(failed, dtS, environment)
219 } }) }
220 }
221 const report = runVehicleSimulation(profile, { seconds: 160 })
222 console.log('VTOL_FAILED_PUSHER_RECOVERY', JSON.stringify({ status: report.status, fault: report.fault, mission: report.mission, finalPositionM: report.finalTruthPositionM, transitions: report.transitions }))
223 expect(report.status).toBe('completed')
224 expect(report.mission.reason).toBe('vtol-recovery-landed')
225 expect(report.path.some(row => row.mission.taskId === 'recover-hover' && row.actuators.slice(0, 4).some(n => n > 0))).toBe(true)
226 expect(report.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed').map(row => row.observation.transition!.mode)).toEqual(['hover', 'hover'])
227 expect(report.path.some(row => row.mission.taskId === 'cruise-leg')).toBe(false)
228 expect(report.transitions.filter(row => row.taskId === 'front')).toHaveLength(1)
229 expect(report.path.filter(row => row.mission.taskId === 'recover-land').at(-1)!.measurement!.values.onSurface).toBe(true)
230 expect(report.finalTruthPositionM[2]).toBe(0)
231 expect(Math.hypot(report.finalTruthPositionM[0], report.finalTruthPositionM[1])).toBeLessThan(0.3)
232 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
233 expect(report.board.expiresAtMs).toBeNull()
234 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
235 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
236})
237
238for (const landingTimeoutMs of [25000, 35000]) test(`back-transition recovery retains the declared ${landingTimeoutMs} ms approach and landing budget`, () => {
239 const profile = declaredRecovery('back'), back = profile.mission.tasks.find(task => task.id === 'back')!
240 if (back.kind !== 'vtol-mode') throw new Error('Expected a VTOL mode task')
241 back.timeoutMs = 15000
242 const hover = profile.mission.tasks.find(task => task.id === 'recover-hover')!, land = profile.mission.tasks.find(task => task.id === 'recover-land')!
243 if (hover.kind !== 'vtol-mode' || land.kind !== 'land') throw new Error('Expected recovery tasks')
244 hover.timeoutMs = 45000; land.timeoutMs = landingTimeoutMs
245 const report = runVehicleSimulation(profile, { seconds: 160 })
246 console.log('VTOL_BACK_TIMEOUT_RECOVERY', JSON.stringify({ landingTimeoutMs, status: report.status, fault: report.fault, mission: report.mission, budget: report.budget, finalPositionM: report.finalTruthPositionM, transitions: report.transitions }))
247 if (landingTimeoutMs === 25000) {
248 expect(report.status).toBe('blocked')
249 expect(report.mission.reason).toBe('task-timeout')
250 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(false)
251 expect(report.finalTruthPositionM[2]).toBeLessThan(-1)
252 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
253 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
254 return
255 }
256 expect(report.status).toBe('completed')
257 expect(report.budget.worstCaseMs).toBe(156520)
258 expect(report.budget.requiredWh).toBeCloseTo(52.17333333333333, 8)
259 expect(report.mission.reason).toBe('vtol-recovery-landed')
260 expect(report.mission.recovery).toMatchObject({ failedTaskId: 'back', hoverTaskId: 'recover-hover', cause: 'timeout' })
261 const original = report.path.find(row => row.mission.taskId === 'back' && row.mission.transition)!.mission.transition!
262 const replacement = report.path.find(row => row.mission.taskId === 'recover-hover')!.mission.transition!
263 expect(replacement.supersedesEventId).toBe(original.eventId)
264 expect(replacement.issuedAtMs).toBe(original.expiresAtMs)
265 expect(replacement.expiresAtMs - replacement.issuedAtMs).toBe(45000)
266 expect(report.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed').map(row => row.observation.transition!.taskId)).toEqual(['initial-hover', 'front', 'recover-hover'])
267 expect(report.transitions.filter(row => row.taskId === 'back')).toHaveLength(1)
268 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
269 expect(Math.hypot(report.finalTruthPositionM[0] - 180, report.finalTruthPositionM[1])).toBeLessThan(0.3)
270 expect(report.finalTruthPositionM[2]).toBe(0)
271 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
272 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
273 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
274})
275
276for (const [sourceTask, atMs, northM] of [['initial-hover', 700, 0], ['front', 13000, 0], ['back', 30000, 180]] as const) test(`an operator abort during ${sourceTask} follows declared hover and observed contact`, () => {
277 const profile = declaredRecovery(sourceTask)
278 const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs, action: 'abort' }] })
279 console.log('VTOL_PHASE_ABORT_LANDING', JSON.stringify({ sourceTask, status: report.status, fault: report.fault, mission: report.mission, finalPositionM: report.finalTruthPositionM, transitions: report.transitions }))
280 expect(report.mission.state).toBe('aborted')
281 expect(report.mission.reason).toBe('vtol-abort-landed')
282 expect(report.mission.recovery).toMatchObject({ failedTaskId: sourceTask, cause: 'operator-abort', triggeredAtMs: atMs })
283 expect(report.trace.rows.at(-1)!.observation.nowMs).toBeGreaterThan(atMs)
284 expect(report.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed').map(row => row.observation.transition!.taskId)).toEqual(sourceTask === 'back' ? ['initial-hover', 'front', 'recover-hover'] : sourceTask === 'front' ? ['initial-hover', 'recover-hover'] : ['recover-hover'])
285 expect(report.path.some(row => row.mission.state === 'completed')).toBe(false)
286 if (sourceTask !== 'back') expect(report.path.some(row => row.mission.taskId === 'cruise-leg')).toBe(false)
287 if (sourceTask !== 'initial-hover') expect(report.path.filter(row => row.atMs >= atMs && row.mission.taskId === 'recover-hover').some(row => row.actuators.slice(0, 4).some(n => n > 0))).toBe(true)
288 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
289 expect(report.finalTruthPositionM[2]).toBe(0)
290 expect(Math.hypot(report.finalTruthPositionM[0] - northM, report.finalTruthPositionM[1])).toBeLessThan(0.3)
291 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
292 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
293 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
294})
295
296for (const [sourceTask, atMs, resumeAtMs, northM] of [['initial-hover', 700, 2000, 0], ['front', 13000, 40000, 0], ['back', 30000, 80000, 180]] as const) {
297 for (const action of ['pause', 'recover'] as const) test(`declared ${sourceTask} ${action} qualifies hover before pausing for explicit resume`, () => {
298 const profile = declaredRecovery(sourceTask)
299 const report = runVehicleSimulation(profile, { seconds: 180, missionActions: [{ atMs, action }, { atMs: resumeAtMs, action: 'resume' }] })
300 console.log('VTOL_PHASE_PAUSE_RECOVERY', JSON.stringify({ sourceTask, action, status: report.status, fault: report.fault, mission: report.mission, transitions: report.transitions }))
301 expect(report.status).toBe('completed')
302 expect(report.mission.reason).toBe('vtol-recovery-landed')
303 expect(report.mission.recovery).toMatchObject({ failedTaskId: sourceTask, cause: `operator-${action}`, triggeredAtMs: atMs })
304 const paused = report.path.filter(row => row.mission.state === 'paused')
305 expect(paused.length).toBeGreaterThan(10)
306 expect(paused.at(-1)!.atMs).toBe(resumeAtMs - 20)
307 expect(paused.every(row => row.mission.taskId === 'recover-land' && row.mission.transition === null && row.mission.reason === 'vtol-recovery-hover-awaiting-resume')).toBe(true)
308 if (sourceTask === 'initial-hover') expect(paused.every(row => row.measurement!.values.onSurface === true && row.actuators.every(n => n === 0))).toBe(true)
309 else expect(paused.every(row => row.measurement!.values.onSurface === false && row.actuators.slice(0, 4).some(n => n > 0))).toBe(true)
310 const held = paused[0]!.mission.targetM!
311 expect(Math.max(...paused.map(row => Math.hypot(...row.truthPositionM.map((n, i) => n - held[i]!))))).toBeLessThan(1)
312 const qualified = report.trace.rows.find(row => row.observation.transition?.taskId === 'recover-hover' && row.observation.transition.outcome === 'confirmed')!
313 expect(qualified.observation.transition!.measurementCapturedAtMs).toBeGreaterThanOrEqual(atMs + (sourceTask === 'initial-hover' ? 200 : 500))
314 if (sourceTask !== 'back') expect(report.path.some(row => row.mission.taskId === 'cruise-leg')).toBe(false)
315 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
316 expect(report.finalTruthPositionM[2]).toBe(0)
317 expect(Math.hypot(report.finalTruthPositionM[0] - northM, report.finalTruthPositionM[1])).toBeLessThan(0.3)
318 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
319 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
320 })
321}