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

@ -159,6 +159,24 @@ pub struct OrchestratorRequest {
/// the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_delegation: Option<String>,
/// Human-facing label for `background.run`. Required by that action, ignored
/// by the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// Executable for `background.run`. Required by that action, ignored by the
/// other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// Arguments for `background.run`. Optional and defaults to an empty list.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
/// Optional working directory for `background.run`, resolved by the
/// application layer under the project root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Optional absolute deadline for `background.run`, epoch milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_ms: Option<u64>,
}
/// A validated orchestrator command — the only thing the application layer acts on.
@ -323,6 +341,25 @@ pub enum OrchestratorCommand {
/// The ticket of the most recent delegation it issued, if any.
last_delegation: Option<TicketId>,
},
/// Start a command-backed first-class background task (`idea_run_in_background`).
///
/// The owner is the connected peer's handshake identity (`requestedBy`), never
/// a model-supplied parameter. The application layer resolves the project from
/// the dispatch context and forces `WakeOwner`.
RunInBackground {
/// Owning agent, parsed from `requestedBy`.
owner: AgentId,
/// Human-facing label.
label: String,
/// Executable to run.
command: String,
/// Command arguments.
args: Vec<String>,
/// Optional working directory, resolved under the project root downstream.
cwd: Option<String>,
/// Optional absolute deadline, epoch milliseconds.
deadline_ms: Option<u64>,
},
}
/// Where IdeA should place a launched agent session.
@ -433,6 +470,19 @@ impl OrchestratorRequest {
last_delegation: self
.parse_optional_ticket(action, self.last_delegation.as_deref())?,
}),
"background.run" => Ok(OrchestratorCommand::RunInBackground {
owner: self.require_from(action)?,
label: self.require("label", action, self.label.as_deref())?,
command: self.require("command", action, self.command.as_deref())?,
args: self.args.clone(),
cwd: self
.cwd
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned),
deadline_ms: self.deadline_ms,
}),
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
name: self.require_name(action)?,
@ -1139,6 +1189,60 @@ mod tests {
);
}
#[test]
fn background_run_keys_owner_on_requester_and_parses_command() {
let owner = uuid::Uuid::from_u128(33);
let r = req(&format!(
r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"long test",
"command":"bash", "args":["-lc","sleep 1 && echo done"],
"cwd":"subdir", "deadlineMs":12345 }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::RunInBackground {
owner: AgentId::from_uuid(owner),
label: "long test".to_owned(),
command: "bash".to_owned(),
args: vec!["-lc".to_owned(), "sleep 1 && echo done".to_owned()],
cwd: Some("subdir".to_owned()),
deadline_ms: Some(12345),
}
);
}
#[test]
fn background_run_requires_handshake_owner_label_and_command() {
assert_eq!(
req(r#"{ "type":"background.run", "label":"x", "command":"bash" }"#).validate(),
Err(OrchestratorError::MissingField {
action: "background.run".to_owned(),
field: "requestedBy".to_owned(),
})
);
let owner = uuid::Uuid::from_u128(33);
assert_eq!(
req(&format!(
r#"{{ "type":"background.run", "requestedBy":"{owner}", "command":"bash" }}"#
))
.validate(),
Err(OrchestratorError::MissingField {
action: "background.run".to_owned(),
field: "label".to_owned(),
})
);
assert_eq!(
req(&format!(
r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"x" }}"#
))
.validate(),
Err(OrchestratorError::MissingField {
action: "background.run".to_owned(),
field: "command".to_owned(),
})
);
}
#[test]
fn unknown_action_is_rejected() {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);