Geofence (INCLUSION+EXCLUSION, ≤500 ms detect→RTL), battery thresholds (RTL@25%/land@15% + signed override), middle-waypoint re-upload (CLEAR_ALL→upload→SET_CURRENT(0)), and post-flight mapobjects push trigger. Adds production MAVLink command issuers for both geofence and battery failsafe families. Implements 6 ACs with 12 integration tests + module unit tests; full workspace test suite green. See batch_09_cycle1_report.md for AC coverage and known limitations. Co-authored-by: Cursor <cursoragent@cursor.com>
13 KiB
Batch 9 (cycle 1) implementation report
Tasks: AZ-652
Component scope: mission_executor
Verdict: PASS_WITH_WARNINGS — proceed; flagged items below.
Tasks
AZ-652 mission_executor_safety_and_resume — Geofence + battery + middle-waypoint + post-flight
Outcome: Implemented. All six acceptance criteria green; production MAVLink command issuers wired for both geofence and battery families.
Production code added:
-
crates/mission_executor/src/internal/geofence.rsGeofenceVerdict { Ok, InclusionExit, ExclusionEntry }— symmetric semantics (both variants treated as faults; the C++ behaviour of silently ignoring EXCLUSION is rejected).GeofenceMonitor— pure point-in-polygon evaluator (ray-casting, no external crate dependency;geowould have pullednum-traitsetc. for one function we can implement in 25 LOC).GeofenceEvent { Violation, RtlIssued, RtlSendFailed }— broadcast surface.GeofenceCommandIssuertrait — separate from the lost-link issuer per the AZ-651 "each failsafe family owns its command surface" pattern.MavlinkGeofenceCommandIssuer— production impl that callsmavlink_layer::MavlinkHandle::send_command(MAV_CMD_NAV_RETURN_TO_LAUNCH).GeofenceDriver— wiring layer; 100 ms tick, edge-triggered RTL (only on Ok→violation), shutdown-aware.
-
crates/mission_executor/src/internal/battery_thresholds.rsBatteryConfig { rtl_threshold_pct, hard_floor_pct }— defaults 25 % / 15 % per task spec.BatteryOverride— signed (signature pre-validated byoperator_bridgeper AZ-689); fields carry operator id + rationale for audit logging.BatteryAction { None, IssueRtl, IssueLandNow }— discriminator returned by the pure monitor.BatteryMonitor— pure logic: latches once it has fired so the same RTL is not re-issued on the next tick; honours active override (suppresses RTL only — hard-floor land is not override-able).BatteryCommandIssuertrait +MavlinkBatteryCommandIssuerproduction impl (MAV_CMD_NAV_RETURN_TO_LAUNCHfor RTL,MAV_CMD_NAV_LANDfor hard-floor land-now).BatteryDriver— wiring layer; subscribes toSYS_STATUS-projected battery percentages, emits audit-log entries for overrides via tracing.
-
crates/mission_executor/src/internal/middle_waypoint.rsMiddleWaypointHint { at, insert_after_seq, label }— externally supplied byscan_controller(the spec excludes the placement algorithm from this task).MissionRePlanner::on_middle_waypoint(hint, current_mission)— runsMISSION_CLEAR_ALL→ upload patched waypoints →MISSION_SET_CURRENT(0)via theMissionDrivertrait. Returns the patched mission so the executor can mirror it into the FSM'smissionfield.MissionRePlanner::on_target_follow_release(reason, original_mission, current_position)— re-uploads the original mission anchored at the current position.
-
crates/mission_executor/src/internal/post_flight.rsMapObjectsPushertrait (production impl ismission_client::MissionClientHandle::push_mapobjects_diffper AZ-647);MapObjectsDiffSourcetrait (production impl ismapobjects_store::MapObjectsStoreHandle::dump_pendingper AZ-654).PostFlightPusher::push_once(mission_id)— called from thePOST_FLIGHT_SYNCentry guard. Errors are logged but never block the executor's progression toDONE(spec is explicit: degraded push surfaces a manual-replay warning; FSM still reachesDONE).
-
crates/mission_executor/src/lib.rsMissionExecutorHandlegaineddriver: Arc<dyn MissionDriver>andhard_floor_active: Arc<AtomicBool>fields.insert_middle_waypoint(Coordinate)now delegates toMissionRePlannerand updates the FSM's mission on success.failsafe_trigger(FailsafeKind)extended to handleBatteryRtl,BatteryHardFloor,GeofenceInclusion,GeofenceExclusion— all transitionFlyMission → Landvia the existingtransition_flymission_to_landhelper;BatteryHardFlooradditionally latcheshard_floor_active.health()flips to red whilehard_floor_activeis set regardless of FSM state.clear_hard_floor()— operator-driven recovery (ground-test workflow, swapped battery).#[doc(hidden)] force_state_for_tests(state)— integration-test back-door so failsafe behaviour can be asserted in theFlyMissionstate without wiring the full transition harness. Hidden from rustdoc and not part of the public API.
Tests:
crates/mission_executor/tests/safety_and_resume.rs(12 integration tests; all green):ac1_inclusion_geofence_exit_triggers_rtl(AC-1).ac2_exclusion_geofence_entry_triggers_rtl(AC-2).ac3a_battery_rtl_at_threshold(AC-3, RTL branch).ac3b_battery_land_now_at_hard_floor_and_flips_health_red(AC-3, hard-floor branch + health).ac4_signed_override_suppresses_battery_rtl(AC-4).ac5_middle_waypoint_reupload_sequence(AC-5; assertsMISSION_CLEAR_ALL→ upload →MISSION_SET_CURRENT(0)order via spy driver).ac6_post_flight_push_triggered_once_executor_reaches_done(AC-6).ac6_degraded_push_does_not_block_caller(AC-6 negative path).battery_rtl_failsafe_transitions_flymission_to_land—failsafe_triggerplumbing.battery_hard_floor_failsafe_latches_health_red— latch persistence + recovery.target_follow_release_recomputes_and_reuploads—MissionRePlanner::on_target_follow_release.battery_override_can_be_applied_via_handle_apply_override_channel— override propagation surface.
- Module unit tests (
internal::geofence::tests6 tests;internal::battery_thresholds::tests8 tests;internal::middle_waypoint::tests4 tests;internal::post_flight::tests2 tests) cover the pure-logic surface.
AC coverage
| AC | Behaviour | Test | Status |
|---|---|---|---|
| AC-1 | INCLUSION exit → RTL ≤500 ms; FSM → Land; alert observable |
ac1_inclusion_geofence_exit_triggers_rtl |
PASS |
| AC-2 | EXCLUSION entry → RTL ≤500 ms (parity with INCLUSION); alert observable | ac2_exclusion_geofence_entry_triggers_rtl |
PASS |
| AC-3a | SYS_STATUS ≤25 % → RTL; FSM → Land |
ac3a_battery_rtl_at_threshold |
PASS |
| AC-3b | SYS_STATUS <15 % → MAV_CMD_NAV_LAND; health → red |
ac3b_battery_land_now_at_hard_floor_and_flips_health_red |
PASS |
| AC-4 | Signed BatteryOverride { until_ts } suppresses RTL; audit-log entry |
ac4_signed_override_suppresses_battery_rtl |
PASS |
| AC-5 | MISSION_CLEAR_ALL → upload → MISSION_SET_CURRENT(0) in order, ≤2 s e2e |
ac5_middle_waypoint_reupload_sequence |
PASS |
| AC-6 | On POST_FLIGHT_SYNC entry → push_mapobjects_diff exactly once; FSM still reaches DONE on push failure |
ac6_post_flight_push_triggered_once_executor_reaches_done, ac6_degraded_push_does_not_block_caller |
PASS |
Code review
Spec compliance: PASS. All six ACs implemented with test seams that demonstrate the spec'd state transitions. The two AC-3 branches and the two AC-6 branches (happy + degraded) are split into separate tests for blast-radius isolation.
Architecture compliance: PASS.
- Layer 3 coordinator (
mission_executor) imports onlyshared,mavlink_layer,mission_client(via traits in this batch), andmapobjects_store(via traits in this batch). No new Layer 3 ↔ Layer 3 imports. MavlinkGeofenceCommandIssuerandMavlinkBatteryCommandIssuerare the production wiring for the two new failsafe families; both callmavlink_layer::MavlinkHandle::send_command(CommandLong)via the existingmavlink_layerPublic API (same surface AZ-651'sMavlinkCommandIssueruses for lost-link).- The
MAV_CMD_NAV_LANDconstant is co-located with the battery driver since that is the only family that issues it;MAV_CMD_NAV_RETURN_TO_LAUNCHcontinues to live ininternal::lost_linkand is re-exported (both families share the constant rather than defining a duplicate).
SRP: PASS.
geofence.rs— pure monitor + driver + production command issuer; one file because the three concepts are tightly coupled and the file is ~470 LOC.battery_thresholds.rs— same structure for battery.middle_waypoint.rs— pure replanner + types; no driver task (it is invoked synchronously byMissionExecutorHandle::insert_middle_waypoint).post_flight.rs— pure orchestrator + two traits; no MAVLink dependency (the push goes throughmission_client).
Runtime completeness: PASS. The Runtime Completeness section of the spec required real point-in-polygon, real SYS_STATUS decode, and real MAV_CMD_* issuance. All three are present:
- Point-in-polygon: ray-casting in
geofence::point_in_polygon(deterministic, branch-coverage tested). SYS_STATUSdecode: the battery driver consumesshared::models::telemetry::UavSysStatuswhich is already produced bymavlink_layer'sMavlinkProjection(AZ-649).MAV_CMD_*issuance:MavlinkGeofenceCommandIssuerandMavlinkBatteryCommandIssuerboth call the productionMavlinkHandle::send_commandsurface.
Test discipline: PASS. Each AC maps to one named test (two branches each for AC-3 and AC-6). AAA pattern with language-appropriate comment syntax (// Arrange / // Act / // Assert). Spy implementations (SpyGeofenceIssuer, SpyBatteryIssuer, SpyMissionDriver, SpyPusher) record calls in Arc<Mutex<Vec<_>>> and are asserted on directly — no "no error thrown" tests.
Security quick-scan: PASS. No string-interpolated commands; no untrusted input parsing in this batch. BatteryOverride signature validation is excluded from this task's scope (handled by operator_bridge per AZ-689). The driver assumes the override surface has already verified signatures upstream — this is documented in the type's docstring.
Performance scan: PASS. Geofence monitor ticks at 10 Hz × O(total vertices); with the operational ≤8 fences × ≤32 vertices typical for a single mission this is a few hundred FLOPs per tick — well under the AZ-652 ≤500 ms response budget. The 100 ms tick gives a worst-case 100 ms detection latency, plus the MAVLink command round-trip; well inside ≤500 ms.
Cross-task consistency: N/A — this batch contains a single task.
Module-layout drift (minor)
_docs/02_document/module-layout.md lists crates/mission_executor/src/internal/geofence/* (a folder). This batch implements it as a single file (crates/mission_executor/src/internal/geofence.rs). The file is ~470 LOC and cohesive (pure monitor + driver + production command issuer); splitting into a folder for this batch would be premature. If a future batch adds new geofence variants (cylinder, altitude floor) or polygon preprocessing (R-tree), the file becomes a folder at that point. Flagged here so the next module-layout sync picks it up.
Known limitations (warnings)
-
MavlinkBatteryCommandIssuer::issue_land_nowpasses allparam_*zeroed. Perarchitecture.md §7.7this asks the airframe to pick the safest reachable landing point. If a future BIT item or operator setting wants to bias toward a specific recovery point, the issuer gains aCoordinateparameter at that point. Currently no caller supplies one. -
force_state_for_testsis hidden from rustdoc but is a public symbol. It is marked#[doc(hidden)]and only used bytests/safety_and_resume.rs. An alternative would be acfg(test)-only module, but that does not work for integration tests (which compile against the public API). This is the same back-door pattern used by several existing FSM crates in the workspace. -
Audit-log persistence is a
tracing::info!call, not a database write. The spec excludesshared::auditpersistence from this task; the driver emits a structuredtracing::info!(target = "audit", ...)entry which the runtime'stracingsubscriber routes to the audit sink wired byshared::audit(when it lands). This matches the AZ-651 lost-link audit-log pattern.
Auto-fix attempts during the batch
cargo fmt -p mission_executorstraighteneduse mavlink_layer::{CommandLong, MavlinkHandle, SendCommandError};after adding the production issuers.- Removed an unused
mpscimport fromtests/safety_and_resume.rs(initial draft used a channel; final version uses awatchfor telemetry replay). clippy -p mission_executor --tests -- -D warningsis green.
Test reproduction
cargo build -p mission_executor --tests
cargo test -p mission_executor # all green
cargo test --test safety_and_resume -p mission_executor # 12 tests; 0 failed
cargo clippy -p mission_executor --tests -- -D warnings
cargo test --workspace # all green
Candidates for batch 10
- AZ-653
gimbal_a40_transport— opens up thegimbal_linkBIT evaluator slot (AZ-650 batch 8 noted it as the natural next slot). - AZ-689
operator_bridge_signed_commands— closes the upstream signature-validation gap referenced by AC-4's audit-log note here.
Batch 10 sizing: one of the above; not both. AZ-653 unblocks more downstream BIT slots; AZ-689 closes a documented gap in this batch's audit-log surface.