feat(background): déclencheur in-app des tâches de fond via MCP idea_run_in_background (BE-1+BE-2)

Ferme la boucle B8 côté surface agent : un agent peut lancer une commande
en tâche de fond depuis l'app, sans dépendance à un déclencheur externe.

- domain : variante OrchestratorCommand::RunInBackground.
- infrastructure : outil MCP idea_run_in_background (déclaration + schéma +
  map_tool_call), tests de mapping et compteur de catalogue.
- application : handler → SpawnBackgroundCommand (owner=requester, WakeOwner).
- app-tauri : câblage .with_spawn_background_command + catalogue MCP 23→24.

Tests verts : domain 452 / application 467 / infrastructure 489 /
app-tauri 236, build --release vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:44:48 +02:00
parent 41278bd632
commit f08dae62eb
6 changed files with 542 additions and 20 deletions

View File

@ -14,6 +14,7 @@
//! infrastructure adapter's job. That keeps this fully unit-testable with fakes.
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
@ -39,6 +40,7 @@ use crate::agent::{
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
};
use crate::background::{SpawnBackgroundCommand, SpawnBackgroundCommandInput};
use crate::error::AppError;
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
use crate::orchestrator::{
@ -89,6 +91,55 @@ fn bound_task_text(value: &str, max_bytes: usize) -> String {
value[..end].to_owned()
}
fn resolve_background_cwd(
project_root: &ProjectPath,
cwd: Option<String>,
) -> Result<ProjectPath, AppError> {
let root = Path::new(project_root.as_str());
let raw = cwd.as_deref().map(str::trim).filter(|s| !s.is_empty());
let candidate = match raw {
None => root.to_path_buf(),
Some(value) => {
let path = Path::new(value);
if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
}
}
};
let normalized = normalize_path_no_parent(&candidate)?;
if !normalized.starts_with(root) {
return Err(AppError::Invalid(
"background task cwd must stay under the project root".to_owned(),
));
}
let Some(path) = normalized.to_str() else {
return Err(AppError::Invalid(
"background task cwd is not valid UTF-8".to_owned(),
));
};
ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
}
fn normalize_path_no_parent(path: &Path) -> Result<PathBuf, AppError> {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => out.push(prefix.as_os_str()),
Component::RootDir => out.push(component.as_os_str()),
Component::CurDir => {}
Component::Normal(part) => out.push(part),
Component::ParentDir => {
return Err(AppError::Invalid(
"background task cwd must not contain `..`".to_owned(),
))
}
}
}
Ok(out)
}
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
///
/// A target agent's turn can be long (reasoning + tool use), so the cap is
@ -433,6 +484,9 @@ pub struct OrchestratorService {
/// `idea_ask_agent` comme [`BackgroundTaskKind::HeadlessRendezvous`]. `None` ⇒
/// comportement historique sans persistance de tâche (call sites non câblés).
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
/// Use case B8 qui produit une tâche de fond command-backed depuis la surface
/// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré.
spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -504,9 +558,17 @@ impl OrchestratorService {
ask_liveness_probe: None,
ask_ceiling: ASK_AGENT_CEILING,
background_tasks: None,
spawn_background_command: None,
}
}
/// Branche le producteur B8 command-backed pour `idea_run_in_background`.
#[must_use]
pub fn with_spawn_background_command(mut self, spawn: Arc<SpawnBackgroundCommand>) -> Self {
self.spawn_background_command = Some(spawn);
self
}
/// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
/// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
///
@ -1096,9 +1158,70 @@ impl OrchestratorService {
)
.await
}
OrchestratorCommand::RunInBackground {
owner,
label,
command,
args,
cwd,
deadline_ms,
} => {
self.run_in_background(project, owner, label, command, args, cwd, deadline_ms)
.await
}
}
}
async fn run_in_background(
&self,
project: &Project,
owner: AgentId,
label: String,
command: String,
args: Vec<String>,
cwd: Option<String>,
deadline_ms: Option<u64>,
) -> Result<OrchestratorOutcome, AppError> {
let spawn = self.spawn_background_command.as_ref().ok_or_else(|| {
AppError::Invalid("background command runner is not configured".to_owned())
})?;
let cwd = resolve_background_cwd(&project.root, cwd)?;
let output = spawn
.execute(SpawnBackgroundCommandInput {
project_id: project.id,
owner_agent_id: owner,
label,
command: domain::ports::SpawnSpec {
command,
args,
cwd,
env: Vec::new(),
context_plan: None,
sandbox: None,
},
wake_policy: BackgroundTaskWakePolicy::WakeOwner,
deadline_ms,
})
.await?;
let state = match output.task.state {
BackgroundTaskState::Queued => "Queued",
BackgroundTaskState::Running => "Running",
BackgroundTaskState::Waiting => "Waiting",
BackgroundTaskState::Completed => "Completed",
BackgroundTaskState::Failed => "Failed",
BackgroundTaskState::Cancelled => "Cancelled",
BackgroundTaskState::Expired => "Expired",
};
Ok(OrchestratorOutcome {
detail: serde_json::json!({
"taskId": output.task.id.to_string(),
"state": state,
})
.to_string(),
reply: None,
})
}
/// Returns the injected C7 use cases, or a typed error when unwired.
fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> {
self.context_guard.as_deref().ok_or_else(|| {