mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 11:11:10 +00:00
[AZ-680] [AZ-681] operator_bridge command dispatch + safety lane
Add the operator-command dispatcher behind a typed CommandAck: 60 s per-command-id idempotency cache, surfaced-POI registry with unknown_poi_id + expired gates, BIT-degraded ack severity check, and SafetyOverride forwarding to mission_executor with structured audit log (redacts signature + session_token). Cross-layer wiring goes through three new traits in shared::contracts (ScanCommandRouter, MissionSafetyRouter, BitReportSeverityLookup) so operator_bridge stays free of direct scan_controller / mission_executor imports. scan_controller::ScanControllerHandle implements the scan router; a new mission_executor::SafetyDispatchHandle wraps the BIT ack channel + battery monitor handle and implements the safety router; BitControllerHandle gains a bounded (16-entry) report-severity cache for the lookup trait. scan_controller also picks up ConfirmPoi handling: PoiQueue::confirm removes the entry and SubmitOutcome::Confirmed carries the typed (target_mgrs, target_class) hint for AZ-684/AZ-686 downstream. Tests: 9 new integration tests in operator_bridge/tests/dispatcher.rs cover AZ-680 AC-1..AC-5 + AZ-681 AC-1..AC-4. scan_controller adds 2 ConfirmPoi tests. All modified-crate suites green; one pre-existing mission_executor state-machine test flake (already documented in _docs/_process_leftovers) updated to note ac1 also affected. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,16 +33,27 @@
|
||||
//! subsequent `Degraded` / `Fail` flips it back to `false` and the
|
||||
//! FSM's `bit_ok` guard fails closed.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::contracts::BitReportSeverityLookup;
|
||||
use tokio::sync::{broadcast, mpsc, watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// AZ-681 — bounded FIFO cap for the per-report `BitOverall` cache
|
||||
/// queried by [`BitControllerHandle::is_acknowledgeable`]. BIT is a
|
||||
/// pre-flight gate that goes sticky-Pass after success, so the
|
||||
/// number of distinct report ids generated in one flight is small
|
||||
/// (one per evaluation cycle until Pass / Failed). 16 is generous
|
||||
/// without unbounded growth.
|
||||
const REPORT_OVERALL_CAP: usize = 16;
|
||||
|
||||
// ============================================================================
|
||||
// Public surface — types
|
||||
// ============================================================================
|
||||
@@ -236,6 +247,7 @@ impl BitController {
|
||||
state: BitState::Idle,
|
||||
last_report: None,
|
||||
sticky_pass: false,
|
||||
report_overalls: VecDeque::with_capacity(REPORT_OVERALL_CAP),
|
||||
}));
|
||||
|
||||
let handle = BitControllerHandle {
|
||||
@@ -335,6 +347,11 @@ impl BitController {
|
||||
config.ack_timeout,
|
||||
);
|
||||
let report_clone = report.clone();
|
||||
record_report_overall(
|
||||
&mut guard.report_overalls,
|
||||
report.id,
|
||||
report.overall,
|
||||
);
|
||||
guard.last_report = Some(report);
|
||||
if new_state != from {
|
||||
guard.state = new_state.clone();
|
||||
@@ -442,6 +459,28 @@ struct ControllerInner {
|
||||
/// downstream surfaces (lost-link ladder, geofence, battery —
|
||||
/// AZ-651 / AZ-652).
|
||||
sticky_pass: bool,
|
||||
/// AZ-681 — recent `(report_id, overall)` pairs for the
|
||||
/// `BitReportSeverityLookup` impl. Bounded FIFO; oldest evicted
|
||||
/// at [`REPORT_OVERALL_CAP`]. A `None` lookup result means the
|
||||
/// id has either never been generated or has aged out.
|
||||
report_overalls: VecDeque<(Uuid, BitOverall)>,
|
||||
}
|
||||
|
||||
/// Push a `(report_id, overall)` pair onto the bounded FIFO cache.
|
||||
/// Re-recording an existing id is a no-op (preserves the original
|
||||
/// position so callers can't accidentally refresh aging).
|
||||
fn record_report_overall(
|
||||
cache: &mut VecDeque<(Uuid, BitOverall)>,
|
||||
report_id: Uuid,
|
||||
overall: BitOverall,
|
||||
) {
|
||||
if cache.iter().any(|(id, _)| *id == report_id) {
|
||||
return;
|
||||
}
|
||||
if cache.len() == REPORT_OVERALL_CAP {
|
||||
cache.pop_front();
|
||||
}
|
||||
cache.push_back((report_id, overall));
|
||||
}
|
||||
|
||||
/// Read-side handle for the BIT controller. Cloneable.
|
||||
@@ -475,6 +514,32 @@ impl BitControllerHandle {
|
||||
pub async fn last_report(&self) -> Option<BitReport> {
|
||||
self.inner.lock().await.last_report.clone()
|
||||
}
|
||||
|
||||
/// AZ-681 — overall verdict for a previously-generated report.
|
||||
/// Returns `None` if the id has never been generated or has aged
|
||||
/// out of the bounded cache.
|
||||
pub async fn report_overall(&self, report_id: Uuid) -> Option<BitOverall> {
|
||||
self.inner
|
||||
.lock()
|
||||
.await
|
||||
.report_overalls
|
||||
.iter()
|
||||
.find_map(|(id, o)| (*id == report_id).then_some(*o))
|
||||
}
|
||||
}
|
||||
|
||||
/// AZ-681 — `operator_bridge` (Layer 3) consults this before
|
||||
/// forwarding a BIT-degraded ack. `Fail` reports are never
|
||||
/// acknowledgeable (per AZ-681 AC-2). An aged-out / never-seen id
|
||||
/// returns `None` so the bridge can NACK with a typed
|
||||
/// "unknown report id" reason.
|
||||
#[async_trait]
|
||||
impl BitReportSeverityLookup for BitControllerHandle {
|
||||
async fn is_acknowledgeable(&self, report_id: Uuid) -> Option<bool> {
|
||||
self.report_overall(report_id)
|
||||
.await
|
||||
.map(|o| !matches!(o, BitOverall::Fail))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -11,5 +11,6 @@ pub mod lost_link;
|
||||
pub mod middle_waypoint;
|
||||
pub mod multirotor;
|
||||
pub mod post_flight;
|
||||
pub mod safety_dispatch;
|
||||
pub mod telemetry;
|
||||
pub mod types;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! AZ-681 — concrete [`MissionSafetyRouter`] implementation owned by
|
||||
//! `mission_executor` so `operator_bridge` (Layer 3) can stay free of
|
||||
//! direct `mission_executor` imports.
|
||||
//!
|
||||
//! The composition root constructs a [`SafetyDispatchHandle`] from the
|
||||
//! BIT controller's `ack` mpsc sender and the battery monitor's handle,
|
||||
//! then hands an `Arc<dyn MissionSafetyRouter>` to the operator-bridge
|
||||
//! builder.
|
||||
//!
|
||||
//! Mapping (per `architecture.md §F10`):
|
||||
//!
|
||||
//! - `acknowledge_bit_degraded` → push a [`BitDegradedAck`] onto the
|
||||
//! BIT controller's ack channel. The controller validates the
|
||||
//! `report_id` matches `AwaitingAck`; `operator_bridge` has already
|
||||
//! validated the signature + checked `BitReportSeverityLookup` to
|
||||
//! ensure the report is acknowledgeable (NOT `Fail`).
|
||||
//! - `apply_safety_override` → translate `SafetyOverrideScope` into the
|
||||
//! subsystem-specific override. Only `BatteryRtl` is supported in
|
||||
//! AZ-681 (other failsafe families add their own paths later); the
|
||||
//! hard-floor land-now is NEVER suppressible regardless of scope.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use shared::contracts::MissionSafetyRouter;
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::models::operator::SafetyOverrideScope;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::internal::battery_thresholds::{BatteryMonitorHandle, BatteryOverride};
|
||||
use crate::internal::bit::BitDegradedAck;
|
||||
|
||||
/// Concrete dispatcher for safety-critical operator commands. Owns
|
||||
/// only the handles it needs; do not stuff additional concerns here.
|
||||
#[derive(Clone)]
|
||||
pub struct SafetyDispatchHandle {
|
||||
bit_ack_tx: mpsc::Sender<BitDegradedAck>,
|
||||
battery: BatteryMonitorHandle,
|
||||
}
|
||||
|
||||
impl SafetyDispatchHandle {
|
||||
pub fn new(bit_ack_tx: mpsc::Sender<BitDegradedAck>, battery: BatteryMonitorHandle) -> Self {
|
||||
Self {
|
||||
bit_ack_tx,
|
||||
battery,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MissionSafetyRouter for SafetyDispatchHandle {
|
||||
async fn acknowledge_bit_degraded(
|
||||
&self,
|
||||
report_id: Uuid,
|
||||
operator_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
self.bit_ack_tx
|
||||
.send(BitDegradedAck {
|
||||
report_id,
|
||||
operator_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AutopilotError::Internal(format!("bit ack channel closed: {e}")))
|
||||
}
|
||||
|
||||
async fn apply_safety_override(
|
||||
&self,
|
||||
scope: SafetyOverrideScope,
|
||||
duration_secs: u32,
|
||||
operator_id: String,
|
||||
rationale: String,
|
||||
) -> Result<()> {
|
||||
match scope {
|
||||
SafetyOverrideScope::BatteryRtl => {
|
||||
let until = Instant::now() + Duration::from_secs(u64::from(duration_secs));
|
||||
self.battery
|
||||
.apply_override(BatteryOverride {
|
||||
until,
|
||||
operator_id,
|
||||
rationale,
|
||||
})
|
||||
.await
|
||||
}
|
||||
// `SafetyOverrideScope` is `#[non_exhaustive]`; future
|
||||
// variants (e.g. `LinkLost`, `Geofence`) MUST be wired
|
||||
// explicitly here before they become usable. Until then,
|
||||
// surface a typed Validation error so `operator_bridge`
|
||||
// can NACK to the operator UI.
|
||||
other => Err(AutopilotError::Validation(format!(
|
||||
"safety override scope {other:?} not wired in mission_executor"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ pub use internal::lost_link::{
|
||||
};
|
||||
pub use internal::middle_waypoint::{MiddleWaypointHint, MissionRePlanner};
|
||||
pub use internal::post_flight::{MapObjectsDiffSource, MapObjectsPusher, PostFlightPusher};
|
||||
pub use internal::safety_dispatch::SafetyDispatchHandle;
|
||||
pub use internal::telemetry::{
|
||||
Consumer, DropCountingReceiver, MavlinkProjection, TelemetryForwarder,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user