Skip to main content

awaken_server/services/
run_control_service.rs

1//! Framework-level run control operations.
2//!
3//! This service centralizes the semantics used by HTTP routes and protocol
4//! adapters for active-run lookup, cancellation, interrupt, HITL decisions, and
5//! user input injection.
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use awaken_runtime::RunActivation;
11use awaken_server_contract::contract::lifecycle::RunStatus;
12use awaken_server_contract::contract::mailbox::MailboxInterrupt;
13use awaken_server_contract::contract::message::Message;
14use awaken_server_contract::contract::storage::{
15    RunQuery, RunRecord, RunWaitingState, StorageError,
16};
17use awaken_server_contract::contract::suspension::ToolCallResume;
18
19use crate::app::RunModuleState;
20use crate::mailbox::{Mailbox, MailboxError, MailboxSubmitResult};
21use std::sync::Arc;
22
23/// How injected user input should interact with any active work.
24#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum InputMode {
27    /// Queue the new input behind the current active run.
28    #[default]
29    Queue,
30    /// Interrupt the active run for the thread, then queue the new input.
31    InterruptThenQueue,
32    /// Append input to the current open waiting run and continue the same run ID.
33    ResumeOpenRun,
34}
35
36/// Thread interrupt policy.
37#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum InterruptMode {
40    /// Request cooperative cancellation and supersede queued mailbox dispatches.
41    #[default]
42    Graceful,
43}
44
45/// A run that is still controllable.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct ActiveRun {
48    pub thread_id: String,
49    pub run_id: String,
50    pub agent_id: String,
51    pub status: RunStatus,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub termination_reason: Option<awaken_server_contract::contract::lifecycle::TerminationReason>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub dispatch_id: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub session_id: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub waiting: Option<RunWaitingState>,
60}
61
62impl From<RunRecord> for ActiveRun {
63    fn from(run: RunRecord) -> Self {
64        Self {
65            thread_id: run.thread_id,
66            run_id: run.run_id,
67            agent_id: run.agent_id,
68            status: run.status,
69            termination_reason: run.termination_reason,
70            dispatch_id: run.dispatch_id,
71            session_id: run.session_id,
72            waiting: run.waiting,
73        }
74    }
75}
76
77/// Errors raised by framework run-control operations.
78#[derive(Debug, Error)]
79pub enum RunControlError {
80    #[error("thread not found: {0}")]
81    ThreadNotFound(String),
82    #[error("run not found: {0}")]
83    RunNotFound(String),
84    #[error("decision target not found: {0}")]
85    DecisionTargetNotFound(String),
86    #[error("storage error: {0}")]
87    Store(#[from] StorageError),
88    #[error("mailbox error: {0}")]
89    Mailbox(#[from] MailboxError),
90}
91
92/// Unified control plane for active runs.
93#[derive(Clone)]
94pub struct RunControlService {
95    state: RunModuleState,
96}
97
98impl RunControlService {
99    pub fn new(state: RunModuleState) -> Self {
100        Self { state }
101    }
102
103    fn mailbox(&self) -> Arc<Mailbox> {
104        self.state.mailbox()
105    }
106
107    /// Return the most recent running or waiting run for a thread.
108    pub async fn get_active_run(
109        &self,
110        thread_id: &str,
111    ) -> Result<Option<ActiveRun>, RunControlError> {
112        if let Some(thread) = self.state.store().load_thread(thread_id).await? {
113            if let Some(active) = self
114                .load_projected_run(
115                    thread_id,
116                    thread.active_run_id.as_deref(),
117                    &[RunStatus::Running],
118                )
119                .await?
120            {
121                return Ok(Some(active));
122            }
123
124            if let Some(open) = self
125                .load_projected_run(
126                    thread_id,
127                    thread.open_run_id.as_deref(),
128                    &[RunStatus::Running, RunStatus::Waiting],
129                )
130                .await?
131            {
132                return Ok(Some(open));
133            }
134        }
135
136        self.scan_active_run(thread_id).await
137    }
138
139    async fn load_projected_run(
140        &self,
141        thread_id: &str,
142        run_id: Option<&str>,
143        allowed_statuses: &[RunStatus],
144    ) -> Result<Option<ActiveRun>, RunControlError> {
145        let Some(run_id) = run_id else {
146            return Ok(None);
147        };
148        let Some(run) = self.state.store().load_run(run_id).await? else {
149            return Ok(None);
150        };
151        if run.thread_id == thread_id && allowed_statuses.contains(&run.status) {
152            Ok(Some(ActiveRun::from(run)))
153        } else {
154            Ok(None)
155        }
156    }
157
158    async fn scan_active_run(&self, thread_id: &str) -> Result<Option<ActiveRun>, RunControlError> {
159        let mut candidates = Vec::new();
160        for status in [RunStatus::Running, RunStatus::Waiting] {
161            let page = self
162                .state
163                .store()
164                .list_runs(&RunQuery {
165                    offset: 0,
166                    limit: 200,
167                    thread_id: Some(thread_id.to_string()),
168                    status: Some(status),
169                    id_prefix: None,
170                })
171                .await?;
172            candidates.extend(page.items);
173        }
174
175        Ok(candidates
176            .into_iter()
177            .max_by_key(|run| run.updated_at)
178            .map(ActiveRun::from))
179    }
180
181    /// Submit a tool-call decision to a waiting active run.
182    ///
183    /// Remote live delivery is at-least-once when its ack is lost before the
184    /// durable fallback is enqueued; `(tool_call_id, decision_id)` identifies
185    /// duplicate decisions.
186    pub async fn decide(
187        &self,
188        id: &str,
189        tool_call_id: String,
190        resume: ToolCallResume,
191    ) -> Result<(), RunControlError> {
192        let mailbox = self.mailbox();
193        if mailbox
194            .send_decision_live(
195                &self.state.scoped_id(id),
196                tool_call_id.clone(),
197                resume.clone(),
198            )
199            .await?
200        {
201            Ok(())
202        } else {
203            self.enqueue_durable_decision(id, tool_call_id, resume)
204                .await
205        }
206    }
207
208    async fn enqueue_durable_decision(
209        &self,
210        id: &str,
211        tool_call_id: String,
212        resume: ToolCallResume,
213    ) -> Result<(), RunControlError> {
214        let run = if let Some(run) = self.state.store().load_run(id).await? {
215            run
216        } else if let Some(active) = self.get_active_run(id).await? {
217            self.state
218                .store()
219                .load_run(&active.run_id)
220                .await?
221                .ok_or_else(|| RunControlError::RunNotFound(active.run_id.clone()))?
222        } else {
223            return Err(RunControlError::DecisionTargetNotFound(id.to_string()));
224        };
225
226        if run.status != RunStatus::Waiting
227            || !run.is_resumable_waiting()
228            || !waiting_contains_ticket(&run, &tool_call_id)
229        {
230            return Err(RunControlError::DecisionTargetNotFound(id.to_string()));
231        }
232
233        let request = RunActivation::new(run.thread_id.clone(), Vec::new())
234            .with_agent_id(run.agent_id.clone())
235            .with_continue_run_id(run.run_id.clone())
236            .with_decisions(vec![(tool_call_id.clone(), resume.clone())]);
237        let mailbox = self.mailbox();
238        mailbox
239            .submit_background(self.state.scope_activation(request))
240            .await?;
241        mailbox
242            .record_mailbox_decision_received_for_run(
243                &run,
244                &tool_call_id,
245                &resume,
246                "durable_dispatch",
247            )
248            .await;
249        Ok(())
250    }
251
252    /// Cancel an active run or queued mailbox dispatch by run ID, dispatch ID, or thread ID.
253    pub async fn cancel_run(&self, id: &str) -> Result<(), RunControlError> {
254        if self.mailbox().cancel(&self.state.scoped_id(id)).await? {
255            Ok(())
256        } else {
257            Err(RunControlError::RunNotFound(id.to_string()))
258        }
259    }
260
261    /// Interrupt a thread, superseding queued work and cancelling the active run.
262    pub async fn interrupt_thread(
263        &self,
264        thread_id: &str,
265        _mode: InterruptMode,
266    ) -> Result<MailboxInterrupt, RunControlError> {
267        let interrupted = self
268            .mailbox()
269            .interrupt(&self.state.scoped_id(thread_id))
270            .await?;
271        if interrupted.active_dispatch.is_some() || interrupted.superseded_count > 0 {
272            Ok(self.state.unscope_interrupt(interrupted))
273        } else {
274            Err(RunControlError::ThreadNotFound(thread_id.to_string()))
275        }
276    }
277
278    /// Inject messages into a thread, optionally interrupting the active run first.
279    pub async fn inject_user_input(
280        &self,
281        thread_id: &str,
282        agent_id: Option<String>,
283        messages: Vec<Message>,
284        mode: InputMode,
285    ) -> Result<MailboxSubmitResult, RunControlError> {
286        let thread = self
287            .state
288            .store()
289            .load_thread(thread_id)
290            .await?
291            .ok_or_else(|| RunControlError::ThreadNotFound(thread_id.to_string()))?;
292
293        if mode == InputMode::InterruptThenQueue {
294            let _ = self
295                .mailbox()
296                .interrupt(&self.state.scoped_id(thread_id))
297                .await
298                .map_err(RunControlError::Mailbox)?;
299        }
300
301        let mut request = RunActivation::new(thread_id.to_string(), messages);
302        if mode == InputMode::ResumeOpenRun {
303            let run = self
304                .load_open_waiting_run(thread_id, thread.open_run_id.as_deref())
305                .await?;
306            request = request
307                .with_agent_id(run.agent_id)
308                .with_continue_run_id(run.run_id);
309        } else if let Some(agent_id) = agent_id {
310            request = request.with_agent_id(agent_id);
311        }
312
313        self.mailbox()
314            .submit_background(self.state.scope_activation(request))
315            .await
316            .map(|result| self.state.unscope_submit_result(result))
317            .map_err(RunControlError::Mailbox)
318    }
319
320    /// Inject messages into the active run when possible, otherwise queue them.
321    pub async fn inject_user_input_live_then_queue(
322        &self,
323        thread_id: &str,
324        agent_id: Option<String>,
325        messages: Vec<Message>,
326    ) -> Result<MailboxSubmitResult, RunControlError> {
327        let _thread = self
328            .state
329            .store()
330            .load_thread(thread_id)
331            .await?
332            .ok_or_else(|| RunControlError::ThreadNotFound(thread_id.to_string()))?;
333
334        let mut request = RunActivation::new(thread_id.to_string(), messages);
335        if let Some(agent_id) = agent_id {
336            request = request.with_agent_id(agent_id);
337        }
338
339        self.mailbox()
340            .submit_live_then_queue(self.state.scope_activation(request), None)
341            .await
342            .map(|result| self.state.unscope_submit_result(result))
343            .map_err(RunControlError::Mailbox)
344    }
345
346    /// Inject messages using an existing run as the thread and agent anchor.
347    pub async fn inject_run_input(
348        &self,
349        run_id: &str,
350        messages: Vec<Message>,
351        mode: InputMode,
352    ) -> Result<MailboxSubmitResult, RunControlError> {
353        let run = self
354            .state
355            .store()
356            .load_run(run_id)
357            .await?
358            .ok_or_else(|| RunControlError::RunNotFound(run_id.to_string()))?;
359
360        if mode == InputMode::InterruptThenQueue {
361            return self
362                .inject_user_input(&run.thread_id, Some(run.agent_id), messages, mode)
363                .await;
364        }
365
366        if run.status == RunStatus::Waiting && run.is_resumable_waiting() {
367            let request = RunActivation::new(run.thread_id.clone(), messages)
368                .with_agent_id(run.agent_id)
369                .with_continue_run_id(run.run_id);
370            return self
371                .mailbox()
372                .submit_background(self.state.scope_activation(request))
373                .await
374                .map(|result| self.state.unscope_submit_result(result))
375                .map_err(RunControlError::Mailbox);
376        }
377
378        self.inject_user_input(&run.thread_id, Some(run.agent_id), messages, mode)
379            .await
380    }
381
382    /// Inject messages using an existing run as the live-delivery anchor.
383    pub async fn inject_run_input_live_then_queue(
384        &self,
385        run_id: &str,
386        messages: Vec<Message>,
387    ) -> Result<MailboxSubmitResult, RunControlError> {
388        let run = self
389            .state
390            .store()
391            .load_run(run_id)
392            .await?
393            .ok_or_else(|| RunControlError::RunNotFound(run_id.to_string()))?;
394
395        let request =
396            RunActivation::new(run.thread_id.clone(), messages).with_agent_id(run.agent_id.clone());
397        self.mailbox()
398            .submit_live_then_queue(
399                self.state.scope_activation(request),
400                Some(&self.state.scoped_id(&run.run_id)),
401            )
402            .await
403            .map(|result| self.state.unscope_submit_result(result))
404            .map_err(RunControlError::Mailbox)
405    }
406
407    async fn load_open_waiting_run(
408        &self,
409        thread_id: &str,
410        open_run_id: Option<&str>,
411    ) -> Result<RunRecord, RunControlError> {
412        let Some(open_run_id) = open_run_id else {
413            return Err(RunControlError::RunNotFound(format!(
414                "open run for thread {thread_id}"
415            )));
416        };
417        let run = self
418            .state
419            .store()
420            .load_run(open_run_id)
421            .await?
422            .ok_or_else(|| RunControlError::RunNotFound(open_run_id.to_string()))?;
423        if run.thread_id != thread_id
424            || run.status != RunStatus::Waiting
425            || !run.is_resumable_waiting()
426        {
427            return Err(RunControlError::RunNotFound(open_run_id.to_string()));
428        }
429        Ok(run)
430    }
431}
432
433fn waiting_contains_ticket(run: &RunRecord, target: &str) -> bool {
434    let Some(waiting) = run.waiting.as_ref() else {
435        return false;
436    };
437    waiting.ticket_ids.iter().any(|id| id == target)
438        || waiting
439            .tickets
440            .iter()
441            .any(|ticket| ticket.ticket_id == target || ticket.tool_call_id == target)
442}