//! 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, /// `Restart`: handle to the currently-running action task, so a new /// trigger can abort it before starting a fresh one. current: Arc>>, /// `Queued`: serializes runs in arrival order (one at a time, FIFO via /// fair async mutex acquisition). queue_lock: Arc>, /// `max: N` (and bounded `Parallel`): caps concurrent runs at N. /// `None` when no cap applies. semaphore: Option>, } 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) { 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) { 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) { // 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) { 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) { 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; } } }