Skip to main content

talos_agent/
scheduler.rs

1//! Session-scoped scheduled follow-up scheduler.
2//!
3//! Scheduled fires enter the same Actor-owned structured submission protocol as
4//! interactive steering (ADR-056 / TUI-044). A timer firing is not considered
5//! delivered when it merely enters the bounded SQ: the scheduler retains the
6//! exact immutable fire identity until the Session Actor returns a durable
7//! `AcceptedPending`/`AlreadyAccepted` receipt. Lost receipts are reconciled
8//! with the same identity, and recurring schedules do not advance while a fire
9//! remains unresolved.
10
11use std::collections::HashMap;
12use std::fmt;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::Duration;
16
17use async_trait::async_trait;
18use talos_core::session::{
19    MAX_SUBMISSION_ITEM_BYTES, SessionOp, StructuredSubmission, SubmissionItem, SubmissionKind,
20    SubmissionReceipt, SubmissionReceiptDisposition, SubmissionSource,
21};
22use talos_core::tool::{AgentTool, ToolFamily, ToolNature, ToolResult};
23use tokio::sync::{mpsc, oneshot};
24use tokio::task::JoinHandle;
25use tokio::time::Instant;
26use tokio_util::sync::CancellationToken;
27
28/// Minimum allowed one-shot delay.
29pub(crate) const MIN_DELAY_SECS: u64 = 1;
30/// Maximum allowed one-shot delay (24 hours).
31pub(crate) const MAX_DELAY_SECS: u64 = 86_400;
32/// Minimum allowed recurring interval.
33pub(crate) const MIN_INTERVAL_SECS: u64 = 5;
34/// Maximum allowed recurring interval (1 hour).
35pub(crate) const MAX_INTERVAL_SECS: u64 = 3_600;
36
37const SCHEDULER_COMMAND_CAPACITY: usize = 64;
38const DELIVERY_SEND_TIMEOUT: Duration = Duration::from_millis(250);
39const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(1);
40const DELIVERY_RETRY_DELAY: Duration = Duration::from_secs(1);
41
42/// Visible prefix retained for transcript and provider readability.
43pub(crate) const SCHEDULED_FOLLOWUP_LABEL: &str = "[scheduled-followup]";
44
45static NEXT_TASK_SEQ: AtomicU64 = AtomicU64::new(1);
46static NEXT_DELIVERY_SEQ: AtomicU64 = AtomicU64::new(1);
47
48pub(crate) fn validate_delay_secs(delay_secs: u64) -> Result<(), String> {
49    if delay_secs < MIN_DELAY_SECS {
50        return Err(format!(
51            "delay_secs must be at least {MIN_DELAY_SECS}; got {delay_secs}"
52        ));
53    }
54    if delay_secs > MAX_DELAY_SECS {
55        return Err(format!(
56            "delay_secs must be at most {MAX_DELAY_SECS}; got {delay_secs}"
57        ));
58    }
59    Ok(())
60}
61
62pub(crate) fn validate_interval_secs(interval_secs: u64) -> Result<(), String> {
63    if interval_secs < MIN_INTERVAL_SECS {
64        return Err(format!(
65            "interval_secs must be at least {MIN_INTERVAL_SECS}; got {interval_secs}"
66        ));
67    }
68    if interval_secs > MAX_INTERVAL_SECS {
69        return Err(format!(
70            "interval_secs must be at most {MAX_INTERVAL_SECS}; got {interval_secs}"
71        ));
72    }
73    Ok(())
74}
75
76pub(crate) fn label_scheduled_message(message: &str) -> String {
77    format!("{SCHEDULED_FOLLOWUP_LABEL} {message}")
78}
79
80fn validate_scheduled_message(message: &str) -> Result<String, String> {
81    if message.is_empty() {
82        return Err("message must not be empty".into());
83    }
84    let labeled = label_scheduled_message(message);
85    if labeled.len() > MAX_SUBMISSION_ITEM_BYTES {
86        return Err(format!(
87            "message exceeds the structured submission item limit of {MAX_SUBMISSION_ITEM_BYTES} UTF-8 bytes"
88        ));
89    }
90    Ok(labeled)
91}
92
93pub(crate) fn next_task_id() -> String {
94    format!("sched_{}", NEXT_TASK_SEQ.fetch_add(1, Ordering::Relaxed))
95}
96
97fn next_delivery_identity(task_id: &str) -> String {
98    let registration = NEXT_DELIVERY_SEQ.fetch_add(1, Ordering::Relaxed);
99    format!("scheduler:{task_id}:registration:{registration}")
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub(crate) enum ScheduleKind {
104    OneShot,
105    Recurring { interval: Duration },
106}
107
108impl fmt::Display for ScheduleKind {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            Self::OneShot => write!(f, "one-shot"),
112            Self::Recurring { interval } => write!(f, "recurring ({}s)", interval.as_secs()),
113        }
114    }
115}
116
117#[derive(Debug, Clone)]
118#[allow(dead_code)]
119pub(crate) struct ScheduledTaskInfo {
120    pub id: String,
121    pub message: String,
122    pub kind: ScheduleKind,
123    pub created_at: Instant,
124    pub fire_at: Instant,
125}
126
127impl ScheduledTaskInfo {
128    #[must_use]
129    pub fn remaining(&self) -> Duration {
130        self.fire_at.saturating_duration_since(Instant::now())
131    }
132
133    #[must_use]
134    fn delivery_state(&self) -> &'static str {
135        if self.remaining().is_zero() {
136            "delivery-blocked"
137        } else {
138            "scheduled"
139        }
140    }
141}
142
143#[derive(Debug)]
144pub(crate) enum ScheduleRegistrationResult {
145    Registered { task_id: String },
146    InvalidDuration { reason: String },
147    InvalidMessage { reason: String },
148}
149
150#[derive(Debug)]
151pub(crate) enum CancelResult {
152    Cancelled,
153    NotFound,
154}
155
156#[derive(Debug)]
157#[allow(dead_code)]
158pub(crate) enum ScheduleCommand {
159    RegisterOneShot {
160        id: Option<String>,
161        message: String,
162        delay: Duration,
163        response_tx: oneshot::Sender<ScheduleRegistrationResult>,
164    },
165    RegisterRecurring {
166        id: Option<String>,
167        message: String,
168        interval: Duration,
169        response_tx: oneshot::Sender<ScheduleRegistrationResult>,
170    },
171    Cancel {
172        id: String,
173        response_tx: oneshot::Sender<CancelResult>,
174    },
175    List {
176        response_tx: oneshot::Sender<Vec<ScheduledTaskInfo>>,
177    },
178    Shutdown,
179}
180
181#[derive(Clone)]
182pub(crate) struct SchedulerHandle {
183    cmd_tx: mpsc::Sender<ScheduleCommand>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub(crate) enum SchedulerSendError {
188    Full,
189    Closed,
190}
191
192impl SchedulerHandle {
193    pub(crate) fn new(cmd_tx: mpsc::Sender<ScheduleCommand>) -> Self {
194        Self { cmd_tx }
195    }
196
197    pub(crate) async fn send(&self, command: ScheduleCommand) -> Result<(), SchedulerSendError> {
198        match self.cmd_tx.try_send(command) {
199            Ok(()) => Ok(()),
200            Err(mpsc::error::TrySendError::Full(_)) => Err(SchedulerSendError::Full),
201            Err(mpsc::error::TrySendError::Closed(_)) => Err(SchedulerSendError::Closed),
202        }
203    }
204}
205
206impl fmt::Debug for SchedulerHandle {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        f.debug_struct("SchedulerHandle")
209            .field("cmd_tx", &"mpsc::Sender<ScheduleCommand>")
210            .finish()
211    }
212}
213
214struct ActiveTask {
215    info: ScheduledTaskInfo,
216    handle: JoinHandle<()>,
217}
218
219struct AcceptedFire {
220    task_id: String,
221    next_fire_at: Option<Instant>,
222}
223
224pub(crate) struct SchedulerActor {
225    cmd_rx: mpsc::Receiver<ScheduleCommand>,
226    sq_tx: mpsc::Sender<SessionOp>,
227    session_generation: u64,
228    cancel_token: CancellationToken,
229    tasks: HashMap<String, ActiveTask>,
230    accepted_tx: mpsc::UnboundedSender<AcceptedFire>,
231    accepted_rx: mpsc::UnboundedReceiver<AcceptedFire>,
232}
233
234impl SchedulerActor {
235    pub(crate) fn new(
236        cmd_rx: mpsc::Receiver<ScheduleCommand>,
237        sq_tx: mpsc::Sender<SessionOp>,
238        session_generation: u64,
239        cancel_token: CancellationToken,
240    ) -> Self {
241        let (accepted_tx, accepted_rx) = mpsc::unbounded_channel();
242        Self {
243            cmd_rx,
244            sq_tx,
245            session_generation,
246            cancel_token,
247            tasks: HashMap::new(),
248            accepted_tx,
249            accepted_rx,
250        }
251    }
252
253    pub(crate) async fn run(mut self) {
254        loop {
255            tokio::select! {
256                biased;
257
258                _ = self.cancel_token.cancelled() => break,
259
260                Some(accepted) = self.accepted_rx.recv() => {
261                    match accepted.next_fire_at {
262                        None => {
263                            self.tasks.remove(&accepted.task_id);
264                        }
265                        Some(next_fire_at) => {
266                            if let Some(task) = self.tasks.get_mut(&accepted.task_id) {
267                                task.info.fire_at = next_fire_at;
268                            }
269                        }
270                    }
271                }
272
273                command = self.cmd_rx.recv() => {
274                    match command {
275                        Some(ScheduleCommand::RegisterOneShot {
276                            id,
277                            message,
278                            delay,
279                            response_tx,
280                        }) => self.handle_register_one_shot(id, message, delay, response_tx),
281                        Some(ScheduleCommand::RegisterRecurring {
282                            id,
283                            message,
284                            interval,
285                            response_tx,
286                        }) => self.handle_register_recurring(
287                            id,
288                            message,
289                            interval,
290                            response_tx,
291                        ),
292                        Some(ScheduleCommand::Cancel { id, response_tx }) => {
293                            self.handle_cancel(id, response_tx);
294                        }
295                        Some(ScheduleCommand::List { response_tx }) => {
296                            self.handle_list(response_tx);
297                        }
298                        Some(ScheduleCommand::Shutdown) | None => break,
299                    }
300                }
301            }
302        }
303
304        for (_, task) in self.tasks.drain() {
305            task.handle.abort();
306        }
307    }
308
309    fn handle_register_one_shot(
310        &mut self,
311        id: Option<String>,
312        message: String,
313        delay: Duration,
314        response_tx: oneshot::Sender<ScheduleRegistrationResult>,
315    ) {
316        if let Err(reason) = validate_delay_secs(delay.as_secs()) {
317            let _ = response_tx.send(ScheduleRegistrationResult::InvalidDuration { reason });
318            return;
319        }
320        let labeled_message = match validate_scheduled_message(&message) {
321            Ok(message) => message,
322            Err(reason) => {
323                let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage { reason });
324                return;
325            }
326        };
327
328        let task_id = id.unwrap_or_else(next_task_id);
329        if self.tasks.contains_key(&task_id) {
330            let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage {
331                reason: format!("task ID {task_id} is already active"),
332            });
333            return;
334        }
335        let delivery_identity = next_delivery_identity(&task_id);
336        let now = Instant::now();
337        let sq_tx = self.sq_tx.clone();
338        let session_generation = self.session_generation;
339        let accepted_tx = self.accepted_tx.clone();
340        let task_id_for_fire = task_id.clone();
341        let message_for_fire = labeled_message.clone();
342
343        let handle = tokio::spawn(async move {
344            tokio::time::sleep(delay).await;
345            let submission =
346                scheduled_submission(&delivery_identity, 1, session_generation, message_for_fire);
347            deliver_until_accepted(&sq_tx, submission).await;
348            let _ = accepted_tx.send(AcceptedFire {
349                task_id: task_id_for_fire,
350                next_fire_at: None,
351            });
352        });
353
354        self.tasks.insert(
355            task_id.clone(),
356            ActiveTask {
357                info: ScheduledTaskInfo {
358                    id: task_id.clone(),
359                    message: labeled_message,
360                    kind: ScheduleKind::OneShot,
361                    created_at: now,
362                    fire_at: now + delay,
363                },
364                handle,
365            },
366        );
367        let _ = response_tx.send(ScheduleRegistrationResult::Registered { task_id });
368    }
369
370    fn handle_register_recurring(
371        &mut self,
372        id: Option<String>,
373        message: String,
374        interval: Duration,
375        response_tx: oneshot::Sender<ScheduleRegistrationResult>,
376    ) {
377        if let Err(reason) = validate_interval_secs(interval.as_secs()) {
378            let _ = response_tx.send(ScheduleRegistrationResult::InvalidDuration { reason });
379            return;
380        }
381        let labeled_message = match validate_scheduled_message(&message) {
382            Ok(message) => message,
383            Err(reason) => {
384                let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage { reason });
385                return;
386            }
387        };
388
389        let task_id = id.unwrap_or_else(next_task_id);
390        if self.tasks.contains_key(&task_id) {
391            let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage {
392                reason: format!("task ID {task_id} is already active"),
393            });
394            return;
395        }
396        let delivery_identity = next_delivery_identity(&task_id);
397        let now = Instant::now();
398        let sq_tx = self.sq_tx.clone();
399        let session_generation = self.session_generation;
400        let accepted_tx = self.accepted_tx.clone();
401        let task_id_for_fire = task_id.clone();
402        let message_for_fire = labeled_message.clone();
403
404        let handle = tokio::spawn(async move {
405            let mut fire_sequence = 1_u64;
406            tokio::time::sleep(interval).await;
407            loop {
408                let submission = scheduled_submission(
409                    &delivery_identity,
410                    fire_sequence,
411                    session_generation,
412                    message_for_fire.clone(),
413                );
414                deliver_until_accepted(&sq_tx, submission).await;
415
416                let next_fire_at = Instant::now() + interval;
417                if accepted_tx
418                    .send(AcceptedFire {
419                        task_id: task_id_for_fire.clone(),
420                        next_fire_at: Some(next_fire_at),
421                    })
422                    .is_err()
423                {
424                    break;
425                }
426                let Some(next_sequence) = fire_sequence.checked_add(1) else {
427                    tracing::warn!(
428                        task_id = %task_id_for_fire,
429                        "recurring scheduler exhausted its fire sequence"
430                    );
431                    break;
432                };
433                fire_sequence = next_sequence;
434                tokio::time::sleep(interval).await;
435            }
436        });
437
438        self.tasks.insert(
439            task_id.clone(),
440            ActiveTask {
441                info: ScheduledTaskInfo {
442                    id: task_id.clone(),
443                    message: labeled_message,
444                    kind: ScheduleKind::Recurring { interval },
445                    created_at: now,
446                    fire_at: now + interval,
447                },
448                handle,
449            },
450        );
451        let _ = response_tx.send(ScheduleRegistrationResult::Registered { task_id });
452    }
453
454    fn handle_cancel(&mut self, id: String, response_tx: oneshot::Sender<CancelResult>) {
455        if let Some(task) = self.tasks.remove(&id) {
456            task.handle.abort();
457            let _ = response_tx.send(CancelResult::Cancelled);
458        } else {
459            let _ = response_tx.send(CancelResult::NotFound);
460        }
461    }
462
463    fn handle_list(&self, response_tx: oneshot::Sender<Vec<ScheduledTaskInfo>>) {
464        let snapshot = self.tasks.values().map(|task| task.info.clone()).collect();
465        let _ = response_tx.send(snapshot);
466    }
467}
468
469fn scheduled_submission(
470    delivery_identity: &str,
471    fire_sequence: u64,
472    session_generation: u64,
473    text: String,
474) -> StructuredSubmission {
475    let submission_id = format!("{delivery_identity}:fire:{fire_sequence}");
476    StructuredSubmission {
477        id: submission_id.clone(),
478        source: SubmissionSource::Scheduler,
479        sender_generation: session_generation,
480        items: vec![SubmissionItem {
481            id: format!("{submission_id}:item:1"),
482            enqueue_sequence: fire_sequence,
483            kind: SubmissionKind::UserTurn,
484            text,
485            attachments: Vec::new(),
486        }],
487    }
488}
489
490async fn deliver_until_accepted(sq_tx: &mpsc::Sender<SessionOp>, submission: StructuredSubmission) {
491    let (receipt_tx, mut receipt_rx) = mpsc::unbounded_channel();
492    let mut submit_required = true;
493    let mut attempts = 0_u64;
494
495    loop {
496        attempts = attempts.saturating_add(1);
497        let operation = if submit_required {
498            SessionOp::SubmitStructuredTracked {
499                submission: submission.clone(),
500                receipt_tx: Some(receipt_tx.clone()),
501            }
502        } else {
503            SessionOp::ReconcileStructuredTracked {
504                submission: submission.clone(),
505                receipt_tx: Some(receipt_tx.clone()),
506            }
507        };
508
509        match tokio::time::timeout(DELIVERY_SEND_TIMEOUT, sq_tx.send(operation)).await {
510            Ok(Ok(())) => {}
511            Ok(Err(_)) | Err(_) => {
512                log_blocked_delivery(&submission.id, attempts, "session queue unavailable");
513                tokio::time::sleep(DELIVERY_RETRY_DELAY).await;
514                continue;
515            }
516        }
517
518        let receipt = tokio::time::timeout(
519            DELIVERY_RECEIPT_TIMEOUT,
520            next_matching_receipt(&mut receipt_rx, &submission.id),
521        )
522        .await;
523        match receipt {
524            Ok(Some(receipt)) if receipt.disposition.has_durable_custody() => return,
525            Ok(Some(receipt)) => {
526                submit_required = match receipt.disposition {
527                    SubmissionReceiptDisposition::NotAccepted => true,
528                    SubmissionReceiptDisposition::Rejected { reason } => {
529                        if attempts == 1 || attempts.is_power_of_two() {
530                            tracing::warn!(
531                                submission_id = %submission.id,
532                                ?reason,
533                                attempts,
534                                "scheduled fire remains blocked before durable custody"
535                            );
536                        }
537                        true
538                    }
539                    SubmissionReceiptDisposition::AcceptedPending
540                    | SubmissionReceiptDisposition::AlreadyAccepted { .. } => false,
541                };
542            }
543            Ok(None) | Err(_) => {
544                // The submit may have committed while its receipt was lost. Ask
545                // the same Actor authority before considering a resend.
546                submit_required = false;
547                log_blocked_delivery(&submission.id, attempts, "durable receipt unavailable");
548            }
549        }
550        tokio::time::sleep(DELIVERY_RETRY_DELAY).await;
551    }
552}
553
554async fn next_matching_receipt(
555    receipt_rx: &mut mpsc::UnboundedReceiver<SubmissionReceipt>,
556    submission_id: &str,
557) -> Option<SubmissionReceipt> {
558    while let Some(receipt) = receipt_rx.recv().await {
559        if receipt.submission_id == submission_id && receipt.source == SubmissionSource::Scheduler {
560            return Some(receipt);
561        }
562    }
563    None
564}
565
566fn log_blocked_delivery(submission_id: &str, attempts: u64, reason: &str) {
567    if attempts == 1 || attempts.is_power_of_two() {
568        tracing::warn!(
569            submission_id,
570            attempts,
571            reason,
572            "scheduled fire retained for bounded retry"
573        );
574    }
575}
576
577#[allow(dead_code)]
578pub(crate) fn spawn_scheduler_actor(
579    sq_tx: mpsc::Sender<SessionOp>,
580    session_generation: u64,
581    cancel_token: CancellationToken,
582) -> (SchedulerHandle, JoinHandle<()>) {
583    let (cmd_tx, cmd_rx) = mpsc::channel(SCHEDULER_COMMAND_CAPACITY);
584    let handle = SchedulerHandle::new(cmd_tx);
585    let actor = SchedulerActor::new(cmd_rx, sq_tx, session_generation, cancel_token);
586    let join = tokio::spawn(async move { actor.run().await });
587    (handle, join)
588}
589
590pub fn create_delay_tool_and_scheduler() -> (Arc<dyn AgentTool>, PendingSchedulerActor) {
591    let (cmd_tx, cmd_rx) = mpsc::channel(SCHEDULER_COMMAND_CAPACITY);
592    let handle = SchedulerHandle::new(cmd_tx);
593    let tool: Arc<dyn AgentTool> = Arc::new(DelayTool::new(handle));
594    (tool, PendingSchedulerActor { cmd_rx })
595}
596
597pub fn create_scheduler_tools() -> (Vec<Arc<dyn AgentTool>>, PendingSchedulerActor) {
598    let (cmd_tx, cmd_rx) = mpsc::channel(SCHEDULER_COMMAND_CAPACITY);
599    let handle = SchedulerHandle::new(cmd_tx);
600    let tools: Vec<Arc<dyn AgentTool>> = vec![
601        Arc::new(DelayTool::new(handle.clone())),
602        Arc::new(ScheduleTool::new(handle.clone())),
603        Arc::new(ListScheduledTasksTool::new(handle.clone())),
604        Arc::new(CancelScheduledTaskTool::new(handle)),
605    ];
606    (tools, PendingSchedulerActor { cmd_rx })
607}
608
609pub struct PendingSchedulerActor {
610    cmd_rx: mpsc::Receiver<ScheduleCommand>,
611}
612
613impl PendingSchedulerActor {
614    pub fn spawn(
615        self,
616        sq_tx: mpsc::Sender<SessionOp>,
617        session_generation: u64,
618        cancel_token: CancellationToken,
619    ) -> JoinHandle<()> {
620        let actor = SchedulerActor::new(self.cmd_rx, sq_tx, session_generation, cancel_token);
621        tokio::spawn(async move { actor.run().await })
622    }
623}
624
625impl fmt::Debug for PendingSchedulerActor {
626    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
627        f.debug_struct("PendingSchedulerActor")
628            .field("cmd_rx", &"mpsc::Receiver<ScheduleCommand>")
629            .finish()
630    }
631}
632
633pub(crate) struct DelayTool {
634    handle: SchedulerHandle,
635}
636
637impl DelayTool {
638    pub(crate) fn new(handle: SchedulerHandle) -> Self {
639        Self { handle }
640    }
641}
642
643#[async_trait]
644impl AgentTool for DelayTool {
645    fn name(&self) -> &str {
646        "delay"
647    }
648
649    fn description(&self) -> &str {
650        "Schedule a one-shot delayed follow-up message. Delivery transfers only after the Session Actor durably accepts the exact scheduled fire. Session-scoped; minimum 1 second, maximum 86400 seconds."
651    }
652
653    fn parameters(&self) -> serde_json::Value {
654        serde_json::json!({
655            "type": "object",
656            "properties": {
657                "message": {"type": "string", "description": "Follow-up message."},
658                "delay_secs": {
659                    "type": "integer",
660                    "minimum": MIN_DELAY_SECS,
661                    "maximum": MAX_DELAY_SECS
662                }
663            },
664            "required": ["message", "delay_secs"]
665        })
666    }
667
668    async fn execute(&self, input: serde_json::Value) -> ToolResult {
669        let message = match input.get("message").and_then(|value| value.as_str()) {
670            Some(message) if !message.is_empty() => message.to_owned(),
671            _ => return ToolResult::error("missing or empty 'message' field"),
672        };
673        let delay_secs = match input.get("delay_secs").and_then(|value| value.as_u64()) {
674            Some(delay_secs) => delay_secs,
675            None => {
676                return ToolResult::error(
677                    "missing or invalid 'delay_secs' field (expected a positive integer)",
678                );
679            }
680        };
681        if let Err(reason) = validate_delay_secs(delay_secs) {
682            return ToolResult::error(reason);
683        }
684
685        let (response_tx, response_rx) = oneshot::channel();
686        if let Err(error) = self
687            .handle
688            .send(ScheduleCommand::RegisterOneShot {
689                id: None,
690                message,
691                delay: Duration::from_secs(delay_secs),
692                response_tx,
693            })
694            .await
695        {
696            return scheduler_send_error(error);
697        }
698        registration_result(response_rx.await, format!("Delay: {delay_secs} second(s)"))
699    }
700
701    fn nature(&self) -> ToolNature {
702        ToolNature::Execute
703    }
704
705    fn family(&self) -> ToolFamily {
706        ToolFamily::Extension
707    }
708}
709
710pub(crate) struct ScheduleTool {
711    handle: SchedulerHandle,
712}
713
714impl ScheduleTool {
715    pub(crate) fn new(handle: SchedulerHandle) -> Self {
716        Self { handle }
717    }
718}
719
720#[async_trait]
721impl AgentTool for ScheduleTool {
722    fn name(&self) -> &str {
723        "schedule"
724    }
725
726    fn description(&self) -> &str {
727        "Schedule a recurring follow-up. Each fire uses a stable structured identity and the next interval does not begin until Actor durable acceptance. Session-scoped; interval 5 to 3600 seconds."
728    }
729
730    fn parameters(&self) -> serde_json::Value {
731        serde_json::json!({
732            "type": "object",
733            "properties": {
734                "message": {"type": "string", "description": "Recurring follow-up message."},
735                "interval_secs": {
736                    "type": "integer",
737                    "minimum": MIN_INTERVAL_SECS,
738                    "maximum": MAX_INTERVAL_SECS
739                }
740            },
741            "required": ["message", "interval_secs"]
742        })
743    }
744
745    async fn execute(&self, input: serde_json::Value) -> ToolResult {
746        let message = match input.get("message").and_then(|value| value.as_str()) {
747            Some(message) if !message.is_empty() => message.to_owned(),
748            _ => return ToolResult::error("missing or empty 'message' field"),
749        };
750        let interval_secs = match input.get("interval_secs").and_then(|value| value.as_u64()) {
751            Some(interval_secs) => interval_secs,
752            None => {
753                return ToolResult::error(
754                    "missing or invalid 'interval_secs' field (expected a positive integer)",
755                );
756            }
757        };
758        if let Err(reason) = validate_interval_secs(interval_secs) {
759            return ToolResult::error(reason);
760        }
761
762        let (response_tx, response_rx) = oneshot::channel();
763        if let Err(error) = self
764            .handle
765            .send(ScheduleCommand::RegisterRecurring {
766                id: None,
767                message,
768                interval: Duration::from_secs(interval_secs),
769                response_tx,
770            })
771            .await
772        {
773            return scheduler_send_error(error);
774        }
775        registration_result(
776            response_rx.await,
777            format!("Interval: {interval_secs} second(s)"),
778        )
779    }
780
781    fn nature(&self) -> ToolNature {
782        ToolNature::Execute
783    }
784
785    fn family(&self) -> ToolFamily {
786        ToolFamily::Extension
787    }
788}
789
790pub(crate) struct ListScheduledTasksTool {
791    handle: SchedulerHandle,
792}
793
794impl ListScheduledTasksTool {
795    pub(crate) fn new(handle: SchedulerHandle) -> Self {
796        Self { handle }
797    }
798}
799
800#[async_trait]
801impl AgentTool for ListScheduledTasksTool {
802    fn name(&self) -> &str {
803        "list_scheduled_tasks"
804    }
805
806    fn description(&self) -> &str {
807        "List active scheduled tasks, including whether a due fire is blocked awaiting durable Actor custody."
808    }
809
810    fn parameters(&self) -> serde_json::Value {
811        serde_json::json!({"type": "object", "properties": {}})
812    }
813
814    async fn execute(&self, _input: serde_json::Value) -> ToolResult {
815        let (response_tx, response_rx) = oneshot::channel();
816        if let Err(error) = self
817            .handle
818            .send(ScheduleCommand::List { response_tx })
819            .await
820        {
821            return scheduler_send_error(error);
822        }
823        match response_rx.await {
824            Ok(tasks) if tasks.is_empty() => ToolResult::success("No active scheduled tasks."),
825            Ok(tasks) => {
826                const MAX_DISPLAY: usize = 20;
827                let total = tasks.len();
828                let mut text = format!("{total} active task(s):\n");
829                for info in tasks.iter().take(MAX_DISPLAY) {
830                    text.push_str(&format!(
831                        "  {} | {} | next: {}s | {}\n",
832                        info.id,
833                        info.kind,
834                        info.remaining().as_secs(),
835                        info.delivery_state(),
836                    ));
837                }
838                let omitted = total.saturating_sub(MAX_DISPLAY);
839                if omitted > 0 {
840                    text.push_str(&format!("... and {omitted} more task(s) not shown\n"));
841                }
842                ToolResult::success(text.trim_end().to_owned())
843            }
844            Err(_) => ToolResult::error("scheduler dropped the request"),
845        }
846    }
847
848    fn nature(&self) -> ToolNature {
849        ToolNature::Read
850    }
851
852    fn family(&self) -> ToolFamily {
853        ToolFamily::Extension
854    }
855}
856
857pub(crate) struct CancelScheduledTaskTool {
858    handle: SchedulerHandle,
859}
860
861impl CancelScheduledTaskTool {
862    pub(crate) fn new(handle: SchedulerHandle) -> Self {
863        Self { handle }
864    }
865}
866
867#[async_trait]
868impl AgentTool for CancelScheduledTaskTool {
869    fn name(&self) -> &str {
870        "cancel_scheduled_task"
871    }
872
873    fn description(&self) -> &str {
874        "Cancel an active scheduled follow-up by task ID."
875    }
876
877    fn parameters(&self) -> serde_json::Value {
878        serde_json::json!({
879            "type": "object",
880            "properties": {"task_id": {"type": "string"}},
881            "required": ["task_id"]
882        })
883    }
884
885    async fn execute(&self, input: serde_json::Value) -> ToolResult {
886        let task_id = match input.get("task_id").and_then(|value| value.as_str()) {
887            Some(task_id) if !task_id.is_empty() => task_id.to_owned(),
888            _ => return ToolResult::error("missing or empty 'task_id' field"),
889        };
890        let (response_tx, response_rx) = oneshot::channel();
891        if let Err(error) = self
892            .handle
893            .send(ScheduleCommand::Cancel {
894                id: task_id.clone(),
895                response_tx,
896            })
897            .await
898        {
899            return scheduler_send_error(error);
900        }
901        match response_rx.await {
902            Ok(CancelResult::Cancelled) => {
903                ToolResult::success(format!("Task {task_id} cancelled."))
904            }
905            Ok(CancelResult::NotFound) => {
906                ToolResult::success(format!("Task {task_id} not found or already completed."))
907            }
908            Err(_) => ToolResult::error("scheduler dropped the request"),
909        }
910    }
911
912    fn nature(&self) -> ToolNature {
913        ToolNature::Execute
914    }
915
916    fn family(&self) -> ToolFamily {
917        ToolFamily::Extension
918    }
919
920    fn summary_fields(&self) -> &'static [&'static str] {
921        &["task_id"]
922    }
923}
924
925fn scheduler_send_error(error: SchedulerSendError) -> ToolResult {
926    match error {
927        SchedulerSendError::Full => ToolResult::error("scheduler is busy; try again"),
928        SchedulerSendError::Closed => ToolResult::error("scheduler is not available"),
929    }
930}
931
932fn registration_result(
933    result: Result<ScheduleRegistrationResult, oneshot::error::RecvError>,
934    timing: String,
935) -> ToolResult {
936    match result {
937        Ok(ScheduleRegistrationResult::Registered { task_id }) => ToolResult::success(format!(
938            "Scheduled follow-up registered.\nTask ID: {task_id}\n{timing}"
939        )),
940        Ok(ScheduleRegistrationResult::InvalidDuration { reason })
941        | Ok(ScheduleRegistrationResult::InvalidMessage { reason }) => ToolResult::error(reason),
942        Err(_) => ToolResult::error("scheduler dropped the request"),
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use talos_core::session::{PendingSubmissionState, SubmissionRejectionReason};
950
951    async fn yield_times(times: usize) {
952        for _ in 0..times {
953            tokio::task::yield_now().await;
954        }
955    }
956
957    async fn advance_delivery_retry() {
958        // `advance` only moves timers that already exist. Give the delivery
959        // future time to arm its receipt timeout, move that timeout, then let
960        // it arm the separate retry sleep before moving the clock again.
961        yield_times(10).await;
962        tokio::time::advance(DELIVERY_RECEIPT_TIMEOUT).await;
963        yield_times(10).await;
964        tokio::time::advance(DELIVERY_RETRY_DELAY).await;
965        yield_times(20).await;
966    }
967
968    async fn register_one_shot(handle: &SchedulerHandle, id: &str, delay: Duration) {
969        let (response_tx, response_rx) = oneshot::channel();
970        handle
971            .send(ScheduleCommand::RegisterOneShot {
972                id: Some(id.to_owned()),
973                message: "check the build".into(),
974                delay,
975                response_tx,
976            })
977            .await
978            .expect("operation should succeed");
979        assert!(matches!(
980            response_rx.await.expect("operation should succeed"),
981            ScheduleRegistrationResult::Registered { .. }
982        ));
983    }
984
985    fn split_tracked_operation(
986        operation: SessionOp,
987    ) -> (
988        bool,
989        StructuredSubmission,
990        mpsc::UnboundedSender<SubmissionReceipt>,
991    ) {
992        match operation {
993            SessionOp::SubmitStructuredTracked {
994                submission,
995                receipt_tx: Some(receipt_tx),
996            } => (true, submission, receipt_tx),
997            SessionOp::ReconcileStructuredTracked {
998                submission,
999                receipt_tx: Some(receipt_tx),
1000            } => (false, submission, receipt_tx),
1001            other => panic!("expected tracked structured operation, got {other:?}"),
1002        }
1003    }
1004
1005    fn accept(
1006        submission: &StructuredSubmission,
1007        receipt_tx: &mpsc::UnboundedSender<SubmissionReceipt>,
1008        disposition: SubmissionReceiptDisposition,
1009    ) {
1010        receipt_tx
1011            .send(SubmissionReceipt {
1012                session_id: "session-test".into(),
1013                session_generation: submission.sender_generation,
1014                submission_id: submission.id.clone(),
1015                reservation_id: submission.id.clone(),
1016                receipt_id: "receipt-test".into(),
1017                source: submission.source,
1018                item_count: submission.items.len(),
1019                total_text_bytes: submission.total_text_bytes(),
1020                disposition,
1021            })
1022            .expect("operation should succeed");
1023    }
1024
1025    async fn list(handle: &SchedulerHandle) -> Vec<ScheduledTaskInfo> {
1026        let (response_tx, response_rx) = oneshot::channel();
1027        handle
1028            .send(ScheduleCommand::List { response_tx })
1029            .await
1030            .expect("operation should succeed");
1031        response_rx.await.expect("operation should succeed")
1032    }
1033
1034    #[test]
1035    fn validates_bounds_and_label() {
1036        assert!(validate_delay_secs(MIN_DELAY_SECS).is_ok());
1037        assert!(validate_delay_secs(0).is_err());
1038        assert!(validate_interval_secs(MIN_INTERVAL_SECS).is_ok());
1039        assert!(validate_interval_secs(MIN_INTERVAL_SECS - 1).is_err());
1040        assert_eq!(
1041            label_scheduled_message("check"),
1042            "[scheduled-followup] check"
1043        );
1044    }
1045
1046    #[tokio::test(start_paused = true)]
1047    async fn scheduled_fire_uses_the_bound_actor_generation() {
1048        let (sq_tx, mut sq_rx) = mpsc::channel(8);
1049        let (handle, _join) = spawn_scheduler_actor(sq_tx, 7, CancellationToken::new());
1050        register_one_shot(&handle, "generation-bound", Duration::from_secs(1)).await;
1051
1052        tokio::time::advance(Duration::from_secs(2)).await;
1053        yield_times(10).await;
1054        let (is_submit, submission, receipt_tx) =
1055            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1056        assert!(is_submit);
1057        assert_eq!(submission.source, SubmissionSource::Scheduler);
1058        assert_eq!(submission.sender_generation, 7);
1059
1060        accept(
1061            &submission,
1062            &receipt_tx,
1063            SubmissionReceiptDisposition::AcceptedPending,
1064        );
1065        yield_times(10).await;
1066        assert!(list(&handle).await.is_empty());
1067    }
1068
1069    #[tokio::test(start_paused = true)]
1070    async fn one_shot_transfers_only_after_durable_receipt() {
1071        let (sq_tx, mut sq_rx) = mpsc::channel(8);
1072        let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1073        register_one_shot(&handle, "one", Duration::from_secs(1)).await;
1074
1075        tokio::time::advance(Duration::from_secs(2)).await;
1076        yield_times(10).await;
1077        let (is_submit, submission, receipt_tx) =
1078            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1079        assert!(is_submit);
1080        assert_eq!(submission.source, SubmissionSource::Scheduler);
1081        assert!(
1082            submission.items[0]
1083                .text
1084                .starts_with(SCHEDULED_FOLLOWUP_LABEL)
1085        );
1086        assert_eq!(list(&handle).await.len(), 1);
1087
1088        accept(
1089            &submission,
1090            &receipt_tx,
1091            SubmissionReceiptDisposition::AcceptedPending,
1092        );
1093        yield_times(10).await;
1094        assert!(list(&handle).await.is_empty());
1095    }
1096
1097    #[tokio::test(start_paused = true)]
1098    async fn lost_ack_reconciles_the_exact_fire_identity() {
1099        let (sq_tx, mut sq_rx) = mpsc::channel(8);
1100        let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1101        register_one_shot(&handle, "lost-ack", Duration::from_secs(1)).await;
1102
1103        tokio::time::advance(Duration::from_secs(2)).await;
1104        yield_times(10).await;
1105        let (_, submitted, _lost_receipt_tx) =
1106            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1107
1108        advance_delivery_retry().await;
1109        let (is_submit, reconciled, receipt_tx) =
1110            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1111        assert!(!is_submit);
1112        assert_eq!(reconciled, submitted);
1113
1114        accept(
1115            &reconciled,
1116            &receipt_tx,
1117            SubmissionReceiptDisposition::AlreadyAccepted {
1118                state: PendingSubmissionState::Running,
1119                turn_id: Some("turn-1".into()),
1120            },
1121        );
1122        yield_times(10).await;
1123        assert!(list(&handle).await.is_empty());
1124    }
1125
1126    #[tokio::test(start_paused = true)]
1127    async fn closed_session_queue_retains_a_blocked_fire() {
1128        let (sq_tx, sq_rx) = mpsc::channel(1);
1129        let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1130        drop(sq_rx);
1131        register_one_shot(&handle, "closed", Duration::from_secs(1)).await;
1132
1133        tokio::time::advance(Duration::from_secs(5)).await;
1134        yield_times(20).await;
1135        let tasks = list(&handle).await;
1136        assert_eq!(tasks.len(), 1);
1137        assert_eq!(tasks[0].delivery_state(), "delivery-blocked");
1138    }
1139
1140    #[tokio::test(start_paused = true)]
1141    async fn rejection_retries_without_changing_identity() {
1142        let (sq_tx, mut sq_rx) = mpsc::channel(8);
1143        let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1144        register_one_shot(&handle, "rejected", Duration::from_secs(1)).await;
1145
1146        tokio::time::advance(Duration::from_secs(2)).await;
1147        yield_times(10).await;
1148        let (_, first, receipt_tx) =
1149            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1150        accept(
1151            &first,
1152            &receipt_tx,
1153            SubmissionReceiptDisposition::Rejected {
1154                reason: SubmissionRejectionReason::LimitExceeded,
1155            },
1156        );
1157
1158        yield_times(10).await;
1159        tokio::time::advance(DELIVERY_RETRY_DELAY).await;
1160        yield_times(20).await;
1161        let (is_submit, retry, _) =
1162            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1163        assert!(is_submit);
1164        assert_eq!(retry, first);
1165        assert_eq!(list(&handle).await.len(), 1);
1166    }
1167
1168    #[tokio::test(start_paused = true)]
1169    async fn recurring_does_not_replace_an_unresolved_fire() {
1170        let (sq_tx, mut sq_rx) = mpsc::channel(32);
1171        let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1172        let (response_tx, response_rx) = oneshot::channel();
1173        handle
1174            .send(ScheduleCommand::RegisterRecurring {
1175                id: Some("recurring".into()),
1176                message: "tick".into(),
1177                interval: Duration::from_secs(5),
1178                response_tx,
1179            })
1180            .await
1181            .expect("operation should succeed");
1182        assert!(response_rx.await.is_ok());
1183
1184        tokio::time::advance(Duration::from_secs(6)).await;
1185        yield_times(10).await;
1186        let (_, first, _) =
1187            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1188
1189        advance_delivery_retry().await;
1190        let (is_submit, retry, receipt_tx) =
1191            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1192        assert!(!is_submit);
1193        assert_eq!(
1194            retry.id, first.id,
1195            "later interval replaced unresolved fire"
1196        );
1197        assert!(sq_rx.try_recv().is_err());
1198
1199        accept(
1200            &retry,
1201            &receipt_tx,
1202            SubmissionReceiptDisposition::AcceptedPending,
1203        );
1204        yield_times(20).await;
1205
1206        tokio::time::advance(Duration::from_secs(4)).await;
1207        yield_times(10).await;
1208        assert!(sq_rx.try_recv().is_err());
1209        tokio::time::advance(Duration::from_secs(2)).await;
1210        yield_times(10).await;
1211        let (_, second, _) =
1212            split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1213        assert_ne!(second.id, first.id);
1214        assert!(second.id.ends_with(":fire:2"));
1215    }
1216
1217    #[tokio::test(start_paused = true)]
1218    async fn cancellation_aborts_an_unaccepted_fire() {
1219        let (sq_tx, mut sq_rx) = mpsc::channel(8);
1220        let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1221        register_one_shot(&handle, "cancel", Duration::from_secs(1)).await;
1222        tokio::time::advance(Duration::from_secs(2)).await;
1223        yield_times(10).await;
1224        let _ = sq_rx.try_recv().expect("operation should succeed");
1225
1226        let (response_tx, response_rx) = oneshot::channel();
1227        handle
1228            .send(ScheduleCommand::Cancel {
1229                id: "cancel".into(),
1230                response_tx,
1231            })
1232            .await
1233            .expect("operation should succeed");
1234        assert!(matches!(
1235            response_rx.await.expect("operation should succeed"),
1236            CancelResult::Cancelled
1237        ));
1238        tokio::time::advance(Duration::from_secs(10)).await;
1239        yield_times(20).await;
1240        assert!(sq_rx.try_recv().is_err());
1241    }
1242
1243    #[test]
1244    fn tool_natures_remain_permission_safe() {
1245        let (tools, _pending) = create_scheduler_tools();
1246        assert_eq!(tools[0].nature(), ToolNature::Execute);
1247        assert_eq!(tools[1].nature(), ToolNature::Execute);
1248        assert_eq!(tools[2].nature(), ToolNature::Read);
1249        assert_eq!(tools[3].nature(), ToolNature::Execute);
1250    }
1251}