fix(orchestrator,agent): ask_agent survit au restart de la cible same-project et cross-project — #289 (RestartRendezvousCoordinator stable par RuntimeAgentKey (projectId, agentId) partagé entre le rendez-vous ask_structured et le scheduler AppAgentSelfRestarter: l'ask actif garde verrou de tour, ticket logique et conversation_id, abandonne le drain de l'ancienne session sans no-reply puis capture le Final de continuation de la session relancée (SELF_RESTART_CONTINUATION_PROMPT partagé, un seul tour de continuation); branche delegated du scheduler: pas de preempt/cancel_head/mark_idle locaux, ready + wait_for_handoff, échecs restart propagés explicitement et cancel en cascade sans relance de continuation; watchdog d'inactivité réarmé par l'activité de contrôle restart; corrélation cross-project (requesterProjectId, requesterAgentId, correlationId) conservée jusqu'au résultat post-restart puis supprimée; stop_agent d'une cible en restart annule le rendez-vous au lieu d'un NotFound; le scheduler standalone conserve son comportement historique sans ask actif; QA verte: orchestrator_service 87/87 dont 6 tests restart (final post-restart, nettoyage busy/workstate/registre, corrélation cross-project, failure explicite, cancel cross-project + stop same-project pendant restart), application lib 171/171 dont watchdog réarmé, backend 89+7 ignored, cargo check -p backend, fmt --check; réserves: pas d'E2E AppImage/vrai provider, 7 tests backend ignorés bind socket local; scripts/ hors périmètre; aucun push)

This commit is contained in:
2026-09-08 22:53:52 +02:00
parent db91b00699
commit d6d989187b
9 changed files with 1204 additions and 61 deletions

View File

@ -1037,9 +1037,6 @@ 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>,
@ -1049,6 +1046,7 @@ struct AppAgentSelfRestarter {
events: Arc<dyn EventBus>,
contexts: ResumeContexts,
active: Arc<Mutex<HashSet<domain::RuntimeAgentKey>>>,
rendezvous: Arc<application::RestartRendezvousCoordinator>,
}
fn self_restart_launch_input(
@ -1134,6 +1132,7 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
}
let restart_id = Uuid::new_v4().to_string();
let delegated = self.rendezvous.begin(key, restart_id.clone());
self.events.publish(DomainEvent::AgentRestartRequested {
agent_id: agent,
restart_id: restart_id.clone(),
@ -1148,6 +1147,7 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
let events = Arc::clone(&self.events);
let contexts = Arc::clone(&self.contexts);
let active = Arc::clone(&self.active);
let rendezvous = Arc::clone(&self.rendezvous);
let restart_id_task = restart_id.clone();
let project = project.clone();
spawn_detached(async move {
@ -1176,7 +1176,9 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
});
publish_phase(AgentRestartPhase::Stopping);
input.preempt(key);
if !delegated {
input.preempt(key);
}
let stop_result = application::terminal::stop_live_agent_runtime_in_project(
&project,
agent,
@ -1186,12 +1188,17 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
.await;
publish_phase(AgentRestartPhase::ReleasingInput);
if let Some(ticket) = input.busy_state(key).ticket() {
mailbox.cancel_head(key, ticket);
if !delegated {
if let Some(ticket) = input.busy_state(key).ticket() {
mailbox.cancel_head(key, ticket);
}
input.mark_idle(key);
}
input.mark_idle(key);
let fail = |phase, error: AppError| {
if delegated {
rendezvous.fail(key, &restart_id_task, error.to_string());
}
events.publish(DomainEvent::AgentRestartFailed {
agent_id: agent,
restart_id: restart_id_task.clone(),
@ -1199,6 +1206,11 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
message: error.to_string(),
});
};
let finish_coordination = || {
if delegated {
rendezvous.finish(key, &restart_id_task);
}
};
match stop_result {
Ok(stopped) if !stopped.is_empty() => {}
Ok(_) => {
@ -1206,16 +1218,28 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
AgentRestartPhase::Stopping,
AppError::NotFound(format!("running runtime for agent {agent}")),
);
finish_coordination();
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
Err(error) => {
fail(AgentRestartPhase::Stopping, error);
finish_coordination();
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
}
if delegated && rendezvous.is_cancelled(key, &restart_id_task) {
fail(
AgentRestartPhase::ReleasingInput,
AppError::Process("restart cancelled with its active rendezvous".to_owned()),
);
finish_coordination();
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,
@ -1232,11 +1256,48 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
Ok(output) => output,
Err(error) => {
fail(AgentRestartPhase::Launching, error);
finish_coordination();
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
};
if delegated {
if rendezvous.is_cancelled(key, &restart_id_task) {
let _ = application::terminal::stop_live_agent_runtime_in_project(
&project,
agent,
live.as_ref(),
close_terminal.as_ref(),
)
.await;
fail(
AgentRestartPhase::Launching,
AppError::Process(
"restart cancelled before the continuation turn".to_owned(),
),
);
finish_coordination();
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
publish_phase(AgentRestartPhase::Resuming);
rendezvous.ready(key, &restart_id_task);
if let Err(message) = rendezvous.wait_for_handoff(key, &restart_id_task).await {
fail(AgentRestartPhase::Resuming, AppError::Process(message));
finish_coordination();
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
events.publish(DomainEvent::AgentRestartCompleted {
agent_id: agent,
restart_id: restart_id_task.clone(),
});
finish_coordination();
active.lock().ok().map(|mut set| set.remove(&key));
return;
}
publish_phase(AgentRestartPhase::Resuming);
let resume_result = if let Some(session) = live
.structured
@ -1247,10 +1308,10 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
Arc::clone(&mailbox),
project.id,
agent,
SELF_RESTART_PROMPT,
application::SELF_RESTART_CONTINUATION_PROMPT,
);
let result = session
.send(SELF_RESTART_PROMPT)
.send(application::SELF_RESTART_CONTINUATION_PROMPT)
.await
.map(|stream| stream.for_each(drop))
.map_err(|error| AppError::Process(error.to_string()));
@ -1272,7 +1333,7 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
let ticket = domain::mailbox::Ticket::new(
domain::mailbox::TicketId::new_random(),
"IdeA",
SELF_RESTART_PROMPT,
application::SELF_RESTART_CONTINUATION_PROMPT,
);
let _pending = input.enqueue(key, ticket);
Ok(())
@ -3496,6 +3557,7 @@ impl BackendCore {
Arc::clone(&events_port),
resumer,
));
let restart_rendezvous = Arc::new(application::RestartRendezvousCoordinator::new());
let self_restart = Arc::new(AppAgentSelfRestarter {
launch_agent: Arc::clone(&launch_agent),
close_terminal: Arc::clone(&close_terminal),
@ -3505,6 +3567,7 @@ impl BackendCore {
events: Arc::clone(&events_port),
contexts: Arc::clone(&resume_contexts),
active: Arc::new(Mutex::new(HashSet::new())),
rendezvous: Arc::clone(&restart_rendezvous),
}) 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).
@ -3576,6 +3639,7 @@ impl BackendCore {
.with_conversations(Arc::clone(&conversation_registry))
.with_events(Arc::clone(&events_port))
.with_self_restart(self_restart)
.with_restart_rendezvous(restart_rendezvous)
// 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.