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

@ -113,6 +113,34 @@ pub struct AgentLaunchFailedDto {
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DomainEventDto {
/// A requester-scoped agent self-restart was accepted.
#[serde(rename_all = "camelCase")]
AgentRestartRequested {
agent_id: String,
restart_id: String,
reason: Option<String>,
},
/// A self-restart changed phase.
#[serde(rename_all = "camelCase")]
AgentRestartPhaseChanged {
agent_id: String,
restart_id: String,
phase: String,
},
/// A self-restart completed.
#[serde(rename_all = "camelCase")]
AgentRestartCompleted {
agent_id: String,
restart_id: String,
},
/// A self-restart failed after scheduling.
#[serde(rename_all = "camelCase")]
AgentRestartFailed {
agent_id: String,
restart_id: String,
phase: String,
message: String,
},
/// A project was created.
#[serde(rename_all = "camelCase")]
ProjectCreated {
@ -787,6 +815,42 @@ fn rendezvous_conversation_id(rendezvous: &Option<RendezvousContext>) -> Option<
impl From<&DomainEvent> for DomainEventDto {
fn from(e: &DomainEvent) -> Self {
match e {
DomainEvent::AgentRestartRequested {
agent_id,
restart_id,
reason,
} => Self::AgentRestartRequested {
agent_id: agent_id.to_string(),
restart_id: restart_id.clone(),
reason: reason.clone(),
},
DomainEvent::AgentRestartPhaseChanged {
agent_id,
restart_id,
phase,
} => Self::AgentRestartPhaseChanged {
agent_id: agent_id.to_string(),
restart_id: restart_id.clone(),
phase: restart_phase_label(*phase).to_owned(),
},
DomainEvent::AgentRestartCompleted {
agent_id,
restart_id,
} => Self::AgentRestartCompleted {
agent_id: agent_id.to_string(),
restart_id: restart_id.clone(),
},
DomainEvent::AgentRestartFailed {
agent_id,
restart_id,
phase,
message,
} => Self::AgentRestartFailed {
agent_id: agent_id.to_string(),
restart_id: restart_id.clone(),
phase: restart_phase_label(*phase).to_owned(),
message: message.clone(),
},
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
project_id: project_id.to_string(),
},
@ -1312,6 +1376,15 @@ impl From<&DomainEvent> for DomainEventDto {
}
}
fn restart_phase_label(phase: domain::events::AgentRestartPhase) -> &'static str {
match phase {
domain::events::AgentRestartPhase::Stopping => "stopping",
domain::events::AgentRestartPhase::ReleasingInput => "releasingInput",
domain::events::AgentRestartPhase::Launching => "launching",
domain::events::AgentRestartPhase::Resuming => "resuming",
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -1336,6 +1409,32 @@ mod tests {
ConversationId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn agent_restart_events_expose_stable_camel_case_contract() {
let id = agent(288);
let phase = DomainEventDto::from(&DomainEvent::AgentRestartPhaseChanged {
agent_id: id,
restart_id: "restart-288".to_owned(),
phase: domain::events::AgentRestartPhase::ReleasingInput,
});
let phase = serde_json::to_value(phase).expect("serialisable");
assert_eq!(phase["type"], "agentRestartPhaseChanged");
assert_eq!(phase["agentId"], id.to_string());
assert_eq!(phase["restartId"], "restart-288");
assert_eq!(phase["phase"], "releasingInput");
let failed = DomainEventDto::from(&DomainEvent::AgentRestartFailed {
agent_id: id,
restart_id: "restart-288".to_owned(),
phase: domain::events::AgentRestartPhase::Launching,
message: "launch failed".to_owned(),
});
let failed = serde_json::to_value(failed).expect("serialisable");
assert_eq!(failed["type"], "agentRestartFailed");
assert_eq!(failed["phase"], "launching");
assert_eq!(failed["message"], "launch failed");
}
#[test]
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {

View File

@ -13,16 +13,16 @@ use std::sync::{Arc, Mutex};
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
use application::{
AddIssueAttachment, AgentResumer, AgentWakeService, AllocateManagedTemp, AppError,
AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AssignedSkillResolver,
AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues,
BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile,
CheckEmbedderSuggestion, ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed,
CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles,
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue,
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, CreateTemplate,
DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
AddIssueAttachment, AgentRestartScheduled, AgentResumer, AgentSelfRestartScheduler,
AgentWakeService, AllocateManagedTemp, AppError, AssignIssueAgent, AssignSkillToAgent,
AssignTicketToSprint, AssignedSkillResolver, AttachLiveAgent, AuthenticateSession,
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, ClearChatDraft,
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
@ -64,6 +64,7 @@ use application::{
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::events::AgentRestartPhase;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
@ -1003,6 +1004,12 @@ pub struct ResumeContext {
pub rows: u16,
/// Largeur de la cellule au dernier lancement (colonnes PTY).
pub cols: u16,
/// Exact hosting node supplied by the last driving launch (`None` = background).
pub node_id: Option<domain::NodeId>,
/// Logical conversation id supplied by the hosting cell.
pub conversation_id: Option<String>,
/// Whether the last driving launch selected the structured chat runtime.
pub require_structured: bool,
}
/// Registre partagé `(project_id, agent_id) → ResumeContext` (composition root ↔ commande
@ -1030,6 +1037,265 @@ struct AppAgentResumer {
contexts: ResumeContexts,
}
const SELF_RESTART_PROMPT: &str =
"IdeA vient de te redémarrer. Reprends immédiatement le travail en cours là où tu l'avais laissé.";
struct AppAgentSelfRestarter {
launch_agent: Arc<LaunchAgent>,
close_terminal: Arc<CloseTerminal>,
live: Arc<LiveSessions>,
input: Arc<dyn domain::input::InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
events: Arc<dyn EventBus>,
contexts: ResumeContexts,
active: Arc<Mutex<HashSet<domain::RuntimeAgentKey>>>,
}
fn self_restart_launch_input(
context: ResumeContext,
agent_id: AgentId,
mcp_runtime: Option<McpRuntime>,
) -> LaunchAgentInput {
LaunchAgentInput {
project: context.project,
agent_id,
rows: context.rows,
cols: context.cols,
node_id: context.node_id,
conversation_id: context.conversation_id,
mcp_runtime,
allow_structured_alongside_pty: false,
require_structured: context.require_structured,
fresh_start: false,
managed_temp_exposure: application::ManagedTempExposureRequest::default(),
}
}
#[cfg(test)]
mod self_restart_contract_tests {
use super::*;
#[test]
fn relaunch_preserves_conversation_and_never_requests_fresh_start() {
let project = Project::new(
ProjectId::from_uuid(Uuid::from_u128(288)),
"restart",
domain::project::ProjectPath::new("/tmp/idea-restart-contract").unwrap(),
domain::remote::RemoteRef::local(),
0,
)
.unwrap();
let agent = AgentId::from_uuid(Uuid::from_u128(1));
let node = domain::NodeId::from_uuid(Uuid::from_u128(2));
let input = self_restart_launch_input(
ResumeContext {
project,
rows: 31,
cols: 97,
node_id: Some(node),
conversation_id: Some("conversation-288".to_owned()),
require_structured: true,
},
agent,
None,
);
assert_eq!(input.agent_id, agent);
assert_eq!(input.node_id, Some(node));
assert_eq!(input.conversation_id.as_deref(), Some("conversation-288"));
assert!(input.require_structured);
assert!(!input.fresh_start);
}
}
impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
fn schedule(
&self,
project: &Project,
agent: AgentId,
reason: Option<String>,
) -> Result<AgentRestartScheduled, AppError> {
let key = domain::RuntimeAgentKey::new(project.id, agent);
let snapshots = self.live.live_agent_snapshots_for(project.id, &agent);
if snapshots.is_empty() {
return Err(AppError::Invalid(format!(
"requester agent {agent} is not live"
)));
}
{
let mut active = self.active.lock().map_err(|_| {
AppError::Internal("agent restart registry lock poisoned".to_owned())
})?;
if !active.insert(key) {
return Err(AppError::Invalid(format!(
"a restart is already scheduled for agent {agent}"
)));
}
}
let restart_id = Uuid::new_v4().to_string();
self.events.publish(DomainEvent::AgentRestartRequested {
agent_id: agent,
restart_id: restart_id.clone(),
reason,
});
let launch_agent = Arc::clone(&self.launch_agent);
let close_terminal = Arc::clone(&self.close_terminal);
let live = Arc::clone(&self.live);
let input = Arc::clone(&self.input);
let mailbox = Arc::clone(&self.mailbox);
let events = Arc::clone(&self.events);
let contexts = Arc::clone(&self.contexts);
let active = Arc::clone(&self.active);
let restart_id_task = restart_id.clone();
let project = project.clone();
spawn_detached(async move {
// Leave the MCP server enough time to serialize and flush the scheduled
// acknowledgement before teardown kills the requester's bridge process.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let publish_phase = |phase| {
events.publish(DomainEvent::AgentRestartPhaseChanged {
agent_id: agent,
restart_id: restart_id_task.clone(),
phase,
});
};
let context = contexts.lock().ok().and_then(|map| map.get(&key).cloned());
let snapshot = snapshots[0];
let context = context.unwrap_or(ResumeContext {
project: project.clone(),
rows: 24,
cols: 80,
node_id: None,
conversation_id: live
.structured
.meta_for_session(&snapshot.session_id)
.and_then(|(_, _, _, conversation)| conversation),
require_structured: snapshot.kind == application::LiveSessionKind::Structured,
});
publish_phase(AgentRestartPhase::Stopping);
input.preempt(key);
let stop_result = application::terminal::stop_live_agent_runtime_in_project(
&project,
agent,
live.as_ref(),
close_terminal.as_ref(),
)
.await;
publish_phase(AgentRestartPhase::ReleasingInput);
if let Some(ticket) = input.busy_state(key).ticket() {
mailbox.cancel_head(key, ticket);
}
input.mark_idle(key);
let fail = |phase, error: AppError| {
events.publish(DomainEvent::AgentRestartFailed {
agent_id: agent,
restart_id: restart_id_task.clone(),
phase,
message: error.to_string(),
});
};
match stop_result {
Ok(stopped) if !stopped.is_empty() => {}
Ok(_) => {
fail(
AgentRestartPhase::Stopping,
AppError::NotFound(format!("running runtime for agent {agent}")),
);
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
Err(error) => {
fail(AgentRestartPhase::Stopping, error);
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
}
publish_phase(AgentRestartPhase::Launching);
let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
exe,
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
.as_cli_arg()
.to_owned(),
project_id: project.id.as_uuid().simple().to_string(),
requester: agent.to_string(),
});
let launched = launch_agent
.execute(self_restart_launch_input(context, agent, mcp_runtime))
.await;
let launched = match launched {
Ok(output) => output,
Err(error) => {
fail(AgentRestartPhase::Launching, error);
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
};
publish_phase(AgentRestartPhase::Resuming);
let resume_result = if let Some(session) = live
.structured
.session_for_agent_in_project(project.id, &agent)
{
let turn = StructuredHumanTurnGuard::start(
Arc::clone(&input),
Arc::clone(&mailbox),
project.id,
agent,
SELF_RESTART_PROMPT,
);
let result = session
.send(SELF_RESTART_PROMPT)
.await
.map(|stream| stream.for_each(drop))
.map_err(|error| AppError::Process(error.to_string()));
turn.finish();
result
} else if let Some(handle) = live.pty.handle(&launched.session.id) {
if let Some(profile) = launched.profile.as_ref() {
input.bind_handle_with_submit(
key,
handle,
domain::input::SubmitConfig::new(
profile.submit_sequence.clone(),
profile.submit_delay_ms,
),
);
} else {
input.bind_handle(key, handle);
}
let ticket = domain::mailbox::Ticket::new(
domain::mailbox::TicketId::new_random(),
"IdeA",
SELF_RESTART_PROMPT,
);
let _pending = input.enqueue(key, ticket);
Ok(())
} else {
Err(AppError::Process(format!(
"agent {agent} has no live input after relaunch"
)))
};
match resume_result {
Ok(()) => events.publish(DomainEvent::AgentRestartCompleted {
agent_id: agent,
restart_id: restart_id_task.clone(),
}),
Err(error) => fail(AgentRestartPhase::Resuming, error),
}
active.lock().ok().map(|mut set| set.remove(&key));
});
Ok(AgentRestartScheduled { restart_id })
}
}
/// Guard for a direct human turn sent to a structured agent session.
///
/// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider
@ -3230,6 +3496,16 @@ impl BackendCore {
Arc::clone(&events_port),
resumer,
));
let self_restart = Arc::new(AppAgentSelfRestarter {
launch_agent: Arc::clone(&launch_agent),
close_terminal: Arc::clone(&close_terminal),
live: Arc::clone(&live_sessions),
input: Arc::clone(&input_mediator),
mailbox: Arc::clone(&mailbox),
events: Arc::clone(&events_port),
contexts: Arc::clone(&resume_contexts),
active: Arc::new(Mutex::new(HashSet::new())),
}) as Arc<dyn AgentSelfRestartScheduler>;
// Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une
// `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed).
// Patron `sweep_stalled` : spawner detache (pas `tokio::spawn` — `build`
@ -3299,6 +3575,7 @@ impl BackendCore {
.with_project_store(Arc::clone(&store_port))
.with_conversations(Arc::clone(&conversation_registry))
.with_events(Arc::clone(&events_port))
.with_self_restart(self_restart)
// Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour
// que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP
// réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip.