Files
IdeA/crates/domain/src/background_task.rs
Blomios 7007b2cc7e feat(backend,frontend): tâches planifiées projet — tool MCP idea_scheduled_task_* et menu utilisateur #305, fix transcript lié indisponible après reboot #314 (QA verte)
#305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité <app_data_dir>/scheduled-project-tasks/<projectId>.json, schedules bornés V1 interval/daily/weekly avec fuseau, cycle À approuver/Active/Suspendue avec approbation obligatoire des tâches demandées par agent; use cases application/scheduled_task (list/create/update/delete/run_now/reconcile/fire) réutilisant BackgroundTask pour les runs avec distinction planifié/manuel; store infrastructure dédié + scheduler reconcile au boot sans rattrapage en rafale, cwd confiné au projet; 7 tools MCP idea_scheduled_task_* (list/read lecture seule, create/update/delete/run_now/set_enabled write-actions soumises aux permissions natives); commandes Tauri + routes HTTP aux contrats DTO identiques (camelCase nextRunAtMs/lastRunAtMs/lastTaskId/createdBy/updatedBy); frontend: gateways Tauri/HTTP/mock + types ports/domain, surface projet 'Tâches planifiées' (liste utilisateur+agent unifiée, création/édition avec récapitulatif avant activation, approbation/refus, exécuter maintenant, suspendre/reprendre, suppression), catalogage surface web (webSurfaceCatalog/viewPlacement/ViewPanelBody/WebWorkspace); docs/scheduled-project-tasks.md et sdk docs/services.md (047c38b: pas un service plugin V1, pas d'émulation via cron système).

#314: correction de 'Conversation liée — transcript indisponible' après reboot: restauration du transcript lié côté application (orchestrator service, lifecycle, session_limit, background sink, terminal registry) et côté frontend (adapters agent/http frames/stream/requestResponse, workStateNormalization, CustomAgentChatView avec état et action utile), tests dto_chat/agent_lifecycle/structured_registry_d1/background_completion_sink étendus.

QA verte rapportée par QA sur feature/305-scheduled-project-tasks-mcp, sans bloquant, pour #305 et #314.

Hors périmètre worktree: tickets non liés, attachments/, scripts/; aucun push.
2026-09-11 10:32:01 +02:00

580 lines
19 KiB
Rust

