Skip to main content

awaken_runtime/extensions/background/
manager.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use awaken_runtime_contract::StateError;
6use parking_lot::RwLock;
7use tokio::sync::Mutex;
8use tokio::task::JoinHandle;
9
10mod run_cancellation;
11use crate::cancellation::{CancellationHandle, CancellationToken};
12use crate::inbox::InboxSender;
13use crate::state::{MutationBatch, StateStore};
14
15use super::state::{
16    BackgroundTaskStateAction, BackgroundTaskStateKey, BackgroundTaskStateSnapshot,
17    PersistedTaskMeta,
18};
19use super::types::{
20    AgentTaskContext, TaskContext, TaskEvent, TaskId, TaskParentContext, TaskResult, TaskStatus,
21    TaskSummary,
22};
23use super::{
24    BackgroundTaskExecutionContext, current_background_task_context, current_tool_lineage_context,
25    scope_background_task_context,
26};
27
28/// Errors from [`BackgroundTaskManager::send_task_inbox_message`].
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum SendError {
31    /// No task with this ID exists.
32    TaskNotFound,
33    /// Caller's thread does not own the task.
34    NotOwner,
35    /// Task has already reached a terminal state.
36    TaskTerminated(TaskStatus),
37    /// Task is not a sub-agent (has no inbox).
38    NoInbox,
39    /// Inbox receiver was dropped (sub-agent ended).
40    InboxClosed,
41}
42
43impl std::fmt::Display for SendError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::TaskNotFound => write!(f, "task not found"),
47            Self::NotOwner => write!(f, "caller does not own this task"),
48            Self::TaskTerminated(s) => write!(f, "task already {}", s.as_str()),
49            Self::NoInbox => write!(f, "task has no inbox (not a sub-agent)"),
50            Self::InboxClosed => write!(f, "sub-agent inbox closed"),
51        }
52    }
53}
54
55impl std::error::Error for SendError {}
56
57/// Reserved names that cannot be used as task names.
58const RESERVED_NAMES: &[&str] = &["parent", "self", "all", "broadcast"];
59
60/// Errors from task spawn operations.
61#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
62#[non_exhaustive]
63pub enum SpawnError {
64    /// The name is reserved by the system.
65    #[error("'{0}' is a reserved name")]
66    ReservedName(String),
67    /// Another running task on this thread already has this name.
68    #[error("a running task named '{0}' already exists")]
69    DuplicateName(String),
70    /// No state store has been configured for the manager.
71    #[error("background task state store is not configured")]
72    StoreNotConfigured,
73    /// Commit to the state store failed.
74    #[error(transparent)]
75    State(#[from] StateError),
76    /// The owning agent run has already been cancelled.
77    #[error("parent run '{0}' has already been cancelled")]
78    ParentRunCancelled(String),
79}
80
81#[derive(Debug, thiserror::Error)]
82enum MetaCommitError {
83    #[error("background task state store is not configured")]
84    StoreUnavailable,
85    #[error(transparent)]
86    State(#[from] StateError),
87}
88
89impl From<MetaCommitError> for SpawnError {
90    fn from(err: MetaCommitError) -> Self {
91        match err {
92            MetaCommitError::StoreUnavailable => Self::StoreNotConfigured,
93            MetaCommitError::State(e) => Self::State(e),
94        }
95    }
96}
97
98/// Runtime-only handle for a live background task.
99///
100/// Contains only non-serializable runtime handles (cancel, join, inbox).
101/// All metadata (status, error, result, timestamps) lives in the StateStore
102/// under [`BackgroundTaskStateKey`].
103struct TaskHandle {
104    task_id: TaskId,
105    owner_thread_id: String,
106    cancel_handle: CancellationHandle,
107    _join_handle: JoinHandle<()>,
108    /// Inbox sender for sub-agent tasks (allows parent to send messages).
109    agent_inbox: Option<InboxSender>,
110}
111
112/// Thread-scoped handle table for background tasks.
113///
114/// Spawns, tracks, cancels, and queries background tasks.
115/// Task metadata (status, error, result, timestamps) is stored in the
116/// [`StateStore`] as the single source of truth. This struct only holds
117/// runtime handles (cancel, join, inbox).
118pub struct BackgroundTaskManager {
119    handles: Mutex<HashMap<TaskId, TaskHandle>>,
120    counter: AtomicU64,
121    owner_inbox: RwLock<Option<InboxSender>>,
122    store: std::sync::OnceLock<StateStore>,
123    cancelled_run_ids: RwLock<HashSet<String>>,
124}
125
126impl BackgroundTaskManager {
127    pub fn new() -> Self {
128        Self {
129            handles: Mutex::new(HashMap::new()),
130            counter: AtomicU64::new(0),
131            owner_inbox: RwLock::new(None),
132            store: std::sync::OnceLock::new(),
133            cancelled_run_ids: RwLock::new(HashSet::new()),
134        }
135    }
136
137    /// Set the inbox sender that background tasks receive for pushing messages to the owner thread.
138    pub fn set_owner_inbox(&self, inbox: InboxSender) {
139        *self.owner_inbox.write() = Some(inbox);
140    }
141
142    /// Provide the state store for metadata persistence.
143    ///
144    /// Called once during plugin registration or run start. Subsequent
145    /// calls are silently ignored (OnceLock semantics).
146    pub fn set_store(&self, store: StateStore) {
147        let _ = self.store.set(store);
148    }
149
150    /// Validate a task name: check reserved names and uniqueness.
151    fn validate_name(&self, name: &str, owner_thread_id: &str) -> Result<(), SpawnError> {
152        if RESERVED_NAMES.contains(&name) {
153            return Err(SpawnError::ReservedName(name.to_string()));
154        }
155        // Check uniqueness among running tasks on this thread
156        if let Some(store) = self.store()
157            && let Some(snap) = store.read::<BackgroundTaskStateKey>()
158        {
159            for meta in snap.tasks.values() {
160                if meta.owner_thread_id == owner_thread_id
161                    && !meta.status.is_terminal()
162                    && meta.name.as_deref() == Some(name)
163                {
164                    return Err(SpawnError::DuplicateName(name.to_string()));
165                }
166            }
167        }
168        Ok(())
169    }
170
171    /// Returns a reference to the store, if set.
172    fn store(&self) -> Option<&StateStore> {
173        self.store.get()
174    }
175
176    fn owner_inbox(&self) -> Option<InboxSender> {
177        self.owner_inbox.read().clone()
178    }
179
180    #[cfg(test)]
181    pub(crate) fn panic_while_holding_owner_inbox_lock_for_test(&self) {
182        let _guard = self.owner_inbox.write();
183        panic!("owner_inbox lock test panic");
184    }
185
186    #[cfg(test)]
187    pub(crate) fn has_owner_inbox_for_test(&self) -> bool {
188        self.owner_inbox().is_some()
189    }
190
191    fn next_task_id(&self) -> TaskId {
192        let n = self.counter.fetch_add(1, Ordering::Relaxed);
193        format!("bg_{n}")
194    }
195
196    /// Reserve a unique task id before spawning.
197    ///
198    /// Used by callers that must persist external single-flight state before
199    /// the task closure can emit events.
200    pub(crate) fn reserve_task_id(&self) -> TaskId {
201        self.next_task_id()
202    }
203
204    fn merge_ambient_parent_context(
205        &self,
206        mut parent_context: TaskParentContext,
207    ) -> TaskParentContext {
208        if parent_context.task_id.is_none()
209            && let Some(context) = current_background_task_context()
210        {
211            if parent_context.run_id.is_none() {
212                parent_context.run_id = context.run_id;
213            }
214            parent_context.task_id = Some(context.task_id);
215        }
216
217        if let Some(context) = current_tool_lineage_context() {
218            if parent_context.run_id.is_none() {
219                parent_context.run_id = Some(context.run_id);
220            }
221            if parent_context.call_id.is_none() && !context.call_id.is_empty() {
222                parent_context.call_id = Some(context.call_id);
223            }
224            if parent_context.agent_id.is_none() && !context.agent_id.is_empty() {
225                parent_context.agent_id = Some(context.agent_id);
226            }
227        }
228
229        parent_context
230    }
231
232    /// Commit a state update to the store.
233    fn commit_meta(&self, action: BackgroundTaskStateAction) -> Result<u64, MetaCommitError> {
234        let Some(store) = self.store() else {
235            return Err(MetaCommitError::StoreUnavailable);
236        };
237
238        let mut batch = MutationBatch::new();
239        batch.update::<BackgroundTaskStateKey>(action);
240        store.commit(batch).map_err(Into::into)
241    }
242
243    fn commit_meta_or_warn(
244        &self,
245        action: BackgroundTaskStateAction,
246        operation: &'static str,
247        task_id: &str,
248    ) {
249        if let Err(error) = self.commit_meta(action) {
250            metrics::counter!(
251                "awaken_background_task_state_commit_failures_total",
252                "operation" => operation
253            )
254            .increment(1);
255            tracing::warn!(
256                operation,
257                task_id,
258                error = %error,
259                "background task metadata commit failed"
260            );
261        }
262    }
263
264    fn terminal_event(task_id: &str, result: &TaskResult) -> TaskEvent {
265        match result {
266            TaskResult::Success(val) => TaskEvent::Completed {
267                task_id: task_id.to_string(),
268                result: Some(val.clone()),
269            },
270            TaskResult::Failed(err) => TaskEvent::Failed {
271                task_id: task_id.to_string(),
272                error: err.clone(),
273            },
274            TaskResult::Cancelled => TaskEvent::Cancelled {
275                task_id: task_id.to_string(),
276            },
277        }
278    }
279
280    /// Spawn a background task.
281    ///
282    /// `name` is an optional short identifier for addressing (e.g. "researcher").
283    /// If provided, it must be unique among running tasks on this thread and
284    /// must not be a reserved name ("parent", "self", "all", "broadcast").
285    pub async fn spawn<F, Fut>(
286        self: &Arc<Self>,
287        owner_thread_id: &str,
288        task_type: &str,
289        name: Option<&str>,
290        description: &str,
291        parent_context: TaskParentContext,
292        task_fn: F,
293    ) -> Result<TaskId, SpawnError>
294    where
295        F: FnOnce(TaskContext) -> Fut + Send + 'static,
296        Fut: std::future::Future<Output = TaskResult> + Send + 'static,
297    {
298        let task_id = self.reserve_task_id();
299        self.spawn_with_task_id(
300            task_id,
301            owner_thread_id,
302            task_type,
303            name,
304            description,
305            parent_context,
306            task_fn,
307        )
308        .await
309    }
310
311    #[allow(clippy::too_many_arguments)]
312    pub(crate) async fn spawn_with_task_id<F, Fut>(
313        self: &Arc<Self>,
314        task_id: TaskId,
315        owner_thread_id: &str,
316        task_type: &str,
317        name: Option<&str>,
318        description: &str,
319        parent_context: TaskParentContext,
320        task_fn: F,
321    ) -> Result<TaskId, SpawnError>
322    where
323        F: FnOnce(TaskContext) -> Fut + Send + 'static,
324        Fut: std::future::Future<Output = TaskResult> + Send + 'static,
325    {
326        let parent_context = self.merge_ambient_parent_context(parent_context);
327        if let Some(n) = name {
328            self.validate_name(n, owner_thread_id)?;
329        }
330        if let Some(run_id) = self.is_parent_run_cancelled(&parent_context) {
331            return Err(SpawnError::ParentRunCancelled(run_id));
332        }
333        let (cancel_handle, cancel_token) = CancellationToken::new_pair();
334        let now = now_ms();
335
336        let ctx = TaskContext {
337            task_id: task_id.clone(),
338            cancel_token,
339            inbox: self.owner_inbox(),
340        };
341
342        let task_name = name.map(|n| n.to_string());
343
344        // Commit initial metadata to the store
345        self.commit_meta(BackgroundTaskStateAction::Upsert(Box::new(
346            PersistedTaskMeta {
347                task_id: task_id.clone(),
348                owner_thread_id: owner_thread_id.to_string(),
349                task_type: task_type.to_string(),
350                name: task_name.clone(),
351                description: description.to_string(),
352                status: TaskStatus::Running,
353                error: None,
354                result: None,
355                created_at_ms: now,
356                completed_at_ms: None,
357                parent_context: parent_context.clone(),
358            },
359        )))
360        .map_err(SpawnError::from)?;
361
362        let manager = Arc::clone(self);
363        let tid = task_id.clone();
364        let owner_inbox = self.owner_inbox();
365        let owner = owner_thread_id.to_string();
366        let ttype = task_type.to_string();
367        let tname = task_name.clone();
368        let desc = description.to_string();
369        let parent_context_for_task = parent_context.clone();
370
371        let join_handle = tokio::spawn(async move {
372            let result = scope_background_task_context(
373                BackgroundTaskExecutionContext {
374                    manager: manager.clone(),
375                    task_id: tid.clone(),
376                    run_id: parent_context_for_task.run_id.clone(),
377                },
378                task_fn(ctx),
379            )
380            .await;
381            let completed_at = now_ms();
382
383            // Update metadata in the store
384            let (status, error, result_val) = match &result {
385                TaskResult::Success(val) => (TaskStatus::Completed, None, Some(val.clone())),
386                TaskResult::Failed(err) => (TaskStatus::Failed, Some(err.clone()), None),
387                TaskResult::Cancelled => (TaskStatus::Cancelled, None, None),
388            };
389
390            manager.commit_meta_or_warn(
391                BackgroundTaskStateAction::Upsert(Box::new(PersistedTaskMeta {
392                    task_id: tid.clone(),
393                    owner_thread_id: owner,
394                    task_type: ttype,
395                    name: tname,
396                    description: desc,
397                    status,
398                    error,
399                    result: result_val,
400                    created_at_ms: now,
401                    completed_at_ms: Some(completed_at),
402                    parent_context: parent_context_for_task,
403                })),
404                "task_completion",
405                &tid,
406            );
407
408            // Notify owner via inbox (terminal event).
409            if let Some(ref inbox) = owner_inbox {
410                let event = Self::terminal_event(&tid, &result);
411                inbox.send(
412                    serde_json::to_value(&event).expect("TaskEvent serialization is infallible"),
413                );
414            }
415        });
416
417        let handle = TaskHandle {
418            task_id: task_id.clone(),
419            owner_thread_id: owner_thread_id.to_string(),
420            cancel_handle,
421            _join_handle: join_handle,
422            agent_inbox: None,
423        };
424
425        self.insert_handle_and_cancel_if_parent_run_cancelled(
426            task_id.clone(),
427            handle,
428            &parent_context,
429        )
430        .await;
431        Ok(task_id)
432    }
433
434    /// Cancel a running task.
435    pub async fn cancel(&self, task_id: &str) -> bool {
436        let handles = self.handles.lock().await;
437        if let Some(handle) = handles.get(task_id) {
438            // Check status from the store
439            if let Some(store) = self.store()
440                && let Some(snap) = store.read::<BackgroundTaskStateKey>()
441                && let Some(meta) = snap.tasks.get(task_id)
442                && meta.status.is_terminal()
443            {
444                return false;
445            }
446            handle.cancel_handle.cancel();
447            return true;
448        }
449        false
450    }
451
452    /// Cancel a task and every known descendant task in the same manager.
453    ///
454    /// Descendants are discovered through `TaskParentContext.task_id`.
455    /// Returns the number of live tasks whose cancellation token was signalled.
456    pub async fn cancel_tree(&self, task_id: &str) -> usize {
457        let Some(task_ids) = self.task_tree_ids(task_id) else {
458            return 0;
459        };
460
461        let handles = self.handles.lock().await;
462        let store_snap = self
463            .store()
464            .and_then(|s| s.read::<BackgroundTaskStateKey>());
465        let mut count = 0;
466        for task_id in task_ids {
467            let Some(handle) = handles.get(&task_id) else {
468                continue;
469            };
470            let is_terminal = store_snap
471                .as_ref()
472                .and_then(|snap| snap.tasks.get(&task_id))
473                .map(|meta| meta.status.is_terminal())
474                .unwrap_or(false);
475            if !is_terminal {
476                handle.cancel_handle.cancel();
477                count += 1;
478            }
479        }
480        count
481    }
482
483    /// Cancel all running tasks for a given thread.
484    /// Returns the number of tasks cancelled.
485    pub async fn cancel_all(&self, owner_thread_id: &str) -> usize {
486        let handles = self.handles.lock().await;
487        let store_snap = self
488            .store()
489            .and_then(|s| s.read::<BackgroundTaskStateKey>());
490        let mut count = 0;
491        for handle in handles.values() {
492            if handle.owner_thread_id != owner_thread_id {
493                continue;
494            }
495            let is_terminal = store_snap
496                .as_ref()
497                .and_then(|snap| snap.tasks.get(&handle.task_id))
498                .map(|m| m.status.is_terminal())
499                .unwrap_or(false);
500            if !is_terminal {
501                handle.cancel_handle.cancel();
502                count += 1;
503            }
504        }
505        count
506    }
507
508    /// List all tasks for a given owner thread.
509    pub async fn list(&self, owner_thread_id: &str) -> Vec<TaskSummary> {
510        if let Some(store) = self.store()
511            && let Some(snap) = store.read::<BackgroundTaskStateKey>()
512        {
513            return snap
514                .tasks
515                .values()
516                .filter(|m| m.owner_thread_id == owner_thread_id)
517                .map(Self::meta_to_summary)
518                .collect();
519        }
520        Vec::new()
521    }
522
523    /// Get the summary of a specific task.
524    pub async fn get(&self, task_id: &str) -> Option<TaskSummary> {
525        self.store()
526            .and_then(|s| s.read::<BackgroundTaskStateKey>())
527            .and_then(|snap| snap.tasks.get(task_id).map(Self::meta_to_summary))
528    }
529
530    fn meta_to_summary(m: &PersistedTaskMeta) -> TaskSummary {
531        TaskSummary {
532            task_id: m.task_id.clone(),
533            task_type: m.task_type.clone(),
534            description: m.description.clone(),
535            status: m.status,
536            error: m.error.clone(),
537            result: m.result.clone(),
538            created_at_ms: m.created_at_ms,
539            completed_at_ms: m.completed_at_ms,
540            parent_context: m.parent_context.clone(),
541        }
542    }
543
544    /// Restore persisted task metadata from a snapshot into the store.
545    pub(crate) async fn restore_for_thread(
546        &self,
547        owner_thread_id: &str,
548        snapshot: &BackgroundTaskStateSnapshot,
549    ) {
550        // First, write the snapshot data into the store
551        if let Some(store) = self.store() {
552            // Merge with existing data: only add tasks not already present
553            let existing = store.read::<BackgroundTaskStateKey>().unwrap_or_default();
554
555            for (task_id, meta) in &snapshot.tasks {
556                if existing.tasks.contains_key(task_id) {
557                    continue;
558                }
559
560                // Update counter
561                if let Some(n) = task_id
562                    .strip_prefix("bg_")
563                    .and_then(|s| s.parse::<u64>().ok())
564                {
565                    self.counter
566                        .fetch_max(n.saturating_add(1), Ordering::Relaxed);
567                }
568
569                let handles = self.handles.lock().await;
570                let has_live_handle = handles.contains_key(task_id);
571                drop(handles);
572
573                let mut to_store = meta.clone();
574                to_store.owner_thread_id = owner_thread_id.to_string();
575
576                // Orphan detection
577                if meta.status == TaskStatus::Running && !has_live_handle {
578                    to_store.status = TaskStatus::Failed;
579                    to_store.error =
580                        Some("task orphaned: runtime restarted while running".to_string());
581                }
582
583                self.commit_meta_or_warn(
584                    BackgroundTaskStateAction::Upsert(Box::new(to_store)),
585                    "restore_task_metadata",
586                    task_id,
587                );
588            }
589        }
590    }
591
592    /// Returns true if any task for the given thread is still running.
593    pub async fn has_running(&self, owner_thread_id: &str) -> bool {
594        if let Some(store) = self.store()
595            && let Some(snap) = store.read::<BackgroundTaskStateKey>()
596        {
597            return snap
598                .tasks
599                .values()
600                .any(|m| m.owner_thread_id == owner_thread_id && !m.status.is_terminal());
601        }
602        // Fallback: check handles if store not available
603        self.handles
604            .lock()
605            .await
606            .values()
607            .any(|h| h.owner_thread_id == owner_thread_id)
608    }
609
610    /// Spawn a sub-agent as a background task with its own inbox.
611    ///
612    /// `name` is an optional short identifier for addressing via `send_message`.
613    pub async fn spawn_agent<F, Fut>(
614        self: &Arc<Self>,
615        owner_thread_id: &str,
616        name: Option<&str>,
617        description: &str,
618        parent_context: TaskParentContext,
619        task_fn: F,
620    ) -> Result<TaskId, SpawnError>
621    where
622        F: FnOnce(CancellationToken, InboxSender, crate::inbox::InboxReceiver) -> Fut
623            + Send
624            + 'static,
625        Fut: std::future::Future<Output = TaskResult> + Send + 'static,
626    {
627        self.spawn_agent_with_context(owner_thread_id, name, description, parent_context, |ctx| {
628            task_fn(ctx.cancel_token, ctx.inbox_sender, ctx.inbox_receiver)
629        })
630        .await
631    }
632
633    /// Spawn a sub-agent as a background task while exposing the spawned task
634    /// ID to the closure for lineage-aware coordination.
635    pub async fn spawn_agent_with_context<F, Fut>(
636        self: &Arc<Self>,
637        owner_thread_id: &str,
638        name: Option<&str>,
639        description: &str,
640        parent_context: TaskParentContext,
641        task_fn: F,
642    ) -> Result<TaskId, SpawnError>
643    where
644        F: FnOnce(AgentTaskContext) -> Fut + Send + 'static,
645        Fut: std::future::Future<Output = TaskResult> + Send + 'static,
646    {
647        let parent_context = self.merge_ambient_parent_context(parent_context);
648        if let Some(n) = name {
649            self.validate_name(n, owner_thread_id)?;
650        }
651        if let Some(run_id) = self.is_parent_run_cancelled(&parent_context) {
652            return Err(SpawnError::ParentRunCancelled(run_id));
653        }
654        let task_id = self.next_task_id();
655        let (cancel_handle, cancel_token) = CancellationToken::new_pair();
656        let now = now_ms();
657
658        let (child_inbox_tx, child_inbox_rx) = crate::inbox::inbox_channel();
659        let stored_sender = child_inbox_tx.clone();
660
661        let task_name = name.map(|n| n.to_string());
662
663        // Commit initial metadata
664        self.commit_meta(BackgroundTaskStateAction::Upsert(Box::new(
665            PersistedTaskMeta {
666                task_id: task_id.clone(),
667                owner_thread_id: owner_thread_id.to_string(),
668                task_type: "sub_agent".to_string(),
669                name: task_name.clone(),
670                description: description.to_string(),
671                status: TaskStatus::Running,
672                error: None,
673                result: None,
674                created_at_ms: now,
675                completed_at_ms: None,
676                parent_context: parent_context.clone(),
677            },
678        )))
679        .map_err(SpawnError::from)?;
680
681        let manager = Arc::clone(self);
682        let tid = task_id.clone();
683        let owner_inbox = self.owner_inbox();
684        let owner = owner_thread_id.to_string();
685        let tname = task_name.clone();
686        let desc = description.to_string();
687        let parent_context_for_task = parent_context.clone();
688
689        let join_handle = tokio::spawn(async move {
690            let result = scope_background_task_context(
691                BackgroundTaskExecutionContext {
692                    manager: manager.clone(),
693                    task_id: tid.clone(),
694                    run_id: parent_context_for_task.run_id.clone(),
695                },
696                task_fn(AgentTaskContext {
697                    task_id: tid.clone(),
698                    cancel_token,
699                    inbox_sender: child_inbox_tx,
700                    inbox_receiver: child_inbox_rx,
701                }),
702            )
703            .await;
704            let completed_at = now_ms();
705
706            let (status, error, result_val) = match &result {
707                TaskResult::Success(val) => (TaskStatus::Completed, None, Some(val.clone())),
708                TaskResult::Failed(err) => (TaskStatus::Failed, Some(err.clone()), None),
709                TaskResult::Cancelled => (TaskStatus::Cancelled, None, None),
710            };
711
712            manager.commit_meta_or_warn(
713                BackgroundTaskStateAction::Upsert(Box::new(PersistedTaskMeta {
714                    task_id: tid.clone(),
715                    owner_thread_id: owner,
716                    task_type: "sub_agent".to_string(),
717                    name: tname,
718                    description: desc,
719                    status,
720                    error,
721                    result: result_val,
722                    created_at_ms: now,
723                    completed_at_ms: Some(completed_at),
724                    parent_context: parent_context_for_task,
725                })),
726                "sub_agent_completion",
727                &tid,
728            );
729
730            let event = Self::terminal_event(&tid, &result);
731            if let Some(ref inbox) = owner_inbox {
732                inbox.send(
733                    serde_json::to_value(&event).expect("TaskEvent serialization is infallible"),
734                );
735            }
736        });
737
738        let handle = TaskHandle {
739            task_id: task_id.clone(),
740            owner_thread_id: owner_thread_id.to_string(),
741            cancel_handle,
742            _join_handle: join_handle,
743            agent_inbox: Some(stored_sender),
744        };
745
746        self.insert_handle_and_cancel_if_parent_run_cancelled(
747            task_id.clone(),
748            handle,
749            &parent_context,
750        )
751        .await;
752        Ok(task_id)
753    }
754
755    /// Send a message to a child task's live inbox.
756    ///
757    /// This is the internal low-latency transport for parent→child
758    /// communication within the same process. For cross-agent or durable
759    /// messaging, use the mailbox-based `send_message` tool instead.
760    pub async fn send_task_inbox_message(
761        &self,
762        task_id: &str,
763        owner_thread_id: &str,
764        sender_agent_id: &str,
765        content: &str,
766    ) -> Result<(), SendError> {
767        let handles = self.handles.lock().await;
768        let handle = handles.get(task_id).ok_or(SendError::TaskNotFound)?;
769
770        // Authorization: sender must be on the same thread that owns the task
771        if handle.owner_thread_id != owner_thread_id {
772            return Err(SendError::NotOwner);
773        }
774
775        // Check status from the store
776        if let Some(store) = self.store()
777            && let Some(snap) = store.read::<BackgroundTaskStateKey>()
778            && let Some(meta) = snap.tasks.get(task_id)
779            && meta.status.is_terminal()
780        {
781            return Err(SendError::TaskTerminated(meta.status));
782        }
783
784        let inbox = handle.agent_inbox.as_ref().ok_or(SendError::NoInbox)?;
785
786        let event = TaskEvent::Custom {
787            task_id: task_id.to_string(),
788            event_type: "agent_message".to_string(),
789            payload: serde_json::json!({
790                "from": sender_agent_id,
791                "content": content,
792            }),
793        };
794
795        if inbox.send(serde_json::to_value(&event).expect("TaskEvent serialization is infallible"))
796        {
797            Ok(())
798        } else {
799            Err(SendError::InboxClosed)
800        }
801    }
802
803    pub(crate) fn task_tree_ids(&self, task_id: &str) -> Option<Vec<TaskId>> {
804        let snapshot = self
805            .store()
806            .and_then(|store| store.read::<BackgroundTaskStateKey>())?;
807        if !snapshot.tasks.contains_key(task_id) {
808            return None;
809        }
810
811        let mut ordered = Vec::new();
812        let mut stack = vec![task_id.to_string()];
813        while let Some(current) = stack.pop() {
814            if ordered.iter().any(|seen| seen == &current) {
815                continue;
816            }
817            ordered.push(current.clone());
818            for meta in snapshot.tasks.values() {
819                if meta.parent_context.task_id.as_deref() == Some(current.as_str()) {
820                    stack.push(meta.task_id.clone());
821                }
822            }
823        }
824        Some(ordered)
825    }
826
827    pub(crate) fn resolve_live_child_task(
828        &self,
829        parent_task_id: &str,
830        name_or_task_id: &str,
831    ) -> Option<TaskId> {
832        let snapshot = self.store()?.read::<BackgroundTaskStateKey>()?;
833        for meta in snapshot.tasks.values() {
834            if meta.status.is_terminal() {
835                continue;
836            }
837            if meta.parent_context.task_id.as_deref() != Some(parent_task_id) {
838                continue;
839            }
840            if meta.task_id == name_or_task_id || meta.name.as_deref() == Some(name_or_task_id) {
841                return Some(meta.task_id.clone());
842            }
843        }
844        None
845    }
846
847    pub(crate) fn resolve_live_child_run(
848        &self,
849        parent_run_id: &str,
850        name_or_task_id: &str,
851    ) -> Option<TaskId> {
852        let snapshot = self.store()?.read::<BackgroundTaskStateKey>()?;
853        for meta in snapshot.tasks.values() {
854            if meta.status.is_terminal() {
855                continue;
856            }
857            if meta.parent_context.run_id.as_deref() != Some(parent_run_id)
858                || meta.parent_context.task_id.is_some()
859            {
860                continue;
861            }
862            if meta.task_id == name_or_task_id || meta.name.as_deref() == Some(name_or_task_id) {
863                return Some(meta.task_id.clone());
864            }
865        }
866        None
867    }
868
869    #[cfg(test)]
870    pub(crate) async fn persisted_snapshot(&self) -> HashMap<TaskId, PersistedTaskMeta> {
871        if let Some(store) = self.store()
872            && let Some(snap) = store.read::<BackgroundTaskStateKey>()
873        {
874            return snap.tasks;
875        }
876        HashMap::new()
877    }
878}
879
880impl Default for BackgroundTaskManager {
881    fn default() -> Self {
882        Self::new()
883    }
884}
885
886use awaken_runtime_contract::now_ms;