1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TaskTransition {
41 Terminal,
43 AwaitingInput,
45 Message,
47}
48
49impl TaskTransition {
50 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 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#[async_trait]
82pub trait TaskTransitionObserver: Send + Sync + 'static {
83 async fn on_transition(
86 &self,
87 task: &SessionTask,
88 transition: TaskTransition,
89 ) -> anyhow::Result<()>;
90}
91
92pub 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 pub fn with_observer(mut self, observer: Arc<dyn TaskTransitionObserver>) -> Self {
128 self.observers.push(observer);
129 self
130 }
131
132 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 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 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 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 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 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 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}