//! First-class background task model.
//!
//! This module is pure domain: it defines the persisted task state and enforces
//! local invariants only. Running commands, storing tasks and waking agents are
//! adapter/application concerns behind ports.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::conversation::ConversationId;
use crate::ids::{AgentId, ProjectId, RendezvousId, ScheduleId, ScheduledTaskId, TaskId};
use crate::mailbox::TicketId;
/// Maximum length for human-facing task labels.
pub const BACKGROUND_TASK_LABEL_MAX_CHARS: usize = 160;
/// Maximum size for summary/error/reason text fields.
pub const BACKGROUND_TASK_TEXT_MAX_BYTES: usize = 64 * 1024;
/// Maximum size retained for stdout/stderr tails.
pub const BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES: usize = 16 * 1024;
/// Lifecycle state of a background task.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BackgroundTaskState {
/// Created and waiting to be started.
Queued,
/// Currently executing.
Running,
/// Suspended while waiting for an external event.
Waiting,
/// Cancellation has been accepted and the runner is stopping the resource.
Cancelling,
/// Finished successfully.
Completed,
/// Finished with an error.
Failed,
/// Cancelled before natural completion.
Cancelled,
/// Cancellation was requested but the runner could not stop the resource.
CancelFailed,
/// Expired after its deadline.
Expired,
}
impl BackgroundTaskState {
/// Returns whether this state is terminal.
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(
self,
Self::Completed | Self::Failed | Self::Cancelled | Self::CancelFailed | Self::Expired
)
}
}
/// What IdeA should do with the owner when the task completes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BackgroundTaskWakePolicy {
/// Wake the owning agent so it can observe the completion.
WakeOwner,
/// Persist the completion only.
RecordOnly,
}
/// Provenance of one concrete run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(
rename_all = "camelCase",
rename_all_fields = "camelCase",
tag = "kind"
)]
pub enum BackgroundTaskOrigin {
/// Direct/manual launch or legacy task.
#[default]
Manual,
/// Occurrence of a durable scheduled definition.
Scheduled {
/// Durable definition that produced this run.
scheduled_task_id: ScheduledTaskId,
/// Persisted occurrence timestamp represented by this run.
planned_at_ms: u64,
},
}
/// Structural link from a background task to a composite inter-agent rendezvous.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTaskRendezvousLink {
/// Stable business rendezvous id, independent from any task id.
pub rendezvous_id: RendezvousId,
/// Mailbox ticket that must receive the eventual business `Final`.
pub ticket_id: TicketId,
/// Agent that requested the rendezvous, when known.
pub requester_agent_id: Option<AgentId>,
/// Target agent doing the work.
pub target_agent_id: AgentId,
/// Conversation entered by the target turn.
pub conversation_id: ConversationId,
}
/// Kind-specific payload of a background task.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum BackgroundTaskKind {
/// A non-interactive command.
Command {
/// Human-facing label.
label: String,
},
/// A headless inter-agent rendezvous.
HeadlessRendezvous {
/// Agent that requested the rendezvous, when known.
requester_agent_id: Option<AgentId>,
/// Target agent doing the work.
target_agent_id: AgentId,
/// Existing mailbox ticket.
ticket_id: TicketId,
/// Conversation entered by the target turn.
conversation_id: ConversationId,
},
/// Resume of a structured agent session.
SessionResume {
/// Agent to resume.
agent_id: AgentId,
/// Scheduler handle that triggered the resume, when any.
schedule_id: Option<ScheduleId>,
/// Provider conversation id to resume, when any.
conversation_id: Option<String>,
},
/// Internal maintenance work.
Maintenance {
/// Human-facing label.
label: String,
},
}
/// Persisted first-class background task.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTask {
/// Stable task id.
pub id: TaskId,
/// Owning project.
pub project_id: ProjectId,
/// Agent to notify according to [`BackgroundTaskWakePolicy`].
pub owner_agent_id: AgentId,
/// Kind-specific payload.
pub kind: BackgroundTaskKind,
/// Optional composite rendezvous this task participates in.
#[serde(default)]
pub rendezvous: Option<BackgroundTaskRendezvousLink>,
/// Optional parent background task. Descendants are cancelled with their parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_task_id: Option<TaskId>,
/// Current lifecycle state.
pub state: BackgroundTaskState,
/// Completion wake policy.
pub wake_policy: BackgroundTaskWakePolicy,
/// Manual launch or scheduled occurrence provenance.
#[serde(default)]
pub origin: BackgroundTaskOrigin,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
pub updated_at_ms: u64,
/// Optional absolute deadline, epoch milliseconds.
pub deadline_ms: Option<u64>,
/// Terminal result. Present if and only if the state is terminal.
pub result: Option<BackgroundTaskResult>,
/// Whether the completion has been delivered to the owner/inbox layer.
pub completion_delivered: bool,
}
impl BackgroundTask {
/// Builds a queued task and validates kind/timestamps.
///
/// # Errors
/// [`BackgroundTaskError`] when kind or timestamps are invalid.
pub fn new(
id: TaskId,
project_id: ProjectId,
owner_agent_id: AgentId,
kind: BackgroundTaskKind,
wake_policy: BackgroundTaskWakePolicy,
now_ms: u64,
deadline_ms: Option<u64>,
) -> Result<Self, BackgroundTaskError> {
kind.validate()?;
let task = Self {
id,
project_id,
owner_agent_id,
kind,
rendezvous: None,
parent_task_id: None,
state: BackgroundTaskState::Queued,
wake_policy,
origin: BackgroundTaskOrigin::Manual,
created_at_ms: now_ms,
updated_at_ms: now_ms,
deadline_ms,
result: None,
completion_delivered: false,
};
task.validate()?;
Ok(task)
}
/// Attaches immutable run provenance before persistence.
#[must_use]
pub fn with_origin(mut self, origin: BackgroundTaskOrigin) -> Self {
self.origin = origin;
self
}
/// Returns a copy linked to a composite inter-agent rendezvous.
#[must_use]
pub fn with_rendezvous(mut self, link: BackgroundTaskRendezvousLink) -> Self {
self.rendezvous = Some(link);
self
}
/// Returns a copy owned by a parent background task.
///
/// # Errors
/// [`BackgroundTaskError`] when the parent would make the task invalid.
pub fn with_parent_task(mut self, parent_task_id: TaskId) -> Result<Self, BackgroundTaskError> {
self.parent_task_id = Some(parent_task_id);
self.validate()?;
Ok(self)
}
/// Returns a copy moved to a non-terminal state.
///
/// Terminal transitions must use [`Self::complete`] so the result/state
/// invariant stays atomic.
///
/// # Errors
/// [`BackgroundTaskError`] when the transition or timestamp is invalid.
pub fn transition(
&self,
to: BackgroundTaskState,
now_ms: u64,
) -> Result<Self, BackgroundTaskError> {
if now_ms < self.updated_at_ms {
return Err(BackgroundTaskError::InvalidTimestamps);
}
if to.is_terminal() {
return Err(BackgroundTaskError::MissingResult);
}
self.ensure_transition_allowed(to)?;
let mut next = self.clone();
next.state = to;
next.updated_at_ms = now_ms;
next.validate()?;
Ok(next)
}
/// Returns a copy moved to the terminal state matching `result`.
///
/// # Errors
/// [`BackgroundTaskError`] when the transition or result is invalid.
pub fn complete(&self, result: BackgroundTaskResult) -> Result<Self, BackgroundTaskError> {
result.validate()?;
if result.finished_at_ms() < self.updated_at_ms {
return Err(BackgroundTaskError::InvalidTimestamps);
}
let to = result.state();
self.ensure_transition_allowed(to)?;
let mut next = self.clone();
next.state = to;
next.updated_at_ms = result.finished_at_ms();
next.result = Some(result);
next.validate()?;
Ok(next)
}
/// Marks the terminal completion as delivered.
///
/// # Errors
/// [`BackgroundTaskError`] if the task is not terminal or already delivered.
pub fn mark_completion_delivered(&self) -> Result<Self, BackgroundTaskError> {
if !self.is_terminal() {
return Err(BackgroundTaskError::MissingResult);
}
if self.completion_delivered {
return Err(BackgroundTaskError::CompletionAlreadyDelivered);
}
let mut next = self.clone();
next.completion_delivered = true;
next.validate()?;
Ok(next)
}
/// Returns whether the current state is terminal.
#[must_use]
pub fn is_terminal(&self) -> bool {
self.state.is_terminal()
}
/// Returns whether a terminal completion still needs delivery.
#[must_use]
pub fn has_pending_completion_delivery(&self) -> bool {
self.is_terminal() && !self.completion_delivered
}
fn ensure_transition_allowed(
&self,
to: BackgroundTaskState,
) -> Result<(), BackgroundTaskError> {
if is_transition_allowed(self.state, to) {
Ok(())
} else {
Err(BackgroundTaskError::InvalidTransition {
from: self.state,
to,
})
}
}
fn validate(&self) -> Result<(), BackgroundTaskError> {
self.kind.validate()?;
if self.updated_at_ms < self.created_at_ms
|| self
.deadline_ms
.is_some_and(|deadline| deadline < self.created_at_ms)
{
return Err(BackgroundTaskError::InvalidTimestamps);
}
match (&self.state, &self.result) {
(state, None) if state.is_terminal() => return Err(BackgroundTaskError::MissingResult),
(state, Some(_)) if !state.is_terminal() => {
return Err(BackgroundTaskError::UnexpectedResult);
}
_ => {}
}
if self.completion_delivered && !self.state.is_terminal() {
return Err(BackgroundTaskError::MissingResult);
}
if self.parent_task_id == Some(self.id) {
return Err(BackgroundTaskError::SelfParent);
}
if let Some(result) = &self.result {
result.validate()?;
if result.state() != self.state {
return Err(BackgroundTaskError::InvalidTransition {
from: self.state,
to: result.state(),
});
}
if result.finished_at_ms() < self.created_at_ms {
return Err(BackgroundTaskError::InvalidTimestamps);
}
}
Ok(())
}
}
/// Result of a terminal background task.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "outcome")]
pub enum BackgroundTaskResult {
/// Successful completion.
Success {
/// Completion timestamp, epoch milliseconds.
finished_at_ms: u64,
/// Process exit code, when command-backed.
exit_code: Option<i32>,
/// Human-readable summary.
summary: String,
/// Bounded stdout tail.
stdout_tail: Option<String>,
/// Bounded stderr tail.
stderr_tail: Option<String>,
},
/// Failed completion.
Failure {
/// Completion timestamp, epoch milliseconds.
finished_at_ms: u64,
/// Process exit code, when command-backed.
exit_code: Option<i32>,
/// Human-readable error.
error: String,
/// Bounded stdout tail.
stdout_tail: Option<String>,
/// Bounded stderr tail.
stderr_tail: Option<String>,
},
/// Cancelled completion.
Cancelled {
/// Completion timestamp, epoch milliseconds.
finished_at_ms: u64,
/// Cancellation reason.
reason: String,
},
/// Cancellation failed.
CancelFailed {
/// Completion timestamp, epoch milliseconds.
finished_at_ms: u64,
/// Human-readable error.
error: String,
},
/// Expired completion.
Expired {
/// Completion timestamp, epoch milliseconds.
finished_at_ms: u64,
/// Expiry reason.
reason: String,
},
}
impl BackgroundTaskKind {
/// Validates kind-specific fields.
///
/// # Errors
/// [`BackgroundTaskError`] when labels are empty or too large.
pub fn validate(&self) -> Result<(), BackgroundTaskError> {
match self {
Self::Command { label } | Self::Maintenance { label } => {
validate_label(label)?;
}
Self::HeadlessRendezvous { .. } | Self::SessionResume { .. } => {}
}
Ok(())
}
}
impl BackgroundTaskResult {
/// Returns the terminal timestamp carried by the result.
#[must_use]
pub fn finished_at_ms(&self) -> u64 {
match self {
Self::Success { finished_at_ms, .. }
| Self::Failure { finished_at_ms, .. }
| Self::Cancelled { finished_at_ms, .. }
| Self::CancelFailed { finished_at_ms, .. }
| Self::Expired { finished_at_ms, .. } => *finished_at_ms,
}
}
/// Validates result payload bounds.
///
/// # Errors
/// [`BackgroundTaskError`] when a text field is too large.
pub fn validate(&self) -> Result<(), BackgroundTaskError> {
match self {
Self::Success {
summary,
stdout_tail,
stderr_tail,
..
} => {
validate_text("summary", summary)?;
validate_tail("stdout_tail", stdout_tail.as_deref())?;
validate_tail("stderr_tail", stderr_tail.as_deref())?;
}
Self::Failure {
error,
stdout_tail,
stderr_tail,
..
} => {
validate_text("error", error)?;
validate_tail("stdout_tail", stdout_tail.as_deref())?;
validate_tail("stderr_tail", stderr_tail.as_deref())?;
}
Self::Cancelled { reason, .. } | Self::Expired { reason, .. } => {
validate_text("reason", reason)?;
}
Self::CancelFailed { error, .. } => {
validate_text("error", error)?;
}
}
Ok(())
}
fn state(&self) -> BackgroundTaskState {
match self {
Self::Success { .. } => BackgroundTaskState::Completed,
Self::Failure { .. } => BackgroundTaskState::Failed,
Self::Cancelled { .. } => BackgroundTaskState::Cancelled,
Self::CancelFailed { .. } => BackgroundTaskState::CancelFailed,
Self::Expired { .. } => BackgroundTaskState::Expired,
}
}
}
/// Errors raised by pure background task invariants.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum BackgroundTaskError {
/// A command/maintenance label is empty.
#[error("background task label is empty")]
EmptyLabel,
/// A bounded text field is too large.
#[error("background task field `{field}` exceeds {max} bytes")]
FieldTooLarge {
/// Field name.
field: &'static str,
/// Maximum allowed size in bytes.
max: usize,
},
/// Timestamps are inconsistent.
#[error("invalid background task timestamps")]
InvalidTimestamps,
/// The requested state transition is not allowed.
#[error("invalid background task state transition from {from:?} to {to:?}")]
InvalidTransition {
/// Current state.
from: BackgroundTaskState,
/// Requested state.
to: BackgroundTaskState,
},
/// A terminal state has no result, or delivery was attempted before terminal.
#[error("background task terminal state requires a result")]
MissingResult,
/// A non-terminal state carries a result.
#[error("background task non-terminal state cannot carry a result")]
UnexpectedResult,
/// Completion delivery was already marked.
#[error("background task completion was already delivered")]
CompletionAlreadyDelivered,
/// A task cannot own itself.
#[error("background task cannot be its own parent")]
SelfParent,
}
fn is_transition_allowed(from: BackgroundTaskState, to: BackgroundTaskState) -> bool {
use BackgroundTaskState::{
CancelFailed, Cancelled, Cancelling, Completed, Expired, Failed, Queued, Running, Waiting,
};
matches!(
(from, to),
(
Queued,
Running | Cancelling | Cancelled | CancelFailed | Expired
) | (
Running,
Waiting | Cancelling | Completed | Failed | Cancelled | CancelFailed | Expired
) | (
Waiting,
Running | Cancelling | Completed | Failed | Cancelled | CancelFailed | Expired
) | (Cancelling, Cancelled | CancelFailed | Expired)
)
}
fn validate_label(label: &str) -> Result<(), BackgroundTaskError> {
if label.trim().is_empty() {
return Err(BackgroundTaskError::EmptyLabel);
}
if label.chars().count() > BACKGROUND_TASK_LABEL_MAX_CHARS {
return Err(BackgroundTaskError::FieldTooLarge {
field: "label",
max: BACKGROUND_TASK_LABEL_MAX_CHARS,
});
}
Ok(())
}
fn validate_text(field: &'static str, value: &str) -> Result<(), BackgroundTaskError> {
if value.len() > BACKGROUND_TASK_TEXT_MAX_BYTES {
return Err(BackgroundTaskError::FieldTooLarge {
field,
max: BACKGROUND_TASK_TEXT_MAX_BYTES,
});
}
Ok(())
}
fn validate_tail(field: &'static str, value: Option<&str>) -> Result<(), BackgroundTaskError> {
if value.is_some_and(|value| value.len() > BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES) {
return Err(BackgroundTaskError::FieldTooLarge {
field,
max: BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
});
}
Ok(())
}