1use 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#[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
62pub 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 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#[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 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 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 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 pub fn has_pending(&self, session_id: SessionId) -> bool {
184 self.pending_len(session_id) > 0
185 }
186}
187
188#[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}