Skip to main content

everruns_core/
task_observer.rs

1// Embeddable in-process task-transition observer (EVE-729).
2//
3// A `TaskTransition` names a lifecycle transition a `SessionTaskRegistry` fires
4// on: reaching a terminal state, entering `awaiting_input`, or emitting an
5// outbound message. A `TaskTransitionObserver` receives those transitions in
6// process — the same seam the server's webhook dispatcher uses, minus HTTP.
7//
8// Design Decision: the enum + trait live in `everruns-core` (not the server) so
9// `everruns-runtime` embedders can observe task transitions without depending on
10// the control-plane server or making HTTP calls. The server webhook dispatcher
11// (`DirectTaskWebhookNotifier`) is one implementation of this trait; in-process
12// embedders provide their own. A `SessionTaskRegistry` fires each real
13// transition once to every registered observer, so an in-process observer sees
14// exactly the same transitions the webhook path fires (see the parity test in
15// `crates/server/src/storage/session_task_store.rs`).
16//
17// Filter semantics: `Terminal` is the regression-safe default (org webhooks only
18// ever fire on it); `AwaitingInput` and `Message` are the non-terminal
19// transitions that are opt-in per delivery target via `event_filter` (EVE-682).
20// The `filter_value` / `event_name` strings are shared with webhook payloads so
21// the two paths stay byte-for-byte aligned.
22
23use std::sync::Arc;
24
25use async_trait::async_trait;
26
27use crate::error::Result;
28use crate::session_task::{
29    CreateSessionTask, NewTaskMessage, SessionTask, SessionTaskFilter, SessionTaskRegistry,
30    SessionTaskState, SessionTaskUpdate, TaskMessage, TaskMessageDirection,
31};
32use crate::typed_id::SessionId;
33
34/// A task lifecycle transition an observer can be notified of.
35///
36/// `Terminal` is the only transition org webhooks ever fire on (regression-safe).
37/// `AwaitingInput` and `Message` are opt-in per delivery target via
38/// `event_filter` (EVE-682).
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TaskTransition {
41    /// Task reached a terminal state (succeeded / failed / canceled).
42    Terminal,
43    /// Task transitioned into `awaiting_input`.
44    AwaitingInput,
45    /// Task emitted an outbound message.
46    Message,
47}
48
49impl TaskTransition {
50    /// The `event_filter` member string that enables this transition.
51    pub fn filter_value(&self) -> &'static str {
52        match self {
53            Self::Terminal => "terminal",
54            Self::AwaitingInput => "awaiting_input",
55            Self::Message => "message",
56        }
57    }
58
59    /// The `event` field value in a delivered webhook payload.
60    pub fn event_name(&self) -> &'static str {
61        match self {
62            Self::Terminal => "task.terminal",
63            Self::AwaitingInput => "task.awaiting_input",
64            Self::Message => "task.message",
65        }
66    }
67}
68
69/// Receive task-transition notifications in process.
70///
71/// A `SessionTaskRegistry` invokes `on_transition` once per real transition for
72/// every registered observer. Implementations must treat delivery as
73/// best-effort: the registry logs errors and never fails the underlying task
74/// operation because an observer returned `Err`. Observers must not block for
75/// long — the registry dispatches them off the task-update path, but a slow
76/// observer still delays its own delivery.
77///
78/// The server webhook dispatcher (`DirectTaskWebhookNotifier`) is one
79/// implementation. Embedders of `everruns-runtime` implement this trait to get
80/// in-process callbacks with the same transition semantics, without HTTP.
81#[async_trait]
82pub trait TaskTransitionObserver: Send + Sync + 'static {
83    /// Handle one task transition. Best-effort: returning `Err` is logged and
84    /// never fails the task operation that produced the transition.
85    async fn on_transition(
86        &self,
87        task: &SessionTask,
88        transition: TaskTransition,
89    ) -> anyhow::Result<()>;
90}
91
92/// A [`SessionTaskRegistry`] decorator that fans real task transitions out to
93/// registered [`TaskTransitionObserver`]s.
94///
95/// This is the reusable, storage-agnostic form of the fan-out the server's
96/// `DbSessionTaskRegistry` performs inline (EVE-729): it wraps *any* inner
97/// registry (in-memory, SQLite, gRPC) so an embedder — e.g. `everruns-runtime`
98/// with a [`crate::wake_queue::SessionWakeQueue`] — gets the same transition
99/// notifications without depending on the control-plane server.
100///
101/// Transition detection mirrors `DbSessionTaskRegistry` exactly so mid-turn and
102/// between-turn delivery agree on *when* a wake fires:
103///
104///   * `Terminal` — fired once when an update moves a non-terminal task into a
105///     terminal state, gated on this update's own intent (an update that does
106///     not set a terminal state never fires it, so a racing heartbeat cannot
107///     wake on another writer's transition).
108///   * `AwaitingInput` — fired once on the transition into `awaiting_input`.
109///   * `Message` — fired for each outbound message.
110///
111/// Observers are awaited in registration order (fast, in-process consumers); a
112/// failing observer is logged and never fails the underlying task op.
113pub struct ObservingTaskRegistry {
114    inner: Arc<dyn SessionTaskRegistry>,
115    observers: Vec<Arc<dyn TaskTransitionObserver>>,
116}
117
118impl ObservingTaskRegistry {
119    pub fn new(inner: Arc<dyn SessionTaskRegistry>) -> Self {
120        Self {
121            inner,
122            observers: Vec::new(),
123        }
124    }
125
126    /// Register an observer to receive every real transition.
127    pub fn with_observer(mut self, observer: Arc<dyn TaskTransitionObserver>) -> Self {
128        self.observers.push(observer);
129        self
130    }
131
132    /// Whether any observer is registered (fan-out is otherwise a no-op).
133    pub fn has_observers(&self) -> bool {
134        !self.observers.is_empty()
135    }
136
137    async fn notify(&self, task: &SessionTask, transition: TaskTransition) {
138        for observer in &self.observers {
139            if let Err(e) = observer.on_transition(task, transition).await {
140                tracing::warn!(
141                    task_id = %task.id,
142                    session_id = %task.session_id,
143                    transition = ?transition,
144                    "TaskTransitionObserver failed (best-effort): {e}"
145                );
146            }
147        }
148    }
149}
150
151#[async_trait]
152impl SessionTaskRegistry for ObservingTaskRegistry {
153    async fn create(&self, input: CreateSessionTask) -> Result<SessionTask> {
154        self.inner.create(input).await
155    }
156
157    async fn update(
158        &self,
159        session_id: SessionId,
160        task_id: &str,
161        update: SessionTaskUpdate,
162    ) -> Result<Option<SessionTask>> {
163        // Only this update's own intent can trigger a wake, so a racing
164        // heartbeat/progress update never fires on another writer's transition.
165        let wants_terminal = update.state.is_some_and(|s| s.is_terminal());
166        let wants_awaiting_input =
167            update.input_request.is_some() || update.state == Some(SessionTaskState::AwaitingInput);
168
169        let needs_prior = self.has_observers() && (wants_terminal || wants_awaiting_input);
170        let prior = if needs_prior {
171            self.inner.get(session_id, task_id).await.ok().flatten()
172        } else {
173            None
174        };
175
176        let updated = self.inner.update(session_id, task_id, update).await?;
177
178        if let (Some(task), Some(prior)) = (&updated, &prior) {
179            if wants_terminal && !prior.state.is_terminal() && task.state.is_terminal() {
180                self.notify(task, TaskTransition::Terminal).await;
181            }
182            if wants_awaiting_input
183                && prior.state != SessionTaskState::AwaitingInput
184                && task.state == SessionTaskState::AwaitingInput
185            {
186                self.notify(task, TaskTransition::AwaitingInput).await;
187            }
188        }
189        Ok(updated)
190    }
191
192    async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>> {
193        self.inner.get(session_id, task_id).await
194    }
195
196    async fn list(
197        &self,
198        session_id: SessionId,
199        filter: Option<&SessionTaskFilter>,
200    ) -> Result<Vec<SessionTask>> {
201        self.inner.list(session_id, filter).await
202    }
203
204    async fn request_cancel(
205        &self,
206        session_id: SessionId,
207        task_id: &str,
208    ) -> Result<Option<SessionTask>> {
209        self.inner.request_cancel(session_id, task_id).await
210    }
211
212    async fn record_message(
213        &self,
214        session_id: SessionId,
215        task_id: &str,
216        message: NewTaskMessage,
217    ) -> Result<TaskMessage> {
218        let direction = message.direction;
219        let stored = self
220            .inner
221            .record_message(session_id, task_id, message)
222            .await?;
223        if direction == TaskMessageDirection::Outbound
224            && self.has_observers()
225            && let Ok(Some(task)) = self.inner.get(session_id, task_id).await
226        {
227            self.notify(&task, TaskTransition::Message).await;
228        }
229        Ok(stored)
230    }
231
232    async fn list_messages(
233        &self,
234        session_id: SessionId,
235        task_id: &str,
236        limit: Option<u32>,
237        after_id: Option<&str>,
238    ) -> Result<Vec<TaskMessage>> {
239        self.inner
240            .list_messages(session_id, task_id, limit, after_id)
241            .await
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn filter_value_and_event_name_are_stable() {
251        // These strings are a wire contract shared with webhook payloads and the
252        // per-task `event_filter`; changing them silently breaks delivery.
253        assert_eq!(TaskTransition::Terminal.filter_value(), "terminal");
254        assert_eq!(
255            TaskTransition::AwaitingInput.filter_value(),
256            "awaiting_input"
257        );
258        assert_eq!(TaskTransition::Message.filter_value(), "message");
259
260        assert_eq!(TaskTransition::Terminal.event_name(), "task.terminal");
261        assert_eq!(
262            TaskTransition::AwaitingInput.event_name(),
263            "task.awaiting_input"
264        );
265        assert_eq!(TaskTransition::Message.event_name(), "task.message");
266    }
267
268    // ---- ObservingTaskRegistry fan-out gating -----------------------------
269
270    use crate::session_task::{
271        SessionTaskState, TaskWakePolicy, apply_task_update, new_session_task,
272    };
273    use crate::typed_id::SessionId;
274    use std::collections::HashMap;
275    use std::sync::Mutex;
276
277    #[derive(Default)]
278    struct MemRegistry {
279        tasks: Mutex<HashMap<String, SessionTask>>,
280    }
281
282    #[async_trait]
283    impl SessionTaskRegistry for MemRegistry {
284        async fn create(&self, input: CreateSessionTask) -> Result<SessionTask> {
285            let task = new_session_task(input, chrono::Utc::now());
286            self.tasks
287                .lock()
288                .unwrap()
289                .insert(task.id.clone(), task.clone());
290            Ok(task)
291        }
292        async fn update(
293            &self,
294            session_id: SessionId,
295            task_id: &str,
296            update: SessionTaskUpdate,
297        ) -> Result<Option<SessionTask>> {
298            let mut tasks = self.tasks.lock().unwrap();
299            let Some(task) = tasks.get_mut(task_id) else {
300                return Ok(None);
301            };
302            if task.session_id != session_id {
303                return Ok(None);
304            }
305            apply_task_update(task, update, chrono::Utc::now());
306            Ok(Some(task.clone()))
307        }
308        async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>> {
309            Ok(self
310                .tasks
311                .lock()
312                .unwrap()
313                .get(task_id)
314                .filter(|t| t.session_id == session_id)
315                .cloned())
316        }
317        async fn list(
318            &self,
319            _session_id: SessionId,
320            _filter: Option<&SessionTaskFilter>,
321        ) -> Result<Vec<SessionTask>> {
322            Ok(Vec::new())
323        }
324        async fn request_cancel(
325            &self,
326            _session_id: SessionId,
327            _task_id: &str,
328        ) -> Result<Option<SessionTask>> {
329            Ok(None)
330        }
331        async fn record_message(
332            &self,
333            _session_id: SessionId,
334            task_id: &str,
335            message: NewTaskMessage,
336        ) -> Result<TaskMessage> {
337            Ok(TaskMessage {
338                id: "tmsg_x".into(),
339                task_id: task_id.into(),
340                direction: message.direction,
341                content: message.content,
342                in_reply_to: message.in_reply_to,
343                created_at: chrono::Utc::now(),
344            })
345        }
346        async fn list_messages(
347            &self,
348            _session_id: SessionId,
349            _task_id: &str,
350            _limit: Option<u32>,
351            _after_id: Option<&str>,
352        ) -> Result<Vec<TaskMessage>> {
353            Ok(Vec::new())
354        }
355    }
356
357    #[derive(Default)]
358    struct Recorder {
359        seen: Mutex<Vec<TaskTransition>>,
360    }
361
362    #[async_trait]
363    impl TaskTransitionObserver for Recorder {
364        async fn on_transition(
365            &self,
366            _task: &SessionTask,
367            transition: TaskTransition,
368        ) -> anyhow::Result<()> {
369            self.seen.lock().unwrap().push(transition);
370            Ok(())
371        }
372    }
373
374    async fn seed_running(reg: &MemRegistry, session_id: SessionId) -> String {
375        reg.create(CreateSessionTask {
376            id: None,
377            session_id,
378            kind: "background_tool".into(),
379            display_name: "T".into(),
380            spec: serde_json::Value::Null,
381            state: SessionTaskState::Running,
382            links: Default::default(),
383            wake_policy: TaskWakePolicy::OnActivity,
384        })
385        .await
386        .unwrap()
387        .id
388    }
389
390    #[tokio::test]
391    async fn fires_terminal_once_and_not_on_heartbeat() {
392        let inner = Arc::new(MemRegistry::default());
393        let recorder = Arc::new(Recorder::default());
394        let reg = ObservingTaskRegistry::new(inner.clone()).with_observer(recorder.clone());
395        let session_id = SessionId::new();
396        let task_id = seed_running(&inner, session_id).await;
397
398        // A heartbeat-only update (no state change) must not fire any transition.
399        reg.update(
400            session_id,
401            &task_id,
402            SessionTaskUpdate {
403                heartbeat_at: Some(chrono::Utc::now()),
404                ..Default::default()
405            },
406        )
407        .await
408        .unwrap();
409        assert!(
410            recorder.seen.lock().unwrap().is_empty(),
411            "heartbeat must not fire a transition"
412        );
413
414        // Transition to terminal fires exactly one Terminal.
415        reg.update(
416            session_id,
417            &task_id,
418            SessionTaskUpdate {
419                state: Some(SessionTaskState::Succeeded),
420                ..Default::default()
421            },
422        )
423        .await
424        .unwrap();
425
426        // A redundant terminal-state update on an already-terminal task must not
427        // re-fire (prior is already terminal).
428        reg.update(
429            session_id,
430            &task_id,
431            SessionTaskUpdate {
432                state: Some(SessionTaskState::Succeeded),
433                ..Default::default()
434            },
435        )
436        .await
437        .unwrap();
438
439        assert_eq!(
440            *recorder.seen.lock().unwrap(),
441            vec![TaskTransition::Terminal],
442            "terminal fires exactly once, never on heartbeat or re-terminal"
443        );
444    }
445
446    #[tokio::test]
447    async fn fires_awaiting_input_only_on_entry() {
448        let inner = Arc::new(MemRegistry::default());
449        let recorder = Arc::new(Recorder::default());
450        let reg = ObservingTaskRegistry::new(inner.clone()).with_observer(recorder.clone());
451        let session_id = SessionId::new();
452        let task_id = seed_running(&inner, session_id).await;
453
454        reg.update(
455            session_id,
456            &task_id,
457            SessionTaskUpdate {
458                input_request: Some(crate::session_task::TaskInputRequest {
459                    id: "ir_1".into(),
460                    prompt: "approve?".into(),
461                    expected: None,
462                }),
463                ..Default::default()
464            },
465        )
466        .await
467        .unwrap();
468
469        assert_eq!(
470            *recorder.seen.lock().unwrap(),
471            vec![TaskTransition::AwaitingInput],
472            "awaiting_input fires once on entry"
473        );
474    }
475}