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");