Skip to main content

everruns_core/
wake_queue.rs

1// Mid-turn task wake delivery (EVE-681, part A).
2//
3// Wake-ups are a registry-level delivery policy: when a task emits qualifying
4// outbound activity (see `TaskWakePolicy`), the owning session's agent is woken
5// so it can react. Historically the only delivery path was a between-turn
6// steering message — a parent that spawned background work finished its turn,
7// idled, and only reacted on its next turn (see the Wake-ups section of
8// `knowledge/runtime-resources/session-tasks.md`).
9//
10// `SessionWakeQueue` adds the *mid-turn* path. It is a per-session queue that
11// sits behind the registry seam: the registry fans qualifying task transitions
12// into it (via the EVE-729 `TaskTransitionObserver` seam), and the agentic turn
13// loop drains it at each iteration boundary — before the next LLM call —
14// injecting the wake payloads as context alongside the reloaded conversation.
15//
16// Exactly-once claim point
17// -------------------------
18// The queue is the single source of truth for an undelivered wake. Each real
19// transition enqueues exactly one `PendingWake` (the registry guarantees one
20// observer notification per real transition). `drain` atomically removes and
21// returns a session's queued wakes under a single lock — that removal *is* the
22// claim. A wake is therefore delivered mid-turn (drained by a running turn's
23// next iteration) XOR queued for the next turn (drained by that turn's first
24// iteration), never both:
25//
26//   * turn cancellation / seal / max-iterations: an undrained wake stays in the
27//     queue and is delivered by the next turn's first drain (between-turn
28//     fallback), because nothing removed it.
29//   * a wake landing mid-loop is visible to the very next iteration, because the
30//     loop drains at the top of every reason step.
31//
32// The queue is process-local. Within a single runtime/worker process it gives
33// exactly-once delivery; durable exactly-once across a worker restart is a
34// property of the *persistent* transition source (the durable signal store on
35// the server path), not of this in-memory queue. The server durable-worker
36// wiring is intentionally out of scope for part A — see the PR / spec notes.
37
38use std::collections::{HashMap, VecDeque};
39use std::sync::Mutex;
40
41use async_trait::async_trait;
42use chrono::{DateTime, Utc};
43
44use crate::session_task::{SessionTask, TaskWakePolicy};
45use crate::task_observer::{TaskTransition, TaskTransitionObserver};
46use crate::typed_id::SessionId;
47
48/// A wake destined for a session's running (or next) turn.
49///
50/// `text` is the rendered, model-facing payload — the task snapshot summary plus
51/// (for `OnActivity`) its latest progress/detail. The loop injects it as a user
52/// message so the next LLM call reacts to it alongside pending tool results.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PendingWake {
55    pub task_id: String,
56    pub session_id: SessionId,
57    pub transition: TaskTransition,
58    pub text: String,
59    pub created_at: DateTime<Utc>,
60}
61
62/// Render the wake text for a task transition under the task's `wake_policy`.
63///
64/// Returns `None` when the policy does not wake on this transition (so the
65/// caller enqueues nothing). This encodes the same gating the between-turn
66/// waker uses (`DbSessionTaskRegistry::maybe_wake_*`) so mid-turn and
67/// between-turn delivery agree on *when* a wake fires:
68///
69///   * `Silent`     — never.
70///   * `OnTerminal` — only on a terminal transition.
71///   * `OnActivity` — terminal, `awaiting_input`, and outbound messages.
72///
73/// The text is rendered purely from the task snapshot: the terminal summary /
74/// result path, the awaiting-input prompt, or the latest progress / detail for
75/// a message. The full message thread stays in the task record; the wake tells
76/// the agent to look.
77pub fn wake_text_for(task: &SessionTask, transition: TaskTransition) -> Option<String> {
78    match (task.wake_policy, transition) {
79        (TaskWakePolicy::Silent, _) => None,
80        (TaskWakePolicy::OnTerminal, TaskTransition::Terminal)
81        | (TaskWakePolicy::OnActivity, TaskTransition::Terminal) => {
82            let mut parts = vec![format!(
83                "Task \"{}\" ({}) finished: {}.",
84                task.display_name, task.id, task.state
85            )];
86            if let Some(summary) = &task.summary {
87                parts.push(format!("- summary: {summary}"));
88            }
89            if let Some(result_path) = &task.result_path {
90                parts.push(format!("- result_path: {result_path}"));
91            }
92            Some(parts.join("\n"))
93        }
94        (TaskWakePolicy::OnTerminal, _) => None,
95        (TaskWakePolicy::OnActivity, TaskTransition::AwaitingInput) => {
96            let prompt = task
97                .input_request
98                .as_ref()
99                .map(|r| r.prompt.as_str())
100                .unwrap_or("Task is awaiting input.");
101            Some(format!(
102                "Task \"{}\" ({}) is awaiting input: {}",
103                task.display_name, task.id, prompt
104            ))
105        }
106        (TaskWakePolicy::OnActivity, TaskTransition::Message) => {
107            // Observers do not receive the message body (it is not on the task
108            // snapshot); surface the latest progress/detail so the agent knows
109            // what changed, and read the thread via `get_task` for the payload.
110            let detail = task
111                .state_detail
112                .as_deref()
113                .filter(|s| !s.trim().is_empty())
114                .or_else(|| task.progress.as_ref().and_then(|p| p.label.as_deref()))
115                .unwrap_or("structured progress update");
116            Some(format!(
117                "Task \"{}\" ({}) sent a message: {}",
118                task.display_name, task.id, detail
119            ))
120        }
121    }
122}
123
124/// Per-session mid-turn wake queue behind the registry seam.
125///
126/// Feed it by registering it as a [`TaskTransitionObserver`] on an
127/// [`crate::task_observer::ObservingTaskRegistry`] (or the server's
128/// `DbSessionTaskRegistry`). Drain it from the turn loop with [`Self::drain`].
129#[derive(Default)]
130pub struct SessionWakeQueue {
131    queues: Mutex<HashMap<SessionId, VecDeque<PendingWake>>>,
132}
133
134impl SessionWakeQueue {
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Enqueue a wake for `task`'s owning session if its policy wakes on
140    /// `transition`. Returns `true` when a wake was enqueued.
141    pub fn note_transition(&self, task: &SessionTask, transition: TaskTransition) -> bool {
142        let Some(text) = wake_text_for(task, transition) else {
143            return false;
144        };
145        let wake = PendingWake {
146            task_id: task.id.clone(),
147            session_id: task.session_id,
148            transition,
149            text,
150            created_at: Utc::now(),
151        };
152        self.queues
153            .lock()
154            .expect("wake queue mutex poisoned")
155            .entry(task.session_id)
156            .or_default()
157            .push_back(wake);
158        true
159    }
160
161    /// Atomically remove and return all wakes queued for `session_id`.
162    ///
163    /// This is the exactly-once claim point: a drained wake is gone from the
164    /// queue and can never be delivered again.
165    pub fn drain(&self, session_id: SessionId) -> Vec<PendingWake> {
166        let mut guard = self.queues.lock().expect("wake queue mutex poisoned");
167        match guard.remove(&session_id) {
168            Some(queue) => queue.into_iter().collect(),
169            None => Vec::new(),
170        }
171    }
172
173    /// Number of wakes currently queued for `session_id` (not consuming).
174    pub fn pending_len(&self, session_id: SessionId) -> usize {
175        self.queues
176            .lock()
177            .expect("wake queue mutex poisoned")
178            .get(&session_id)
179            .map_or(0, VecDeque::len)
180    }
181
182    /// Whether any wake is queued for `session_id` (not consuming).
183    pub fn has_pending(&self, session_id: SessionId) -> bool {
184        self.pending_len(session_id) > 0
185    }
186}
187
188/// The queue is a [`TaskTransitionObserver`] so it can be attached to the same
189/// seam the server webhook dispatcher uses (EVE-729). Terminal and
190/// awaiting-input transitions render at full fidelity from the snapshot; a
191/// message transition renders from the snapshot's progress/detail.
192#[async_trait]
193impl TaskTransitionObserver for SessionWakeQueue {
194    async fn on_transition(
195        &self,
196        task: &SessionTask,
197        transition: TaskTransition,
198    ) -> anyhow::Result<()> {
199        self.note_transition(task, transition);
200        Ok(())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::session_task::{
208        CreateSessionTask, SessionTaskState, TaskInputRequest, new_session_task,
209    };
210
211    fn task_with_policy(policy: TaskWakePolicy) -> SessionTask {
212        let session_id = SessionId::from_seed(1);
213        let mut task = new_session_task(
214            CreateSessionTask {
215                id: Some("task_a".into()),
216                session_id,
217                kind: "subagent".into(),
218                display_name: "Test Runner".into(),
219                spec: serde_json::Value::Null,
220                state: SessionTaskState::Running,
221                links: Default::default(),
222                wake_policy: policy,
223            },
224            Utc::now(),
225        );
226        task.state = SessionTaskState::Succeeded;
227        task
228    }
229
230    #[test]
231    fn policy_matrix_controls_rendering_and_enqueueing() {
232        for (policy, allowed) in [
233            (TaskWakePolicy::Silent, [false, false, false]),
234            (TaskWakePolicy::OnTerminal, [true, false, false]),
235            (TaskWakePolicy::OnActivity, [true, true, true]),
236        ] {
237            for (index, transition) in [
238                TaskTransition::Terminal,
239                TaskTransition::AwaitingInput,
240                TaskTransition::Message,
241            ]
242            .into_iter()
243            .enumerate()
244            {
245                let queue = SessionWakeQueue::new();
246                let task = task_with_policy(policy);
247                assert_eq!(wake_text_for(&task, transition).is_some(), allowed[index]);
248                assert_eq!(queue.note_transition(&task, transition), allowed[index]);
249                assert_eq!(queue.has_pending(task.session_id), allowed[index]);
250                assert_eq!(
251                    queue.pending_len(task.session_id),
252                    usize::from(allowed[index])
253                );
254            }
255        }
256    }
257
258    #[test]
259    fn terminal_text_preserves_identity_status_and_optional_fields() {
260        let mut task = task_with_policy(TaskWakePolicy::OnTerminal);
261        assert_eq!(
262            wake_text_for(&task, TaskTransition::Terminal).as_deref(),
263            Some("Task \"Test Runner\" (task_a) finished: succeeded.")
264        );
265        task.summary = Some("all tests passed".into());
266        task.result_path = Some("/.tasks/task_a/result.json".into());
267        assert_eq!(
268            wake_text_for(&task, TaskTransition::Terminal).as_deref(),
269            Some(
270                "Task \"Test Runner\" (task_a) finished: succeeded.\n- summary: all tests passed\n- result_path: /.tasks/task_a/result.json"
271            )
272        );
273        task.state = SessionTaskState::Failed;
274        task.summary = None;
275        assert_eq!(
276            wake_text_for(&task, TaskTransition::Terminal).as_deref(),
277            Some(
278                "Task \"Test Runner\" (task_a) finished: failed.\n- result_path: /.tasks/task_a/result.json"
279            )
280        );
281    }
282
283    #[test]
284    fn activity_text_uses_input_prompt_and_detail_label_fallbacks() {
285        let mut task = task_with_policy(TaskWakePolicy::OnActivity);
286        task.input_request = Some(TaskInputRequest {
287            id: "input_1".into(),
288            prompt: "pick a branch".into(),
289            expected: None,
290        });
291        assert_eq!(
292            wake_text_for(&task, TaskTransition::AwaitingInput).as_deref(),
293            Some("Task \"Test Runner\" (task_a) is awaiting input: pick a branch")
294        );
295        task.input_request = None;
296        assert_eq!(
297            wake_text_for(&task, TaskTransition::AwaitingInput).as_deref(),
298            Some("Task \"Test Runner\" (task_a) is awaiting input: Task is awaiting input.")
299        );
300        for (detail, label, expected) in [
301            (Some("iteration 4/10"), Some("label"), "iteration 4/10"),
302            (Some(" \u{2003}"), Some("label"), "label"),
303            (None, Some("label"), "label"),
304            (None, None, "structured progress update"),
305        ] {
306            task.state_detail = detail.map(str::to_string);
307            task.progress = label.map(|label| crate::session_task::TaskProgress {
308                current: Some(4),
309                total: Some(10),
310                unit: Some("steps".into()),
311                label: Some(label.into()),
312            });
313            assert_eq!(
314                wake_text_for(&task, TaskTransition::Message).unwrap(),
315                format!("Task \"Test Runner\" (task_a) sent a message: {expected}")
316            );
317        }
318    }
319
320    #[tokio::test]
321    async fn observer_delivery_is_fifo_isolated_and_claimed_once() {
322        let queue = SessionWakeQueue::new();
323        let mut a = task_with_policy(TaskWakePolicy::OnActivity);
324        let mut b = a.clone();
325        b.id = "task_b".into();
326        b.session_id = SessionId::from_seed(2);
327        assert!(queue.drain(a.session_id).is_empty());
328        queue
329            .on_transition(&a, TaskTransition::Terminal)
330            .await
331            .unwrap();
332        a.id = "task_next".into();
333        queue
334            .on_transition(&a, TaskTransition::Message)
335            .await
336            .unwrap();
337        queue
338            .on_transition(&b, TaskTransition::Terminal)
339            .await
340            .unwrap();
341        assert_eq!(queue.pending_len(a.session_id), 2);
342        assert_eq!(
343            queue.pending_len(a.session_id),
344            2,
345            "inspection does not claim wakes"
346        );
347        let wakes = queue.drain(a.session_id);
348        assert_eq!(
349            wakes
350                .iter()
351                .map(|wake| (
352                    wake.task_id.as_str(),
353                    wake.session_id,
354                    wake.transition,
355                    wake.text.as_str()
356                ))
357                .collect::<Vec<_>>(),
358            [
359                (
360                    "task_a",
361                    SessionId::from_seed(1),
362                    TaskTransition::Terminal,
363                    "Task \"Test Runner\" (task_a) finished: succeeded."
364                ),
365                (
366                    "task_next",
367                    SessionId::from_seed(1),
368                    TaskTransition::Message,
369                    "Task \"Test Runner\" (task_next) sent a message: structured progress update"
370                ),
371            ]
372        );
373        assert!(queue.drain(a.session_id).is_empty());
374        assert!(!queue.has_pending(a.session_id));
375        assert_eq!(queue.pending_len(b.session_id), 1);
376        let other = queue.drain(b.session_id);
377        assert_eq!(other.len(), 1);
378        assert_eq!(other[0].task_id, "task_b");
379        assert_eq!(other[0].session_id, SessionId::from_seed(2));
380    }
381
382    #[test]
383    fn concurrent_drains_claim_each_wake_once() {
384        let queue = SessionWakeQueue::new();
385        let mut task = task_with_policy(TaskWakePolicy::OnTerminal);
386        for n in 0..32 {
387            task.id = format!("task_{n}");
388            queue.note_transition(&task, TaskTransition::Terminal);
389        }
390        let barrier = std::sync::Barrier::new(2);
391        let mut ids = std::thread::scope(|scope| {
392            let drain = || {
393                barrier.wait();
394                queue.drain(task.session_id)
395            };
396            let first = scope.spawn(drain);
397            let second = scope.spawn(drain);
398            first
399                .join()
400                .unwrap()
401                .into_iter()
402                .chain(second.join().unwrap())
403                .map(|wake| wake.task_id)
404                .collect::<Vec<_>>()
405        });
406        ids.sort();
407        let mut expected = (0..32).map(|n| format!("task_{n}")).collect::<Vec<_>>();
408        expected.sort();
409        assert_eq!(ids, expected);
410        assert!(!queue.has_pending(task.session_id));
411    }
412
413    #[test]
414    fn draining_releases_per_session_storage_and_allows_reenqueue() {
415        let queue = SessionWakeQueue::new();
416        let task = task_with_policy(TaskWakePolicy::OnTerminal);
417        queue.note_transition(&task, TaskTransition::Terminal);
418        assert_eq!(queue.drain(task.session_id).len(), 1);
419        assert!(
420            queue.queues.lock().unwrap().is_empty(),
421            "drained sessions must not accumulate in the queue"
422        );
423        assert!(queue.note_transition(&task, TaskTransition::Terminal));
424        assert_eq!(queue.drain(task.session_id).len(), 1);
425        assert!(queue.queues.lock().unwrap().is_empty());
426    }
427}