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:
@ -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" }"#);
|
||||
|
||||
Reference in New Issue
Block a user