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

@ -60,6 +60,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_context_read"
| "idea_memory_read"
| "idea_skill_read"
| "idea_run_in_background"
| "idea_workstate_read"
) || is_ticket_tool(tool)
}
@ -103,6 +104,27 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_run_in_background",
description: "Run a command as a first-class IdeA background task. Returns the task id \
immediately; completion is delivered later to your inbox.",
input_schema: json!({
"type": "object",
"properties": {
"label": { "type": "string", "description": "Human-facing label for the background task." },
"command": { "type": "string", "description": "Executable to run." },
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Command arguments."
},
"cwd": { "type": "string", "description": "Working directory under the project root. Defaults to the project root." },
"deadline_ms": { "type": "number", "description": "Optional absolute deadline, epoch milliseconds." }
},
"required": ["label", "command"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_launch_agent",
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
@ -316,6 +338,16 @@ pub fn map_tool_call(
task: s("task"),
..base()
},
"idea_run_in_background" => OrchestratorRequest {
request_type: Some("background.run".to_owned()),
requested_by: Some(requester.to_owned()),
label: s("label"),
command: s("command"),
args: string_array(args.get("args"), name)?,
cwd: s("cwd"),
deadline_ms: optional_u64(args.get("deadline_ms"), name)?,
..base()
},
"idea_stop_agent" => OrchestratorRequest {
request_type: Some("agent.stop".to_owned()),
target_agent: s("target"),
@ -417,6 +449,11 @@ fn base() -> OrchestratorRequest {
intent: None,
progress: None,
last_delegation: None,
label: None,
command: None,
args: Vec::new(),
cwd: None,
deadline_ms: None,
}
}
@ -430,6 +467,33 @@ fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
.map(domain::NodeId::from_uuid)
}
fn string_array(value: Option<&Value>, tool: &str) -> Result<Vec<String>, ToolMapError> {
let Some(value) = value else {
return Ok(Vec::new());
};
let items = value
.as_array()
.ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))?;
items
.iter()
.map(|item| {
item.as_str()
.map(str::to_owned)
.ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))
})
.collect()
}
fn optional_u64(value: Option<&Value>, tool: &str) -> Result<Option<u64>, ToolMapError> {
let Some(value) = value else {
return Ok(None);
};
value
.as_u64()
.map(Some)
.ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
@ -479,6 +543,66 @@ mod tests {
);
}
#[test]
fn run_in_background_maps_to_background_command_with_handshake_owner() {
let cmd = map_tool_call(
"idea_run_in_background",
&json!({
"label": "B8 T1",
"command": "bash",
"args": ["-lc", "sleep 45 && echo DONE"],
"cwd": "crates",
"deadline_ms": 123456
}),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::RunInBackground {
owner: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
label: "B8 T1".to_owned(),
command: "bash".to_owned(),
args: vec!["-lc".to_owned(), "sleep 45 && echo DONE".to_owned()],
cwd: Some("crates".to_owned()),
deadline_ms: Some(123456),
}
);
assert!(tool_returns_reply("idea_run_in_background"));
}
#[test]
fn run_in_background_rejects_absent_requester_and_malformed_args() {
let no_requester = map_tool_call(
"idea_run_in_background",
&json!({ "label": "x", "command": "bash" }),
"",
);
assert!(matches!(no_requester, Err(ToolMapError::Invalid(_))));
assert_eq!(
map_tool_call(
"idea_run_in_background",
&json!({ "label": "x", "command": "bash", "args": "nope" }),
REQ,
),
Err(ToolMapError::BadArguments(
"idea_run_in_background".to_owned()
))
);
assert_eq!(
map_tool_call(
"idea_run_in_background",
&json!({ "label": "x", "command": "bash", "args": [1] }),
REQ,
),
Err(ToolMapError::BadArguments(
"idea_run_in_background".to_owned()
))
);
}
#[test]
fn launch_agent_maps_to_spawn_background_by_default() {
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();