feat(background-task): rendu live des tâches de fond — subscriber UI + canal IPC (#58)

Câble un canal attachable au flux de sortie d'une tâche de fond (runner
infrastructure + commande app-tauri + port/adaptateurs frontend) et le
panneau ProjectWorkStatePanel s'y abonne pour un rendu live au lieu d'un
état figé au dernier snapshot.

Validations obtenues avant commit :
- cargo test -p infrastructure --test background_task_runner : vert
- cargo check -p backend -p app-tauri : vert
- npx vitest run src/features/workstate/workstate.test.tsx : vert
- npm run typecheck : vert
- verdict QA #58 : vert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:48:29 +02:00
parent 6f532d7434
commit b734c237d3
17 changed files with 528 additions and 23 deletions

View File

@ -562,6 +562,20 @@ pub struct ReattachResultDto {
pub scrollback: Vec<u8>,
}
/// Response DTO for `attach_background_task`: the retained bytes to repaint for
/// a background command task before optional live bytes arrive on the channel.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachBackgroundTaskResultDto {
/// The task that was attached (echoed back for the frontend).
pub task_id: String,
/// Recent output bytes: PTY scrollback for a live task, persisted tail for a
/// terminal task.
pub scrollback: Vec<u8>,
/// Whether a live PTY subscription was installed for subsequent output.
pub live: bool,
}
/// Request DTO for `write_terminal`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@ -60,19 +60,19 @@ use domain::ports::{
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
WakeError, WakeReason, WindowStateStore,
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle,
PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore,
SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore,
ToolInvoker, WakeError, WakeReason, WindowStateStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
};
use domain::remote::RemoteKind;
use domain::{
AgentId, AgentInbox, BackgroundTask, BackgroundTaskWakePolicy, DomainEvent, EmbedderProfile,
InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId,
TaskId, TicketId,
AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, BackgroundTaskWakePolicy,
DomainEvent, EmbedderProfile, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus,
InboxSource, Project, ProjectId, TaskId, TicketId,
};
use serde_json::{json, Map, Value};
use uuid::Uuid;
@ -1099,6 +1099,8 @@ pub struct BackendCore {
pub cancel_background_task: Arc<CancelBackgroundTask>,
/// Retry a terminal command task under a fresh task id.
pub retry_background_task: Arc<RetryBackgroundTask>,
/// Concrete command runner retained for infrastructure-only live PTY attach.
pub background_command_runner: Arc<CommandBackgroundRunner>,
/// Store handle used by `list_background_tasks` to read the task read-model.
pub background_task_store: Arc<dyn BackgroundTaskStore>,
// --- Plugins (#43) ---
@ -1255,7 +1257,93 @@ pub struct BackendCore {
pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
}
/// Backend decision for attaching a UI subscriber to a background task.
pub struct BackgroundTaskAttachPlan {
/// Attached task id.
pub task_id: TaskId,
/// Bytes to repaint immediately.
pub scrollback: Vec<u8>,
/// Live PTY handle to subscribe to. `None` means the task is terminal and
/// the persisted tail is the whole attach payload.
pub live_handle: Option<PtyHandle>,
}
fn map_background_task_port_error(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),
}
}
fn background_task_tail_bytes(task: &BackgroundTask) -> Vec<u8> {
match &task.result {
Some(BackgroundTaskResult::Success {
stdout_tail,
stderr_tail,
..
})
| Some(BackgroundTaskResult::Failure {
stdout_tail,
stderr_tail,
..
}) => [stdout_tail.as_deref(), stderr_tail.as_deref()]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("")
.into_bytes(),
Some(BackgroundTaskResult::Cancelled { reason, .. })
| Some(BackgroundTaskResult::Expired { reason, .. }) => reason.clone().into_bytes(),
None => Vec::new(),
}
}
impl BackendCore {
/// Builds an attach plan for a background task output view.
///
/// A running task uses the runner's live PTY handle plus PTY scrollback.
/// A completed task intentionally returns only the persisted output tail and
/// no handle, so callers do not subscribe to a dead PTY.
///
/// # Errors
/// Returns [`AppError::NotFound`] when the task is neither live in the
/// runner nor terminal in the store, and [`AppError::Process`] /
/// [`AppError::Store`] for PTY/store failures.
pub async fn background_task_attach_plan(
&self,
task_id: TaskId,
) -> Result<BackgroundTaskAttachPlan, AppError> {
if let Some(handle) = self.background_command_runner.pty_handle_for(task_id) {
let scrollback = self.pty_port.scrollback(&handle).map_err(AppError::from)?;
return Ok(BackgroundTaskAttachPlan {
task_id,
scrollback,
live_handle: Some(handle),
});
}
let task = self
.background_task_store
.get(task_id)
.await
.map_err(map_background_task_port_error)?
.ok_or_else(|| AppError::NotFound("background task".to_owned()))?;
if task.is_terminal() {
return Ok(BackgroundTaskAttachPlan {
task_id,
scrollback: background_task_tail_bytes(&task),
live_handle: None,
});
}
Err(AppError::NotFound("background task live handle".to_owned()))
}
/// **Composition root.** Builds all adapters and use cases.
///
/// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE
@ -2827,6 +2915,7 @@ impl BackendCore {
spawn_background_command,
cancel_background_task,
retry_background_task,
background_command_runner: Arc::clone(&background_runner),
background_task_store: Arc::clone(&background_tasks_port),
review_plugin_package,
install_plugin_from_archive,