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:
@ -2243,6 +2243,12 @@ pub async fn launch_agent(
|
||||
project: resume_project,
|
||||
rows: request.rows,
|
||||
cols: request.cols,
|
||||
node_id,
|
||||
conversation_id: request
|
||||
.conversation_id
|
||||
.clone()
|
||||
.or_else(|| output.assigned_conversation_id.clone()),
|
||||
require_structured: request.cell_kind == Some(CellKind::Chat),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -165,10 +165,11 @@ pub use model_server::{
|
||||
};
|
||||
pub use orchestrator::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
|
||||
LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
|
||||
ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WakeSessionProvider,
|
||||
WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
||||
AgentRestartScheduled, AgentSelfRestartScheduler, AgentWakeService, AskLivenessProbe,
|
||||
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
|
||||
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
|
||||
RecordTurnProvider, WakeSessionProvider, WatchdogOutcome, WriteMemory,
|
||||
DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
||||
};
|
||||
pub use permission::{
|
||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||
|
||||
@ -19,7 +19,8 @@ pub use rendezvous::{
|
||||
WatchdogOutcome, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
||||
};
|
||||
pub use service::{
|
||||
AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider,
|
||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
AgentRestartScheduled, AgentSelfRestartScheduler, AskLivenessProbe, ContextGuardUseCases,
|
||||
LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome,
|
||||
OrchestratorService, RecordTurnProvider,
|
||||
};
|
||||
pub use wake::{AgentWakeService, WakeSessionProvider};
|
||||
|
||||
@ -63,6 +63,25 @@ use crate::workstate::{
|
||||
GetLiveStateLean, StopAgentExecutionCascade, UpdateLiveState, UpdateLiveStateInput,
|
||||
};
|
||||
|
||||
/// Immediate acknowledgement returned after a self-restart has been accepted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentRestartScheduled {
|
||||
/// Correlation id emitted on all subsequent restart events.
|
||||
pub restart_id: String,
|
||||
}
|
||||
|
||||
/// Composition-root owned scheduler for the destructive, detached restart flow.
|
||||
/// The application orchestrator only authenticates and dispatches the intent.
|
||||
pub trait AgentSelfRestartScheduler: Send + Sync {
|
||||
/// Validates liveness/concurrency synchronously, then owns the detached work.
|
||||
fn schedule(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent: AgentId,
|
||||
reason: Option<String>,
|
||||
) -> Result<AgentRestartScheduled, AppError>;
|
||||
}
|
||||
|
||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
||||
/// resizes the PTY to the real cell size on attach; these are sane starting rows
|
||||
/// /cols so the spawn never fails on a zero-sized terminal.
|
||||
@ -685,6 +704,8 @@ pub struct OrchestratorService {
|
||||
/// Use case B8 qui produit une tâche de fond command-backed depuis la surface
|
||||
/// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré.
|
||||
spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
|
||||
/// Detached self-restart coordinator, wired at the composition root.
|
||||
self_restart: Option<Arc<dyn AgentSelfRestartScheduler>>,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
@ -784,6 +805,7 @@ impl OrchestratorService {
|
||||
background_runner: None,
|
||||
session_limits: None,
|
||||
spawn_background_command: None,
|
||||
self_restart: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -808,6 +830,13 @@ impl OrchestratorService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the requester-scoped detached self-restart coordinator.
|
||||
#[must_use]
|
||||
pub fn with_self_restart(mut self, restart: Arc<dyn AgentSelfRestartScheduler>) -> Self {
|
||||
self.self_restart = Some(restart);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
|
||||
/// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
|
||||
///
|
||||
@ -1456,6 +1485,25 @@ impl OrchestratorService {
|
||||
command: OrchestratorCommand,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
match command {
|
||||
OrchestratorCommand::RestartSelf { agent, reason } => {
|
||||
let scheduled = self
|
||||
.self_restart
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid("idea_restart_self not configured".to_owned())
|
||||
})?
|
||||
.schedule(project, agent, reason)?;
|
||||
let reply = serde_json::json!({
|
||||
"restartId": scheduled.restart_id,
|
||||
"status": "scheduled",
|
||||
"message": "Agent restart scheduled."
|
||||
})
|
||||
.to_string();
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: "agent restart scheduled".to_owned(),
|
||||
reply: Some(reply),
|
||||
})
|
||||
}
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name,
|
||||
profile,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -49,6 +49,42 @@ pub enum OrchestrationSource {
|
||||
/// channel rather than serialised here.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DomainEvent {
|
||||
/// An agent requested a self-restart through its MCP handshake identity.
|
||||
AgentRestartRequested {
|
||||
/// Agent authenticated by the MCP handshake.
|
||||
agent_id: AgentId,
|
||||
/// Correlation id shared by all events in this restart.
|
||||
restart_id: String,
|
||||
/// Optional model-provided diagnostic reason.
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// A scheduled self-restart moved to another runtime phase.
|
||||
AgentRestartPhaseChanged {
|
||||
/// Restarting agent.
|
||||
agent_id: AgentId,
|
||||
/// Restart correlation id.
|
||||
restart_id: String,
|
||||
/// Newly entered phase.
|
||||
phase: AgentRestartPhase,
|
||||
},
|
||||
/// A self-restart relaunched the agent and submitted its internal resume turn.
|
||||
AgentRestartCompleted {
|
||||
/// Restarted agent.
|
||||
agent_id: AgentId,
|
||||
/// Restart correlation id.
|
||||
restart_id: String,
|
||||
},
|
||||
/// A self-restart failed after it had already been acknowledged as scheduled.
|
||||
AgentRestartFailed {
|
||||
/// Agent whose restart failed.
|
||||
agent_id: AgentId,
|
||||
/// Restart correlation id.
|
||||
restart_id: String,
|
||||
/// Phase that failed.
|
||||
phase: AgentRestartPhase,
|
||||
/// Human-readable failure safe to expose in the UI.
|
||||
message: String,
|
||||
},
|
||||
/// A project was created.
|
||||
ProjectCreated {
|
||||
/// The new project.
|
||||
@ -667,6 +703,19 @@ pub enum DomainEvent {
|
||||
},
|
||||
}
|
||||
|
||||
/// Stable phases exposed by the agent self-restart event stream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentRestartPhase {
|
||||
/// Existing runtime teardown.
|
||||
Stopping,
|
||||
/// Busy state and mailbox cleanup.
|
||||
ReleasingInput,
|
||||
/// Activation through `LaunchAgent`.
|
||||
Launching,
|
||||
/// Submission of the internal continuation turn.
|
||||
Resuming,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -260,6 +260,13 @@ pub struct OrchestratorRequest {
|
||||
/// proof the request was well-formed (Parse, don't validate).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OrchestratorCommand {
|
||||
/// Schedule a restart of the MCP requester itself.
|
||||
RestartSelf {
|
||||
/// Requesting agent, strictly parsed from the MCP handshake identity.
|
||||
agent: AgentId,
|
||||
/// Optional diagnostic reason. It is never used as an identity or target.
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// Create the agent if unknown (with `profile` + optional initial context),
|
||||
/// then launch it — exactly as the UI would.
|
||||
SpawnAgent {
|
||||
@ -476,6 +483,15 @@ impl OrchestratorRequest {
|
||||
pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> {
|
||||
let action = self.action_name()?;
|
||||
match action {
|
||||
"agent.restart_self" => Ok(OrchestratorCommand::RestartSelf {
|
||||
agent: self.require_from(action)?,
|
||||
reason: self
|
||||
.task
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned),
|
||||
}),
|
||||
"spawn_agent" => Ok(OrchestratorCommand::SpawnAgent {
|
||||
name: self.require_name(action)?,
|
||||
profile: Some(self.require("profile", action, self.profile.as_deref())?),
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1658,6 +1658,12 @@ async fn execute_launch_agent_for_ws(
|
||||
project: resume_project,
|
||||
rows: request.rows,
|
||||
cols: request.cols,
|
||||
node_id,
|
||||
conversation_id: request
|
||||
.conversation_id
|
||||
.clone()
|
||||
.or_else(|| output.assigned_conversation_id.clone()),
|
||||
require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user