feat(mcp,orchestrator,frontend): tool idea_restart_self, redemarrage autonome d'un agent — #288 (tool core self-only via requester MCP handshake, retour immediat {restartId, status: scheduled} puis orchestration detachee: snapshot reprise, stop PTY/structured, liberation Busy/mailbox, relance LaunchAgent meme conversation_id fresh_start=false, injection interne invisible; events domain/wire agentRestartRequested/PhaseChanged/Completed/Failed phases stopping/releasingInput/launching/resuming; CLI custom: separateurs exacts redemarrage de l'agent + etat termine/echoue, deduplication (restartId, event), verrouillage composer; TUI native: overlay AgentRestartOverlay sans injection xterm, progress/annonces projetes jusqu'a idle; QA verte: mcp_server restart_self 1/1, restart-self 3/3, backend self_restart_contract + events wire 2/2, frontend 3 suites 92/92, npm build, cargo check --workspace, fmt --check, diff --check; reserves hors perimetre: web-server 103/110 fixture PTY preexistante, 34 echecs npm test globaux listProjectSettings/web hors #288; scripts/ hors perimetre)

This commit is contained in:
2026-09-08 22:04:18 +02:00
parent ee259dc93a
commit 63537572b8
23 changed files with 1207 additions and 37 deletions

View File

@ -72,6 +72,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_run_in_background",
"idea_launch_agent",
"idea_stop_agent",
"idea_restart_self",
"idea_update_context",
"idea_context_propose",
"idea_context_update",
@ -155,6 +156,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_memory_read"
| "idea_skill_read"
| "idea_run_in_background"
| "idea_restart_self"
| "idea_workstate_read"
) || is_ticket_tool(tool)
|| is_template_tool(tool)
@ -193,6 +195,17 @@ pub fn is_plugin_tool(tool: &str) -> bool {
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
let mut tools = vec![
ToolDef {
name: "idea_restart_self",
description: "Restart this requesting agent and resume its current work with freshly resolved capabilities.",
input_schema: json!({
"type": "object",
"properties": {
"reason": { "type": "string" }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_project_list",
description: "List known IdeA projects for cross-project agent discovery.",
@ -543,6 +556,15 @@ pub fn map_tool_call(
// Translate the tool into the wire-level request shape the watcher already
// uses (v2 `type`), then let `validate` enforce the invariants once.
let request = match name {
"idea_restart_self" => {
reject_unknown_keys(name, args, &["reason"])?;
OrchestratorRequest {
request_type: Some("agent.restart_self".to_owned()),
requested_by: Some(requester.to_owned()),
task: s("reason"),
..base()
}
}
"idea_list_agents" => OrchestratorRequest {
request_type: Some("agent.list".to_owned()),
..base()
@ -845,6 +867,51 @@ mod tests {
assert!(tool_returns_reply("idea_ask_agents"));
}
#[test]
fn restart_self_is_catalogued_and_uses_only_handshake_requester() {
let definition = catalogue()
.into_iter()
.find(|tool| tool.name == "idea_restart_self")
.expect("restart-self tool advertised");
assert_eq!(
tool_access(definition.name),
Some(McpToolAccess::WriteAction)
);
assert!(tool_returns_reply(definition.name));
let expected = domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap());
assert_eq!(
map_tool_call(
"idea_restart_self",
&json!({ "reason": "plugin installed" }),
REQ,
)
.unwrap(),
OrchestratorCommand::RestartSelf {
agent: expected,
reason: Some("plugin installed".to_owned()),
}
);
assert_eq!(
map_tool_call(
"idea_restart_self",
&json!({ "target": "someone-else" }),
REQ,
),
Err(ToolMapError::BadArguments("idea_restart_self".to_owned()))
);
}
#[test]
fn restart_self_rejects_missing_or_malformed_requester() {
for requester in ["", "not-an-agent-id"] {
assert!(matches!(
map_tool_call("idea_restart_self", &json!({}), requester),
Err(ToolMapError::Invalid(_))
));
}
}
#[test]
fn project_tools_are_classified_and_return_inline_replies() {
for tool in [

View File

@ -53,8 +53,9 @@ use domain::{
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
AgentRestartScheduled, AgentSelfRestartScheduler, AppError, CloseTerminal,
CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService,
StructuredSessions, TerminalSessions, UpdateAgentContext,
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
@ -578,6 +579,25 @@ fn capturing_events() -> (
(publish, captured)
}
#[derive(Default)]
struct FakeSelfRestart {
calls: Mutex<Vec<(ProjectId, AgentId, Option<String>)>>,
}
impl AgentSelfRestartScheduler for FakeSelfRestart {
fn schedule(
&self,
project: &Project,
agent: AgentId,
reason: Option<String>,
) -> Result<AgentRestartScheduled, AppError> {
self.calls.lock().unwrap().push((project.id, agent, reason));
Ok(AgentRestartScheduled {
restart_id: "restart-test-1".to_owned(),
})
}
}
// --- JSON-RPC framing helpers ---------------------------------------------
/// A `tools/call` request envelope for `tool` with `arguments`.
@ -762,6 +782,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_ask_agent",
"idea_ask_agents",
"idea_run_in_background",
"idea_restart_self",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
@ -812,8 +833,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
49,
"exactly the forty-nine exposed idea_* tools; got {names:?}"
50,
"exactly the fifty exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -988,6 +1009,33 @@ async fn cross_project_actions_are_advertised_only_when_allowed_and_denial_is_ac
// 2. tools/call → the right OrchestratorCommand (observed through the fakes)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn restart_self_returns_scheduled_payload_and_uses_handshake_identity() {
let (base, _sessions) = build_service(FakeContexts::new());
let restart = Arc::new(FakeSelfRestart::default());
let service = Arc::try_unwrap(base)
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
.with_self_restart(Arc::clone(&restart) as Arc<dyn AgentSelfRestartScheduler>);
let requester = AgentId::from_uuid(Uuid::from_u128(288));
let response = server(Arc::new(service))
.for_requester(requester.to_string())
.handle_raw(&tools_call(
288,
"idea_restart_self",
json!({ "reason": "new plugin" }),
))
.await
.expect("restart call replies before detached teardown");
let result = response.result.expect("successful tool result");
let payload: Value = serde_json::from_str(result_text(&result)).unwrap();
assert_eq!(payload["restartId"], "restart-test-1");
assert_eq!(payload["status"], "scheduled");
assert_eq!(
restart.calls.lock().unwrap().as_slice(),
&[(project().id, requester, Some("new plugin".to_owned()))]
);
}
#[tokio::test]
async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
let contexts = FakeContexts::new();