also looking at this
feat: add declared VTOL recovery and landing routes
#80
13 files
+558
-52
| @@ -491,6 +491,8 @@ jobs: | ||
| 491 | 491 | ${{ runner.temp }}/execution-browser-evidence/*.png |
| 492 | 492 | ${{ runner.temp }}/execution-browser-evidence/compiled.html |
| 493 | 493 | ${{ 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 | |
| 494 | 496 | ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log |
| 495 | 497 | purge-namespace: |
| 496 | 498 | runs-on: ubuntu-latest |
| @@ -79,8 +79,8 @@ outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air mod | ||
| 79 | 79 | `./buddy vtol:hover` separately exercises [VTOL development](docs/vehicles-vtol.md): |
| 80 | 80 | vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also |
| 81 | 81 | includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. The |
| 82 | command stays hover-only. Controlled transition recovery and abort landing, complete hybrid energy | |
| 83 | qualification and a selectable VTOL mission workspace remain open. | |
| 82 | command stays hover-only. Plans can explicitly declare bounded hover-and-land transition recovery. | |
| 83 | Complete recovery/abort envelopes, hybrid energy qualification and a selectable VTOL mission workspace remain open. | |
| 84 | 84 | |
| 85 | 85 | ## Simulated carried load |
| 86 | 86 | |
| @@ -58,8 +58,9 @@ from [firmware SITL](sitl.md) and physical testing. | ||
| 58 | 58 | |
| 59 | 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only command and an |
| 60 | 60 | experimental TypeScript library profile for modeled front transition, cruise, back transition and |
| 61 | vertical landing, with stable-phase pause and resume. Controlled transition recovery and abort | |
| 62 | landing, complete hybrid energy qualification and the common VTOL mission workspace remain open. | |
| 61 | vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery | |
| 62 | routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission | |
| 63 | workspace remain open. | |
| 63 | 64 | |
| 64 | 65 | ## Explore a workflow |
| 65 | 66 | |
| @@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 16 | 16 | | 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) | |
| 17 | 17 | | 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) | |
| 18 | 18 | | 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) | | |
| 20 | 20 | | 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) | |
| 21 | 21 | | 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) | |
| 22 | 22 | | 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) | |
| @@ -140,20 +140,75 @@ before that return. Sensor loss, RC takeover, kill and host loss retain authorit | ||
| 140 | 140 | |
| 141 | 141 | `recover` pauses the already running session until explicit `resume`. Fresh-process restoration after |
| 142 | 142 | host loss or a changed estimator reference remains a separate gate. Pending initial-hover, front or |
| 143 | back mode work retains uncertain-outcome refusal for pause/recover. Those refusals release modeled | |
| 144 | outputs; a controlled airborne recovery and observed landing still require an explicit route. | |
| 145 | `abort` terminates the modeled mission and releases outputs as well. | |
| 143 | back mode work without a declared recovery route retains uncertain-outcome refusal for pause/recover. | |
| 144 | Those refusals release modeled outputs. Without that route, `abort` also terminates the modeled mission | |
| 145 | and releases outputs. Neither result establishes an airborne landing. | |
| 146 | 146 | |
| 147 | 147 | Task execution time stops during pause. Global endurance and the declared energy charge continue, |
| 148 | 148 | so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal. |
| 149 | 149 | Operator actions never extend the original planning allowance or rearm revoked authority. The |
| 150 | 150 | transition library and these actions remain separate from the hover-only CLI and common workspace. |
| 151 | 151 | |
| 152 | ## Declared transition recovery routes | |
| 153 | ||
| 154 | A `vtol-mode` task can declare `onFailure` as the ID of a dedicated hover task followed by a | |
| 155 | terminal landing task on the declared contact plane. The default development profile has no such | |
| 156 | route. Plans opt in explicitly, and every recovery edge participates in the existing acyclic time | |
| 157 | and reserve calculation before session resources are created. Normal success edges cannot enter | |
| 158 | the recovery hover, and recovery cannot retry the interrupted task or declare another failure route. | |
| 159 | ||
| 160 | For example, declare a return to the origin after an interrupted front transition: | |
| 161 | ||
| 162 | ```ts | |
| 163 | const profile = createVtolTransitionProfile() | |
| 164 | const front = profile.mission.tasks.find(task => task.id === 'front')! | |
| 165 | if (front.kind !== 'vtol-mode') throw new Error('Expected front mode task') | |
| 166 | front.onFailure = 'recover-hover' | |
| 167 | profile.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 | ) | |
| 171 | const aborted = runVehicleSimulation(profile, { | |
| 172 | seconds: 160, | |
| 173 | missionActions: [{ atMs: 13000, action: 'abort' }], | |
| 174 | }) | |
| 175 | ``` | |
| 176 | ||
| 177 | The imports are the same as in the previous example. Timeout or a correlated controller abort uses | |
| 178 | the declared route. An operator `abort`, `pause` or `recover` while that mode task owns execution | |
| 179 | uses it too. A new hover request binds `supersedesEventId` to the actual cancelled request and records | |
| 180 | its original task, cause and epoch in `mission.recovery`. Before the first command dispatch, the | |
| 181 | failed event is null and no cancellation ID is invented. Late old feedback cannot satisfy the new | |
| 182 | request. Qualified source and heading dwell starts again for that new observation; it never retries | |
| 183 | the original transition or regains revoked authority. | |
| 184 | ||
| 185 | Pause and recover continue controlled motion until hover is observed, then hold for a **fresh explicit | |
| 186 | resume**. Resuming follows the recovery landing route. An early resume cannot skip that qualification, | |
| 187 | and 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 | |
| 190 | route. A timeout/controller rejection that recovers to contact ends with `vtol-recovery-landed`. | |
| 191 | ||
| 192 | Landing holds the captured estimated approach height until horizontal position and speed qualify | |
| 193 | descent to the selected site. Actual failed-pusher and expired-back campaigns retain rotor-supported | |
| 194 | hover and fresh contact. The original 25-second back-recovery approach/landing case still times out | |
| 195 | airborne. A separately declared 35-second allowance completes after about 26.94 seconds, charged to | |
| 196 | the complete 156,520 ms / 52.1733 Wh worst-case plan. A normal 45-second back plus 45-second recovery | |
| 197 | hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase | |
| 198 | abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting | |
| 199 | 176,520 ms / 58.84 Wh. These remain declared 1200 W planning charges, without identified electrical | |
| 200 | power or measured battery endurance. | |
| 201 | ||
| 202 | Sensor loss, reference/clock guards, RC takeover, kill, fence, reserve and global endurance remain | |
| 203 | authoritative during recovery. Host loss still produces a stale host snapshot and separate independent | |
| 204 | lease expiry. Fault-induced neutral outputs do not become a landing claim. These bounded library | |
| 205 | campaigns do not qualify arbitrary wind, failed lift rotors, every transition state or physical recovery. | |
| 206 | ||
| 152 | 207 | ## Remaining implementation |
| 153 | 208 | |
| 154 | 209 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal |
| 155 | controller does not establish complete VTOL support. Controlled front/back recovery and fresh-process | |
| 156 | restoration, explicit bounded transition abort and landing, the complete hybrid power/reserve model, native transition CLI and | |
| 210 | controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort | |
| 211 | envelopes, fresh-process restoration, the complete hybrid power/reserve model, native transition CLI and | |
| 157 | 212 | the common mission workspace still require implementation and qualification. Wind campaigns and |
| 158 | 213 | broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms, |
| 159 | 214 | helicopters and other airframe families remain separate work. Native board control, measured airframe |
| @@ -63,7 +63,8 @@ TypeScript controllers. Native board execution, external firmware SITL, HIL and | ||
| 63 | 63 | vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md) |
| 64 | 64 | covers the hover-only command and an experimental lift-and-cruise library profile. The latter |
| 65 | 65 | executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and |
| 66 | resume. Controlled transition recovery and abort landing, complete hybrid energy qualification, | |
| 66 | resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort | |
| 67 | envelopes, hybrid energy qualification, | |
| 67 | 68 | a native transition command and a VTOL selection in the common workspace remain open. Helicopter, |
| 68 | 69 | soaring, blimp and other airframe families remain explicit roadmap work. |
| 69 | 70 | |
| @@ -14,7 +14,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) { | ||
| 14 | 14 | } |
| 15 | 15 | export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null } |
| 16 | 16 | export 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') } | |
| 18 | 18 | 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() |
| 19 | 19 | const p = input as unknown as VehicleMissionPlan |
| 20 | 20 | 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 | ||
| 27 | 27 | if (t.kind === 'start') { fields.push('next'); if (t.id !== p.start || !id(t.next)) invalid(); edges = [t.next] } |
| 28 | 28 | else if (t.kind === 'abort') { /* Terminal release, never an emergency landing. */ } |
| 29 | 29 | else if (t.kind === 'vtol-mode') { |
| 30 | fields.push('mode', 'headingRad', 'timeoutMs', 'next') | |
| 30 | fields.push('mode', 'headingRad', 'timeoutMs', 'next', 'onFailure') | |
| 31 | 31 | 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] | |
| 33 | 34 | } |
| 34 | 35 | else if (t.kind === 'branch') { |
| 35 | 36 | fields.push('batteryAtLeast', 'then', 'otherwise') |
| @@ -58,6 +59,15 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 58 | 59 | if (!keys(t, fields)) invalid() |
| 59 | 60 | nodes.push({ id: t.id, durationMs, edges }) |
| 60 | 61 | } |
| 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 | } | |
| 61 | 71 | if (p.tasks.find(t => t.id === p.start)?.kind !== 'start') invalid() |
| 62 | 72 | const worstCaseMs = missionGraphBudget(nodes, p.start, vehicleMissionLimits.maximumMs) |
| 63 | 73 | const requiredWh = p.power.maxW * worstCaseMs / 3600000 |
| @@ -87,9 +97,10 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 87 | 97 | let lastNow: number | null = null, startedAt: number | null = null, reference: string | null = null, taskElapsedMs = 0, settledMs = 0 |
| 88 | 98 | let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null |
| 89 | 99 | let transition: VehicleVtolTransitionCommand | null = null |
| 100 | let recovery: VehicleMissionSnapshot['recovery'] | |
| 90 | 101 | let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null |
| 91 | 102 | 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 } } : {}) } } | |
| 93 | 104 | function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null } |
| 94 | 105 | function advance(next: string) { |
| 95 | 106 | taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered' |
| @@ -98,6 +109,30 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 98 | 109 | if (goal) targetM = goal as VehicleMissionSnapshot['targetM'] |
| 99 | 110 | if (task.kind === 'abort') { state = 'aborted'; targetM = null; reason = 'mission-abort-task' } |
| 100 | 111 | } |
| 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 | } | |
| 101 | 136 | return { |
| 102 | 137 | snapshot, |
| 103 | 138 | trace(): VehicleMissionTrace { return structuredClone({ version: 2, evidence: 'owned-vehicle-mission-simulation', plan, rows }) }, |
| @@ -120,7 +155,16 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 120 | 155 | else if (o.payloadAck !== null && pending === null) block('unsolicited-payload-ack') |
| 121 | 156 | else if (o.transition && transition === null) block('unsolicited-vtol-transition') |
| 122 | 157 | } |
| 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 | } | |
| 124 | 168 | else if (action === 'start' && state === 'ready') { |
| 125 | 169 | const needed = plan.power.reservePercent + 100 * plan.power.maxW * worstCaseMs / 3600000 / plan.power.availableWh |
| 126 | 170 | 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) { | ||
| 128 | 172 | } |
| 129 | 173 | else if ((action === 'pause' && state === 'running') || (action === 'recover' && active(state))) { |
| 130 | 174 | 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 | } | |
| 132 | 184 | else { state = 'paused'; holdM = [...o.positionM]; settledMs = 0; reason = action === 'recover' ? 'checkpoint-recovered-awaiting-resume' : 'operator-pause' } |
| 133 | 185 | } |
| 134 | 186 | else if (action === 'resume' && state === 'paused') { state = 'running'; settledMs = 0; reason = 'operator-resume' } |
| 135 | 187 | if (state === 'paused') targetM = holdM ? [...holdM] : null |
| 136 | if (state === 'running') { | |
| 188 | if (state === 'running' && recovery?.triggeredAtMs !== o.nowMs) { | |
| 137 | 189 | const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt |
| 138 | 190 | 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 | } | |
| 140 | 198 | else if (t.kind === 'abort') { state = 'aborted'; reason = 'mission-abort-task'; targetM = null } |
| 141 | 199 | else if (t.kind === 'start') advance(t.next) |
| 142 | 200 | 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) { | ||
| 146 | 204 | reason = 'vtol-transition-requested' |
| 147 | 205 | } |
| 148 | 206 | targetM = [...transition.positionM] |
| 149 | const observed = o.transition | |
| 207 | const observed = readTransitionObservation(o) | |
| 150 | 208 | 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' } | |
| 166 | 214 | } |
| 215 | else reason = 'vtol-transition-progress' | |
| 167 | 216 | } |
| 168 | 217 | } |
| 169 | 218 | else if (t.kind === 'payload') { |
| @@ -182,7 +231,10 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 182 | 231 | if (reached && settledMs >= t.settleMs) { |
| 183 | 232 | holdM = [...goal] as VehicleMissionSnapshot['targetM'] |
| 184 | 233 | 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 | } | |
| 186 | 238 | } |
| 187 | 239 | } |
| 188 | 240 | } |
| @@ -32,11 +32,11 @@ export interface VehiclePayloadCommand { eventId: string, taskId: string, operat | ||
| 32 | 32 | export 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> } |
| 33 | 33 | export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void } |
| 34 | 34 | export type VehicleVtolMode = 'hover' | 'cruise' |
| 35 | export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string } | |
| 35 | export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string, supersedesEventId?: string } | |
| 36 | 36 | export 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 } |
| 37 | 37 | export type VehicleMissionTask = |
| 38 | 38 | | { 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 } | |
| 40 | 40 | | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string } |
| 41 | 41 | | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 42 | 42 | | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| @@ -71,6 +71,8 @@ export interface VehicleMissionSnapshot { | ||
| 71 | 71 | payload: VehiclePayloadCommand | null |
| 72 | 72 | /** Present only for plans with explicit VTOL mode tasks; legacy recording shapes stay unchanged. */ |
| 73 | 73 | 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 } | |
| 74 | 76 | reason: string |
| 75 | 77 | elapsedMs: number |
| 76 | 78 | } |
| @@ -22,9 +22,11 @@ export function createVtolFlightController(): VehicleController { | ||
| 22 | 22 | 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 })) |
| 23 | 23 | let hasWingControl = false, backSlow = false |
| 24 | 24 | let loiterCenter: SensorVector | null = null |
| 25 | let landingTask = '', approachDownM: number | null = null | |
| 25 | 26 | let lastNow: number | null = null, identity: string | null = null, fault = '' |
| 26 | 27 | let phase: VehicleVtolTransitionObservation['phase'] = 'hover', request: VehicleVtolTransitionCommand | null = null, signature = '', confirmedEvent = '' |
| 27 | 28 | let stableSource: number | null = null, stableDecision: number | null = null |
| 29 | let lastMissionTask: string | null = null, recoveryBinding: { eventId: string, signature: string } | null = null | |
| 28 | 30 | 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 } } |
| 29 | 31 | function neutral(c: VehicleControllerContext, reason: string) { fault ||= reason; return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) } |
| 30 | 32 | return { |
| @@ -57,6 +59,8 @@ export function createVtolFlightController(): VehicleController { | ||
| 57 | 59 | 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') |
| 58 | 60 | } |
| 59 | 61 | catch { return neutral(c, 'vtol-flight-observation-unqualified') } |
| 62 | const priorTask = lastMissionTask | |
| 63 | lastMissionTask = c.mission.taskId | |
| 60 | 64 | const incoming = c.mission.transition |
| 61 | 65 | if (incoming) { |
| 62 | 66 | let anchor: number[] |
| @@ -65,20 +69,39 @@ export function createVtolFlightController(): VehicleController { | ||
| 65 | 69 | 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') |
| 66 | 70 | const encoded = JSON.stringify(incoming) |
| 67 | 71 | 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 } | |
| 68 | 86 | if (request?.eventId !== incoming.eventId && incoming.mode === 'cruise') { |
| 69 | 87 | if (phase !== 'hover' || measurement!.values.onSurface) return neutral(c, 'vtol-front-requires-airborne-hover') |
| 70 | 88 | request = structuredClone(incoming); signature = encoded; phase = 'front-transition'; hasWingControl = true; stableSource = null; stableDecision = null |
| 71 | 89 | } |
| 72 | 90 | 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') | |
| 74 | 92 | request = structuredClone(incoming); signature = encoded; phase = 'back-transition'; backSlow = false; stableSource = null; stableDecision = null |
| 75 | 93 | for (const pid of velocityPid) pid.reset() |
| 76 | 94 | } |
| 77 | 95 | } |
| 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 | } | |
| 79 | 101 | const [roll, pitch, yaw] = quaternionEuler(q!), values = measurement!.values |
| 80 | 102 | const speed = values.airspeedMps as number, alpha = values.alphaRad as number, beta = values.betaRad as number |
| 81 | 103 | 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] } | |
| 82 | 105 | const pausedContact = phase === 'hover' && c.mission.state === 'paused' && values.onSurface === true && values.stopped === true |
| 83 | 106 | 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') |
| 84 | 107 | 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 { | ||
| 120 | 143 | } |
| 121 | 144 | else { |
| 122 | 145 | 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 | } | |
| 124 | 151 | const desiredVelocity = goal.map((n, i) => n - position![i]!) as SensorVector |
| 125 | 152 | const horizontal = Math.hypot(desiredVelocity[0], desiredVelocity[1]) |
| 126 | 153 | if (horizontal > 3) { desiredVelocity[0] *= 3 / horizontal; desiredVelocity[1] *= 3 / horizontal } |
| @@ -21,6 +21,8 @@ const { executionWords: words } = await import('../../../resources/functions/dro | ||
| 21 | 21 | let holdArmed = false, holdUsed = false, held = false, releaseCredit: (() => void) | undefined |
| 22 | 22 | let delayStart = true, releaseStart: (() => void) | undefined |
| 23 | 23 | let streamStarts = 0, startRequests = 0, admissionRequests = 0, closeStream: (() => Promise<void>) | undefined |
| 24 | let closeWorkspaceStream: (() => Promise<void>) | undefined, releaseWorkspace: (() => void) | undefined | |
| 25 | let holdWorkspace = false, workspaceHeld = false | |
| 24 | 26 | |
| 25 | 27 | // Diagnostic state belongs only to this disposable fixture, never the execution controller. |
| 26 | 28 | type RouteClass = 'execution-start' | 'execution-close' | 'execution-admission' | 'execution-events' | 'execution-status' | 'workspace-read' |
| @@ -51,7 +53,7 @@ function diagnosticSnapshot() { | ||
| 51 | 53 | } |
| 52 | 54 | return { |
| 53 | 55 | 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(), | |
| 55 | 57 | slot: slot === null ? null : { |
| 56 | 58 | ordinal: ordinal(slot), |
| 57 | 59 | 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) => { | ||
| 138 | 140 | assert(rows.some(row => row.digest !== seeded.source.digest)) |
| 139 | 141 | const committed = await fixture.snapshot(seeded.key) |
| 140 | 142 | 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') | |
| 141 | 149 | return Response.json({ ok: true }) |
| 142 | 150 | } |
| 151 | if (action === 'release-workspace') { assert(workspaceHeld, 'Release only the held native renewal'); releaseWorkspace?.(); return Response.json({ ok: true }) } | |
| 143 | 152 | if (action === 'read-only') return Response.json({ token: await fixture.credential(fixture.sender.id, ['public-safety:read']) }) |
| 144 | 153 | if (action === 'revoke') { await fixture.tokens.revokeToken(fixture.senderToken); return Response.json({ ok: true }) } |
| 145 | 154 | return new Response(null, { status: 404 }) |
| @@ -153,15 +162,25 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon | ||
| 153 | 162 | if (admissionOrdinal !== null) lastAdmission = { ordinal: admissionOrdinal, settled: false, status: null, elapsedMs: null } |
| 154 | 163 | let status: number | null = null |
| 155 | 164 | 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 | } | |
| 156 | 173 | const response = await next() |
| 157 | 174 | status = response?.status ?? null |
| 158 | 175 | 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++ | |
| 161 | 179 | const reader = response.body.getReader() |
| 162 | 180 | let closed = false, output: ReadableStreamDefaultController<Uint8Array> | undefined |
| 163 | 181 | 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 | |
| 165 | 184 | // Opaque native chunks pass unchanged. Only the private fixture can end a connection. |
| 166 | 185 | const body = new ReadableStream<Uint8Array>({ |
| 167 | 186 | start(controller) { output = controller }, |
| @@ -188,7 +207,7 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon | ||
| 188 | 207 | }) |
| 189 | 208 | const page = () => new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } }) |
| 190 | 209 | route.get('/incidents', page) |
| 191 | for (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) | |
| 210 | for (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) | |
| 192 | 211 | const server = await route.serve({ hostname: '127.0.0.1', port: 0, nativeRoutes: false }) |
| 193 | 212 | if (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) } |
| 194 | 213 | await 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 { | ||
| 197 | 216 | assert.equal(await child.exited, 0, 'Isolated real execution browser driver must pass') |
| 198 | 217 | } |
| 199 | 218 | finally { |
| 200 | releaseStart?.(); releaseCredit?.() | |
| 201 | await closeStream?.(); await server.stop(true) | |
| 219 | releaseStart?.(); releaseCredit?.(); releaseWorkspace?.() | |
| 220 | await closeStream?.(); await closeWorkspaceStream?.(); await server.stop(true) | |
| 202 | 221 | const slot = executionOwner().slot as { execution: string, binding: { scope: string }, producer: string, worker: Worker | null } | null |
| 203 | 222 | if (slot?.producer === 'pending') { |
| 204 | 223 | const core = await import('../../../resources/functions/drone/public-safety/execution/service') |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import assert from 'node:assert/strict' |
| 2 | import { mkdir, readFile } from 'node:fs/promises' | |
| 2 | import { mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 3 | 3 | import { basename, join } from 'node:path' |
| 4 | 4 | import { startWorkspaceBrowser } from './workspace-browser' |
| 5 | 5 | import { settleExecutionDiagnostics } from './execution-diagnostics' |
| @@ -41,6 +41,7 @@ async function failureDiagnostics() { | ||
| 41 | 41 | workspaceStatus: selected('[data-workspace-status]', ['disconnected', 'connecting', 'synchronizing', 'current', 'reconnecting', 'refused']), |
| 42 | 42 | executionPhase: selected('[data-execution-phase]', ['no current run', 'waiting-for-consumer', 'running', 'stopping', 'completed', 'canceled', 'failed', 'discarded']), |
| 43 | 43 | hasExecutionId: !!document.querySelector('[data-execution-id]'), hasCompletion: !!document.querySelector('[data-execution-completion]'), |
| 44 | hasSelectedSource: !!document.querySelector('[data-workspace-source]'), | |
| 44 | 45 | startDisabled: start ? start.disabled === true : null, admitDisabled: admit ? admit.disabled === true : null, |
| 45 | 46 | hasSourceExists: !!document.querySelector('[data-execution-source-exists]'), hasExecutionError: !!document.querySelector('[data-execution-error]') |
| 46 | 47 | }; |
| @@ -53,7 +54,9 @@ async function failureDiagnostics() { | ||
| 53 | 54 | admissionRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution/admission')).length : null, |
| 54 | 55 | } |
| 55 | 56 | 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' }) | |
| 57 | 60 | } |
| 58 | 61 | catch { console.error(JSON.stringify({ label: 'execution-browser-failure-diagnostics', phase, available: false })) } |
| 59 | 62 | finally { |
| @@ -130,10 +133,32 @@ try { | ||
| 130 | 133 | await first.screenshot(join(evidence, 'execution-completed-mobile.png'), 375, 900) |
| 131 | 134 | assert.equal(await first.evaluate('Math.max(0, document.documentElement.scrollWidth - innerWidth)'), 0, 'Execution UUID and digest wrap at375px') |
| 132 | 135 | 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 | })()`) | |
| 133 | 149 | await first.click('Admit completed source') |
| 134 | 150 | await first.wait("!!document.querySelector('[data-execution-source-exists]')", 'Explicit admission must refresh actual source catalog', 15000) |
| 135 | 151 | 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' }) | |
| 137 | 162 | assert.equal(await first.evaluate("document.querySelector('#incident-note')?.value"), 'Later unsent observation survives execution success') |
| 138 | 163 | mark('read-only credential') |
| 139 | 164 | const { token } = await control('read-only') |
| @@ -116,6 +116,7 @@ | ||
| 116 | 116 | "./unit/cinema-managed.test.ts", |
| 117 | 117 | "./unit/cinema-counted-encoder.test.ts", |
| 118 | 118 | "./unit/cinema-counted-translation.test.ts", |
| 119 | "./unit/vehicle-lifecycle.test.ts" | |
| 119 | "./unit/vehicle-lifecycle.test.ts", | |
| 120 | "./unit/vtol-recovery.test.ts" | |
| 120 | 121 | ] |
| 121 | 122 | } |
| @@ -0,0 +1,321 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { createVehicleMissionEngine, parseVehicleMissionTrace, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 5 | import { createVtolVehicleHoverController } from '../../resources/functions/drone/vehicles/vtol/controller' | |
| 6 | import { createVtolFlightController } from '../../resources/functions/drone/vehicles/vtol/flight-controller' | |
| 7 | import type { VehicleControllerContext } from '../../resources/functions/drone/vehicles/types' | |
| 8 | ||
| 9 | function 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 | ||
| 20 | for (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 | ||
| 43 | test('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 | ||
| 77 | test('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 | ||
| 96 | test('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 | ||
| 109 | test('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 | ||
| 123 | test('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 | ||
| 148 | for (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 | ||
| 173 | test('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 | ||
| 185 | for (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 | ||
| 196 | for (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. | |
| 211 | test('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 | ||
| 238 | for (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 | ||
| 276 | for (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 | ||
| 296 | for (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 | } | |