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())
}

View File

@ -2866,3 +2866,167 @@ mod ls6_pagination_tests {
assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none());
}
}
// ---------------------------------------------------------------------------
// Background tasks (B8 — first-class background command)
// ---------------------------------------------------------------------------
use domain::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId,
};
/// One first-class background task as seen by the frontend (camelCase wire shape).
///
/// Mirrors the persisted [`BackgroundTask`], flattening the terminal
/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the
/// UI has a single shape for every lifecycle state. Optional fields are omitted
/// from the wire when absent (`skip_serializing_if`).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTaskDto {
/// Stable task id (UUID string).
pub task_id: String,
/// Owning agent id (UUID string).
pub owner_agent_id: String,
/// Owning project id (UUID string).
pub project_id: String,
/// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
/// `"maintenance"`).
pub kind: String,
/// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
/// `"failed"`, `"cancelled"`, `"expired"`).
pub state: String,
/// Process exit code, when the terminal result carries one.
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
/// Human-readable summary / error / reason of the terminal result.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// Bounded stdout tail (merged output for PTY-backed commands).
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout_tail: Option<String>,
/// Bounded stderr tail (unset for PTY-backed commands, which merge streams).
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr_tail: Option<String>,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
pub updated_at_ms: u64,
}
/// Kind discriminant string for a [`BackgroundTaskKind`].
fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str {
match kind {
BackgroundTaskKind::Command { .. } => "command",
BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous",
BackgroundTaskKind::SessionResume { .. } => "sessionResume",
BackgroundTaskKind::Maintenance { .. } => "maintenance",
}
}
/// Lifecycle state string for a [`BackgroundTaskState`].
fn background_state_label(state: BackgroundTaskState) -> &'static str {
match state {
BackgroundTaskState::Queued => "queued",
BackgroundTaskState::Running => "running",
BackgroundTaskState::Waiting => "waiting",
BackgroundTaskState::Completed => "completed",
BackgroundTaskState::Failed => "failed",
BackgroundTaskState::Cancelled => "cancelled",
BackgroundTaskState::Expired => "expired",
}
}
impl From<BackgroundTask> for BackgroundTaskDto {
fn from(task: BackgroundTask) -> Self {
let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result {
Some(BackgroundTaskResult::Success {
exit_code,
summary,
stdout_tail,
stderr_tail,
..
}) => (
*exit_code,
Some(summary.clone()),
stdout_tail.clone(),
stderr_tail.clone(),
),
Some(BackgroundTaskResult::Failure {
exit_code,
error,
stdout_tail,
stderr_tail,
..
}) => (
*exit_code,
Some(error.clone()),
stdout_tail.clone(),
stderr_tail.clone(),
),
Some(BackgroundTaskResult::Cancelled { reason, .. })
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
(None, Some(reason.clone()), None, None)
}
None => (None, None, None, None),
};
Self {
task_id: task.id.to_string(),
owner_agent_id: task.owner_agent_id.to_string(),
project_id: task.project_id.to_string(),
kind: background_kind_label(&task.kind).to_owned(),
state: background_state_label(task.state).to_owned(),
exit_code,
summary,
stdout_tail,
stderr_tail,
created_at_ms: task.created_at_ms,
updated_at_ms: task.updated_at_ms,
}
}
}
/// Parses a task-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_task_id(raw: &str) -> Result<TaskId, ErrorDto> {
uuid::Uuid::parse_str(raw)
.map(TaskId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid task id: {raw}"),
})
}
/// Request DTO for `spawn_background_command`.
///
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the
/// project host's PTY and its completion is delivered to `ownerAgentId`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpawnBackgroundCommandRequestDto {
/// Owning project id (UUID string).
pub project_id: String,
/// Agent that owns completion delivery (UUID string).
pub owner_agent_id: String,
/// Human-facing label.
pub label: String,
/// Executable to run.
pub command: String,
/// Arguments.
#[serde(default)]
pub args: Vec<String>,
/// Working directory (absolute path).
pub cwd: String,
/// Extra environment variables.
#[serde(default)]
pub env: Vec<(String, String)>,
/// When `true` (the default), wake the owner on completion; otherwise only
/// record it.
#[serde(default)]
pub record_only: bool,
/// Optional absolute deadline, epoch milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_ms: Option<u64>,
}

View File

@ -214,6 +214,10 @@ pub fn run() {
commands::recall_memory,
commands::resolve_memory_links,
commands::move_tab_to_new_window,
commands::spawn_background_command,
commands::cancel_background_task,
commands::retry_background_task,
commands::list_background_tasks,
])
.run(tauri::generate_context!())
.expect("error while running IdeA Tauri application");

