feat(background): runner PTY B8 + boucle sink fermée + refactor point-2
Livre le lot backend B8 des tâches de fond : - infrastructure : runner de commandes concret (CommandBackgroundRunner) sur le port BackgroundTaskRunner, tail borné (bounded_tail/BoundedTail), éclatement du module background_task en sous-modules (mod/runner/tail, sink extrait de l'ancien background_task.rs). - application : nouveau module background exposant les cas d'usage SpawnBackgroundCommand, CancelBackgroundTask, RetryBackgroundTask et le port BackgroundCommandArchive. - domain : refactor point-2 de l'arbitrage Architect — sortie du trait BackgroundCommandArchive de la couche domaine vers application. - app-tauri : câblage runtime (commands, dto, state, lib) des commandes spawn/cancel/retry et de la boucle de complétion sink fermée en composition root. Build workspace + tests application/infrastructure verts (QA). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
300
crates/application/src/background/mod.rs
Normal file
300
crates/application/src/background/mod.rs
Normal file
@ -0,0 +1,300 @@
|
||||
//! 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<dyn BackgroundTaskStore>,
|
||||
runner: Arc<dyn BackgroundTaskRunner>,
|
||||
clock: Arc<dyn Clock>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl SpawnBackgroundCommand {
|
||||
/// Wires the use case to its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn BackgroundTaskStore>,
|
||||
runner: Arc<dyn BackgroundTaskRunner>,
|
||||
clock: Arc<dyn Clock>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
) -> 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<SpawnBackgroundCommandOutput, AppError> {
|
||||
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<u64>,
|
||||
) -> Result<BackgroundTask, AppError> {
|
||||
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<BackgroundTask>,
|
||||
}
|
||||
|
||||
/// Requests cancellation of a running background task.
|
||||
pub struct CancelBackgroundTask {
|
||||
store: Arc<dyn BackgroundTaskStore>,
|
||||
runner: Arc<dyn BackgroundTaskRunner>,
|
||||
}
|
||||
|
||||
impl CancelBackgroundTask {
|
||||
/// Wires the use case to its ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn BackgroundTaskStore>, runner: Arc<dyn BackgroundTaskRunner>) -> 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<CancelBackgroundTaskOutput, AppError> {
|
||||
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<SpawnSpec>;
|
||||
}
|
||||
|
||||
/// 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<dyn BackgroundTaskStore>,
|
||||
archive: Arc<dyn BackgroundCommandArchive>,
|
||||
spawn: Arc<SpawnBackgroundCommand>,
|
||||
}
|
||||
|
||||
impl RetryBackgroundTask {
|
||||
/// Wires the use case to its ports and the spawn use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn BackgroundTaskStore>,
|
||||
archive: Arc<dyn BackgroundCommandArchive>,
|
||||
spawn: Arc<SpawnBackgroundCommand>,
|
||||
) -> 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<SpawnBackgroundCommandOutput, AppError> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user