wifi-densepose/v2/crates/homecore-automation/src/runmode.rs

154 lines
6.4 KiB
Rust

//! Per-automation run-mode machinery (ADR-162, completes ADR-161 §A5).
//!
//! ADR-161 implemented `RunMode::Single` (a per-automation `AtomicBool`
//! re-entrancy guard) and `Parallel`, but honestly left `Restart`, `Queued`
//! and `max: N` as "ACCEPTED-FUTURE / unbounded parallel" — every non-Single
//! mode spawned an unbounded task. This module makes them real:
//!
//! | Mode | Semantics implemented |
//! |------|-----------------------|
//! | `Single` / `IgnoreFirst` | re-entrancy guard: skip while a run is in flight (ADR-161). |
//! | `Restart` | **cancel** the in-flight run (`tokio::task::AbortHandle`) and start a fresh one. |
//! | `Queued` | **serialize**: runs execute sequentially in arrival order via a per-automation async mutex — nothing is dropped. |
//! | `Parallel` | spawn on every trigger (optionally capped, see below). |
//! | `max: N` | cap concurrency at **N** via a per-automation semaphore; triggers beyond N **queue** (await a permit) rather than running concurrently — matching HA's bounded `parallel`/`queued`. |
//!
//! Each registered automation owns one [`RunState`]; the engine calls
//! [`RunState::dispatch`] on every (trigger + conditions-passed) event.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{Mutex as AsyncMutex, Semaphore};
use homecore::HomeCore;
use crate::action::ExecutionContext;
use crate::automation::{Automation, RunMode};
/// Per-automation runtime state backing the run-mode dispatch.
///
/// Cheap to clone (all fields are `Arc`); the engine clones it into each
/// spawned run so the machinery (abort handle, queue mutex, semaphore) is
/// shared across all triggers of the same automation.
#[derive(Clone)]
pub struct RunState {
/// `Single`/`IgnoreFirst` re-entrancy guard (ADR-161 §A5).
running: Arc<AtomicBool>,
/// `Restart`: handle to the currently-running action task, so a new
/// trigger can abort it before starting a fresh one.
current: Arc<Mutex<Option<tokio::task::AbortHandle>>>,
/// `Queued`: serializes runs in arrival order (one at a time, FIFO via
/// fair async mutex acquisition).
queue_lock: Arc<AsyncMutex<()>>,
/// `max: N` (and bounded `Parallel`): caps concurrent runs at N.
/// `None` when no cap applies.
semaphore: Option<Arc<Semaphore>>,
}
impl RunState {
/// Build run-state for an automation, sizing the concurrency semaphore
/// from its `max:` field (only meaningful for `Queued`/`Parallel`).
pub fn new(automation: &Automation) -> Self {
let semaphore = automation
.max
.filter(|n| *n > 0)
.map(|n| Arc::new(Semaphore::new(n)));
Self {
running: Arc::new(AtomicBool::new(false)),
current: Arc::new(Mutex::new(None)),
queue_lock: Arc::new(AsyncMutex::new(())),
semaphore,
}
}
/// Dispatch one trigger for `automation` according to its `RunMode`.
/// Honors Single re-entrancy, Restart cancel-and-replace, Queued
/// serialization, and `max:` concurrency capping.
pub fn dispatch(&self, hc: &HomeCore, automation: Arc<Automation>) {
match automation.mode {
RunMode::Single | RunMode::IgnoreFirst => self.dispatch_single(hc, automation),
RunMode::Restart => self.dispatch_restart(hc, automation),
RunMode::Queued => self.dispatch_queued(hc, automation),
RunMode::Parallel => self.dispatch_parallel(hc, automation),
}
}
/// `Single`: skip if a run is already in flight; clear the flag on done.
fn dispatch_single(&self, hc: &HomeCore, automation: Arc<Automation>) {
if self
.running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return; // already running — skip re-entrant trigger.
}
let hc = hc.clone();
let running = Arc::clone(&self.running);
tokio::spawn(async move {
run_actions(&hc, &automation).await;
running.store(false, Ordering::SeqCst);
});
}
/// `Restart`: abort the in-flight run (if any), then start a fresh one
/// and record its abort handle.
fn dispatch_restart(&self, hc: &HomeCore, automation: Arc<Automation>) {
// Abort any prior run before starting the new one.
if let Some(prev) = self.current.lock().unwrap().take() {
prev.abort();
}
let hc = hc.clone();
let slot = Arc::clone(&self.current);
let handle = tokio::spawn(async move {
run_actions(&hc, &automation).await;
});
*slot.lock().unwrap() = Some(handle.abort_handle());
}
/// `Queued`: serialize via the per-automation async mutex. Each trigger
/// spawns a task that waits its turn, so all triggers run in arrival
/// order, one at a time — nothing is dropped.
fn dispatch_queued(&self, hc: &HomeCore, automation: Arc<Automation>) {
let hc = hc.clone();
let lock = Arc::clone(&self.queue_lock);
let sem = self.semaphore.clone();
tokio::spawn(async move {
// Optional `max:` cap still applies on top of serialization.
let _permit = match &sem {
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
None => None,
};
let _guard = lock.lock().await; // FIFO turn — sequential execution.
run_actions(&hc, &automation).await;
});
}
/// `Parallel`: spawn on every trigger, capped at `max:` if set.
fn dispatch_parallel(&self, hc: &HomeCore, automation: Arc<Automation>) {
let hc = hc.clone();
let sem = self.semaphore.clone();
tokio::spawn(async move {
let _permit = match &sem {
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
None => None,
};
run_actions(&hc, &automation).await;
});
}
}
/// Execute an automation's action sequence once.
async fn run_actions(hc: &HomeCore, automation: &Automation) {
let mut exec_ctx = ExecutionContext::new(hc.clone(), automation.id.clone());
for action in &automation.action {
if let Err(e) = action.execute(&mut exec_ctx).await {
eprintln!(
"[homecore-automation] action error in {}: {e}",
automation.id
);
break;
}
}
}