View File

@ -13,7 +13,8 @@ use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AgentWakeService, AppError, AssignSkillToAgent, AttachLiveAgent,
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
CloseProject, CloseTab, CloseTerminal, RetryBackgroundTask, SpawnBackgroundCommand,
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
@ -39,7 +40,8 @@ use application::{
use async_trait::async_trait;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentWakePort,
BackgroundTaskPortError, BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector,
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore,
Clock, Embedder, EmbedderEnvInspector,
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore,
PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore, WakeError, WakeReason,
@ -57,8 +59,9 @@ use serde_json::{json, Map, Value};
use uuid::Uuid;
use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, BackgroundTaskReadyToDeliver,
ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime,
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
CliAgentRuntime, CommandBackgroundRunner,
CodexPermissionProjector, EmbedderEnvProbe, FsBackgroundTaskStore, FsConversationLog,
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsLiveStateStore, FsMemoryStore,
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
@ -795,6 +798,15 @@ pub struct AppState {
// --- Windows (L10) ---
/// Detach a tab into a new OS window (persists the workspace topology).
pub move_tab: Arc<MoveTabToNewWindow>,
// --- Background tasks (B8) ---
/// Spawn a command-backed first-class background task.
pub spawn_background_command: Arc<SpawnBackgroundCommand>,
/// Cancel a running background task.
pub cancel_background_task: Arc<CancelBackgroundTask>,
/// Retry a terminal command task under a fresh task id.
pub retry_background_task: Arc<RetryBackgroundTask>,
/// Store handle used by `list_background_tasks` to read the task read-model.
pub background_task_store: Arc<dyn BackgroundTaskStore>,
// --- Templates & sync (L7) ---
/// Create a template in the global store.
pub create_template: Arc<CreateTemplate>,
@ -1541,6 +1553,49 @@ impl AppState {
let background_tasks_port = Arc::clone(&background_tasks) as Arc<dyn BackgroundTaskStore>;
let (background_ready_tx, mut background_ready_rx) =
tokio::sync::mpsc::unbounded_channel::<BackgroundTaskReadyToDeliver>();
// --- B8 : runner de commandes + fermeture de la boucle du sink ---
// Le runner concret exécute les tâches command-backed sur le `PtyPort` composé
// (local ici ; SSH/WSL via `RemoteHost` quand ils atterriront — Liskov). Il
// émet UNE complétion par tâche ; le sink (single-writer) persiste l'état
// terminal AVANT de signaler la livraison (persist-avant-signal), puis le pont
// ready→inbox ci-dessous réveille l'agent propriétaire.
let background_runner = Arc::new(CommandBackgroundRunner::new(
Arc::clone(&pty_port),
Arc::clone(&clock) as Arc<dyn Clock>,
));
let background_runner_port =
Arc::clone(&background_runner) as Arc<dyn BackgroundTaskRunner>;
{
let sink = Arc::new(BackgroundCompletionSink::new(
Arc::clone(&background_tasks_port),
background_ready_tx.clone(),
));
let runner = Arc::clone(&background_runner_port);
// `start_from_runner` exige un runtime Tokio ambiant (`Handle::current`) :
// on l'appelle donc DANS la tâche async. Le `JoinHandle` du drain est gardé
// vivant en l'attendant (il ne se termine qu'à la fermeture du flux de
// complétions), ce qui maintient sink + runner en vie pour la session.
tauri::async_runtime::spawn(async move {
let drain = sink.start_from_runner(runner);
let _ = drain.await;
});
}
let spawn_background_command = Arc::new(SpawnBackgroundCommand::new(
Arc::clone(&background_tasks_port),
Arc::clone(&background_runner_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let cancel_background_task = Arc::new(CancelBackgroundTask::new(
Arc::clone(&background_tasks_port),
Arc::clone(&background_runner_port),
));
let retry_background_task = Arc::new(RetryBackgroundTask::new(
Arc::clone(&background_tasks_port),
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
Arc::clone(&spawn_background_command),
));
let background_wake = Arc::new(AgentWakeService::new(
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
Arc::clone(&input_mediator),
@ -1977,6 +2032,10 @@ impl AppState {
fs_port: Arc::clone(&fs_port),
home_dir,
move_tab,
spawn_background_command,
cancel_background_task,
retry_background_task,
background_task_store: Arc::clone(&background_tasks_port),
}
}