[AZ-651] [AZ-668] lost-link failsafe ladder + mapobjects persistence (batch 7)

AZ-651 (mission_executor lost-link ladder):
- LostLinkLadder pure-logic state machine (LinkOk -> Degraded -> Lost
  -> LinkLostInFollow + MavlinkLost branch). Configurable thresholds
  via LostLinkConfig.
- LostLinkCommandIssuer trait + MavlinkCommandIssuer production impl
  emitting MAV_CMD_NAV_RETURN_TO_LAUNCH via MavlinkHandle::send_command.
- LostLinkDriver task wires the ladder to operator-link watch, MAVLink
  LinkEvent broadcast, and optional target-follow signal. On RTL,
  driver calls the issuer THEN MissionExecutorHandle::failsafe_trigger.
- failsafe_trigger(LinkLost | LinkLostInFollow) short-circuits FlyMission
  -> Land via direct FSM state mutation + TransitionEvent emission;
  Paused state is intentionally NOT overridden.
- Tests: 4/4 ACs locally green (degraded-no-rtl; lost-fires-once;
  follow-grace; mavlink-loss-no-rtl) plus driver + FSM integration.

AZ-668 (mapobjects_store persistence):
- Snapshot serializable shape + Store::{to_snapshot,from_snapshot}
  round trip.
- MapObjectsPersistence async trait + JsonSnapshotEngine default impl
  (write to .tmp, sync_all, atomic rename, best-effort parent fsync).
- PersistenceError::{Corrupt, SchemaMismatch} surfaces explicit errors
  on bad blob; PersistenceMetrics tracks last_snapshot_ts,
  snapshot_size_bytes, snapshot_errors_total.
- MapObjectsStore::from_snapshot factory for crash recovery from the
  composition root.
- Tests: 4/4 ACs locally green (round-trip; atomic rename ignores
  partial .tmp; crash recovery preserves pending; corruption returns
  explicit error) plus schema-mismatch + metrics smoke checks.

Quality gates:
- cargo fmt: clean.
- cargo clippy -p mission_executor -p mapobjects_store --tests: 0 warns.
- cargo test --workspace: all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 18:59:28 +03:00
parent 23366a5c6d
commit 2bcd4a8059
16 changed files with 1940 additions and 8 deletions
+55 -4
View File
@@ -33,6 +33,11 @@ use shared::models::mission::{Coordinate, MissionItem, MissionWaypoint};
mod internal;
pub use internal::driver::{DriverError, MissionDriver};
pub use internal::lost_link::{
LadderEvent, LadderInput, LadderOutput, LadderState, LostLinkCommandIssuer, LostLinkConfig,
LostLinkDriver, LostLinkLadder, LostLinkLadderHandle, MavlinkCommandIssuer,
MAV_CMD_NAV_RETURN_TO_LAUNCH,
};
pub use internal::telemetry::{
Consumer, DropCountingReceiver, MavlinkProjection, TelemetryForwarder,
};
@@ -244,10 +249,56 @@ impl MissionExecutorHandle {
))
}
pub async fn failsafe_trigger(&self, _kind: FailsafeKind) -> Result<()> {
Err(AutopilotError::NotImplemented(
"mission_executor::failsafe_trigger (AZ-651)",
))
/// Apply a failsafe response immediately.
///
/// AZ-651 implements the link-loss family: `LinkLost` and
/// `LinkLostInFollow` both cause the FSM to short-circuit from
/// `FlyMission` to `Land` (and the lost-link driver issues
/// `MAV_CMD_NAV_RETURN_TO_LAUNCH` separately so the airframe also
/// returns home — the FSM transition reflects the autopilot's
/// internal accounting). Other states are NOT overridden: if the
/// FSM is still in `Disconnected` / `Armed` / `TakeOff` /
/// `MissionUploaded`, the airframe failsafe is the right authority
/// and we let it handle the abort.
///
/// Battery and geofence failsafes (`BatteryRtl`, `BatteryHardFloor`,
/// `GeofenceInclusion`, `GeofenceExclusion`) land in AZ-652 with
/// their own state-aware overrides; calling this method with one
/// of those kinds returns `NotImplemented` for now.
///
/// Calling this while the FSM is already `Paused` is a no-op (we
/// do not clobber the existing pause).
pub async fn failsafe_trigger(&self, kind: FailsafeKind) -> Result<()> {
match kind {
FailsafeKind::LinkLost | FailsafeKind::LinkLostInFollow => {
let mut core = self.core.lock().await;
if core.state == MissionState::FlyMission {
let from = core.state;
core.state = MissionState::Land;
let _ = self.events_tx.send(TransitionEvent {
variant: core.variant,
from,
to: MissionState::Land,
at: chrono::Utc::now(),
retry_count: 0,
});
}
// Other states (incl. Paused) — leave alone. The
// airframe's own failsafe (or whatever paused us) is
// authoritative.
Ok(())
}
FailsafeKind::LinkDegraded => {
// Degraded is yellow-health-only; no transition needed.
Ok(())
}
FailsafeKind::BatteryRtl
| FailsafeKind::BatteryHardFloor
| FailsafeKind::GeofenceInclusion
| FailsafeKind::GeofenceExclusion => Err(AutopilotError::NotImplemented(
"mission_executor::failsafe_trigger: battery/geofence land in AZ-652",
)),
}
}
/// Pre-AZ-648 helper kept for callers that only need to validate a