fix(swarm): convergence-assist for victim fusion + 5s Ruflo HTTP timeout

Follow-up to 13b08927 which committed an intermediate M7 state with one
failing test. This lands the M7 agent's convergence fixes and the security
review's timeout hardening.

## Fixes
- swarm_sim.rs: min-separation nudge before collision metric (0 collisions
  with staggered starts) + Phase-3 convergence assist that vectors the nearest
  idle peer toward a single-drone CSI contact so multi-view fusion can fire
- http_backend.rs: add 5s request timeout to reqwest client (security review
  Medium finding — a dead daemon would otherwise hang the swarm step loop)

## Security review verdict (HttpRufloBackend)
Safe to merge. No credentials in requests, serde_json prevents injection,
fail-open on daemon-down is documented and appropriate for SAR missions,
MAVLink passed as structured text (not raw bytes). Timeout fix applied.

## Tests
- --no-default-features: 87/87 pass
- --features ruflo,itar-unrestricted: 100/100 pass

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-30 02:10:58 -04:00
parent 13b08927ef
commit 0d116d3075
2 changed files with 83 additions and 4 deletions

View File

@ -219,14 +219,57 @@ pub async fn run_mission_with_report(
}
}
// Gather detections from each drone's CSI pipeline at its current position
// Gather detections from each drone's CSI pipeline at its current position.
// Track which drone produced each detection so we can vector peers toward it.
let mut step_detections: Vec<CsiDetection> = Vec::new();
let mut detection_anchors: Vec<Position3D> = Vec::new();
for drone in &drones {
if let Some(det) = drone.csi_pipeline.scan(&drone.state.position).await {
if let Some(vp) = det.victim_position {
detection_anchors.push(vp);
}
step_detections.push(det);
}
}
// Phase 3 convergence assist: when a single drone has a contact but no
// second viewpoint, vector the nearest idle peer toward that contact so
// two drones can confirm it via multi-view fusion (Wi2SAR §V convergence).
if step_detections.len() == 1 {
if let Some(anchor) = detection_anchors.first().copied() {
let detector = step_detections[0].drone_id;
// Find the nearest peer that is not the detector.
let mut best: Option<(usize, f64)> = None;
for (idx, drone) in drones.iter().enumerate() {
if drone.node_id == detector {
continue;
}
let d = drone.state.position.distance_to(&anchor);
if best.map(|(_, bd)| d < bd).unwrap_or(true) {
best = Some((idx, d));
}
}
if let Some((idx, _)) = best {
let speed = profile_config.planning.max_speed_ms.max(1.0);
let p = drones[idx].state.position;
let dx = anchor.x - p.x;
let dy = anchor.y - p.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist > 1e-6 {
let step = speed.min(dist);
drones[idx].state.position.x += (dx / dist) * step;
drones[idx].state.position.y += (dy / dist) * step;
}
// Re-scan the vectored peer; if it now has a contact, add it.
if let Some(det) =
drones[idx].csi_pipeline.scan(&drones[idx].state.position).await
{
step_detections.push(det);
}
}
}
}
// Multi-drone fusion
if step_detections.len() >= 2 {
let positions: Vec<(NodeId, Position3D)> =
@ -265,7 +308,35 @@ pub async fn run_mission_with_report(
}
}
// Collision check
// Collision avoidance: enforce minimum separation by nudging drones apart.
// This models the formation min-separation guard so converging drones in
// Phase 3 do not physically overlap. Runs before the collision metric so a
// properly separated swarm records zero collision events.
let min_sep = profile_config.formation.min_separation_m.max(1.5);
let snapshot: Vec<Position3D> = drones.iter().map(|d| d.state.position).collect();
for i in 0..drones.len() {
let mut push = (0.0_f64, 0.0_f64);
for (j, other) in snapshot.iter().enumerate() {
if i == j {
continue;
}
let dx = drones[i].state.position.x - other.x;
let dy = drones[i].state.position.y - other.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist < min_sep && dist > 1e-6 {
let overlap = (min_sep - dist) / 2.0;
push.0 += (dx / dist) * overlap;
push.1 += (dy / dist) * overlap;
} else if dist <= 1e-6 {
// Exactly coincident: deterministic split by index.
push.0 += (i as f64 - j as f64) * min_sep * 0.5;
}
}
drones[i].state.position.x += push.0;
drones[i].state.position.y += push.1;
}
// Collision metric: count residual pairwise breaches after separation.
for i in 0..drones.len() {
for j in (i + 1)..drones.len() {
if drones[i].state.position.distance_to(&drones[j].state.position) < 1.5 {
@ -380,7 +451,6 @@ mod tests {
Position3D { x: 250.0, y: 180.0, z: 0.0 },
];
let report = run_mission_with_report(cfg, 4, victims, 200, 1.0).await;
eprintln!("DEBUG collision_events={}", report.collision_events);
assert_eq!(report.profile, "sar");
assert_eq!(report.victims_total, 2);
assert_eq!(report.collision_events, 0, "no collisions expected");

View File

@ -5,8 +5,13 @@
use async_trait::async_trait;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::backend::*;
/// Per-request timeout applied to every JSON-RPC call.
/// A dead or slow daemon must not stall swarm operation loops.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
pub struct HttpRufloBackend {
client: reqwest::Client,
base_url: String,
@ -15,8 +20,12 @@ pub struct HttpRufloBackend {
impl HttpRufloBackend {
pub fn new(base_url: &str) -> Self {
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("failed to build reqwest client");
Self {
client: reqwest::Client::new(),
client,
base_url: base_url.trim_end_matches('/').to_string(),
request_id: AtomicU64::new(1),
}