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:
2026-07-03 14:48:46 +02:00
parent 5d88c952ec
commit 8cac1470ac
12 changed files with 1180 additions and 11 deletions

View File

@ -30,7 +30,8 @@ use domain::ports::PtyHandle;
use crate::dto::{
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
BackgroundTaskDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto,
ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto,
@ -2555,3 +2556,160 @@ pub async fn resolve_memory_links(
.map(MemoryLinksDto::from)
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Background tasks (B8 — first-class background command)
// ---------------------------------------------------------------------------
/// Maps a background-task port error to the frontend [`ErrorDto`] shape.
fn background_error(err: domain::ports::BackgroundTaskPortError) -> ErrorDto {
use domain::ports::BackgroundTaskPortError as E;
let code = match &err {
E::NotFound => "NOT_FOUND",
E::AlreadyExists | E::Invalid(_) => "INVALID",
E::Runner(_) => "PROCESS",
E::Store(_) => "STORE",
};
ErrorDto {
code: code.to_owned(),
message: err.to_string(),
}
}
/// `spawn_background_command` — start a command-backed first-class background
/// task whose completion is delivered to the owning agent's inbox.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/path, `PROCESS` if the
/// command cannot be spawned, `STORE` on persistence failure).
#[tauri::command]
pub async fn spawn_background_command(
request: crate::dto::SpawnBackgroundCommandRequestDto,
state: State<'_, AppState>,
) -> Result<BackgroundTaskDto, ErrorDto> {
let project_id = parse_project_id(&request.project_id)?;
let owner_agent_id = parse_agent_id(&request.owner_agent_id)?;
let cwd = domain::project::ProjectPath::new(request.cwd.clone()).map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid working directory: {}", request.cwd),
})?;
let command = domain::ports::SpawnSpec {
command: request.command,
args: request.args,
cwd,
env: request.env,
context_plan: None,
sandbox: None,
};
let wake_policy = if request.record_only {
domain::BackgroundTaskWakePolicy::RecordOnly
} else {
domain::BackgroundTaskWakePolicy::WakeOwner
};
state
.spawn_background_command
.execute(application::SpawnBackgroundCommandInput {
project_id,
owner_agent_id,
label: request.label,
command,
wake_policy,
deadline_ms: request.deadline_ms,
})
.await
.map(|out| BackgroundTaskDto::from(out.task))
.map_err(ErrorDto::from)
}
/// `cancel_background_task` — request cancellation of a running background task.
///
/// The terminal `Cancelled` state is written by the completion sink; this returns
/// the task as currently persisted (absent if it no longer exists).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `PROCESS`/`STORE` on
/// failure).
#[tauri::command]
pub async fn cancel_background_task(
task_id: String,
state: State<'_, AppState>,
) -> Result<Option<BackgroundTaskDto>, ErrorDto> {
let id = parse_task_id(&task_id)?;
state
.cancel_background_task
.execute(id)
.await
.map(|out| out.task.map(BackgroundTaskDto::from))
.map_err(ErrorDto::from)
}
/// `retry_background_task` — re-run a terminal command task under a **new** task
/// id (the original id is never reused).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or a non-retryable task,
/// `NOT_FOUND` if the task is unknown, `PROCESS`/`STORE` on failure).
#[tauri::command]
pub async fn retry_background_task(
task_id: String,
state: State<'_, AppState>,
) -> Result<BackgroundTaskDto, ErrorDto> {
let id = parse_task_id(&task_id)?;
state
.retry_background_task
.execute(id)
.await
.map(|out| BackgroundTaskDto::from(out.task))
.map_err(ErrorDto::from)
}
/// `list_background_tasks` — read the background-task read-model for a project,
/// optionally narrowed to one owning agent.
///
/// Returns the union of the agent's open tasks and undelivered completions,
/// filtered to `projectId`. Without `agentId`, only the project's undelivered
/// completions are returned (the store cannot enumerate open tasks project-wide).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure).
#[tauri::command]
pub async fn list_background_tasks(
project_id: String,
agent_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<BackgroundTaskDto>, ErrorDto> {
let project_id = parse_project_id(&project_id)?;
let agent_id = match &agent_id {
Some(raw) => Some(parse_agent_id(raw)?),
None => None,
};
let store = &state.background_task_store;
let mut by_id: std::collections::HashMap<domain::TaskId, domain::BackgroundTask> =
std::collections::HashMap::new();
if let Some(agent_id) = agent_id {
for task in store
.list_open_for_agent(agent_id)
.await
.map_err(background_error)?
{
by_id.insert(task.id, task);
}
}
for task in store
.list_undelivered_completions()
.await
.map_err(background_error)?
{
by_id.entry(task.id).or_insert(task);
}
let mut tasks: Vec<domain::BackgroundTask> = by_id
.into_values()
.filter(|task| task.project_id == project_id)
.filter(|task| agent_id.map_or(true, |a| task.owner_agent_id == a))
.collect();
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
Ok(tasks.into_iter().map(BackgroundTaskDto::from).collect())
}