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

@ -21,10 +21,11 @@ use domain::ids::SkillId;
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryStore, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
SessionPlan, SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore,
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError,
PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
@ -34,14 +35,17 @@ use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{
BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, OrchestratorCommand,
OrchestratorRequest, PtySize, SessionId, TaskId,
};
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
TerminalSessions, UpdateAgentContext, UpdateLiveState,
SpawnBackgroundCommand, TerminalSessions, UpdateAgentContext, UpdateLiveState,
};
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
use domain::ports::LiveStateStore;
@ -370,6 +374,104 @@ impl PtyPort for FakePty {
}
}
#[derive(Clone, Default)]
struct RecordingBackgroundStore {
tasks: Arc<Mutex<HashMap<TaskId, BackgroundTask>>>,
}
impl RecordingBackgroundStore {
fn task(&self, id: TaskId) -> Option<BackgroundTask> {
self.tasks.lock().unwrap().get(&id).cloned()
}
}
#[async_trait]
impl BackgroundTaskStore for RecordingBackgroundStore {
async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
let mut tasks = self.tasks.lock().unwrap();
if tasks.contains_key(&task.id) {
return Err(BackgroundTaskPortError::AlreadyExists);
}
tasks.insert(task.id, task.clone());
Ok(())
}
async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
Ok(self.task(id))
}
async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
self.tasks.lock().unwrap().insert(task.id, task.clone());
Ok(())
}
async fn list_open_for_agent(
&self,
agent_id: AgentId,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.owner_agent_id == agent_id && !task.is_terminal())
.cloned()
.collect())
}
async fn list_undelivered_completions(
&self,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.has_pending_completion_delivery())
.cloned()
.collect())
}
async fn mark_completion_delivered(
&self,
task_id: TaskId,
) -> Result<(), BackgroundTaskPortError> {
let task = self
.task(task_id)
.ok_or(BackgroundTaskPortError::NotFound)?
.mark_completion_delivered()
.map_err(|err| BackgroundTaskPortError::Invalid(err.to_string()))?;
self.save(&task).await
}
}
#[derive(Clone, Default)]
struct RecordingBackgroundRunner {
specs: Arc<Mutex<Vec<BackgroundTaskSpec>>>,
}
impl RecordingBackgroundRunner {
fn specs(&self) -> Vec<BackgroundTaskSpec> {
self.specs.lock().unwrap().clone()
}
}
#[async_trait]
impl BackgroundTaskRunner for RecordingBackgroundRunner {
async fn spawn(
&self,
spec: BackgroundTaskSpec,
) -> Result<BackgroundTaskHandle, BackgroundTaskPortError> {
let task_id = spec.task_id;
self.specs.lock().unwrap().push(spec);
Ok(BackgroundTaskHandle { task_id })
}
async fn cancel(&self, _task_id: TaskId) -> Result<(), BackgroundTaskPortError> {
Ok(())
}
fn subscribe_completions(&self) -> BackgroundCompletionStream {
Box::new(std::iter::empty())
}
}
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
@ -683,6 +785,71 @@ async fn spawn_unknown_agent_creates_then_launches() {
assert!(launched, "AgentLaunched must be published");
}
#[tokio::test]
async fn run_in_background_dispatch_creates_running_command_for_requester() {
let fx = fixture(FakeContexts::new());
let store = Arc::new(RecordingBackgroundStore::default());
let runner = Arc::new(RecordingBackgroundRunner::default());
let spawn = Arc::new(SpawnBackgroundCommand::new(
Arc::clone(&store) as Arc<dyn BackgroundTaskStore>,
Arc::clone(&runner) as Arc<dyn BackgroundTaskRunner>,
Arc::new(FixedMillisClock(42)) as Arc<dyn domain::ports::Clock>,
Arc::new(SeqIds::new()) as Arc<dyn IdGenerator>,
));
let service = fx.service.with_spawn_background_command(spawn);
let owner = aid(77);
let out = service
.dispatch(
&project(),
OrchestratorCommand::RunInBackground {
owner,
label: "B8 T1".to_owned(),
command: "bash".to_owned(),
args: vec![
"-lc".to_owned(),
"sleep 45 && echo DONE_MARKER_DEVBACKEND_B8_T1".to_owned(),
],
cwd: Some("crates".to_owned()),
deadline_ms: None,
},
)
.await
.expect("dispatch ok");
let payload: serde_json::Value = serde_json::from_str(&out.detail).unwrap();
assert_eq!(payload["state"], "Running");
let task_id =
TaskId::from_uuid(uuid::Uuid::parse_str(payload["taskId"].as_str().unwrap()).unwrap());
let task = store.task(task_id).expect("task persisted");
assert_eq!(task.project_id, project().id);
assert_eq!(task.owner_agent_id, owner);
assert_eq!(task.state, BackgroundTaskState::Running);
assert_eq!(task.wake_policy, BackgroundTaskWakePolicy::WakeOwner);
assert_eq!(
task.kind,
domain::BackgroundTaskKind::Command {
label: "B8 T1".to_owned(),
}
);
let specs = runner.specs();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].task_id, task_id);
assert_eq!(specs[0].owner_agent_id, owner);
assert_eq!(specs[0].wake_policy, BackgroundTaskWakePolicy::WakeOwner);
let command = specs[0].command.as_ref().expect("command spec");
assert_eq!(command.command, "bash");
assert_eq!(
command.args,
vec![
"-lc".to_owned(),
"sleep 45 && echo DONE_MARKER_DEVBACKEND_B8_T1".to_owned(),
]
);
assert_eq!(command.cwd.as_str(), "/home/me/proj/crates");
}
#[tokio::test]
async fn spawn_known_agent_launches_without_recreating() {
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");