//! First-class background command use cases (B8 sub-task 3). //! //! These use cases own the *application* side of a command-backed background //! task: they allocate the [`TaskId`], create the persisted task //! ([`BackgroundTaskState::Queued`] → [`Running`](BackgroundTaskState::Running)) //! and hand a [`BackgroundTaskSpec`] to the runner. They talk **only** to ports: //! [`BackgroundTaskStore`], [`BackgroundTaskRunner`], [`Clock`] and //! [`IdGenerator`], plus the runtime-only [`BackgroundCommandArchive`] abstraction //! defined below (see its doc for why retry recall is not a domain port). //! //! The runner never writes the store; the completion sink does. So beyond the //! initial create/transition, these use cases do not persist terminal state — //! except on a *spawn failure*, where they mark the just-created task `Failed` so //! it never dangles in `Running` with no worker behind it. use std::sync::Arc; use domain::ports::{ BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, Clock, IdGenerator, SpawnSpec, }; use domain::{ AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ProjectId, TaskId, }; use crate::error::AppError; /// Maps a background-task port error into the application error shape. fn map_port_err(err: BackgroundTaskPortError) -> AppError { match err { BackgroundTaskPortError::NotFound => AppError::NotFound("background task".to_owned()), BackgroundTaskPortError::AlreadyExists => { AppError::Invalid("background task already exists".to_owned()) } BackgroundTaskPortError::Invalid(msg) => AppError::Invalid(msg), BackgroundTaskPortError::Runner(msg) => AppError::Process(msg), BackgroundTaskPortError::Store(msg) => AppError::Store(msg), } } /// Input for [`SpawnBackgroundCommand`]. #[derive(Debug, Clone)] pub struct SpawnBackgroundCommandInput { /// Owning project. pub project_id: ProjectId, /// Agent that owns completion delivery. pub owner_agent_id: AgentId, /// Human-facing label. pub label: String, /// Command invocation to run. pub command: SpawnSpec, /// Completion wake policy. pub wake_policy: BackgroundTaskWakePolicy, /// Optional absolute deadline, epoch milliseconds. pub deadline_ms: Option, } /// Output of [`SpawnBackgroundCommand`] / [`RetryBackgroundTask`]. #[derive(Debug, Clone)] pub struct SpawnBackgroundCommandOutput { /// The created task, in its `Running` state. pub task: BackgroundTask, } /// Creates a command-backed background task and hands it to the runner. pub struct SpawnBackgroundCommand { store: Arc, runner: Arc, clock: Arc, ids: Arc, } impl SpawnBackgroundCommand { /// Wires the use case to its ports. #[must_use] pub fn new( store: Arc, runner: Arc, clock: Arc, ids: Arc, ) -> Self { Self { store, runner, clock, ids, } } /// Allocates a task id, persists it (`Queued`→`Running`) and spawns it. /// /// # Errors /// [`AppError`] if a store/runner operation or a domain invariant fails. pub async fn execute( &self, input: SpawnBackgroundCommandInput, ) -> Result { let task = self .create_and_run( input.project_id, input.owner_agent_id, input.label, input.command, input.wake_policy, input.deadline_ms, ) .await?; Ok(SpawnBackgroundCommandOutput { task }) } /// Shared create/transition/spawn path, reused by retry. async fn create_and_run( &self, project_id: ProjectId, owner_agent_id: AgentId, label: String, command: SpawnSpec, wake_policy: BackgroundTaskWakePolicy, deadline_ms: Option, ) -> Result { let task_id = TaskId::from_uuid(self.ids.new_uuid()); let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0); let task = BackgroundTask::new( task_id, project_id, owner_agent_id, BackgroundTaskKind::Command { label }, wake_policy, now, deadline_ms, ) .map_err(|e| AppError::Invalid(e.to_string()))?; self.store.create(&task).await.map_err(map_port_err)?; let running = task .transition(BackgroundTaskState::Running, now) .map_err(|e| AppError::Invalid(e.to_string()))?; self.store.save(&running).await.map_err(map_port_err)?; let spec = BackgroundTaskSpec { task_id, project_id, owner_agent_id, kind: running.kind.clone(), wake_policy, command: Some(command), deadline_ms, }; if let Err(err) = self.runner.spawn(spec).await { // Never leave the task dangling in `Running` with no worker: mark it // Failed so reconcile/UI see a terminal state. Best-effort persist. if let Ok(failed) = running.complete(BackgroundTaskResult::Failure { finished_at_ms: now, exit_code: None, error: format!("failed to spawn background command: {err}"), stdout_tail: None, stderr_tail: None, }) { let _ = self.store.save(&failed).await; } return Err(map_port_err(err)); } Ok(running) } } /// Output of [`CancelBackgroundTask`]. #[derive(Debug, Clone)] pub struct CancelBackgroundTaskOutput { /// The task as currently persisted, if it still exists. The terminal /// `Cancelled` state is written by the completion sink, not here. pub task: Option, } /// Requests cancellation of a running background task. pub struct CancelBackgroundTask { store: Arc, runner: Arc, } impl CancelBackgroundTask { /// Wires the use case to its ports. #[must_use] pub fn new(store: Arc, runner: Arc) -> Self { Self { store, runner } } /// Signals the runner to cancel `task_id` (idempotent). /// /// # Errors /// [`AppError`] if the runner or store fails. pub async fn execute(&self, task_id: TaskId) -> Result { self.runner.cancel(task_id).await.map_err(map_port_err)?; let task = self.store.get(task_id).await.map_err(map_port_err)?; Ok(CancelBackgroundTaskOutput { task }) } } /// Read-only recall of the command invocation a background task was spawned with. /// /// This is a **runtime, in-memory** abstraction, *not* a domain port: the recall /// lives wherever the runner keeps its live invocations for the current session /// and is deliberately **never persisted** (a [`SpawnSpec`] can carry secrets and /// must never land in `.ideai/background-tasks/*.json`, which travels with the /// project). Retry is therefore **session-scoped** in V1 — a task whose runner no /// longer remembers its spec (e.g. after a restart) simply cannot be retried. /// /// The persisted [`BackgroundTask`] carries no [`SpawnSpec`] (the command line is /// an execution detail, not domain state). Retry, however, must re-run the *same* /// command under a fresh task id, so it recovers the original invocation from /// whatever component still holds it — the runner. Keeping that recall behind this /// application-level trait lets [`RetryBackgroundTask`] stay decoupled from the /// concrete runner without dragging the concept into the domain. pub trait BackgroundCommandArchive: Send + Sync { /// Returns the [`SpawnSpec`] a command-backed task was spawned with, if the /// runner still remembers it. `None` when the task is unknown, was not /// command-backed, or is no longer in the current session's registry. fn spec_for(&self, task_id: TaskId) -> Option; } /// Re-runs a terminal command-backed task under a brand-new task id. /// /// The original command is recovered from the [`BackgroundCommandArchive`]; the /// deadline is intentionally dropped (an absolute past deadline would expire the /// retry instantly). pub struct RetryBackgroundTask { store: Arc, archive: Arc, spawn: Arc, } impl RetryBackgroundTask { /// Wires the use case to its ports and the spawn use case. #[must_use] pub fn new( store: Arc, archive: Arc, spawn: Arc, ) -> Self { Self { store, archive, spawn, } } /// Retries `task_id`, returning the freshly-created task. /// /// # Errors /// [`AppError::NotFound`] if the task is unknown, [`AppError::Invalid`] if it /// is not a terminal command task or its command is no longer available. pub async fn execute(&self, task_id: TaskId) -> Result { let old = self .store .get(task_id) .await .map_err(map_port_err)? .ok_or_else(|| AppError::NotFound(format!("background task {task_id}")))?; if !old.is_terminal() { return Err(AppError::Invalid( "cannot retry a task that is still running".to_owned(), )); } let label = match &old.kind { BackgroundTaskKind::Command { label } => label.clone(), _ => { return Err(AppError::Invalid( "only command-backed tasks can be retried".to_owned(), )) } }; let command = self.archive.spec_for(task_id).ok_or_else(|| { AppError::Invalid("original command is no longer available for retry".to_owned()) })?; let task = self .spawn .create_and_run( old.project_id, old.owner_agent_id, label, command, old.wake_policy, None, ) .await?; Ok(SpawnBackgroundCommandOutput { task }) } }