Skip to main content

theway_daemon/trigger_engine/
runtime.rs

1//! RFC 1 (issue #20) dedup window + cycle suppression engine.
2//!
3//! Pure logic, no IO. `AgentHarness::handle_trigger` (follow-up sub-PR) wraps this engine
4//! into the agent loop entrypoint, but the dedup / cycle decisions live here so they are
5//! independently testable.
6//!
7//! Behaviour matches RFC 1 §5:
8//! - **Dedup window**: same `idempotency_key` seen twice within
9//!   [`TriggerRuntimeConfig::dedup_window`] (default 5 minutes) → outcome depends on the
10//!   *first* trigger's [`ReplacementPolicy`] (per RFC 1 §11 fixed decision #4 — sources
11//!   declare per-event; the runtime trusts the first arrival's declaration to set the
12//!   window's collapse semantics).
13//! - **Cycle suppression**: when the same `trace_id` exceeds
14//!   [`TriggerRuntimeConfig::cycle_hop_limit`] (default 5) → forced
15//!   [`EvaluationOutcome::CycleSuppressed`]. Each accepted trigger bumps the per-trace hop
16//!   counter; the runtime calls [`TriggerRuntime::record_follow_up_hop`] before spawning
17//!   sub-triggers that share the parent's trace.
18
19use std::collections::HashMap;
20use std::time::Duration;
21
22use chrono::{DateTime, Utc};
23use parking_lot::Mutex;
24
25use super::types::{ReplacementPolicy, Trigger};
26
27/// Tunable knobs for [`TriggerRuntime`]. The runtime never mutates these; callers can swap
28/// them via [`TriggerRuntime::new`] or [`TriggerRuntime::with_config`].
29#[derive(Clone, Copy, Debug)]
30pub struct TriggerRuntimeConfig {
31    /// How long after a successful admission the same `idempotency_key` is considered a
32    /// duplicate. RFC 1 §5 default: 5 minutes. Capped at 24h to bound memory.
33    pub dedup_window: Duration,
34    /// Maximum `trace_id` chain depth before the runtime forces
35    /// [`EvaluationOutcome::CycleSuppressed`]. RFC 1 §5 default: 5.
36    pub cycle_hop_limit: u32,
37}
38
39impl TriggerRuntimeConfig {
40    pub const DEFAULT_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
41    pub const DEFAULT_CYCLE_HOP_LIMIT: u32 = 5;
42    /// Upper bound enforced by [`TriggerRuntime::with_config`]; anything larger is clamped
43    /// down because the dedup registry is in-memory and would otherwise grow unbounded.
44    pub const MAX_DEDUP_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
45}
46
47impl Default for TriggerRuntimeConfig {
48    fn default() -> Self {
49        Self {
50            dedup_window: Self::DEFAULT_DEDUP_WINDOW,
51            cycle_hop_limit: Self::DEFAULT_CYCLE_HOP_LIMIT,
52        }
53    }
54}
55
56/// Result of running a [`Trigger`] through [`TriggerRuntime::evaluate`]. Subsequent runtime
57/// state (state machine transitions, session audit, permission evaluator) consumes this.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub enum EvaluationOutcome {
60    /// First admission of this `idempotency_key` in the current dedup window AND the
61    /// `trace_id` is still within `cycle_hop_limit`. The runtime should advance the state
62    /// machine to `Accepted` (subject to subsequent permission evaluation).
63    Accept,
64    /// The same `idempotency_key` has been seen before in the dedup window. The first
65    /// trigger's `ReplacementPolicy` decides what happens; the runtime's audit record
66    /// captures the previous `trace_id` so the user can correlate which event "won".
67    Deduped {
68        replacement_policy: ReplacementPolicy,
69        previous_trace_id: String,
70    },
71    /// Cycle suppression fired: the `trace_id` has already passed through this runtime
72    /// `hop_count` times, exceeding `cycle_hop_limit`.
73    CycleSuppressed { hop_count: u32 },
74}
75
76/// In-memory dedup + cycle registry shared across all `NotificationHook` sources for a
77/// single agent / daemon. Cloning is cheap; the actual state lives behind an `Arc<Mutex>`.
78#[derive(Clone, Debug)]
79pub struct TriggerRuntime {
80    inner: std::sync::Arc<Mutex<Inner>>,
81    config: TriggerRuntimeConfig,
82}
83
84impl Default for TriggerRuntime {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[derive(Debug)]
91struct Inner {
92    /// `idempotency_key` → first-arrival entry. Pruned lazily on every [`evaluate`].
93    dedup: HashMap<String, DedupEntry>,
94    /// `trace_id` → hop count. Lazy pruning is not safe here (we cannot tell when a trace
95    /// is "done"), so we cap each entry's lifetime to one cycle window (= `dedup_window`,
96    /// reused for simplicity) and prune the same way as the dedup map.
97    cycle: HashMap<String, CycleEntry>,
98    /// Monotonic counters surfaced through [`TriggerRuntime::snapshot`] for TUI / `/triggers`
99    /// observability. These never decrement and survive entry pruning.
100    deduped_total: u64,
101    cycle_suppressed_total: u64,
102    accepted_total: u64,
103}
104
105/// Point-in-time view of the runtime's dedup + cycle bookkeeping. Cheap to copy; surfaced
106/// via `TriggerExecutor::notification_status_snapshot` for status banners and `/triggers`
107/// rendering.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub struct TriggerRuntimeSnapshot {
110    /// Number of distinct `idempotency_key` entries currently inside the dedup window.
111    pub dedup_entries: usize,
112    /// Number of distinct `trace_id` chains currently inside the cycle window.
113    pub active_traces: usize,
114    /// Lifetime count of triggers that admitted (advanced the dedup map + cycle counter).
115    pub accepted_total: u64,
116    /// Lifetime count of triggers that were dropped because their `idempotency_key`
117    /// matched an entry still inside the dedup window.
118    pub deduped_total: u64,
119    /// Lifetime count of triggers that were dropped because their `trace_id` exceeded
120    /// `cycle_hop_limit`.
121    pub cycle_suppressed_total: u64,
122}
123
124#[derive(Clone, Debug)]
125struct DedupEntry {
126    received_at: DateTime<Utc>,
127    replacement_policy: ReplacementPolicy,
128    trace_id: String,
129}
130
131#[derive(Clone, Debug)]
132struct CycleEntry {
133    last_seen_at: DateTime<Utc>,
134    hop_count: u32,
135}
136
137impl TriggerRuntime {
138    /// Construct a runtime with [`TriggerRuntimeConfig::default`].
139    pub fn new() -> Self {
140        Self::with_config(TriggerRuntimeConfig::default())
141    }
142
143    /// Construct a runtime with a custom config. `dedup_window` is clamped to
144    /// [`TriggerRuntimeConfig::MAX_DEDUP_WINDOW`].
145    pub fn with_config(mut config: TriggerRuntimeConfig) -> Self {
146        if config.dedup_window > TriggerRuntimeConfig::MAX_DEDUP_WINDOW {
147            config.dedup_window = TriggerRuntimeConfig::MAX_DEDUP_WINDOW;
148        }
149        Self {
150            inner: std::sync::Arc::new(Mutex::new(Inner {
151                dedup: HashMap::new(),
152                cycle: HashMap::new(),
153                deduped_total: 0,
154                cycle_suppressed_total: 0,
155                accepted_total: 0,
156            })),
157            config,
158        }
159    }
160
161    /// Point-in-time view of the dedup / cycle bookkeeping plus lifetime counters. Intended
162    /// for status banners; cheap (one mutex lock + struct copy). Lifetime counters never
163    /// decrement so consumers can build delta UIs without missing intermediate events.
164    pub fn snapshot(&self) -> TriggerRuntimeSnapshot {
165        let inner = self.inner.lock();
166        TriggerRuntimeSnapshot {
167            dedup_entries: inner.dedup.len(),
168            active_traces: inner.cycle.len(),
169            accepted_total: inner.accepted_total,
170            deduped_total: inner.deduped_total,
171            cycle_suppressed_total: inner.cycle_suppressed_total,
172        }
173    }
174
175    /// Convenience getter for the active configuration. Useful in tests and status output.
176    pub fn config(&self) -> TriggerRuntimeConfig {
177        self.config
178    }
179
180    /// Decide whether a fresh trigger should be admitted, deduped, or cycle-suppressed.
181    /// Pure (modulo wall-clock pruning); does NOT advance the trigger state machine —
182    /// that's the harness's job after it sees the outcome.
183    ///
184    /// Side effects (when the outcome is [`EvaluationOutcome::Accept`]):
185    /// - inserts the `idempotency_key` → first-arrival entry into the dedup map
186    /// - bumps the `trace_id` hop counter
187    ///
188    /// On [`EvaluationOutcome::Deduped`] or [`EvaluationOutcome::CycleSuppressed`] the
189    /// internal maps are *not* mutated for that trigger (the prior entry stands; the cycle
190    /// counter does not advance on a suppressed trigger).
191    pub fn evaluate(&self, trigger: &Trigger) -> EvaluationOutcome {
192        let mut inner = self.inner.lock();
193        let now = trigger.received_at;
194
195        prune_expired(&mut inner.dedup, now, self.config.dedup_window);
196        prune_expired_cycle(&mut inner.cycle, now, self.config.dedup_window);
197
198        // Dedup check runs first because a duplicate event is never "real" for cycle
199        // counting — we do not want a deduped event to consume hop budget.
200        if let Some(prev) = inner.dedup.get(&trigger.idempotency_key) {
201            let outcome = EvaluationOutcome::Deduped {
202                replacement_policy: prev.replacement_policy,
203                previous_trace_id: prev.trace_id.clone(),
204            };
205            inner.deduped_total = inner.deduped_total.saturating_add(1);
206            return outcome;
207        }
208
209        // Cycle check runs against the trace counter as it stands BEFORE this trigger; if
210        // we are already at the limit, suppress without advancing.
211        if let Some(existing) = inner.cycle.get(&trigger.trace_id) {
212            if existing.hop_count >= self.config.cycle_hop_limit {
213                let outcome = EvaluationOutcome::CycleSuppressed {
214                    hop_count: existing.hop_count,
215                };
216                inner.cycle_suppressed_total = inner.cycle_suppressed_total.saturating_add(1);
217                return outcome;
218            }
219        }
220
221        // Admit. Record both the dedup entry and the hop bump in one atomic critical section.
222        inner.dedup.insert(
223            trigger.idempotency_key.clone(),
224            DedupEntry {
225                received_at: now,
226                replacement_policy: trigger.replacement_policy,
227                trace_id: trigger.trace_id.clone(),
228            },
229        );
230        inner
231            .cycle
232            .entry(trigger.trace_id.clone())
233            .and_modify(|e| {
234                e.hop_count = e.hop_count.saturating_add(1);
235                e.last_seen_at = now;
236            })
237            .or_insert(CycleEntry {
238                hop_count: 1,
239                last_seen_at: now,
240            });
241        inner.accepted_total = inner.accepted_total.saturating_add(1);
242
243        EvaluationOutcome::Accept
244    }
245
246    /// Record an additional hop on `trace_id` without going through dedup. Called by the
247    /// harness immediately before spawning a follow-up trigger that inherits the parent's
248    /// trace (e.g. an `AgentDelegate` trigger emitted by a tool call).
249    ///
250    /// `now` is wall-clock time at the moment the follow-up is queued; used both to bump
251    /// the entry's `last_seen_at` and to drive lazy pruning of stale trace entries.
252    pub fn record_follow_up_hop(&self, trace_id: &str, now: DateTime<Utc>) {
253        let mut inner = self.inner.lock();
254        prune_expired_cycle(&mut inner.cycle, now, self.config.dedup_window);
255        inner
256            .cycle
257            .entry(trace_id.to_string())
258            .and_modify(|e| {
259                e.hop_count = e.hop_count.saturating_add(1);
260                e.last_seen_at = now;
261            })
262            .or_insert(CycleEntry {
263                hop_count: 1,
264                last_seen_at: now,
265            });
266    }
267
268    /// Test helper: snapshot the current dedup map size. Public for white-box tests; not
269    /// part of the public surface users build against.
270    #[cfg(test)]
271    pub(crate) fn dedup_entry_count(&self) -> usize {
272        self.inner.lock().dedup.len()
273    }
274
275    /// Test helper: snapshot the current trace map size.
276    #[cfg(test)]
277    pub(crate) fn cycle_entry_count(&self) -> usize {
278        self.inner.lock().cycle.len()
279    }
280}
281
282fn prune_expired(map: &mut HashMap<String, DedupEntry>, now: DateTime<Utc>, window: Duration) {
283    let cutoff =
284        now - chrono::Duration::from_std(window).expect("dedup_window fits in chrono::Duration");
285    map.retain(|_, entry| entry.received_at >= cutoff);
286}
287
288fn prune_expired_cycle(
289    map: &mut HashMap<String, CycleEntry>,
290    now: DateTime<Utc>,
291    window: Duration,
292) {
293    let cutoff =
294        now - chrono::Duration::from_std(window).expect("dedup_window fits in chrono::Duration");
295    map.retain(|_, entry| entry.last_seen_at >= cutoff);
296}
297
298#[cfg(test)]
299// Test files live in `tests/trigger_engine/runtime/` (mirror of src), pulled in by
300// path so they keep unit-test semantics (private access). See docs/rust-test-files.md.
301tests_bridge_macro::tests_bridge!("trigger_engine/runtime");